Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 35s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 57s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 37s
PR Gates / Docs update reminder (pull_request) Successful in 1m0s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
review-verdict/h10 Review-verdict: MERGEABLE @ a7d91bf (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 45s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m27s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m4s
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 7s
`docs.no-session-narrative` reaches every durable artifact, but its detector scanned only `docs/**/*.md` and root markdown, and nothing had ever swept the rest. The issue named four sites from one grep and called them a floor. Deriving the population instead — a whitespace-joined sweep over every tracked file outside the detector, for the detector's own phrasings plus the attribution and review-round class #812 found — gave 453 sites in 108 files at `fb5592971`, and a second pass for phrasings the first list missed (hyphenated `round-N`, "an earlier version", "the reviewer proved") added residuals in the same files. Every site was classified with #812's three dispositions (CUT / SEVER / KEEP with its sub-kind) under the who-benefits test; the per-site manifests are on the PR. The rejected designs, tested-and-rejected fixtures, measurements and traps stay; the attribution of who found them and the round in which they were found go. The detector's population grows to `.claude/`, `.gitea/`, `.husky/` and `scripts/` regardless of extension, minus the detector and its own test (whose fixtures ARE the phrasings) and minus `scripts/tests/fixtures/` (test data, including decision-record copies — the same reasoning as the records' own exemption, and what keeps the record's depth measurement true), and `--all` lists tracked REGULAR files only — a symlink's content is its target and a gitlink has none. The #812 argument for leaving `docs/superpowers/**` in the population runs the other way here: `--diff` sees only ADDED lines, and 287 of the 453 sites were under 30 days old — this corpus is where narrative is being added, so the advisory nudge has reach. Density agrees: 56 line-mode hits over the 113 regular files the predicate admits, against 9 over 66 docs files before #812. `web/` and C# stay out on the same measurement (3 of 74 PATTERNS-matching sites, ~4,600 files). The predicate did not grow: PATTERNS matched 74 of 453 sites, and widening the word list to the attribution class is the treadmill the withdrawn parity test ran on. The population oracle is restated over segments with the new arms, the synthetic cross product gains the process heads and non-markdown extensions, a fixture witnesses that a tracked symlink is neither scanned nor counted, a `.py.bak` axis separates a by-name exemption from a `startswith` over the same tuple, and eight mutants (drop the process arm, drop the by-name exemption, exempt by `startswith`, drop or add a prefix, drop the fixtures exemption, list only markdown, drop the symlink filter, test the mode per row instead of per path) each redden it. A pre-existing silent drop in `--diff` goes with it: git tab-terminates a `+++` filename that contains a space, and the kept tab made `is_scanned_path` refuse the file with no notice — fixed, with a positive control and its own mutant. Code is unchanged by construction, measured per file type against `origin/main`: Python modules are AST-equal with docstrings stripped, except `#` lines inside the embedded fixture programs (string literals) of three test modules; workflows differ only in `#` lines inside `run:` block scalars; shell, C#, TypeScript and jq are equal with comment lines stripped. The stated exceptions: the detector and its test, 26 vitest titles that carried review-round or severity labels or a reviewer attribution (call sites whose title changed — every changed title line walked back to its `it(` / `it.each(...)(` anchor, so a `' + '` concatenation counts once), two registry note strings and the mutation manifest's prose fields. scripts/tests: 1565 passed. Web: lint, typecheck, 1319 tests green. Closes #876. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEcBoFw7ctrf3Nb7R7x7wk
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.** Migrating the real
|
|
`docs/` tree would make this a one-shot: once the real migration landed, the tree is already
|
|
split, the harness has nothing to move, and the tests fail for a reason that has 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. PROVED: 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.
|
|
|
|
Asserting existence from `docs/decisions/` regardless of where
|
|
the link sits is the wrong base for `docs/decisions.md` (parent `docs/`) and for the
|
|
archive files (parent `docs/decisions/archive/`). It encodes the very bug this is
|
|
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])
|