Files
ersatztv/scripts/tests/test_decisions_validate.py
T
timothyandClaude Opus 5 fefd11dffe
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 20s
PR Gates / Docs update reminder (pull_request) Successful in 28s
PR Gates / decisions lifecycle (pull_request) Successful in 30s
Review verdict / Set review-verdict status (pull_request) Successful in 12s
PR Gates / Script tests (pytest) (pull_request) Successful in 42s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m31s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 18m33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m27s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m24s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
review-verdict/h10 Review-verdict: MERGEABLE @ fefd11d
fix(620): signal corpus size per RECORD; the aggregate becomes an unthresholded trend
Squashed from 7 commits (4 review rounds) to keep the rebase onto #621 tractable; the
full round-by-round history is on PR #642.

corpus was 5658/5600 — over budget and warning again — 3h35m after #619 put it at 5228,
with nobody consolidating anything.

So this does NOT re-baseline. An aggregate over a monotonically growing corpus can only
ratchet; that is the "permanently red, therefore no signal" state #542 re-baselined away
from, and growth is not even a smooth rate to plan against (the corpus FELL from 5089 to
5042 across four days, then gained 427 in one evening as two large records landed).

Replaces it with a per-record prose ceiling (default 60), non-blocking, naming each
record over it — not monotonic, so it can go red AND green, and it points at a file. The
aggregate is still printed as an unthresholded trend notice, with record prose and
non-record scaffolding reported separately because they are not the same unit. The
GENERATED catalog is no longer counted at all: it gains one row per record and no
consolidation can shrink it, which made the metric partly a record COUNT in a line-count
costume.

The calibration test took FOUR versions, and the failures are the durable lesson:
  v1  true by construction (`max(under) <= 60 < min(over)` over lists built by that test)
  v2  a gap WIDTH — a ceiling of 200 also sits in a wide gap, so it passed
  v3  fraction band + "clear air" vs the nearest record above — hostage to an unrelated
      record: one ordinary 62-line addition reddened it with the ceiling correctly
      placed, and the only remedy was to RAISE the ceiling. That is this very treadmill,
      as a hard failure in what #631 makes a blocking job.
  v4  `p90 <= ceiling <= p95` — the property stated directly and scale-free.
Two rules recorded: a guard test must depend only on the thing it guards, and a threshold
over a growing population must be expressed in that population's own terms.

Candidates: all over-ceiling records assessed, each actioned or declined with a reason.
The largest (scan.libraryfolder-unique-identity, 230 lines) is a legitimate DECLINE — a
dozen-odd distinct traps whose only copy that is. Nothing pruned, so no archive or
supersession was required. An automated redundancy metric is explicitly rejected.

Also: `--budget` is accepted but announces its retirement rather than no-opping silently;
the dead `budget_ok` parameter is gone; and five "untresholded" typos are fixed, one of
which was propagating into the generated catalog row and MemPalace's per-key drawer.

Refs #620

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:57:01 +02:00

1081 lines
47 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,
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"
# --- #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