PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 14s
PR Gates / decisions lifecycle (pull_request) Successful in 19s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 29s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m20s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m10s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m57s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Fable's round-5 review ran the 16 tmp_path tests that no prior round could execute (16/16 pass) and mutation-tested every fix. Six of seven reverts were killed; one survived, which is finding 1. 1. The exact-arity refusal in `_token_armed` had ZERO coverage -- deleting it passed all 55 tests, because nothing fed malformed git-log output to that function. Now pinned by a test that stubs `_run` with 2-field and 4-field output and asserts refusal, plus a 3-field control proving the refusal is about arity rather than the token. Verified the new test kills the mutation. 2. `test_integration_separator_in_subject_cannot_inject` did not actually pin the NUL framing: with `\x1f` framing restored and the arity check kept, it still passed, because one or two injected separators break arity and get absorbed. Added a case with THREE separators, which restores a multiple-of-3 arity and would false-arm under that revert -- so it pins the framing itself. 3. The decision record attributed the "old git echoes the trailers atom" case to the arity check. Wrong: an echoed atom is one well-formed field, so arity cannot catch it -- that case is handled by the `git --version` capability probe. Corrected in the record. Not changed: review also noted subject matching is now case-insensitive, so `[DECISIONS-EDIT]` arms where the old substring check was case-sensitive. Deliberate and harmless -- arming still requires typing the token.
740 lines
29 KiB
Python
740 lines
29 KiB
Python
import subprocess
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
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 test_diff_engine_allows_rewrite_with_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)
|
|
|
|
_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 [decisions-edit]")
|
|
|
|
removed, rewritten, *_rest = dv._diff_findings("HEAD~1", "HEAD")
|
|
assert removed == []
|
|
assert 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 [decisions-edit] token. 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)
|
|
|
|
|
|
# ---- edit-token scoping (ersatztv#609) ----
|
|
|
|
|
|
def test_token_armed_by_subject_the_established_form():
|
|
"""All 38 historical uses carry it in the subject (37 appended, one mid-subject)."""
|
|
for subj in (
|
|
"fix(460): write null LastScan on disable-sync [decisions-edit]",
|
|
"docs(434): [decisions-edit] update field-values decision",
|
|
"fix(529): address review [DECISIONS-EDIT]",
|
|
"Merge pull request 'fix(460): ... [decisions-edit]' (#601) from x into main",
|
|
):
|
|
assert dv.token_armed_in(subj, ""), subj
|
|
|
|
|
|
def test_token_armed_by_trailer():
|
|
assert dv.token_armed_in("docs: correct a stale figure", "corrected the 2026-07-17 load number")
|
|
|
|
|
|
def test_token_NOT_armed_by_body_prose():
|
|
"""The #609 defect: a commit DESCRIBING the token silently disarmed the whole guard."""
|
|
for subj in (
|
|
"docs(603): correct the record's own no-backfill claim",
|
|
"fix(609): scope the edit token to the subject line",
|
|
):
|
|
assert not dv.token_armed_in(subj, ""), subj
|
|
|
|
|
|
def test_token_not_armed_by_empty_or_whitespace_trailer():
|
|
assert not dv.token_armed_in("docs: something", "")
|
|
assert not dv.token_armed_in("docs: something", " \n ")
|
|
|
|
|
|
# ---- _token_armed INTEGRATION against real git (ersatztv#609 review round 2) ----
|
|
#
|
|
# The unit tests above only exercise `token_armed_in`. Both false-arm defects found in review
|
|
# (git's `%s` folding the first PARAGRAPH, and an injectable in-band separator) lived in the git
|
|
# plumbing and passed those tests untouched. These drive the real command.
|
|
|
|
|
|
def _repo(tmp_path, messages: list[str]) -> tuple[str, str]:
|
|
"""Build a throwaway repo; return (base_sha, head_sha)."""
|
|
import subprocess
|
|
|
|
def g(*a, **kw):
|
|
return subprocess.run(["git", *a], cwd=tmp_path, capture_output=True, text=True, **kw)
|
|
|
|
for cmd in (("init", "-q", "."), ("config", "user.email", "t@e"), ("config", "user.name", "t")):
|
|
assert g(*cmd).returncode == 0, f"setup failed: {cmd}"
|
|
(tmp_path / "f").write_text("base\n")
|
|
assert g("add", "-A").returncode == 0
|
|
assert g("commit", "-qm", "base").returncode == 0, "base commit rejected"
|
|
base = g("rev-parse", "HEAD").stdout.strip()
|
|
assert len(base) == 40, f"base sha not resolved ({base!r}) — range tests would be vacuous"
|
|
for i, msg in enumerate(messages):
|
|
(tmp_path / "f").write_text(f"{i}\n")
|
|
assert g("add", "-A").returncode == 0
|
|
r = g("commit", "-q", "-F", "-", input=msg)
|
|
assert r.returncode == 0, f"commit rejected, test would pass vacuously: {r.stderr}"
|
|
head = g("rev-parse", "HEAD").stdout.strip()
|
|
assert head != base, "no commit landed — every assertion below would be vacuous"
|
|
return base, head
|
|
|
|
|
|
def _armed(tmp_path, message: str) -> bool:
|
|
import os
|
|
|
|
base, head = _repo(tmp_path, [message])
|
|
cwd = os.getcwd()
|
|
os.chdir(tmp_path)
|
|
try:
|
|
return dv._token_armed(base, head)
|
|
finally:
|
|
os.chdir(cwd)
|
|
|
|
|
|
def test_integration_subject_token_arms(tmp_path):
|
|
assert _armed(tmp_path, "fix(460): write null LastScan [decisions-edit]\n\nbody\n")
|
|
|
|
|
|
def test_integration_trailer_arms(tmp_path):
|
|
assert _armed(tmp_path, "docs: correct a figure\n\nwhy\n\nDecisions-Edit: the load number was wrong\n")
|
|
|
|
|
|
def test_integration_body_prose_does_not_arm(tmp_path):
|
|
assert not _armed(tmp_path, "docs: explain it\n\nThe check matches [decisions-edit] as a substring.\n")
|
|
|
|
|
|
def test_integration_second_line_of_first_paragraph_does_not_arm(tmp_path):
|
|
"""git's %s is the first PARAGRAPH, not the first line — it folds line 2 in with a space.
|
|
|
|
Using %s directly, this message arms the token. It must not: line 2 is body prose.
|
|
"""
|
|
assert not _armed(tmp_path, "fix: harmless subject\nThis explains [decisions-edit] on line two.\n\nbody\n")
|
|
|
|
|
|
def test_integration_separator_in_subject_cannot_inject(tmp_path):
|
|
"""An in-band \\x1f separator was injectable; NUL cannot appear in a commit message."""
|
|
assert not _armed(tmp_path, "docs: harmless\x1fsuffix\n\nbody\n")
|
|
assert not _armed(tmp_path, "docs: harmless\x1e\x1fsuffix\n\nbody\n")
|
|
|
|
|
|
def test_integration_empty_trailer_does_not_arm(tmp_path):
|
|
assert not _armed(tmp_path, "docs: something\n\nbody\n\nDecisions-Edit:\n")
|
|
|
|
|
|
def test_integration_only_a_later_commit_carries_it(tmp_path):
|
|
import os
|
|
|
|
base, head = _repo(tmp_path, ["chore: unrelated\n", "fix: real correction [decisions-edit]\n"])
|
|
cwd = os.getcwd()
|
|
os.chdir(tmp_path)
|
|
try:
|
|
assert dv._token_armed(base, head)
|
|
finally:
|
|
os.chdir(cwd)
|
|
|
|
|
|
def test_integration_unresolvable_refs_do_not_arm(tmp_path):
|
|
"""Guard must still RUN when git can't answer; arming on failure would disable it."""
|
|
assert not _armed(tmp_path, "docs: x\n") or True # build a repo first
|
|
import os
|
|
|
|
cwd = os.getcwd()
|
|
os.chdir(tmp_path)
|
|
try:
|
|
assert dv._token_armed("nope1", "nope2") is False
|
|
finally:
|
|
os.chdir(cwd)
|
|
|
|
|
|
def test_integration_leading_blank_line_does_not_promote_body_to_subject(tmp_path):
|
|
"""`--cleanup=verbatim` allows a message starting blank; %B returns it raw.
|
|
|
|
Skipping leading blanks would promote body prose to "subject" and arm on it.
|
|
"""
|
|
import subprocess
|
|
|
|
d = tmp_path
|
|
subprocess.run(["git", "init", "-q", "."], cwd=d)
|
|
subprocess.run(["git", "config", "user.email", "t@e"], cwd=d)
|
|
subprocess.run(["git", "config", "user.name", "t"], cwd=d)
|
|
(d / "f").write_text("base\n")
|
|
subprocess.run(["git", "add", "-A"], cwd=d)
|
|
subprocess.run(["git", "commit", "-qm", "base"], cwd=d)
|
|
base = subprocess.run(["git", "rev-parse", "HEAD"], cwd=d, capture_output=True, text=True).stdout.strip()
|
|
(d / "f").write_text("x\n")
|
|
subprocess.run(["git", "add", "-A"], cwd=d)
|
|
msg = "\nThis body prose mentions [decisions-edit] and must not arm.\n"
|
|
r = subprocess.run(
|
|
["git", "commit", "-q", "--cleanup=verbatim", "-F", "-"], cwd=d, input=msg, text=True, capture_output=True
|
|
)
|
|
assert r.returncode == 0, f"commit rejected, test would be vacuous: {r.stderr}"
|
|
head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=d, capture_output=True, text=True).stdout.strip()
|
|
assert head != base
|
|
import os
|
|
|
|
cwd = os.getcwd()
|
|
os.chdir(d)
|
|
try:
|
|
assert dv.subject_of(msg) == ""
|
|
assert not dv._token_armed(base, head)
|
|
finally:
|
|
os.chdir(cwd)
|
|
|
|
|
|
def test_old_git_falls_back_to_subject_only(monkeypatch):
|
|
"""Exercises the real `_token_armed` branch, not just the predicate.
|
|
|
|
Round-3 review caught that the previous version of this test never called `_token_armed`, so
|
|
deleting the guard entirely would have left it green.
|
|
"""
|
|
calls = {}
|
|
|
|
def fake_run(args):
|
|
if args[:2] == ["git", "--version"]:
|
|
return calls["version"]
|
|
# one commit: sha, body, trailer — the trailer field is the UNEXPANDED atom, as an old
|
|
# git would emit it verbatim.
|
|
return "sha\x00docs: plain subject\n\x00%(trailers:key=Decisions-Edit,valueonly)\x00"
|
|
|
|
monkeypatch.setattr(dv, "_run", fake_run)
|
|
|
|
calls["version"] = "git version 2.20.1\n" # too old: trailers ignored, subject-only
|
|
assert not dv._token_armed("a", "b"), "unexpanded atom must not be read as a trailer value"
|
|
|
|
calls["version"] = "git version 2.39.5\n" # new enough: the field is a real value
|
|
assert dv._token_armed("a", "b"), "a modern git's trailer value must still arm"
|
|
|
|
|
|
def test_old_git_still_honours_a_tokened_subject(monkeypatch):
|
|
def fake_run(args):
|
|
if args[:2] == ["git", "--version"]:
|
|
return "git version 2.20.1\n"
|
|
return "sha\x00fix: real [decisions-edit]\n\x00%(trailers:key=Decisions-Edit,valueonly)\x00"
|
|
|
|
monkeypatch.setattr(dv, "_run", fake_run)
|
|
assert dv._token_armed("a", "b"), "subject must arm even when trailers are unusable"
|
|
|
|
|
|
def test_git_version_probe_parses_and_fails_safe(monkeypatch):
|
|
for out, want in [
|
|
("git version 2.39.5\n", True),
|
|
("git version 2.22.0\n", True),
|
|
("git version 2.21.9\n", False),
|
|
("git version 3.0.0\n", True),
|
|
("", False),
|
|
(None, False),
|
|
("not a version string", False),
|
|
# round 4: unanchored matching let a wrapper's own version win, enabling trailers on a git
|
|
# that cannot expand them — the falsely-arming direction.
|
|
("wrapper 2026.1; git version 2.20.1", False),
|
|
("some-shim 9.9\ngit version 2.39.5", False),
|
|
("git version 2.39.5 (Apple Git-154)", True),
|
|
("git version 2.40.0.rc1", True),
|
|
("git version 123.4.5", True),
|
|
# round 4: unbounded digits raised ValueError instead of returning the documented False
|
|
("git version " + "9" * 5000 + ".1", False),
|
|
]:
|
|
monkeypatch.setattr(dv, "_run", lambda _a, _o=out: _o)
|
|
assert dv._git_supports_trailer_atom() is want, out
|
|
|
|
|
|
def test_malformed_arity_refuses_to_arm(monkeypatch):
|
|
"""Round-5 mutation testing: deleting the arity check passed the whole suite.
|
|
|
|
Reachable only via truncated `git log` output (NUL cannot appear in a commit message), and the
|
|
failure direction is safe — refuse to arm, so the guard still runs. Pinned so a refactor can't
|
|
drop it silently.
|
|
"""
|
|
|
|
def stub(out):
|
|
def fake_run(args):
|
|
return "git version 2.39.5\n" if args[:2] == ["git", "--version"] else out
|
|
|
|
return fake_run
|
|
|
|
# 2 fields instead of 3 (truncated mid-record), with the token present in the subject:
|
|
monkeypatch.setattr(dv, "_run", stub("sha\x00fix: real [decisions-edit]\n\x00"))
|
|
assert not dv._token_armed("a", "b"), "malformed arity must refuse to arm"
|
|
|
|
# 4 fields — an extra separator
|
|
monkeypatch.setattr(dv, "_run", stub("sha\x00fix: real [decisions-edit]\n\x00\x00extra\x00"))
|
|
assert not dv._token_armed("a", "b"), "malformed arity must refuse to arm"
|
|
|
|
# exactly 3 → the same token DOES arm, proving the refusal above is about arity, not the token
|
|
monkeypatch.setattr(dv, "_run", stub("sha\x00fix: real [decisions-edit]\n\x00\x00"))
|
|
assert dv._token_armed("a", "b"), "well-formed arity with a tokened subject must arm"
|
|
|
|
|
|
def test_separator_injection_needs_more_than_arity_to_be_caught(tmp_path):
|
|
"""Strengthens the injection test: THREE \\x1f's restore a multiple-of-3 arity.
|
|
|
|
Under the old in-band framing a subject with three separators would parse as well-formed and
|
|
its tail could be read as a trailer. With NUL framing the bytes are inert, so this pins the
|
|
framing itself rather than leaning on the arity check to absorb it.
|
|
"""
|
|
assert not _armed(tmp_path, "docs: a\x1fb\x1fc\x1fd\n\nbody\n")
|