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
209 lines
7.8 KiB
Python
209 lines
7.8 KiB
Python
"""Equivalence harness for the #610 one-file-per-record migration.
|
|
|
|
The migration is a MOVE: content relocates, nothing is rewritten. That makes correctness
|
|
*provable* rather than reviewable — parse the corpus before, run the migration, parse it after,
|
|
and assert the two Record sets are identical field-for-field with byte-identical rationale.
|
|
|
|
**Runs against a checked-in FIXTURE, not the live corpus.** An earlier version migrated the real
|
|
`docs/` tree, which made it a one-shot: the moment the real migration landed, the tree was already
|
|
split, the harness had nothing to move, and the tests failed for a reason that had nothing to do
|
|
with the code under test. A fixture keeps these tests exercising the *script* rather than the
|
|
repo's current state, so they stay meaningful and green after the migration ships.
|
|
|
|
The one-off proof against the real corpus was run separately at migration time: 168 records before
|
|
and after, zero lost, zero gained, zero field mismatches, zero rationale bodies differing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import scripts.decisions_lib as dl
|
|
import scripts.migrate_decisions_split as mig
|
|
|
|
|
|
def _independent_strip(rec: dl.Record) -> str:
|
|
"""Strip the metadata block WITHOUT reusing the migration's own helper.
|
|
|
|
Deliberately a second implementation. Computing the "before" baseline with
|
|
`mig._rationale_body` made the byte-identity proof circular: a bug in that one function
|
|
corrupts both sides equally and the assertion still passes. Review proved it — collapsing
|
|
interior blank lines there left all harness tests green.
|
|
"""
|
|
lines = rec.body.split("\n")
|
|
while lines and not lines[0].strip():
|
|
lines.pop(0)
|
|
if lines and lines[0].lstrip().startswith("`key:"):
|
|
lines.pop(0)
|
|
while lines and lines[0].strip():
|
|
lines.pop(0)
|
|
return "\n".join(lines).strip()
|
|
|
|
|
|
FIXTURE = Path(__file__).parent / "fixtures" / "premigration"
|
|
|
|
COMPARED_FIELDS = (
|
|
"key",
|
|
"status",
|
|
"since",
|
|
"supersedes",
|
|
"superseded_by",
|
|
"stale_after",
|
|
"rule",
|
|
"signals",
|
|
"mechanics",
|
|
"sources",
|
|
)
|
|
|
|
# The fixture is static, so an exact count is a real completeness guard here rather than a number
|
|
# that rots every time a record lands on main.
|
|
EXPECTED_RECORDS = 4
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def migrated(tmp_path_factory):
|
|
"""Copy the fixture corpus to tmp, point decisions_lib at it, run the real migration."""
|
|
tmp = tmp_path_factory.mktemp("corpus")
|
|
shutil.copytree(FIXTURE, tmp / "docs")
|
|
|
|
orig = (dl.REPO_ROOT, dl.DECISIONS_MD, dl.TOPIC_DIR, dl.ARCHIVE_DIR, dl.RECORDS_DIR)
|
|
dl.REPO_ROOT = tmp
|
|
dl.DECISIONS_MD = tmp / "docs" / "decisions.md"
|
|
dl.TOPIC_DIR = tmp / "docs" / "decisions"
|
|
dl.ARCHIVE_DIR = dl.TOPIC_DIR / "archive"
|
|
dl.RECORDS_DIR = dl.TOPIC_DIR / "records"
|
|
try:
|
|
before = {}
|
|
for f in [dl.DECISIONS_MD, *sorted(dl.TOPIC_DIR.glob("*.md")), *sorted(dl.ARCHIVE_DIR.rglob("*.md"))]:
|
|
for r in dl.parse_file(f):
|
|
if r.key and r.heading not in dl.SKIP_HEADINGS:
|
|
before[r.key] = (r, _independent_strip(r))
|
|
|
|
mig.main([])
|
|
|
|
after = {}
|
|
for f in [*dl.RECORDS_DIR.rglob("*.md"), *dl.ARCHIVE_DIR.rglob("*.md")]:
|
|
for r in dl.parse_file(f):
|
|
if r.key:
|
|
after[r.key] = (r, r.body)
|
|
yield before, after, tmp
|
|
finally:
|
|
dl.REPO_ROOT, dl.DECISIONS_MD, dl.TOPIC_DIR, dl.ARCHIVE_DIR, dl.RECORDS_DIR = orig
|
|
|
|
|
|
def test_fixture_is_actually_pre_migration(migrated):
|
|
"""Guard against the harness silently testing nothing if the fixture ever gets migrated."""
|
|
before, _, _ = migrated
|
|
assert len(before) == EXPECTED_RECORDS, f"fixture parsed {len(before)}, expected {EXPECTED_RECORDS}"
|
|
|
|
|
|
def test_every_record_survives_with_the_same_key(migrated):
|
|
before, after, _ = migrated
|
|
assert set(before) == set(after), (
|
|
f"lost: {sorted(set(before) - set(after))} / gained: {sorted(set(after) - set(before))}"
|
|
)
|
|
|
|
|
|
def test_every_metadata_field_round_trips(migrated):
|
|
before, after, _ = migrated
|
|
diffs = []
|
|
for key, (b, _) in before.items():
|
|
a, _ = after[key]
|
|
for f in COMPARED_FIELDS:
|
|
if getattr(b, f) != getattr(a, f):
|
|
diffs.append(f"{key}.{f}: {getattr(b, f)!r} -> {getattr(a, f)!r}")
|
|
assert not diffs, "\n".join(diffs)
|
|
|
|
|
|
def test_title_round_trips_from_the_heading(migrated):
|
|
before, after, _ = migrated
|
|
diffs = [
|
|
f"{k}: {b.heading!r} -> {after[k][0].heading!r}"
|
|
for k, (b, _) in before.items()
|
|
if b.heading != after[k][0].heading
|
|
]
|
|
assert not diffs, "\n".join(diffs)
|
|
|
|
|
|
def test_rationale_bodies_are_byte_identical(migrated):
|
|
"""Including interior blank lines and backticks — the bytes are the payload."""
|
|
before, after, _ = migrated
|
|
diffs = [k for k, (_, body) in before.items() if body != after[k][1]]
|
|
assert not diffs, f"{len(diffs)} rationale bodies changed: {diffs}"
|
|
|
|
|
|
def test_path_matches_key(migrated):
|
|
_, after, _tmp = migrated
|
|
bad = []
|
|
for key, (rec, _) in after.items():
|
|
area, _, topic = key.partition(".")
|
|
if rec.source.parent.name != area or rec.source.stem != topic:
|
|
bad.append(f"{key} -> {rec.source}")
|
|
assert not bad, "\n".join(bad)
|
|
|
|
|
|
def test_archived_records_land_in_the_archive_wing(migrated):
|
|
_, after, _tmp = migrated
|
|
misfiled = [
|
|
key
|
|
for key, (rec, _) in after.items()
|
|
if (rec.status in ("superseded", "retired")) != ("archive" in rec.source.parts)
|
|
]
|
|
assert not misfiled, misfiled
|
|
|
|
|
|
def test_source_files_are_kept_and_retain_their_narrative(migrated):
|
|
"""Preamble prose is often the only copy — it must survive, not be deleted with the records."""
|
|
_, _, tmp = migrated
|
|
kept = tmp / "docs" / "decisions.md"
|
|
assert kept.exists(), "decisions.md was deleted"
|
|
assert "the only copy of it" in kept.read_text()
|
|
topic = tmp / "docs" / "decisions" / "topic.md"
|
|
assert topic.exists() and "the only copy of this narrative" in topic.read_text()
|
|
|
|
|
|
def test_no_records_remain_in_the_legacy_files(migrated):
|
|
_, _, tmp = migrated
|
|
leftovers = {}
|
|
for f in [tmp / "docs" / "decisions.md", *sorted((tmp / "docs" / "decisions").glob("*.md"))]:
|
|
if f.name in dl._NON_DECISION_FILES:
|
|
continue
|
|
keyed = [r.key for r in dl.parse_file(f) if r.key]
|
|
if keyed:
|
|
leftovers[f.name] = keyed
|
|
assert not leftovers, leftovers
|
|
|
|
|
|
def test_same_file_anchor_bullets_are_dropped(migrated):
|
|
"""The split makes them WRONG, not merely stale — they point at headings that left the file."""
|
|
_, _, tmp = migrated
|
|
assert "](#" not in (tmp / "docs" / "decisions.md").read_text(), "a same-file anchor now dangles"
|
|
|
|
|
|
def test_every_generated_link_resolves_from_its_own_file(migrated):
|
|
"""Resolve each link relative to the file it LIVES IN — the base the reader's browser uses.
|
|
|
|
The earlier version of this test asserted existence from `docs/decisions/` regardless of where
|
|
the link sat, which is the wrong base for `docs/decisions.md` (parent `docs/`) and for the
|
|
archive files (parent `docs/decisions/archive/`). It therefore encoded the very bug it was
|
|
meant to catch: 105 links dangled while it stayed green.
|
|
"""
|
|
_, _, tmp = migrated
|
|
import re as _re
|
|
|
|
dangling = []
|
|
checked = 0
|
|
for f in tmp.rglob("*.md"):
|
|
if mig._MOVED_HEADING not in f.read_text():
|
|
continue
|
|
section = f.read_text().split(mig._MOVED_HEADING, 1)[1]
|
|
for link in _re.findall(r"\]\(([^)#]+)\)", section):
|
|
checked += 1
|
|
if not (f.parent / link).resolve().exists():
|
|
dangling.append(f"{f.relative_to(tmp)} -> {link}")
|
|
assert checked, "no generated links found — test would pass vacuously"
|
|
assert not dangling, f"{len(dangling)} dangling of {checked}:\n" + "\n".join(dangling[:10])
|