2026-06-10 13:57:42 +02:00
|
|
|
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 == []
|
|
|
|
|
|
|
|
|
|
|
2026-06-10 14:48:55 +02:00
|
|
|
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 == []
|
|
|
|
|
|
|
|
|
|
|
2026-06-10 13:57:42 +02:00
|
|
|
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 == []
|
2026-06-17 17:49:41 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 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 == []
|