PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Failing after 23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 16m5s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
168 records -> docs/decisions/records/<area>/<topic>.md (163 active, 23 dirs) and docs/decisions/archive/<area>/<topic>.md (5 archived). The filename IS the key, so one-active-record-per-key becomes a filesystem property rather than a validator check, and supersession becomes a `git mv`. WHY: the monolith was a concurrency problem before an aesthetic one. A 3,900-line append target made parallel sessions collide -- PR #605 and PR #614 both hit append-vs-append conflicts during routine rebases, and hand-resolving those inside the corpus is exactly the operation the rationale-rewrite guard exists to police. HOW IT IS VERIFIED: a ~170-file diff cannot be meaningfully read, so correctness does not rest on reading it. The parser was taught BOTH formats first, so the body-diff guard parses the old form at the merge-base and the new form at head -- the migration validates itself, no bypass. The proof is a field-level equivalence harness: 168 records before and after, zero lost, zero gained, zero field mismatches, zero rationale bodies differing. Reviewers should scrutinise the harness; it is the actual evidence. What measuring caught that reading would not have: - ~500 lines sit OUTSIDE any record -- decisions.md's lifecycle schema and each topic file's preamble, mostly the only copy. Source files are kept and stripped, never deleted. They also cannot be filed per-area: topic files hold several areas and 4 of 23 areas span several files. - Archive discovery was a non-recursive glob; after the split it found ZERO archived records, surfacing as four bogus "supersedes points to unknown key" errors rather than an obvious failure. - ~32 live docs point into the corpus BY DATE, which the split dangles. Each stripped file now ends with a generated "Records formerly in this file" index, which also rescues the identical breadcrumbs in old issue comments. - decisions.md's "In this file:" list was 97 same-file anchor bullets that the split makes WRONG, not merely stale. Dropped; the generated index replaces them with links that resolve. The equivalence harness now runs against a checked-in FIXTURE, not the live corpus. The earlier version migrated the real tree, which made it a one-shot: the moment the migration landed there was nothing left to move and the tests failed for reasons unrelated to the code. A fixture keeps them testing the SCRIPT rather than the repo's current state. Keys preserved verbatim, warts included: `sched` (12) and `scheduling` (1) remain two directories for one concept. Renaming a key is not a move -- it changes identity, breaks the equivalence proof, and invalidates MemPalace's per-key drawers. Taxonomy normalisation is separate work. refs #610
225 lines
9.8 KiB
Python
225 lines
9.8 KiB
Python
#!/usr/bin/env python3
|
|
"""One-shot migration (ersatztv#610): split the decision corpus into one file per record.
|
|
|
|
docs/decisions.md -> docs/decisions/records/<area>/<topic>.md (one per record)
|
|
docs/decisions/<topic-file>.md + the source file KEPT, stripped to its narrative
|
|
docs/decisions/archive/*.md -> docs/decisions/archive/<area>/<topic>.md
|
|
|
|
Run from the repo root: PYTHONPATH=. python3 scripts/migrate_decisions_split.py [--dry-run]
|
|
|
|
Two properties this script exists to guarantee, both checked by
|
|
`scripts/tests/test_migration_equivalence.py`:
|
|
|
|
1. **Nothing is rewritten.** Every record's rationale body is copied byte-for-byte, and every
|
|
metadata field round-trips unchanged. The migration is a MOVE, so a field-level before/after
|
|
comparison is a complete correctness proof.
|
|
2. **No prose is lost.** A few hundred lines of the corpus are NOT inside any record — file
|
|
preambles that explain why a topic file exists, cross-reference notes, and decisions.md's
|
|
lifecycle schema header. Those files are kept and stripped to exactly that prose, never deleted.
|
|
(They cannot be filed per-area: topic files hold several areas, and 4 of 23 areas span several
|
|
files.) The one thing deliberately dropped is same-file anchor bullets, which the split makes
|
|
*wrong* rather than merely stale — the generated index replaces them with links that resolve.
|
|
Run with `--dry-run` for the current counts rather than trusting a number written here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
import scripts.decisions_lib as dl
|
|
|
|
# Emitted in this order so every record file reads the same way.
|
|
_FIELD_ORDER = [
|
|
("key", "key"),
|
|
("title", None), # from Record.heading
|
|
("status", "status"),
|
|
("since", "since"),
|
|
("supersedes", "supersedes"),
|
|
("superseded-by", "superseded_by"),
|
|
("stale-after", "stale_after"),
|
|
("rule", "rule"),
|
|
("signals", "signals"),
|
|
("mechanics", "mechanics"),
|
|
("sources", "sources"),
|
|
]
|
|
|
|
|
|
def record_path(rec: dl.Record) -> Path:
|
|
"""`records/<area>/<topic>.md` for an active record, `archive/<area>/<topic>.md` otherwise.
|
|
|
|
Identity stays the KEY; the path is derived from it, and the validator enforces the two agree.
|
|
Every key in the corpus is exactly two dotted segments, so this is unambiguous — a key with
|
|
inner dots would need a rule and there are none.
|
|
"""
|
|
area, _, topic = (rec.key or "").partition(".")
|
|
root = dl.ARCHIVE_DIR if rec.status in ("superseded", "retired") else dl.RECORDS_DIR
|
|
return root / area / f"{topic}.md"
|
|
|
|
|
|
def render_record(rec: dl.Record) -> str:
|
|
"""YAML frontmatter + the rationale body, copied verbatim."""
|
|
meta: dict[str, object] = {}
|
|
for fm_key, field in _FIELD_ORDER:
|
|
val = rec.heading if fm_key == "title" else getattr(rec, field)
|
|
if val is None:
|
|
continue # absent stays absent; "" is emitted so empty-vs-absent survives
|
|
meta[fm_key] = val
|
|
fm = yaml.safe_dump(
|
|
meta,
|
|
sort_keys=False,
|
|
allow_unicode=True,
|
|
default_flow_style=False,
|
|
width=10**9, # never fold: keep every scalar on one line so diffs stay readable
|
|
)
|
|
return f"---\n{fm}---\n\n{_rationale_body(rec)}\n"
|
|
|
|
|
|
def _rationale_body(rec: dl.Record) -> str:
|
|
"""The record body with its contiguous metadata block stripped — the prose, byte-for-byte.
|
|
|
|
Mirrors decisions_validate._rationale's contiguous-block rule, but preserves interior blank
|
|
lines and indentation (that function normalizes whitespace for comparison; here the bytes are
|
|
the payload).
|
|
"""
|
|
lines = rec.body.splitlines()
|
|
i = 0
|
|
while i < len(lines) and not lines[i].strip():
|
|
i += 1
|
|
if i < len(lines) and lines[i].strip().startswith("`key:"):
|
|
j = i + 1
|
|
while j < len(lines) and lines[j].strip():
|
|
j += 1
|
|
lines = lines[:i] + lines[j:]
|
|
return "\n".join(lines).strip()
|
|
|
|
|
|
_MOVED_HEADING = "## Records formerly in this file"
|
|
|
|
|
|
def _moved_index(records: list[dl.Record], src: Path) -> str:
|
|
"""A generated 'where did it go' index appended to each stripped file.
|
|
|
|
Live docs point into the corpus BY DATE — "see `decisions.md` 2026-07-10" — roughly 32 such
|
|
references across 12 files. The split would dangle every one of them. This index keeps them
|
|
resolvable: the reader lands on the file the pointer names and finds the date, the title, and
|
|
the record's new path. Cheaper and far less error-prone than rewriting 32 references by hand,
|
|
and it degrades gracefully for the historical issue comments that use the same form.
|
|
"""
|
|
if not records:
|
|
return ""
|
|
lines = [
|
|
_MOVED_HEADING,
|
|
"",
|
|
"Each record below moved to its own file under `records/` (ersatztv#610); the rationale is",
|
|
"unchanged. Resolve by **key** — that is the stable identity. A date-based pointer from",
|
|
"another doc or an old issue comment should land here and then follow the link.",
|
|
"",
|
|
]
|
|
for r in sorted(records, key=lambda x: (x.heading, x.key or "")):
|
|
# Relative to the SOURCE FILE'S OWN PARENT, not to TOPIC_DIR. The stripped files sit at
|
|
# three different depths — docs/decisions.md in `docs/`, the topic files in
|
|
# `docs/decisions/`, the archive files in `docs/decisions/archive/` — so a single fixed
|
|
# base produces links that resolve correctly from exactly one of them and dangle from the
|
|
# other two. That is how 105 links, including every one in the corpus's primary entry
|
|
# file, pointed at nonexistent paths.
|
|
dest = os.path.relpath(record_path(r), src.parent)
|
|
lines.append(f"- {r.heading} — [`{r.key}`]({dest})")
|
|
return "\n".join(lines)
|
|
|
|
|
|
# A bullet whose link is a bare same-file anchor, e.g. `- [2026-07-10 — Title](#2026-07-10--title)`.
|
|
# decisions.md's "In this file:" index is ~97 of these. Once the records move out, every one of them
|
|
# points at a heading that no longer exists in that file — and the generated "Records formerly in
|
|
# this file" section replaces them with links that actually resolve. Anything else keeps its bullet.
|
|
_SAME_FILE_ANCHOR_BULLET = re.compile(r"^\s*[-*]\s*\[[^\]]*\]\(#[^)]*\)\s*$")
|
|
|
|
|
|
def _preamble(path: Path, records: list[dl.Record]) -> str:
|
|
"""Everything in `path` that is not inside a record — kept, never deleted.
|
|
|
|
Two exceptions are dropped rather than kept, because the split makes them *wrong* rather than
|
|
merely stale: same-file anchor bullets (see above).
|
|
"""
|
|
text = path.read_text(encoding="utf-8")
|
|
lines = text.splitlines()
|
|
drop: set[int] = set()
|
|
for r in records:
|
|
start = r.lineno - 1 # heading line, 1-indexed in Record
|
|
for n in range(start, start + 1 + len(r.body.splitlines())):
|
|
drop.add(n)
|
|
kept = [ln for n, ln in enumerate(lines) if n not in drop and not _SAME_FILE_ANCHOR_BULLET.match(ln)]
|
|
# collapse the blank runs left behind where a bullet list used to be
|
|
out: list[str] = []
|
|
for ln in kept:
|
|
if not ln.strip() and out and not out[-1].strip():
|
|
continue
|
|
out.append(ln)
|
|
while out and not out[-1].strip():
|
|
out.pop()
|
|
return "\n".join(out)
|
|
|
|
|
|
def plan() -> tuple[list[tuple[dl.Record, Path]], dict[Path, str], dict[Path, int]]:
|
|
"""(record -> destination) pairs, and (source file -> preamble to keep)."""
|
|
sources = [p for p in dl.active_files() if p.exists()]
|
|
sources += sorted(p for p in dl.ARCHIVE_DIR.glob("*.md") if p.name not in dl._NON_DECISION_FILES)
|
|
moves: list[tuple[dl.Record, Path]] = []
|
|
preambles: dict[Path, str] = {}
|
|
kept_prose: dict[Path, int] = {} # ORIGINAL prose only, excluding the generated index
|
|
for src in sources:
|
|
if dl.RECORDS_DIR in src.parents or dl.has_frontmatter(src.read_text(encoding="utf-8")):
|
|
continue # already migrated
|
|
recs = [r for r in dl.parse_file(src) if r.heading not in dl.SKIP_HEADINGS]
|
|
keyed = [r for r in recs if r.key]
|
|
if len(keyed) != len(recs):
|
|
unkeyed = [r.heading for r in recs if not r.key]
|
|
raise SystemExit(f"{src}: {len(unkeyed)} record(s) without a key, cannot file by key: {unkeyed}")
|
|
moves += [(r, record_path(r)) for r in keyed]
|
|
body = _preamble(src, recs)
|
|
idx = _moved_index(keyed, src)
|
|
kept_prose[src] = len(body.splitlines())
|
|
preambles[src] = f"{body.rstrip()}\n\n{idx}" if idx else body
|
|
return moves, preambles, kept_prose
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--dry-run", action="store_true", help="report the plan, write nothing")
|
|
args = ap.parse_args(argv)
|
|
|
|
moves, preambles, kept_prose = plan()
|
|
|
|
dests = [d for _, d in moves]
|
|
if len(set(dests)) != len(dests):
|
|
dupes = sorted({str(d) for d in dests if dests.count(d) > 1})
|
|
raise SystemExit(f"destination collision — two records map to the same path: {dupes}")
|
|
|
|
print(f"{len(moves)} records -> {len({d.parent for d in dests})} directories")
|
|
idx_lines = sum(len(v.splitlines()) for v in preambles.values()) - sum(kept_prose.values())
|
|
print(
|
|
f"{len(preambles)} source files kept: {sum(kept_prose.values())} lines of original prose "
|
|
f"preserved, plus {idx_lines} generated index lines"
|
|
)
|
|
if args.dry_run:
|
|
for rec, dest in sorted(moves, key=lambda m: str(m[1]))[:5]:
|
|
print(f" {rec.key} -> {dest.relative_to(dl.REPO_ROOT)}")
|
|
print(" …")
|
|
return 0
|
|
|
|
for rec, dest in moves:
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
dest.write_text(render_record(rec), encoding="utf-8")
|
|
for src, text in preambles.items():
|
|
src.write_text(text.rstrip("\n") + "\n", encoding="utf-8")
|
|
print("migration written")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|