feat(review): add adr-structure check to repo-scan

Flags numbered ADRs missing a mandatory section (Status/Context/Decision/
Consequences) or with an unparseable Status line. Presence only, not order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sjat 2026-06-10 13:57:42 +02:00
parent ce3319cbed
commit a3ea0f7d80
2 changed files with 98 additions and 0 deletions

View file

@ -41,6 +41,16 @@ LIST_ITEM_RE = re.compile(r"^\s*(\d+\.|[-*+])\s+(.*)")
DEFER_REF_RE = re.compile(r"ADR-(\d{3})\D{0,40}?deferred\D{0,12}?(\d+)", re.I)
RESOLVE_WORD_RE = re.compile(r"\b(?:resolv\w*|decid\w*|address\w*|complet\w*|done)\b", re.I)
# ADR-structure check (ADR-023): numbered ADRs must carry the four mandatory
# sections and a parseable Status line. Presence only — section ORDER is a
# template-demonstrated convention, not machine-enforced.
ADR_FILE_RE = re.compile(r"^\d{3}-.*\.md$")
ADR_REQUIRED_SECTIONS = ("Status", "Context", "Decision", "Consequences")
ADR_STATUS_LINE_RE = re.compile(
r"^(Accepted \(\d{4}-\d{2}-\d{2}\)"
r"|Superseded by ADR-\d{3}"
r"|Deprecated \(\d{4}-\d{2}-\d{2}\))")
def _is_defer_heading(text):
t = text.strip().lower()
@ -95,6 +105,41 @@ def deferred_findings(adr_files, defer_refs):
return out
def adr_structure_findings(adr_files):
"""adr_files: {rel_path: [lines]} for docs/decisions/*.md.
Flags numbered ADRs (NNN-*.md) missing a mandatory section or whose Status
section has no parseable lifecycle line. Non-numbered files (e.g.
adr-template.md) are skipped. Section order is NOT checked (ADR-023)."""
out = []
for rpath, lines in sorted(adr_files.items()):
if not ADR_FILE_RE.match(os.path.basename(rpath)):
continue
headings = {}
for i, line in enumerate(lines):
m = re.match(r"^##\s+(\w+)", line)
if m:
headings.setdefault(m.group(1), i)
missing = [s for s in ADR_REQUIRED_SECTIONS if s not in headings]
if missing:
out.append({"check": "adr-structure", "severity": "medium",
"path": rpath, "line": 1,
"detail": f"missing mandatory section(s): {', '.join(missing)}"})
if "Status" in headings:
body = []
for line in lines[headings["Status"] + 1:]:
if line.startswith("## "):
break
body.append(line)
status_text = next((ln.strip() for ln in body if ln.strip()), "")
if not ADR_STATUS_LINE_RE.match(status_text):
out.append({"check": "adr-structure", "severity": "medium",
"path": rpath, "line": headings["Status"] + 1,
"detail": "Status not parseable (want 'Accepted (YYYY-MM-DD)', "
"'Superseded by ADR-NNN', or 'Deprecated (YYYY-MM-DD)'); "
f"got: {status_text[:60]!r}"})
return out
def walk_files():
for dirpath, dirnames, filenames in os.walk(ROOT):
dirnames[:] = [d for d in dirnames if d not in PRUNE]
@ -213,6 +258,7 @@ def scan():
findings.append({"check": "broken-path-ref", "severity": "medium", "path": rpath,
"line": i, "detail": f"references '{ref}' which does not exist"})
findings.extend(deferred_findings(adr_files, defer_refs))
findings.extend(adr_structure_findings(adr_files))
return findings

52
tests/test_repo_scan.py Normal file
View file

@ -0,0 +1,52 @@
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_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 == []