|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Repository-local ADR governance checks. |
| 3 | +
|
| 4 | +Enforces: |
| 5 | +- ADR files live under docs/adr/ and use ADR-NNNN-short-title.md. |
| 6 | +- Required sections exist. |
| 7 | +- Status is present and from the allowed lifecycle. |
| 8 | +- Accepted ADRs are immutable after acceptance. If a decision changes, create a |
| 9 | + new ADR and supersede by reference rather than editing the Accepted ADR. |
| 10 | +""" |
| 11 | +from __future__ import annotations |
| 12 | + |
| 13 | +import os |
| 14 | +import re |
| 15 | +import subprocess |
| 16 | +import sys |
| 17 | +from pathlib import Path |
| 18 | + |
| 19 | +ADR_DIR = Path("docs/adr") |
| 20 | +ALLOWED_STATUSES = {"Proposed", "Accepted", "Superseded", "Deprecated", "Rejected"} |
| 21 | +REQUIRED_SECTIONS = ["Context", "Decision", "Consequences", "Validation"] |
| 22 | +FILENAME_RE = re.compile(r"^ADR-\d{4}-[a-z0-9][a-z0-9-]*\.md$") |
| 23 | +STATUS_RE = re.compile(r"(?im)^\s*(?:[-*]\s*)?.*?Status.*?:\s*(.+?)\s*$") |
| 24 | + |
| 25 | + |
| 26 | +def run(cmd: list[str]) -> str: |
| 27 | + return subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL).strip() |
| 28 | + |
| 29 | + |
| 30 | +def status_of(text: str) -> str | None: |
| 31 | + m = STATUS_RE.search(text) |
| 32 | + return m.group(1).strip().strip("*").strip() if m else None |
| 33 | + |
| 34 | + |
| 35 | +def base_ref() -> str | None: |
| 36 | + ref = os.environ.get("GITHUB_BASE_REF") |
| 37 | + if ref: |
| 38 | + return f"origin/{ref}" |
| 39 | + # On push, compare against first parent where available. |
| 40 | + try: |
| 41 | + return run(["git", "rev-parse", "HEAD^1"]) |
| 42 | + except Exception: |
| 43 | + return None |
| 44 | + |
| 45 | + |
| 46 | +def changed_files_against_base(base: str) -> list[str]: |
| 47 | + try: |
| 48 | + return run(["git", "diff", "--name-only", f"{base}...HEAD"]).splitlines() |
| 49 | + except Exception: |
| 50 | + try: |
| 51 | + return run(["git", "diff", "--name-only", f"{base}", "HEAD"]).splitlines() |
| 52 | + except Exception: |
| 53 | + return [] |
| 54 | + |
| 55 | + |
| 56 | +def file_at(ref: str, path: str) -> str | None: |
| 57 | + try: |
| 58 | + return run(["git", "show", f"{ref}:{path}"]) |
| 59 | + except Exception: |
| 60 | + return None |
| 61 | + |
| 62 | + |
| 63 | +def main() -> int: |
| 64 | + errors: list[str] = [] |
| 65 | + if not ADR_DIR.exists(): |
| 66 | + print("No docs/adr directory; nothing to validate.") |
| 67 | + return 0 |
| 68 | + |
| 69 | + adr_files = sorted(p for p in ADR_DIR.glob("ADR-*.md") if p.is_file()) |
| 70 | + base = base_ref() |
| 71 | + changed = changed_files_against_base(base) if base else [] |
| 72 | + changed_adr_paths = {Path(name) for name in changed if name.startswith("docs/adr/ADR-") and name.endswith(".md")} |
| 73 | + |
| 74 | + # Grandfather legacy ADRs when first installing governance. Enforce full |
| 75 | + # structure on ADRs touched by this PR, while still checking duplicate |
| 76 | + # numbers across the full directory. |
| 77 | + files_to_validate = sorted((Path(p) for p in changed_adr_paths if Path(p).exists()), key=str) if base else adr_files |
| 78 | + |
| 79 | + seen_numbers: dict[str, Path] = {} |
| 80 | + for path in adr_files: |
| 81 | + number = path.name.split("-", 2)[1] if "-" in path.name else path.name |
| 82 | + if number in seen_numbers: |
| 83 | + errors.append(f"{path}: duplicate ADR number also used by {seen_numbers[number]}") |
| 84 | + seen_numbers[number] = path |
| 85 | + |
| 86 | + for path in files_to_validate: |
| 87 | + if not FILENAME_RE.match(path.name): |
| 88 | + errors.append(f"{path}: filename must match ADR-NNNN-short-title.md") |
| 89 | + text = path.read_text(encoding="utf-8") |
| 90 | + st = status_of(text) |
| 91 | + if not st: |
| 92 | + errors.append(f"{path}: missing Status") |
| 93 | + elif st not in ALLOWED_STATUSES: |
| 94 | + errors.append(f"{path}: invalid Status '{st}' (allowed: {', '.join(sorted(ALLOWED_STATUSES))})") |
| 95 | + for section in REQUIRED_SECTIONS: |
| 96 | + if not re.search(rf"(?im)^##\s+{re.escape(section)}\b", text): |
| 97 | + errors.append(f"{path}: missing required section '## {section}'") |
| 98 | + |
| 99 | + if base: |
| 100 | + for name in changed: |
| 101 | + if not (name.startswith("docs/adr/ADR-") and name.endswith(".md")): |
| 102 | + continue |
| 103 | + old = file_at(base, name) |
| 104 | + if old is None: |
| 105 | + continue |
| 106 | + old_status = status_of(old) |
| 107 | + if old_status == "Accepted": |
| 108 | + errors.append( |
| 109 | + f"{name}: Accepted ADRs are immutable. Create a new superseding ADR instead of editing this file." |
| 110 | + ) |
| 111 | + |
| 112 | + if errors: |
| 113 | + print("ADR governance failed:") |
| 114 | + for e in errors: |
| 115 | + print(f"- {e}") |
| 116 | + return 1 |
| 117 | + print(f"ADR governance passed ({len(adr_files)} ADR file(s) checked).") |
| 118 | + return 0 |
| 119 | + |
| 120 | +if __name__ == "__main__": |
| 121 | + raise SystemExit(main()) |
0 commit comments