PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 17s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m15s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m41s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
This is why `decisions lifecycle` went red, and it was NOT the known flake. I came close to dismissing it as one for the second time this session, because an earlier red on another branch genuinely was. The dual-format parser imported PyYAML to read frontmatter. `decisions-guard` does `setup-python` and installs NOTHING, so once the corpus was migrated every record became unparseable there: ModuleNotFoundError, job fails. The same would hit the Husky pre-commit hook and every contributor's machine. Installing PyYAML in CI is the wrong fix: READING happens everywhere -- CI, the hook, every dev -- while WRITING happens once, in a migration a human runs deliberately. So the read path is now dependency-free and only `migrate_decisions_split` (the writer) still imports yaml. A hand-rolled parser is only safe if it provably matches the library that WROTE the files, so `test_frontmatter_reader_matches_pyyaml_on_every_real_record` compares the two field-by-field across all 169 real records (importorskip, so it is skipped rather than failing where PyYAML is absent) with a >100-file guard against near-vacuity. It is narrow by construction: the frontmatter is machine-generated with default_flow_style=False and width=10**9, so every value is a single-line scalar, and the reader bails to None on anything nested. Verified by running all four affected entry points against a shim that makes `import yaml` raise: validate --base/--head, catalog --check, the kickoff guard, and the plain validate the pre-commit hook calls. All exit 0. refs #610
263 lines
10 KiB
Python
263 lines
10 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).
|
|
|
|
Two OPTIONAL fields (ersatztv#603, adopted from OKF v0.2's lifecycle/provenance families) may also
|
|
appear — `stale-after: YYYY-MM-DD` on the metadata line, and a `**Sources:**` line in the metadata
|
|
block. Absence is never an error; see decisions_validate for how they are checked.
|
|
"""
|
|
|
|
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"
|
|
RECORDS_DIR = TOPIC_DIR / "records" # split layout (#610): one YAML-frontmatter record per file
|
|
|
|
# H2 headings that are document structure, not decision records. "Records formerly in this file"
|
|
# is the generated where-did-it-go index the #610 split appends to each stripped file.
|
|
SKIP_HEADINGS = {"Index", "Active catalog", "Contents", "Records formerly in this file"}
|
|
|
|
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
|
|
# Optional (ersatztv#603). `stale_after` is an absolute date, no TTL — the record is due for
|
|
# re-confirmation once today >= it. `sources` cites the evidence the record rests on.
|
|
stale_after: str | None = None
|
|
sources: 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]:
|
|
if has_frontmatter(text):
|
|
return _parse_frontmatter(text, source)
|
|
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
|
|
# NOT `or None`: a present-but-empty `stale-after:` must stay "" so the validator can
|
|
# tell it from an absent field and reject it. Collapsing the two would let a blank or
|
|
# truncated value through as "absent" — a field that silently never fires, which is
|
|
# exactly what the blocking format check exists to prevent.
|
|
rec.stale_after = meta.get("stale-after")
|
|
# 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()
|
|
elif bl2.startswith("**Sources:**"):
|
|
rec.sources = bl2[len("**Sources:**") :].strip()
|
|
break
|
|
rec.body = "\n".join(body_lines).strip()
|
|
records.append(rec)
|
|
i = j
|
|
return records
|
|
|
|
|
|
# --- YAML-frontmatter form (ersatztv#610): one record per file ------------------------------
|
|
#
|
|
# The corpus is migrating from "many `##` records per file, metadata on a backtick line" to "one
|
|
# record per file, metadata in YAML frontmatter". BOTH forms parse here, dispatching on a leading
|
|
# `---`. That is what lets the migration validate itself: the body-diff guard parses the OLD form
|
|
# at the merge-base and the NEW form at head, both yielding Records keyed on `key`, so no one-time
|
|
# bypass is needed for the commit that moves 166 records.
|
|
#
|
|
# Frontmatter keys are the SAME vocabulary as the backtick line (`superseded-by`, `stale-after`),
|
|
# so the on-disk names don't shift under anyone mid-migration.
|
|
|
|
_FM_DELIM = "---"
|
|
_FM_TO_FIELD = {
|
|
"key": "key",
|
|
"status": "status",
|
|
"since": "since",
|
|
"supersedes": "supersedes",
|
|
"superseded-by": "superseded_by",
|
|
"stale-after": "stale_after",
|
|
"rule": "rule",
|
|
"signals": "signals",
|
|
"mechanics": "mechanics",
|
|
"sources": "sources",
|
|
}
|
|
|
|
|
|
def has_frontmatter(text: str) -> bool:
|
|
"""True if `text` opens with a YAML frontmatter fence. Must be the very first line."""
|
|
lines = text.splitlines()
|
|
return bool(lines) and lines[0].rstrip() == _FM_DELIM
|
|
|
|
|
|
def _unquote(raw: str) -> str:
|
|
"""Undo the quoting `yaml.safe_dump` applies. Single-line scalars only.
|
|
|
|
The frontmatter this reads is MACHINE-GENERATED by `migrate_decisions_split.render_record`
|
|
with `default_flow_style=False` and `width=10**9`, so every value is a single-line scalar,
|
|
either plain or quoted. That narrowness is what makes a dependency-free reader safe.
|
|
"""
|
|
raw = raw.strip()
|
|
if len(raw) >= 2 and raw[0] == raw[-1] == "'":
|
|
return raw[1:-1].replace("''", "'")
|
|
if len(raw) >= 2 and raw[0] == raw[-1] == '"':
|
|
body = raw[1:-1]
|
|
out, i = [], 0
|
|
while i < len(body):
|
|
if body[i] == "\\" and i + 1 < len(body):
|
|
nxt = body[i + 1]
|
|
out.append({"n": "\n", "t": "\t", '"': '"', "\\": "\\"}.get(nxt, nxt))
|
|
i += 2
|
|
else:
|
|
out.append(body[i])
|
|
i += 1
|
|
return "".join(out)
|
|
return raw
|
|
|
|
|
|
def _read_frontmatter(block: str) -> dict[str, str] | None:
|
|
"""Parse the frontmatter block WITHOUT PyYAML. None if it doesn't look like our format.
|
|
|
|
Deliberately dependency-free: this read path runs in CI's `decisions lifecycle` job, in the
|
|
Husky pre-commit hook, and on every contributor's machine. Requiring PyYAML there made the
|
|
validator crash with ModuleNotFoundError on a runner that installs nothing — the split's own
|
|
records became unparseable. Writing still uses PyYAML (`migrate_decisions_split`), because that
|
|
is a one-shot run by a human who can install it.
|
|
|
|
`test_frontmatter_reader_matches_pyyaml` asserts this agrees with PyYAML on every real record.
|
|
"""
|
|
meta: dict[str, str] = {}
|
|
for line in block.splitlines():
|
|
if not line.strip() or line.lstrip().startswith("#"):
|
|
continue
|
|
if line[:1].isspace():
|
|
return None # nested/continued structure — not the flat form we emit
|
|
k, sep, v = line.partition(":")
|
|
if not sep:
|
|
return None
|
|
meta[k.strip()] = _unquote(v)
|
|
return meta
|
|
|
|
|
|
def _parse_frontmatter(text: str, source: Path) -> list[Record]:
|
|
lines = text.splitlines()
|
|
end = None
|
|
for i, ln in enumerate(lines[1:], start=1):
|
|
if ln.rstrip() == _FM_DELIM:
|
|
end = i
|
|
break
|
|
if end is None:
|
|
return [] # unterminated frontmatter — malformed; validator reports the missing record
|
|
meta = _read_frontmatter("\n".join(lines[1:end]))
|
|
if not meta:
|
|
return []
|
|
|
|
rec = Record(heading=str(meta.get("title") or "").strip(), source=source, lineno=1)
|
|
for fm_key, field in _FM_TO_FIELD.items():
|
|
if fm_key not in meta:
|
|
continue
|
|
# "" is preserved rather than collapsed to None — the empty-vs-absent distinction
|
|
# `stale-after` depends on (#603).
|
|
setattr(rec, field, meta[fm_key].strip())
|
|
if not rec.status:
|
|
rec.status = "legacy-unmigrated"
|
|
rec.body = "\n".join(lines[end + 1 :]).strip()
|
|
return [rec]
|
|
|
|
|
|
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]:
|
|
"""Every file holding ACTIVE records, in both the legacy and split layouts.
|
|
|
|
Legacy: `docs/decisions.md` + the flat topic files. Split (#610): `docs/decisions/records/**`.
|
|
Both are listed so the two layouts can coexist during the migration and so a ref on either side
|
|
of it parses correctly.
|
|
"""
|
|
files = [DECISIONS_MD]
|
|
files += sorted(p for p in TOPIC_DIR.glob("*.md") if p.name not in _NON_DECISION_FILES)
|
|
files += sorted(RECORDS_DIR.rglob("*.md"))
|
|
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
|