import subprocess from pathlib import Path from typing import Any import pytest import scripts.decisions_lib as dl import scripts.decisions_validate as dv def _rec(**kw: Any) -> dl.Record: base: dict[str, Any] = dict( heading="H", source=Path("x"), lineno=1, status="active", since="2026-01-01", supersedes="none", superseded_by="none", signals="concept · paths: a/b.py · issues: #1", ) base.update(kw) return dl.Record(**base) def _v(recs: list[dl.Record], **kw: Any) -> list[str]: args: dict[str, Any] = dict( archive_keys=set(), catalog_ok=True, removed=[], rewritten=[], archive_records=[], demoted=[], ) args.update(kw) return dv.validate(recs, **args) def test_two_active_same_key_fails(): assert any("more than one active" in e for e in _v([_rec(key="a.b"), _rec(key="a.b")])) def test_bad_key_format_fails(): assert any("key format" in e for e in _v([_rec(key="BadKey")])) def test_dangling_superseded_by_fails(): recs = [_rec(key="a.b", status="superseded", superseded_by="a.c@2026-07-01")] assert any("superseded-by" in e and "a.c" in e for e in _v(recs)) def test_superseded_by_resolves_to_archive_key_passes(): # successor lives in archive → known via archive_keys, no dangling error recs = [_rec(key="a.b", status="superseded", superseded_by="a.c@2026-07-01")] assert not any("superseded-by" in e for e in _v(recs, archive_keys={"a.c"})) def test_reciprocal_superseded_by_with_no_back_link_fails(): # B exists (has a record) but its `supersedes` does not point back to A → dangling back-link. a = _rec(heading="A", key="a.b", status="superseded", superseded_by="a.c@2026-07-01") b = _rec(heading="B", key="a.c", status="active", supersedes="none") assert any("does not point back" in e and "a.c" in e for e in _v([a], archive_records=[b], archive_keys={"a.c"})) def test_reciprocal_supersession_correct_pair_passes(): # active supersedes archived; archived is superseded-by active → reciprocal, no errors. active = _rec(heading="Active", key="a.b", status="active", supersedes="a.c@2026-01-01") archived = _rec(heading="Archived", key="a.c", status="superseded", superseded_by="a.b@2026-07-01") errs = _v([active], archive_records=[archived], archive_keys={"a.c"}) assert errs == [] def test_archive_to_archive_reciprocal_pair_passes(): # A twice-reversed decision: both records live in the archive wing, pointing at each other. a = _rec(heading="A", key="a.b", status="superseded", superseded_by="a.c@2026-07-01") b = _rec(heading="B", key="a.c", status="retired", supersedes="a.b@2026-07-01") errs = _v([], archive_records=[a, b], archive_keys={"a.b", "a.c"}) assert errs == [] def test_archive_to_archive_broken_back_link_fails(): # A.superseded-by=B but B.supersedes=none → the back-link is broken, and neither side is active # (so this could previously only be caught by iterating archive_records, not decision_recs). a = _rec(heading="A", key="a.b", status="superseded", superseded_by="a.c@2026-07-01") b = _rec(heading="B", key="a.c", status="retired", supersedes="none") errs = _v([], archive_records=[a, b], archive_keys={"a.b", "a.c"}) assert any("does not point back" in e and "a.c" in e for e in errs) def test_active_record_with_superseded_by_fails(): recs = [_rec(key="a.b", status="active", superseded_by="a.c@2026-07-01")] assert any("active record cannot already be superseded" in e for e in _v(recs, archive_keys={"a.c"})) def test_removed_active_not_in_archive_fails(): assert any("removed from the active set" in e for e in _v([], removed=["2026-01-01 — Gone (#9)"])) def test_rewritten_rationale_without_token_fails(): assert any( "rationale" in e and "Decisions-Edit" in e for e in _v([_rec(key="a.b")], rewritten=["2026-01-01 — Reworded (#9)"]) ) def test_clean_corpus_passes(): assert _v([_rec(key="a.b"), _rec(key="c.d")]) == [] def test_superseded_record_in_active_fails(): recs = [_rec(key="a.b", status="superseded", superseded_by="a.c@2026-01-01")] assert any("relocate to docs/decisions/archive/" in e for e in _v(recs, archive_keys={"a.c"})) def test_active_record_in_archive_fails(): assert any( "must not live under docs/decisions/archive/" in e for e in _v([], archive_records=[_rec(key="a.b", status="active")]) ) def test_missing_since_fails(): rec = dl.Record( heading="H", source=Path("x"), lineno=1, key="a.b", status="active", since=None, supersedes="none", superseded_by="none", ) assert any("missing required metadata since" in e for e in _v([rec])) def test_missing_signals_fails(): # `**Signals:**` is required (ersatztv#545): it is what MemPalace's keyword recall matches on, so # a record without it ingests with weak metadata and under-surfaces ("no convention exists"). assert any("missing required metadata signals" in e for e in _v([_rec(key="a.b", signals=None)])) def test_empty_signals_fails(): assert any("missing required metadata signals" in e for e in _v([_rec(key="a.b", signals="")])) def test_signals_present_passes(): assert not any("signals" in e for e in _v([_rec(key="a.b", signals="concept · paths: x.py · issues: #1")])) def test_demoted_heading_fails(): assert any( "demoted to legacy-unmigrated" in e for e in _v([_rec(key="a.b")], demoted=["2026-01-01 — Some decision (#1)"]) ) def test_duplicate_metadata_block_fails(): # A migration bug left records with two stacked metadata blocks; the parser only reads the # first, so only an explicit body scan (metadata_line_count) can catch the leftover second block. body = ( "`key: a.b` · `status: active` · `since: 2026-01-01` · `supersedes: none` · `superseded-by: none`\n" "**Rule:** first block rule.\n" "\n" "`key: a.b` · `status: active` · `since: 2026-01-01` · `supersedes: none` · `superseded-by: none`\n" "**Rule:** second block rule.\n" "\n" "Rationale prose goes here." ) rec = _rec(key="a.b", body=body) assert dl.metadata_line_count(rec) == 2 assert any("duplicate metadata block" in e and "2 metadata blocks" in e for e in _v([rec])) def test_single_metadata_block_passes(): body = ( "`key: a.b` · `status: active` · `since: 2026-01-01` · `supersedes: none` · `superseded-by: none`\n" "**Rule:** the only rule.\n" "\n" "Rationale prose goes here." ) rec = _rec(key="a.b", body=body) assert dl.metadata_line_count(rec) == 1 assert not any("duplicate metadata block" in e for e in _v([rec])) def test_contents_heading_is_skipped(): # docs/decisions/*.md topic files use "## Contents" as their index heading (the analog of # decisions.md's "## Index") — it carries no metadata and must not be miscounted as a # legacy-unmigrated record, same as "Index" already isn't. assert "Contents" in dv.SKIP_HEADINGS rec = dl.Record( heading="Contents", source=Path("x"), lineno=1, status="legacy-unmigrated", since=None, supersedes=None, superseded_by=None, ) # validate() itself excludes SKIP_HEADINGS from decision_recs, so a bare Contents record # produces no errors (it isn't checked for required metadata, bad status, etc.) assert _v([rec]) == [] # mirrors main()'s legacy-unmigrated count filter unmigrated = [r for r in [rec] if r.status == "legacy-unmigrated" and r.heading not in dv.SKIP_HEADINGS] assert unmigrated == [] def _git(cwd: Path, *args: str) -> None: subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True) def _write_decisions(path: Path, *, status: str, since: str, rationale: str) -> None: path.write_text( "## 2026-01-01 — Some decision (#1)\n" f"`key: a.b` · `status: {status}` · `since: {since}` · `supersedes: none` · `superseded-by: none`\n" "**Rule:** one-line current rule.\n" "**Signals:** concept · paths: a/b.py · issues: #1\n" "**Mechanics:** docs/foo.md\n" "\n" f"{rationale}\n", encoding="utf-8", ) def test_diff_engine_detects_rationale_rewrite_without_token(tmp_path, monkeypatch): repo = tmp_path / "repo" (repo / "docs").mkdir(parents=True) _git(tmp_path, "init", str(repo)) _git(repo, "config", "user.email", "t@example.com") _git(repo, "config", "user.name", "T") _write_decisions(repo / "docs" / "decisions.md", status="active", since="2026-01-01", rationale="Original prose.") _git(repo, "add", "-A") _git(repo, "commit", "-m", "base") monkeypatch.chdir(repo) # (a) rewrite rationale prose only, no token _write_decisions( repo / "docs" / "decisions.md", status="active", since="2026-01-01", rationale="**Rule:** this looks like metadata but is prose appended later.", ) _git(repo, "add", "-A") _git(repo, "commit", "-m", "rewrite rationale") removed, rewritten, *_rest = dv._diff_findings("HEAD~1", "HEAD") assert removed == [] assert "2026-01-01 — Some decision (#1)" in rewritten def _rewrite_repo(tmp_path: Path, monkeypatch, *commit_msg_paragraphs: str) -> tuple[list[str], list[str]]: """Seed a repo, rewrite the record's rationale under `commit_msg_paragraphs`, return (removed, rewritten). Each paragraph is a separate `-m`, which is how git composes a message with a trailer block: the LAST paragraph is the only one git parses for trailers (verified against git 2.55). """ repo = tmp_path / "repo" (repo / "docs").mkdir(parents=True) _git(tmp_path, "init", str(repo)) _git(repo, "config", "user.email", "t@example.com") _git(repo, "config", "user.name", "T") _write_decisions(repo / "docs" / "decisions.md", status="active", since="2026-01-01", rationale="Original prose.") _git(repo, "add", "-A") _git(repo, "commit", "-m", "base") monkeypatch.chdir(repo) _write_decisions(repo / "docs" / "decisions.md", status="active", since="2026-01-01", rationale="Reworded prose.") _git(repo, "add", "-A") _git(repo, "commit", *[a for p in commit_msg_paragraphs for a in ("-m", p)]) removed, rewritten, *_rest = dv._diff_findings("HEAD~1", "HEAD") return removed, rewritten _RECORD = "2026-01-01 — Some decision (#1)" def test_diff_engine_allows_rewrite_with_trailer(tmp_path, monkeypatch): removed, rewritten = _rewrite_repo(tmp_path, monkeypatch, "rewrite rationale", "Decisions-Edit: yes") assert removed == [] assert rewritten == [] def test_diff_engine_trailer_is_case_insensitive(tmp_path, monkeypatch): _removed, rewritten = _rewrite_repo(tmp_path, monkeypatch, "rewrite rationale", "Decisions-Edit: YES") assert rewritten == [] def test_diff_engine_trailer_coexists_with_coauthor(tmp_path, monkeypatch): # the repo's `commit-msg` hook mandates a Co-Authored-By trailer, so the real-world shape of an # armed commit is a multi-trailer block — the marker must survive sharing it. _removed, rewritten = _rewrite_repo( tmp_path, monkeypatch, "rewrite rationale", "Decisions-Edit: yes\nCo-Authored-By: Someone ", ) assert rewritten == [] def test_diff_engine_prose_mention_of_retired_token_does_not_arm(tmp_path, monkeypatch): """ersatztv#609's live failure: a message *describing* the marker disarmed the whole body-diff. This is the negative control the bare-substring matcher could not pass — under it, `token` went True and all three rewrite comparisons were skipped while CI still reported green. """ _removed, rewritten = _rewrite_repo( tmp_path, monkeypatch, "docs: explain the guard", "No [decisions-edit] marker is needed here, because this commit only moves a record.", ) assert _RECORD in rewritten def test_diff_engine_quoted_trailer_mid_body_does_not_arm(tmp_path, monkeypatch): """The same class one level up: a commit that QUOTES an armed message as an example. Git parses trailers only in the final paragraph, so a `Decisions-Edit: yes` line followed by more prose is documentation, not a marker. Docs commits in this repo routinely carry such examples. """ _removed, rewritten = _rewrite_repo( tmp_path, monkeypatch, "docs: document how to arm the guard", "Contributors write:", "Decisions-Edit: yes", "...as the final paragraph of the message, alongside Co-Authored-By.", ) assert _RECORD in rewritten def test_diff_engine_negative_trailer_value_does_not_arm(tmp_path, monkeypatch): # `Decisions-Edit: no` records a deliberate NON-edit; reading it as consent would recreate #609. _removed, rewritten = _rewrite_repo(tmp_path, monkeypatch, "rewrite rationale", "Decisions-Edit: no") assert _RECORD in rewritten def test_diff_engine_folded_trailer_continuation_does_not_arm(tmp_path, monkeypatch): """A folded value must be judged whole: `no` + continuation ` yes` is the value "no yes". Without `unfold`, git emits the continuation as its own line and ` yes`.strip() arms the exemption on its own — inverting the author's explicit `no`. """ _removed, rewritten = _rewrite_repo(tmp_path, monkeypatch, "rewrite rationale", "Decisions-Edit: no\n yes") assert _RECORD in rewritten def test_diff_engine_merge_commit_body_does_not_arm(tmp_path, monkeypatch): # On a pull_request event, actions/checkout lands on a synthetic merge commit whose body the # forge composes (quoting the PR description) — a trailer parsed out of it is not an author's # deliberate marker, so merge commits are excluded from the range. repo = tmp_path / "repo" (repo / "docs").mkdir(parents=True) _git(tmp_path, "init", str(repo)) _git(repo, "config", "user.email", "t@example.com") _git(repo, "config", "user.name", "T") _write_decisions(repo / "docs" / "decisions.md", status="active", since="2026-01-01", rationale="Original prose.") _git(repo, "add", "-A") _git(repo, "commit", "-m", "base") monkeypatch.chdir(repo) base = subprocess.run( ["git", "rev-parse", "HEAD"], cwd=repo, check=True, capture_output=True, text=True ).stdout.strip() _git(repo, "checkout", "-q", "-b", "feature") _write_decisions(repo / "docs" / "decisions.md", status="active", since="2026-01-01", rationale="Reworded prose.") _git(repo, "add", "-A") _git(repo, "commit", "-m", "rewrite rationale") # the author never armed anything # the forge's synthetic merge, whose body quotes a PR description ending in an example marker _git(repo, "checkout", "-q", base) _git(repo, "merge", "--no-ff", "-q", "feature", "-m", "Merge pull request #1", "-m", "Decisions-Edit: yes") removed, rewritten, *_rest = dv._diff_findings(base, "HEAD") assert removed == [] assert _RECORD in rewritten def test_diff_engine_retired_bracket_token_no_longer_arms(tmp_path, monkeypatch): # the pre-#609 habit (token appended to the subject) is retired, not silently honored. _removed, rewritten = _rewrite_repo(tmp_path, monkeypatch, "rewrite rationale [decisions-edit]") assert _RECORD in rewritten def test_diff_engine_detects_appended_smuggled_rationale(tmp_path, monkeypatch): repo = tmp_path / "repo" (repo / "docs").mkdir(parents=True) _git(tmp_path, "init", str(repo)) _git(repo, "config", "user.email", "t@example.com") _git(repo, "config", "user.name", "T") _write_decisions(repo / "docs" / "decisions.md", status="active", since="2026-01-01", rationale="Original prose.") _git(repo, "add", "-A") _git(repo, "commit", "-m", "base") monkeypatch.chdir(repo) # append-shape exploit: original prose line is PRESERVED, a smuggled **Rule:** paragraph is # appended after a blank line, no edit marker on the commit. The old buggy _rationale() strips # ANY `**Rule:**`/`**Signals:**`/`**Mechanics:**`/`` `key:`` line wherever it appears, so it # silently strips the appended line too → base==head → bypass succeeds. The bounded strip only # removes the contiguous top metadata block, so the appended paragraph survives as prose → # base!=head → flagged. _write_decisions( repo / "docs" / "decisions.md", status="active", since="2026-01-01", rationale="Original prose.\n\n**Rule:** smuggled rewrite that changes the actual meaning.", ) _git(repo, "add", "-A") _git(repo, "commit", "-m", "append smuggled rewrite") removed, rewritten, *_rest = dv._diff_findings("HEAD~1", "HEAD") assert removed == [] assert "2026-01-01 — Some decision (#1)" in rewritten def test_diff_engine_metadata_only_edit_is_free(tmp_path, monkeypatch): repo = tmp_path / "repo" (repo / "docs").mkdir(parents=True) _git(tmp_path, "init", str(repo)) _git(repo, "config", "user.email", "t@example.com") _git(repo, "config", "user.name", "T") _write_decisions(repo / "docs" / "decisions.md", status="active", since="2026-01-01", rationale="Original prose.") _git(repo, "add", "-A") _git(repo, "commit", "-m", "base") monkeypatch.chdir(repo) # metadata-only edit: status/since change, prose unchanged, no token _write_decisions( repo / "docs" / "decisions.md", status="superseded", since="2026-02-01", rationale="Original prose." ) _git(repo, "add", "-A") _git(repo, "commit", "-m", "metadata edit") removed, rewritten, *_rest = dv._diff_findings("HEAD~1", "HEAD") assert removed == [] assert rewritten == [] def _write_archive(path: Path, *, rationale: str) -> None: path.write_text( "## 2026-01-01 — Some archived decision (#2)\n" "`key: a.z` · `status: superseded` · `since: 2026-01-01` · `supersedes: none` · " "`superseded-by: a.b@2026-07-01`\n" "**Rule:** one-line historical rule.\n" "**Signals:** concept · paths: a/z.py · issues: #2\n" "**Mechanics:** docs/foo.md\n" "\n" f"{rationale}\n", encoding="utf-8", ) def test_diff_engine_detects_archive_rationale_rewrite_without_token(tmp_path, monkeypatch): repo = tmp_path / "repo" (repo / "docs" / "decisions" / "archive").mkdir(parents=True) _git(tmp_path, "init", str(repo)) _git(repo, "config", "user.email", "t@example.com") _git(repo, "config", "user.name", "T") archive_path = repo / "docs" / "decisions" / "archive" / "x.md" _write_archive(archive_path, rationale="Original archived prose.") _git(repo, "add", "-A") _git(repo, "commit", "-m", "base") monkeypatch.chdir(repo) _write_archive(archive_path, rationale="Rewritten archived prose, no token.") _git(repo, "add", "-A") _git(repo, "commit", "-m", "rewrite archived rationale") _removed, rewritten, *_rest = dv._diff_findings("HEAD~1", "HEAD") assert "2026-01-01 — Some archived decision (#2)" in rewritten def test_diff_engine_detects_archive_record_removed(tmp_path, monkeypatch): repo = tmp_path / "repo" (repo / "docs" / "decisions" / "archive").mkdir(parents=True) _git(tmp_path, "init", str(repo)) _git(repo, "config", "user.email", "t@example.com") _git(repo, "config", "user.name", "T") archive_path = repo / "docs" / "decisions" / "archive" / "x.md" _write_archive(archive_path, rationale="Original archived prose.") _git(repo, "add", "-A") _git(repo, "commit", "-m", "base") monkeypatch.chdir(repo) archive_path.unlink() _git(repo, "add", "-A") _git(repo, "commit", "-m", "delete archived record") removed, _rewritten, *_rest = dv._diff_findings("HEAD~1", "HEAD") assert "2026-01-01 — Some archived decision (#2)" in removed def test_diff_engine_demoted_migrated_to_legacy_unmigrated(tmp_path, monkeypatch): repo = tmp_path / "repo" (repo / "docs").mkdir(parents=True) _git(tmp_path, "init", str(repo)) _git(repo, "config", "user.email", "t@example.com") _git(repo, "config", "user.name", "T") decisions_path = repo / "docs" / "decisions.md" _write_decisions(decisions_path, status="active", since="2026-01-01", rationale="Original prose.") _git(repo, "add", "-A") _git(repo, "commit", "-m", "base") monkeypatch.chdir(repo) # same heading, metadata block stripped: prose survives, migration silently reverted. decisions_path.write_text( "## 2026-01-01 — Some decision (#1)\n\nOriginal prose.\n", encoding="utf-8", ) _git(repo, "add", "-A") _git(repo, "commit", "-m", "demote to legacy-unmigrated") _removed, _rewritten, demoted = dv._diff_findings("HEAD~1", "HEAD") assert "2026-01-01 — Some decision (#1)" in demoted # ---- optional stale-after / Sources metadata (ersatztv#603) ---- def test_stale_after_absent_is_not_an_error(): assert _v([_rec(key="a.b")]) == [] def test_wellformed_stale_after_is_not_an_error(): assert _v([_rec(key="a.b", stale_after="2027-01-01")]) == [] def test_malformed_stale_after_fails(): for bad in ("2027-13-01", "01-01-2027", "soon", "2027-02-30"): errs = _v([_rec(key="a.b", stale_after=bad)]) assert any("not a YYYY-MM-DD date" in e for e in errs), bad def test_past_due_record_does_not_fail_validation(): """A record going stale is the passage of time, not a defect in the commit under test.""" errs = _v([_rec(key="a.b", stale_after="2020-01-01")]) assert errs == [] def test_stale_records_reports_only_past_due_active(): today = dv.date(2026, 7, 25) recs = [ _rec(key="past.due", heading="Past", stale_after="2026-07-24"), _rec(key="due.today", heading="Today", stale_after="2026-07-25"), # today >= date → stale _rec(key="not.yet", heading="Future", stale_after="2026-07-26"), _rec(key="no.date", heading="Undated"), _rec(key="arch.ived", heading="Archived", status="superseded", stale_after="2020-01-01"), ] assert dv.stale_records(recs, today) == [("Past", "2026-07-24"), ("Today", "2026-07-25")] def test_stale_records_ignores_malformed_date(): """A malformed date is caught as a blocking format error; it must not also crash the notice path.""" assert dv.stale_records([_rec(key="a.b", stale_after="whenever")], dv.date(2026, 7, 25)) == [] def test_sources_is_optional_and_never_required(): assert _v([_rec(key="a.b")]) == [] assert _v([_rec(key="a.b", sources="ci run 1234 · docs/ci-cd.md")]) == [] def test_empty_stale_after_is_malformed_not_absent(): """A blank/truncated value must not pass as 'absent' — it would silently never fire.""" errs = _v([_rec(key="a.b", stale_after="")]) assert any("not a YYYY-MM-DD date" in e for e in errs) def test_noncanonical_iso_forms_are_rejected(): """date.fromisoformat accepts these on py>=3.11; the field's contract is YYYY-MM-DD only. Accepting them would also break the catalog's Review-due sort, which orders on the raw string. """ for bad in ("20270101", "2027-W01-1", "2027-01-01T00:00:00", " 2027-01-01 extra"): errs = _v([_rec(key="a.b", stale_after=bad)]) assert any("not a YYYY-MM-DD date" in e for e in errs), bad def test_malformed_stale_after_on_an_archive_record_is_caught(): arch = _rec(key="a.old", status="superseded", superseded_by="a.b", stale_after="soon") errs = _v([_rec(key="a.b", supersedes="a.old")], archive_records=[arch], archive_keys={"a.old"}) assert any("not a YYYY-MM-DD date" in e for e in errs) # ---- path <-> key correspondence and the filesystem one-active-per-key property (#610) ---- def test_path_key_mismatch_is_an_error(tmp_path, monkeypatch): """The filename IS the key. If they can drift, one-active-per-key stops being structural.""" records_dir = tmp_path / "docs" / "decisions" / "records" monkeypatch.setattr(dv.dl, "RECORDS_DIR", records_dir) monkeypatch.setattr(dv.dl, "ARCHIVE_DIR", tmp_path / "docs" / "decisions" / "archive") wrong = _rec(key="ci.runner-placement", source=records_dir / "api" / "runner-placement.md") assert any("does not match its path" in e for e in _v([wrong])) right = _rec(key="ci.runner-placement", source=records_dir / "ci" / "runner-placement.md") assert not [e for e in _v([right]) if "does not match its path" in e] def test_legacy_multi_record_files_are_exempt_from_the_path_rule(tmp_path, monkeypatch): """A record still living in a multi-record file has no key-derived path to match.""" monkeypatch.setattr(dv.dl, "RECORDS_DIR", tmp_path / "records") monkeypatch.setattr(dv.dl, "ARCHIVE_DIR", tmp_path / "archive") legacy = _rec(key="ci.runner-placement", source=Path("docs/decisions.md")) assert not [e for e in _v([legacy]) if "does not match its path" in e] def test_one_active_per_key_is_unrepresentable_on_disk(): """The old duplicate-key defect cannot be expressed in the split layout. Two active records sharing a key would have to occupy the same path, so the filesystem refuses it — this is the invariant moving from 'validator catches it' to 'cannot happen'. Asserted via the derivation the migration uses, so a change to the path scheme breaks this test. """ import scripts.migrate_decisions_split as mig a = _rec(key="ci.runner-placement", heading="One") b = _rec(key="ci.runner-placement", heading="Two — a different title, same key") assert mig.record_path(a) == mig.record_path(b), "same key must derive the same path" def test_migration_aborts_on_a_destination_collision(tmp_path, monkeypatch): """Two records deriving the same path must abort, never silently overwrite one with the other.""" import scripts.migrate_decisions_split as mig src = tmp_path / "docs" / "decisions.md" src.parent.mkdir(parents=True) src.write_text( "# D\n\n" "## 2026-01-01 — One (#1)\n" "`key: ci.dup` · `status: active` · `since: 2026-01-01` · `supersedes: none` · `superseded-by: none`\n" "**Rule:** r\n**Signals:** s\n\nbody one.\n\n" "## 2026-01-02 — Two (#2)\n" "`key: ci.dup` · `status: active` · `since: 2026-01-02` · `supersedes: none` · `superseded-by: none`\n" "**Rule:** r\n**Signals:** s\n\nbody two.\n" ) monkeypatch.setattr(dv.dl, "REPO_ROOT", tmp_path) monkeypatch.setattr(dv.dl, "DECISIONS_MD", src) monkeypatch.setattr(dv.dl, "TOPIC_DIR", tmp_path / "docs" / "decisions") monkeypatch.setattr(dv.dl, "ARCHIVE_DIR", tmp_path / "docs" / "decisions" / "archive") monkeypatch.setattr(dv.dl, "RECORDS_DIR", tmp_path / "docs" / "decisions" / "records") with pytest.raises(SystemExit, match="collision"): mig.main([]) def test_retitling_a_record_is_not_reported_as_a_removal(tmp_path): """The heading-rename trap (#610 box 2): renaming a heading used to read as an unlogged removal. Matching on `key` makes a retitle a retitle. Driven through the real git-backed diff engine, not the pure helpers, because the defect lived in how records were collected. """ import os import subprocess def g(*a, **kw): return subprocess.run(["git", *a], cwd=tmp_path, capture_output=True, text=True, **kw) rec = ( "# D\n\n## {title}\n" "`key: ci.thing` · `status: active` · `since: 2026-01-01` · `supersedes: none` · `superseded-by: none`\n" "**Rule:** r\n**Signals:** s\n\nUnchanged rationale prose.\n" ) docs = tmp_path / "docs" docs.mkdir() g("init", "-q", ".") g("config", "user.email", "t@e") g("config", "user.name", "t") (docs / "decisions.md").write_text(rec.format(title="2026-01-01 — Original title (#1)")) g("add", "-A") g("commit", "-qm", "base") base = g("rev-parse", "HEAD").stdout.strip() # retitle ONLY — same key, byte-identical rationale (docs / "decisions.md").write_text(rec.format(title="2026-01-01 — Clearer, better title (#1)")) g("add", "-A") g("commit", "-qm", "docs: retitle the record") head = g("rev-parse", "HEAD").stdout.strip() assert head != base cwd = os.getcwd() os.chdir(tmp_path) try: removed, rewritten, demoted = dv._diff_findings(base, head) finally: os.chdir(cwd) assert removed == [], f"a retitle was reported as a removal: {removed}" assert rewritten == [], f"a retitle was reported as a prose rewrite: {rewritten}" assert demoted == [], f"a retitle was reported as a demotion: {demoted}" # --- #621: structural "one keyed record per file" over the record wings ----------------------- # # The defect these cover: a file the dependency-free frontmatter reader cannot parse yields `[]` # and vanishes from the corpus with NO error anywhere — validator OK, catalog "up to date", record # absent. Only a NEWLY ADDED record is affected; an existing one disappearing is already caught by # the no-vanish diff check, which structurally cannot see a record that never existed in the base. def _wing(tmp_path) -> tuple[Path, Path]: """A tmp record-wing pair: (records_dir, archive_dir).""" records = tmp_path / "docs" / "decisions" / "records" archive = tmp_path / "docs" / "decisions" / "archive" (records / "ci").mkdir(parents=True) (archive / "ci").mkdir(parents=True) return records, archive _GOOD = ( "---\n" "key: ci.good\n" "title: '2026-01-01 — Good (#1)'\n" "status: active\n" "since: '2026-01-01'\n" "supersedes: none\n" "superseded-by: none\n" "rule: 'a rule on one quoted line'\n" "signals: 'concept · paths: a/b.py · issues: #1'\n" "mechanics: 'x'\n" "---\n\nRationale prose.\n" ) def test_wing_faults_clean_corpus_is_silent(tmp_path): records, archive = _wing(tmp_path) (records / "ci" / "good.md").write_text(_GOOD) assert dv.record_wing_faults(records, archive) == [] def test_wing_faults_block_scalar_record_fails_loudly(tmp_path): """The exact reproduction from #621: a YAML block scalar — the natural thing to reach for on this corpus's very long `rule:` values — makes the whole record silently invisible.""" records, archive = _wing(tmp_path) bad = records / "ci" / "blockscalar.md" bad.write_text(_GOOD.replace("rule: 'a rule on one quoted line'\n", "rule: >-\n a long rule wrapped\n over two lines\n")) # Precondition: this really is the silent-vanish case, not some other parse error. assert dl.parse_file(bad) == [], "expected the reader to drop the record entirely" faults = dv.record_wing_faults(records, archive) assert len(faults) == 1, faults assert "blockscalar.md" in faults[0] assert "expected exactly 1" in faults[0] def test_wing_faults_unterminated_frontmatter_fails_loudly(tmp_path): records, archive = _wing(tmp_path) bad = records / "ci" / "unterminated.md" bad.write_text("---\nkey: ci.unterminated\nstatus: active\n\nNo closing delimiter.\n") assert dl.parse_file(bad) == [] faults = dv.record_wing_faults(records, archive) assert len(faults) == 1, faults assert "unterminated.md" in faults[0] def test_wing_faults_stray_note_file_fails_loudly(tmp_path): """A file under records/ that is not a record at all — no frontmatter whatsoever.""" records, archive = _wing(tmp_path) (records / "ci" / "notes.md").write_text("# Scratch notes\n\nNot a record.\n") faults = dv.record_wing_faults(records, archive) assert len(faults) == 1, faults assert "notes.md" in faults[0] def test_wing_faults_keyless_record_fails_loudly(tmp_path): """Parses to exactly one record, but carries no `key` — still a fault.""" records, archive = _wing(tmp_path) (records / "ci" / "keyless.md").write_text(_GOOD.replace("key: ci.good\n", "")) faults = dv.record_wing_faults(records, archive) assert len(faults) == 1, faults assert "no `key`" in faults[0] def test_wing_faults_exempts_stripped_legacy_archive_files(tmp_path): """The #610 split left five KEYLESS stripped topic files at the archive TOP level (api.md, scan.md, spa.md, startup.md, release-ci-governance.md) whose only content is a generated "Records formerly in this file" index — that index is what keeps older date-based pointers resolvable. They are indexes, not records, and must stay exempt. Their area-NESTED siblings are real records and are checked.""" records, archive = _wing(tmp_path) (records / "ci" / "good.md").write_text(_GOOD) # a stripped legacy file at the archive top level: parses to a single keyless pseudo-record (archive / "api.md").write_text("# api\n\n## Records formerly in this file\n\n- `api.thing`\n") (archive / "README.md").write_text("# archive\n") assert dv.record_wing_faults(records, archive) == [] # ...but a real archived record nested under an area IS checked (archive / "ci" / "broken.md").write_text("# not a record\n") faults = dv.record_wing_faults(records, archive) assert len(faults) == 1 and "broken.md" in faults[0], faults def test_validate_surfaces_wing_faults_as_errors(tmp_path): """Faults must arrive as validator ERRORS (exit 1), not warnings.""" errs = _v([_rec(key="ci.a", source=Path("docs/decisions/records/ci/a.md"), heading="A")], wing_faults=["docs/decisions/records/ci/x.md: parsed to 0 records, expected exactly 1"]) assert any("x.md" in e for e in errs), errs def test_real_repo_record_wings_are_all_parseable(): """Positive control against the LIVE corpus: every one of the real record files parses to exactly one keyed record. This is what makes the check's clean result meaningful rather than vacuous — if the wings were empty or unreadable, the checks above would pass trivially.""" files = dv.record_wing_files() assert len(files) > 100, f"record wings look empty ({len(files)} files) — check is vacuous" assert dv.record_wing_faults() == [] def test_wing_faults_sees_a_DEEPER_nested_archive_record(tmp_path): """A one-level `archive/*/*.md` glob would exempt the top-level stripped files correctly but silently skip anything nested deeper — a path escaping the check, which is the very failure mode this guard exists to close. The exemption must be 'directly in archive/', not 'exactly one level down'.""" records, archive = _wing(tmp_path) (records / "ci" / "good.md").write_text(_GOOD) (archive / "api.md").write_text("# api\n\n## Records formerly in this file\n") # still exempt deep = archive / "ci" / "sub" deep.mkdir(parents=True) (deep / "broken.md").write_text("# not a record\n") faults = dv.record_wing_faults(records, archive) assert len(faults) == 1 and "broken.md" in faults[0], faults # --- #621 cold-review findings ------------------------------------------------------------------ def test_main_actually_CALLS_the_wing_scan(monkeypatch, capsys): """The guard's only wiring was untested: deleting `wing_faults=record_wing_faults()` from main() left the entire suite green, and a real block-scalar record vanished again with `decisions-validate: OK`. Every other test either calls the collector directly or hands validate() a hand-built list, so nothing pinned that main() invokes it at all — the #609 'marker that printed OK while doing nothing' defect, one level up.""" monkeypatch.setattr(dv, "record_wing_faults", lambda *a, **k: ["SENTINEL-WING-FAULT"]) rc = dv.main([]) assert rc == 1, "a wing fault must fail the validator" assert "SENTINEL-WING-FAULT" in capsys.readouterr().err def test_a_record_is_not_exempted_by_its_BASENAME(tmp_path): """`_NON_DECISION_FILES` is a set of TOPIC-dir names ({README, migration-map, retrieval-eval}). Applying it to the wings meant a genuine record at `records/docs/retrieval-eval.md` was silently skipped — and that path is forced, not hypothetical: the path<->key rule puts key `docs.retrieval-eval` at exactly that filename. Worse, dl.active_files() applies the filter only to the TOPIC_DIR glob, so such a file IS a corpus source while being exempt from the guard.""" records, archive = _wing(tmp_path) (records / "docs").mkdir(parents=True, exist_ok=True) (records / "ci" / "good.md").write_text(_GOOD) (records / "docs" / "retrieval-eval.md").write_text("# just a note, not a record\n") faults = dv.record_wing_faults(records, archive) assert len(faults) == 1 and "retrieval-eval.md" in faults[0], faults def test_archive_toplevel_is_exempt_by_IDENTITY_not_by_location(tmp_path): """Exempting everything directly in `archive/` left that one directory unguarded: a new unparseable `archive/foo.md` would vanish silently. The exemption is for the five #610 stripped INDEX files, so it must test for that shape — one keyless record with a known generated heading — not merely for sitting in that directory.""" records, archive = _wing(tmp_path) (records / "ci" / "good.md").write_text(_GOOD) (archive / "api.md").write_text("# api\n\n## Records formerly in this file\n\n- `api.thing`\n") assert dv.record_wing_faults(records, archive) == [], "a real stripped index must stay exempt" (archive / "foo.md").write_text("rule: >-\n wrapped\n value\n") faults = dv.record_wing_faults(records, archive) assert len(faults) == 1 and "foo.md" in faults[0], faults def test_junk_frontmatter_key_from_a_split_value_is_faulted(tmp_path): """parse-to-WRONG, the case the structural check alone cannot see. A block scalar with an UNINDENTED continuation containing a colon parses to ONE valid keyed record whose `rule` is literally `>-`, plus a junk key from the continuation — silently truncating the real value. PyYAML rejects this input, so the hand reader is more permissive than the writer.""" records, archive = _wing(tmp_path) bad = records / "ci" / "corrupt.md" bad.write_text(_GOOD.replace("rule: 'a rule on one quoted line'\n", "rule: >-\nthe real rule: with a colon\n")) recs = dl.parse_file(bad) assert len(recs) == 1 and recs[0].key, "precondition: this parses to one KEYED record" assert recs[0].rule == ">-", f"precondition: the real value was truncated, got {recs[0].rule!r}" faults = dv.record_wing_faults(records, archive) assert len(faults) == 1 and "unrecognized key" in faults[0], faults def test_an_empty_or_missing_record_wing_is_LOUD(tmp_path): """A guard that reports a clean corpus from a scan of nothing is the defect it exists to close, applied to itself. Reachable via a partial checkout, a renamed directory, or a bad monkeypatch.""" missing = dv.record_wing_faults(tmp_path / "nope" / "records", tmp_path / "nope" / "archive") assert missing and "missing or contains no" in missing[0], missing records, archive = _wing(tmp_path) # exists but holds no *.md empty = dv.record_wing_faults(records, archive) assert empty and "missing or contains no" in empty[0], empty def test_a_wing_root_README_is_exempt_by_PATH_not_by_basename(tmp_path): """`docs/decisions/archive/README.md` really exists (a hand-written directory README), so it must be exempt — but by exact relative path, not by basename. An earlier version excluded ANY wing-root `README.md` on the stated grounds that no such file existed. That was false, and it would additionally have exempted a future `records/README.md` — reintroducing the basename hole one directory over, in the wing that matters most.""" records, archive = _wing(tmp_path) (records / "ci" / "good.md").write_text(_GOOD) (archive / "README.md").write_text("# archive\n\nHand-written directory README.\n") assert dv.record_wing_faults(records, archive) == [], "archive/README.md must stay exempt" (records / "README.md").write_text("# not a record\n") faults = dv.record_wing_faults(records, archive) assert len(faults) == 1 and "README.md" in faults[0], ( f"a README in the ACTIVE wing must NOT inherit the archive exemption: {faults}" ) def test_an_empty_ARCHIVE_wing_is_legitimate(tmp_path): """Deliberate asymmetry with the active-wing check, pinned so nobody 'fixes' it into symmetry. Zero active records means the scan measured nothing and any clean result is vacuous. Zero ARCHIVED records just means nothing has been superseded yet — normal for a young repo and for every fresh clone before the first supersession.""" records, archive = _wing(tmp_path) (records / "ci" / "good.md").write_text(_GOOD) assert dv.record_wing_faults(records, archive) == [], "an empty archive must not fault" # --- #620: per-record ceiling replaces the aggregate budget -------------------------------------- _MINIMAL_RECORD = ( "---\n" "key: ci.a\n" "title: '2026-01-01 — A (#1)'\n" "status: active\n" "since: '2026-01-01'\n" "supersedes: none\n" "superseded-by: none\n" "rule: 'r'\n" "signals: 's'\n" "---\n\nprose.\n" ) def _rec_body(key: str, lines: int) -> dl.Record: return _rec(key=key, heading=key, body="\n".join(f"prose line {i}" for i in range(lines))) def test_oversized_records_flags_only_those_over_the_ceiling(): recs = [_rec_body("a.short", 10), _rec_body("b.exact", 60), _rec_body("c.long", 61)] assert dv.oversized_records(recs, 60) == [("c.long", 61)], "the ceiling must be exclusive" def test_oversized_records_sorts_longest_first(): recs = [_rec_body("a.mid", 80), _rec_body("b.big", 200), _rec_body("c.small", 70)] assert [k for k, _ in dv.oversized_records(recs, 60)] == ["b.big", "a.mid", "c.small"] def test_oversized_records_can_go_green(): """The property the aggregate budget structurally lacked: a corpus that satisfies it. An aggregate over a monotonically growing corpus can only ratchet — it goes red and stays red until someone raises the number. A per-record ceiling is satisfiable and re-satisfiable, which is what makes a red mean something.""" recs = [_rec_body("a.short", 10), _rec_body("b.short", 20)] assert dv.oversized_records(recs, 60) == [] def test_budget_total_excludes_the_generated_catalog(tmp_path, monkeypatch): """#620: the catalog gains one row per record and cannot be consolidated away. Counting it made the 'schedule a consolidation' number partly a record COUNT, pointing the reader at work that cannot be done. Mutation-sensitive by construction: the catalog here is large and distinctively sized, so re-adding it would change the total by exactly 500.""" topic = tmp_path / "docs" / "decisions" records = topic / "records" / "ci" records.mkdir(parents=True) (records / "a.md").write_text(_MINIMAL_RECORD) (topic / "README.md").write_text("\n".join(f"| row {i} |" for i in range(500))) monkeypatch.setattr(dv.dl, "REPO_ROOT", tmp_path) monkeypatch.setattr(dv.dl, "DECISIONS_MD", tmp_path / "docs" / "decisions.md") monkeypatch.setattr(dv.dl, "TOPIC_DIR", topic) monkeypatch.setattr(dv.dl, "ARCHIVE_DIR", topic / "archive") monkeypatch.setattr(dv.dl, "RECORDS_DIR", records) total = dv._budget_total() assert total < 100, f"the 500-line generated catalog leaked into the total ({total})" def test_real_corpus_ceiling_sits_at_the_TAIL_BOUNDARY_of_the_distribution(): """Guards the calibration claim. This is the FOURTH version; the failures are the lesson. v1 `max(under) <= 60 < min(over)` — true by construction of those two lists. v2 a minimum gap WIDTH — but a ceiling of 200 also sits in a wide gap, so it passed. v3 a 2-12% fraction band plus "clear air" measured against `min(over)` — the nearest record ABOVE the ceiling. That made the test a hostage to an unrelated record: one ordinary 62-line addition reddened it with the ceiling correctly placed, and the only remedy the assertion admitted was to RAISE the ceiling. That is the ratchet this whole change abolishes, reinstated as a hard failure in what #631 makes a blocking CI job. The fraction band had the same coupling more slowly (12 more long records breached it), and `0 <= headroom` was vacuous — `max(under)` is by construction <= ceiling. v4 states the property directly and scale-free: **the ceiling marks the start of the tail**, i.e. it sits between the 90th and 95th percentile of record lengths. Percentiles move WITH the corpus, so routine growth cannot ratchet this; it fires only when the ceiling genuinely stops marking the tail boundary, which is exactly when it should be re-derived. """ recs = [r for r in dl.all_active_records() if r.key] assert len(recs) > 100, f"corpus looks empty ({len(recs)}) — this check would be vacuous" ceiling = dv.RECORD_CEILING_DEFAULT # the value the CLI actually uses; cannot drift from here lengths = sorted(dv.record_prose_lines(r) for r in recs) p90 = lengths[int(len(lengths) * 0.90)] p95 = lengths[int(len(lengths) * 0.95)] assert p90 <= ceiling <= p95, ( f"the ceiling ({ceiling}) no longer marks the tail boundary: p90={p90}, p95={p95}. " f"Below p90 it cuts into the bulk and every author will learn to ignore it; above p95 it is " f"parked among the outliers and signals nothing. Re-derive it from the distribution." ) def test_retired_budget_flag_says_it_is_ignored(capsys): """A retired flag must announce itself, not no-op silently. Accepting `--budget 100` and enforcing nothing would be the same 'reports success while doing nothing' defect this change exists to retire (#603 stale-after, #609 marker).""" dv.main(["--budget", "100"]) err = capsys.readouterr().err assert "--budget 100 is RETIRED and was IGNORED" in err, err def test_no_budget_flag_means_no_retirement_warning(capsys): dv.main([]) assert "RETIRED" not in capsys.readouterr().err def test_main_actually_REPORTS_the_ceiling_and_the_trend(capsys): """The new signal's live wiring was untested: `if oversized:` -> `if False:`, or bumping the default ceiling to 999999, left every test green while main() reported nothing. Only the pure function `oversized_records()` was covered — so the replacement signal could silently do nothing, which is the exact defect this change exists to retire.""" dv.main([]) err = capsys.readouterr().err assert "prose lines across" in err, "the aggregate trend notice must always print" assert "exceed the" in err and "prose ceiling" in err, "the per-record ceiling warning must print" over = [r.key for r in dl.all_active_records() if r.key and dv.record_prose_lines(r) > dv.RECORD_CEILING_DEFAULT] assert over, "precondition: the live corpus has at least one over-ceiling record" assert any(k in err for k in over), "the warning must NAME the offending records" def test_trend_notice_reports_record_prose_and_scaffolding_separately(capsys): """The notice used to print `_budget_total()` 'across N records', mixing two incompatible definitions of prose: the total includes ~600 lines of headings and keyless scaffolding belonging to no record, so dividing it by the record count and comparing that to the 60-line ceiling compares incommensurable units — a small instance of the 'the metric is not what it says it is' defect this change indicts.""" dv.main([]) err = capsys.readouterr().err recs = [r for r in dl.all_active_records() if r.key] record_prose = sum(dv.record_prose_lines(r) for r in recs) assert f"{record_prose} prose lines across {len(recs)} records" in err, err assert "non-record scaffolding" in err, err