Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
214 lines
7.3 KiB
Python
214 lines
7.3 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",
|
|
)
|
|
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=[]
|
|
)
|
|
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_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 _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 = 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 = 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 = 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 = dv._diff_findings("HEAD~1", "HEAD")
|
|
assert removed == []
|
|
assert rewritten == []
|