diff --git a/scripts/repo-scan.py b/scripts/repo-scan.py index 6146041..d8bc5f0 100644 --- a/scripts/repo-scan.py +++ b/scripts/repo-scan.py @@ -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 diff --git a/tests/test_repo_scan.py b/tests/test_repo_scan.py new file mode 100644 index 0000000..e8a0542 --- /dev/null +++ b/tests/test_repo_scan.py @@ -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", "\n"] + out = _checks(rs.adr_structure_findings({"docs/decisions/adr-template.md": bare})) + assert out == []