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:
parent
ce3319cbed
commit
a3ea0f7d80
2 changed files with 98 additions and 0 deletions
|
|
@ -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
52
tests/test_repo_scan.py
Normal 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 == []
|
||||
Loading…
Add table
Reference in a new issue