Files
ersatztv/scripts/build_decisions_catalog.py
timothy 8ec1f527e4 fix(610): catalog path links only for the split layout, not every file under docs/decisions
The first cut tested `relative_to(TOPIC_DIR)`, which also matches the LEGACY
multi-record topic files -- so every record in workflow-process.md et al. lost its
anchor and linked to the top of the file instead of to its own record. Caught by
`build_decisions_catalog --check` going stale on the unmigrated corpus, not by
reading. Narrowed to `RECORDS_DIR in src.parents`, which by construction only
matches one-record-per-file.
2026-07-25 19:08:58 +02:00

120 lines
4.8 KiB
Python

#!/usr/bin/env python3
"""Generate docs/decisions/README.md — the compact ACTIVE decision catalog / task router.
Do not edit docs/decisions/README.md by hand; regenerate with this script. The decisions-validate
CI job checks it is in sync.
"""
from __future__ import annotations
import sys
from pathlib import Path
import scripts.decisions_lib as dl
OUTPUT = dl.TOPIC_DIR / "README.md"
BANNER = "<!-- GENERATED by scripts/build_decisions_catalog.py — do not edit by hand. -->"
def _rel(rec) -> str:
"""Link from docs/decisions/README.md to the record.
Split layout (#610): the record IS a file, so the link is a plain relative path with no
anchor — nothing to slug, nothing to keep in sync with a heading. Legacy layout: an anchor
into the file that holds it. Both forms are produced so the catalog is correct on either side
of the migration.
"""
src = Path(rec.source)
# Path form ONLY for the split layout, i.e. a file under records/ that holds exactly one
# record. Testing "is it under docs/decisions/" instead would also match the legacy multi-record
# topic files and silently drop their anchors, sending every one of those links to the top of
# the file rather than to its record.
if dl.RECORDS_DIR in src.parents:
return str(src.relative_to(dl.TOPIC_DIR))
anchor = _anchor(rec.heading)
if src.name == "decisions.md":
return f"../decisions.md#{anchor}"
return f"{src.name}#{anchor}"
def _anchor(heading: str) -> str:
# Gitea slugger: lowercase, keep alphanumerics + underscore, spaces/hyphens → '-' each (one
# hyphen PER such char — Gitea does NOT collapse runs of '-'), drop other punctuation. No
# collapsing, no trimming. Keeping '_' matters — headings like "…(`iptv.base_url`)…" anchor to
# …iptvbase_url…. A " — " (space em-dash space) heading therefore anchors with "--", e.g.
# "2026-07-19 — Foo" → "2026-07-19--foo". VERIFIED against this repo's own working Index
# anchors — do not "fix" this back to a collapsed form without re-checking against Gitea.
out = []
for ch in heading.lower():
if ch.isalnum() or ch == "_":
out.append(ch)
elif ch in " -":
out.append("-")
return "".join(out)
def render_catalog(records) -> str:
active = sorted(
[r for r in records if r.status == "active" and r.key],
key=lambda r: r.key,
)
lines = [
BANNER,
"",
"# Active decisions — catalog / task router",
"",
"The compact current view of settled decisions. Each row is an **active** record; follow",
"the link for rationale. Superseded/retired history lives in `archive/`. Regenerated by",
"`scripts/build_decisions_catalog.py`.",
"",
"| Key | Current rule | Since | Record |",
"| --- | --- | --- | --- |",
]
for r in active:
rule = (r.rule or "").replace("|", "\\|")
lines.append(f"| `{r.key}` | {rule} | {r.since or ''} | [link]({_rel(r)}) |")
lines.append("")
# Review-due section (ersatztv#603). Renders the DATE only — never "today", never a computed
# stale/fresh verdict. This file is checked with --check in CI, so anything time-dependent here
# would drift it red on a calendar boundary with no commit touching the corpus. Evaluating
# today >= stale-after is the validator's job, on its non-blocking notice path.
due = sorted([r for r in active if r.stale_after], key=lambda r: (r.stale_after or "", r.key or ""))
if due:
lines += [
"## Review due",
"",
"Active records that assert facts about the outside world and carry a `stale-after` date.",
"Once that date passes, re-confirm the fact and either extend the date or supersede the",
"record. Sorted soonest-first.",
"",
"| Stale after | Key | Record |",
"| --- | --- | --- |",
]
for r in due:
lines.append(f"| {r.stale_after} | `{r.key}` | [link]({_rel(r)}) |")
lines.append("")
return "\n".join(lines)
def main(argv=None) -> int:
check = "--check" in (argv or sys.argv[1:])
want = render_catalog(dl.all_active_records())
have = OUTPUT.read_text(encoding="utf-8") if OUTPUT.exists() else ""
if check:
if want.strip() != have.strip():
print(
"build_decisions_catalog: docs/decisions/README.md is stale — regenerate.",
file=sys.stderr,
)
return 1
print("build_decisions_catalog: catalog up to date")
return 0
OUTPUT.write_text(want.rstrip("\n") + "\n", encoding="utf-8")
print(f"build_decisions_catalog: wrote {OUTPUT}")
return 0
if __name__ == "__main__":
raise SystemExit(main())