Files
ersatztv/scripts/tests/test_decisions_validate.py
T
timothy 3d720a6bc1
review-verdict/h10 Awaiting review verdict for 3d720a6
Review verdict / Set review-verdict status (pull_request) Successful in 2s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 14s
PR Gates / decisions lifecycle (pull_request) Successful in 28s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m48s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m57s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 22m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(621): exempt by exact path, not basename — my "no basename exemption" claim was false
Re-review found the basename exemption was not actually removed: the code still dropped
any wing-root README.md, and the docstring defended that with "no such file exists
today; the catalog README lives in TOPIC_DIR". That is false — docs/decisions/archive/
README.md exists and parses to []. So the rule text in the record, the catalog row and
ci-cd.md all asserted something the shipped code contradicted.

It was also a live hole, not just wrong prose: dl.active_files() globs RECORDS_DIR.rglob
with NO filter, so a future docs/decisions/records/README.md would be a corpus source
while exempt from the guard — verbatim the hole this PR cites as the reason to stop
using _NON_DECISION_FILES, reintroduced one directory over.

Now exempt by exact RELATIVE PATH ({archive/README.md}), with a test pinning that the
same basename in the ACTIVE wing is still faulted. Mutation-verified.

DISAGREEING with one review finding, deliberately. It asked for the empty-wing
anti-vacuity check to be symmetric across both wings. The semantics differ: zero ACTIVE
records means the scan measured nothing and any clean result is vacuous, while zero
ARCHIVED records is a normal state — nothing superseded yet, true of any young repo and
every fresh clone before the first supersession. Making it symmetric fails a correct
corpus (it reddened 7 tests). The asymmetry stays, with the reason in the code and a
test pinning it so nobody "fixes" it back.

127 passed.

Refs #621
2026-07-26 12:03:43 +02:00

941 lines
40 KiB
Python

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,
budget_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 <s@example.com>",
)
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"