boma/tests/test_repo_scan.py
sjat b0c0150db2 feat(scan): repo-scan rename-incomplete check (kaizen)
When a numbered ADR announces a rename Old->New, flag design-doc lines where
Old still appears in present tense — skipping the announcing ADR, lines that
also name New, and historical/negation cues, and rejecting ADR-NNN tokens as
terms. Structural cousin of stale-deferred; run by /review-repo. Zero findings
on the current tree (the Traefik->Caddy ripple edits have landed). Consumes the
2026-06-14 KEEP-OPEN signal in docs/FRICTION.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:49:41 +02:00

155 lines
6 KiB
Python

import importlib.util
import pathlib
_PATH = pathlib.Path(__file__).resolve().parent.parent / "scripts" / "repo-scan.py"
_spec = importlib.util.spec_from_file_location("repo_scan", _PATH)
rs = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(rs)
GOOD = [
"# ADR-099 — Example\n", "\n",
"## Status\n", "\n", "Accepted (2026-06-10)\n", "\n",
"## Context\n", "\n", "Why.\n", "\n",
"## Decision\n", "\n", "What.\n", "\n",
"## Consequences\n", "\n", "So what.\n",
]
def _checks(findings):
return [f for f in findings if f["check"] == "adr-structure"]
def test_good_adr_has_no_findings():
out = rs.adr_structure_findings({"docs/decisions/099-example.md": GOOD})
assert _checks(out) == []
def test_missing_mandatory_section_is_flagged():
lines = [ln for ln in GOOD if not ln.startswith("## Consequences")]
out = _checks(rs.adr_structure_findings({"docs/decisions/099-example.md": lines}))
assert len(out) == 1
assert "Consequences" in out[0]["detail"]
def test_unparseable_status_is_flagged():
lines = [("Designed, not built.\n" if ln == "Accepted (2026-06-10)\n" else ln)
for ln in GOOD]
out = _checks(rs.adr_structure_findings({"docs/decisions/099-example.md": lines}))
assert len(out) == 1
assert "Status not parseable" in out[0]["detail"]
def test_superseded_status_is_accepted():
lines = [("Superseded by ADR-100 (2026-06-11)\n" if ln == "Accepted (2026-06-10)\n"
else ln) for ln in GOOD]
out = _checks(rs.adr_structure_findings({"docs/decisions/099-example.md": lines}))
assert out == []
def test_proposed_status_is_accepted():
lines = [("Proposed (2026-06-04)\n" if ln == "Accepted (2026-06-10)\n"
else ln) for ln in GOOD]
out = _checks(rs.adr_structure_findings({"docs/decisions/099-example.md": lines}))
assert out == []
def test_non_numbered_file_is_skipped():
bare = ["# ADR template\n", "\n", "## Status\n", "\n", "<!-- hint -->\n"]
out = _checks(rs.adr_structure_findings({"docs/decisions/adr-template.md": bare}))
assert out == []
# --- rename-incomplete -------------------------------------------------------
def _renames(findings):
return [f for f in findings if f["check"] == "rename-incomplete"]
def test_rename_incomplete_flags_lingering_old_name():
# ADR announces `Foo` -> `Bar`; another decisions file still says Foo present-tense.
announcer = {"docs/decisions/050-rename.md": [
"## Decision\n", "We renamed `Foo` to `Bar` across the design docs.\n"]}
other = {} # extra_docs (CAPABILITIES/ROADMAP) — none here
lingering = {"docs/decisions/030-other.md": [
"The Foo proxy renders config from the catalog.\n"]}
announcer.update(lingering)
out = _renames(rs.rename_incomplete_findings(announcer, other))
assert len(out) == 1
assert out[0]["path"] == "docs/decisions/030-other.md"
assert out[0]["line"] == 1
assert out[0]["severity"] == "medium"
assert "Foo" in out[0]["detail"] and "Bar" in out[0]["detail"]
def test_rename_incomplete_clean_rename_has_no_findings():
# The rename announced, and no other doc still mentions Foo.
adr_files = {
"docs/decisions/050-rename.md": [
"## Decision\n", "We renamed `Foo` to `Bar` across the design docs.\n"],
"docs/decisions/030-other.md": [
"The Bar proxy renders config from the catalog.\n"],
}
out = _renames(rs.rename_incomplete_findings(adr_files, {}))
assert out == []
def test_rename_incomplete_skips_historical_cue_line():
# Foo lingers only on a line carrying a historical/negation cue → no finding.
adr_files = {
"docs/decisions/050-rename.md": [
"## Decision\n", "We renamed `Foo` to `Bar` across the design docs.\n"],
"docs/decisions/030-other.md": [
"Foo was rejected; we run Bar now.\n",
"The history of Foo informs the choice.\n"],
}
out = _renames(rs.rename_incomplete_findings(adr_files, {}))
assert out == []
def test_rename_incomplete_skips_announcing_adr_itself():
# The announcing ADR mentions Foo (it has to) — must not flag itself.
adr_files = {
"docs/decisions/050-rename.md": [
"## Decision\n",
"We renamed `Foo` to `Bar`.\n",
"Operators who configured Foo should switch their habits.\n"],
}
out = _renames(rs.rename_incomplete_findings(adr_files, {}))
assert out == []
def test_rename_incomplete_skips_line_naming_new_term():
# A line that mentions both Foo and Bar is explaining the rename → skipped.
adr_files = {
"docs/decisions/050-rename.md": [
"## Decision\n", "We renamed `Foo` to `Bar`.\n"],
"docs/decisions/030-other.md": [
"Foo is being phased out for Bar in this paragraph.\n"],
}
out = _renames(rs.rename_incomplete_findings(adr_files, {}))
assert out == []
def test_rename_incomplete_searches_extra_docs():
# A lingering OLD name in CAPABILITIES.md (an extra_docs file) is flagged.
adr_files = {"docs/decisions/050-rename.md": [
"## Decision\n", "We renamed `Foo` to `Bar`.\n"]}
extra = {"docs/CAPABILITIES.md": ["The Foo proxy is what we deploy.\n"]}
out = _renames(rs.rename_incomplete_findings(adr_files, extra))
assert len(out) == 1
assert out[0]["path"] == "docs/CAPABILITIES.md"
def test_rename_incomplete_ignores_ambiguous_adr_pointer_assertion():
# "the ADR-017 prose ... is updated to read Caddy" must NOT parse ADR-017 as the
# old name (it is a doc pointer). With ADR-017 rejected, no assertion → no finding,
# even though 'ADR-017' appears in many other docs.
adr_files = {
"docs/decisions/024-reverse-proxy.md": [
"## Consequences\n",
'- ADR-017 prose that mentioned Traefik is updated to read "Caddy".\n'],
"docs/decisions/008-testing.md": [
"Level 4 UI verification follows ADR-017.\n"],
}
out = _renames(rs.rename_incomplete_findings(adr_files, {}))
assert out == []