Files
ersatztv/scripts/decisions_lib.py
T
timothyandClaude Opus 4.8 fb6720ea27 fix(521): de-dup 6 overlapped records; guard duplicate metadata blocks; exclude retrieval-eval; complete eval bank [decisions-edit]
- Exclude docs/decisions/retrieval-eval.md from active decision parsing
  (_NON_DECISION_FILES); its `## N.` eval-question headings were being
  miscounted as 7 legacy-unmigrated records.
- Add decisions_lib.metadata_line_count() + a decisions_validate guard
  that fails a record with more than one `key:` metadata line, so a
  stacked-metadata-block migration bug (which the parser silently
  tolerated by reading only the first block) can't recur unnoticed.
  TDD: test_duplicate_metadata_block_fails / test_single_metadata_block_passes.
- De-duplicate the 6 docs/decisions.md records left with two stacked
  metadata blocks (scan.getoraddfolder-db-lookup #488,
  scan.musicvideo-reconciliation #494, scan.jellyfin-mixed-content-library
  #489, iptv.logo-drives-bug-preset #67, ffmpeg.qsv-decode-encode-split
  #498, ci.small-lane-git-only server-management#639), merging the union
  of Signals/paths/issues/Mechanics from both blocks and keeping the
  richer Rule wording; rationale prose untouched.
- Fill in the deferred Q6b row in docs/decisions/retrieval-eval.md now
  that startup.parallel-orientation is active in docs/decisions.md,
  scoring it as a real active-vs-superseded question against the
  archived docs.queue-state-gitea-tracker.
- Regenerate docs/decisions/README.md via build_decisions_catalog.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:54:52 +02:00

127 lines
4.4 KiB
Python

#!/usr/bin/env python3
"""Parse ErsatzTV decision records from docs/decisions.md and docs/decisions/*.md.
A decision record is a Markdown H2 section. A *migrated* record carries a visible metadata
block as its first non-blank content:
## 2026-07-17 — Title … (#406)
`key: ci.runner-placement` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
**Rule:** one-line current rule.
**Signals:** concept · paths: a/b.yml · issues: #406
**Mechanics:** docs/ci-cd.md → CI lanes
<rationale prose …>
An H2 with no metadata line is treated as status `legacy-unmigrated` (migration target).
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
DECISIONS_MD = REPO_ROOT / "docs" / "decisions.md"
TOPIC_DIR = REPO_ROOT / "docs" / "decisions"
ARCHIVE_DIR = TOPIC_DIR / "archive"
STATUSES = {"active", "superseded", "retired", "legacy-unmigrated"}
KEY_RE = re.compile(r"^[a-z0-9]+(\.[a-z0-9-]+)+$")
_HEADING_RE = re.compile(r"^##\s+(.*\S)\s*$")
_META_FIELD_RE = re.compile(r"`([a-z-]+):\s*([^`]*)`")
@dataclass
class Record:
heading: str
source: Path
lineno: int
key: str | None = None
status: str = "legacy-unmigrated"
since: str | None = None
supersedes: str | None = None
superseded_by: str | None = None
rule: str | None = None
signals: str | None = None
mechanics: str | None = None
body: str = ""
def _parse_meta_line(line: str) -> dict[str, str]:
return {m.group(1): m.group(2).strip() for m in _META_FIELD_RE.finditer(line)}
def parse_text(text: str, source: Path) -> list[Record]:
lines = text.splitlines()
records: list[Record] = []
i = 0
while i < len(lines):
m = _HEADING_RE.match(lines[i])
if not m:
i += 1
continue
rec = Record(heading=m.group(1), source=source, lineno=i + 1)
j = i + 1
body_lines: list[str] = []
while j < len(lines) and not _HEADING_RE.match(lines[j]):
body_lines.append(lines[j])
j += 1
for k, bl in enumerate(body_lines):
if not bl.strip():
continue
meta = _parse_meta_line(bl)
if "key" in meta and "status" in meta:
rec.key = meta.get("key") or None
rec.status = meta.get("status") or "legacy-unmigrated"
rec.since = meta.get("since") or None
rec.supersedes = meta.get("supersedes") or None
rec.superseded_by = meta.get("superseded-by") or None
# The metadata block is contiguous: scan only until the first blank line, so a
# bolded **Rule:** appearing later inside rationale prose can't overwrite the real one.
for bl2 in body_lines[k + 1 :]:
if not bl2.strip():
break
if bl2.startswith("**Rule:**"):
rec.rule = bl2[len("**Rule:**") :].strip()
elif bl2.startswith("**Signals:**"):
rec.signals = bl2[len("**Signals:**") :].strip()
elif bl2.startswith("**Mechanics:**"):
rec.mechanics = bl2[len("**Mechanics:**") :].strip()
break
rec.body = "\n".join(body_lines).strip()
records.append(rec)
i = j
return records
def parse_file(path: Path) -> list[Record]:
return parse_text(path.read_text(encoding="utf-8"), path)
def metadata_line_count(rec: Record) -> int:
"""Count how many lines in `rec.body` look like a metadata line (a line starting with
`` `key: `` after stripping). A well-formed record has exactly 1; more indicates a
duplicate/stacked metadata block left behind by a botched migration."""
count = 0
for line in rec.body.splitlines():
if line.strip().startswith("`key:"):
count += 1
return count
_NON_DECISION_FILES = {"README.md", "migration-map.md", "retrieval-eval.md"}
def active_files() -> list[Path]:
files = [DECISIONS_MD]
files += sorted(p for p in TOPIC_DIR.glob("*.md") if p.name not in _NON_DECISION_FILES)
return files
def all_active_records() -> list[Record]:
recs: list[Record] = []
for f in active_files():
if f.exists():
recs += parse_file(f)
return recs