The migration is a MOVE, so correctness is provable rather than reviewable: parse the corpus before, migrate, parse after, assert the Record sets are identical field-for-field with byte-identical rationale. scripts/migrate_decisions_split.py 166 records -> docs/decisions/records/<area>/<topic>.md (active) or docs/decisions/archive/<area>/<topic>.md (superseded/retired), 26 directories. Path is DERIVED from the key, so identity stays the key. Refuses to run if any record lacks a key, and aborts on a destination collision. Source files are KEPT, stripped to their narrative -- not deleted. 506 lines of the corpus sit outside any record: decisions.md's lifecycle-schema header (the status vocabulary, supersession rules, the edit-token contract) and each topic file's preamble explaining why those records exist. For most of it that is the only copy. It also cannot be filed per-area -- topic files hold several areas and 4 of 23 areas span several files -- so the files themselves stay. scripts/tests/test_migration_equivalence.py Runs the real migration against a COPY of the real corpus in tmp_path, never the working tree. Asserts: all 166 records survive with the same keys, every metadata field round-trips, titles round-trip from headings, every rationale body is byte-identical, path matches key, archived records land in the archive wing, the legacy files keep their narrative, and no parseable record is left behind in them. Proven non-vacuous: corrupting one migrated record's prose is caught by the byte-identical check, and deleting one is caught by the survival check. One test-authoring note: an early assertion string-matched "## " to prove no records were left in decisions.md. That is wrong -- the schema header quotes an illustrative "## 2026-07-17 ..." example in prose. Whether records remain is a PARSING question, so the parser-based leftover test is the real invariant.
157 lines
5.3 KiB
Python
157 lines
5.3 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.
|
|
|
|
These tests run the real migration against a COPY of the real corpus in tmp_path. They never touch
|
|
the working tree.
|
|
"""
|
|
|
|
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
|
|
|
|
COMPARED_FIELDS = (
|
|
"key",
|
|
"status",
|
|
"since",
|
|
"supersedes",
|
|
"superseded_by",
|
|
"stale_after",
|
|
"rule",
|
|
"signals",
|
|
"mechanics",
|
|
"sources",
|
|
)
|
|
_SKIP = {"Index", "Active catalog", "Contents"}
|
|
|
|
|
|
def _corpus_files() -> list[Path]:
|
|
files = [p for p in dl.active_files() if p.exists()]
|
|
files += [p for p in sorted(dl.ARCHIVE_DIR.glob("*.md")) if p.name not in dl._NON_DECISION_FILES]
|
|
return files
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def migrated(tmp_path_factory):
|
|
"""Copy the real docs/ into tmp, point decisions_lib at it, run the migration."""
|
|
tmp = tmp_path_factory.mktemp("corpus")
|
|
shutil.copytree(dl.REPO_ROOT / "docs", tmp / "docs")
|
|
|
|
before = {}
|
|
for f in _corpus_files():
|
|
for r in dl.parse_file(f):
|
|
if r.key and r.heading not in _SKIP:
|
|
before[r.key] = (r, mig._rationale_body(r))
|
|
|
|
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:
|
|
mig.main([])
|
|
after_files = [p for p in dl.RECORDS_DIR.rglob("*.md")] + [p for p in dl.ARCHIVE_DIR.rglob("*.md")]
|
|
after = {}
|
|
for f in after_files:
|
|
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_every_record_survives_with_the_same_key(migrated):
|
|
before, after, _ = migrated
|
|
assert before, "harness parsed nothing before the migration — would pass vacuously"
|
|
assert set(before) == set(after), (
|
|
f"lost: {sorted(set(before) - set(after))[:5]} / gained: {sorted(set(after) - set(before))[:5]}"
|
|
)
|
|
|
|
|
|
def test_record_count_is_the_expected_166(migrated):
|
|
before, after, _ = migrated
|
|
assert len(before) == len(after) == 166
|
|
|
|
|
|
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[:10])
|
|
|
|
|
|
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[:10])
|
|
|
|
|
|
def test_rationale_bodies_are_byte_identical(migrated):
|
|
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, e.g. {diffs[:5]}"
|
|
|
|
|
|
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[:10])
|
|
|
|
|
|
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):
|
|
"""The ~500 lines of preamble are the only copy of that prose — they must survive."""
|
|
_, _, tmp = migrated
|
|
kept = tmp / "docs" / "decisions.md"
|
|
assert kept.exists(), "decisions.md was deleted — its lifecycle schema is the only copy"
|
|
text = kept.read_text()
|
|
assert "Lifecycle, not append-only" in text
|
|
# NB: do NOT string-match on "## " here — the schema header quotes an illustrative
|
|
# "## 2026-07-17 — …" example in prose. Whether real records remain is a PARSING question,
|
|
# covered by test_no_records_remain_in_the_legacy_files.
|
|
wp = tmp / "docs" / "decisions" / "workflow-process.md"
|
|
assert wp.exists() and "extracted from" in wp.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
|