opencode-config/.opencode/scripts/check-adr-refs.py
Sergey a8f9aa0bc6
feat: migrate .opencode/ config from opencode (#23)
* feat: migrate .opencode/ config from opencode

* refactor: rename repo refs and sanitize for public

* docs(handoff): add pr-7 handoff + ADR-002

* docs(handoff): fix PR number

* docs: update project map with .opencode/ structure

---------

Co-authored-by: opencode-agent <agent@slaid098.dev>
2026-07-23 22:57:39 +03:00

96 lines
3.1 KiB
Python
Executable file

#!/usr/bin/env python3
"""Check ADR references in markdown files for dangling pointers.
Scans ``.md`` files in the repo for ``ADR-NNN`` references and verifies
that a corresponding ``docs/decisions/NNN-*.md`` file exists. Dangling
references (typos, forward-refs) fail CI — symmetric to
``check-permissions.py`` (ADR-006 pattern).
Self-reference is excluded: if the current file's name starts with
``NNN-``, a reference to ``ADR-NNN`` inside it is OK (an ADR file may
mention its own number).
What is NOT caught: wrong existing refs (``ADR-017`` exists but is
semantically wrong for a given PR) — that requires semantic analysis.
"""
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
ADR_DIR = REPO_ROOT / "docs" / "decisions"
ADR_REF_RE = re.compile(r"\bADR-(\d{3})\b")
EXCLUDE_DIRS = {"node_modules", ".git", "app_data", ".opencode"}
def _is_excluded(path: Path) -> bool:
"""True if ``path`` is inside an excluded directory (node_modules, .git, ...)."""
try:
rel = path.relative_to(REPO_ROOT)
except ValueError:
return True
parts = rel.parts
return any(excl in parts for excl in EXCLUDE_DIRS)
def find_md_files() -> list[Path]:
"""Return all ``.md`` files under REPO_ROOT, excluding node_modules/.git/etc."""
results: list[Path] = []
for md_file in REPO_ROOT.rglob("*.md"):
if _is_excluded(md_file):
continue
results.append(md_file)
return sorted(results)
def find_adr_refs(md_file: Path) -> list[tuple[int, str]]:
"""Return ``[(line_number, adr_number), ...]`` for every ``ADR-NNN`` in ``md_file``."""
refs: list[tuple[int, str]] = []
text = md_file.read_text(encoding="utf-8", errors="replace")
for line_no, line in enumerate(text.splitlines(), start=1):
for match in ADR_REF_RE.finditer(line):
refs.append((line_no, match.group(1)))
return refs
def is_self_reference(md_file: Path, adr_number: str) -> bool:
"""True if ``md_file``'s name starts with ``NNN-`` where NNN == adr_number."""
return md_file.name.startswith(f"{adr_number}-")
def adr_exists(adr_number: str) -> bool:
"""True if ``docs/decisions/NNN-*.md`` exists for the given number."""
if not ADR_DIR.exists():
return False
return any(ADR_DIR.glob(f"{adr_number}-*.md"))
def main() -> None:
all_violations: list[str] = []
for md_file in find_md_files():
refs = find_adr_refs(md_file)
for ref_line, ref_number in refs:
if is_self_reference(md_file, ref_number):
continue
if not adr_exists(ref_number):
all_violations.append(
f" {md_file.relative_to(REPO_ROOT)}:{ref_line}: "
f"ADR-{ref_number} reference, but "
f"docs/decisions/{ref_number}-*.md does not exist"
)
if not all_violations:
print("OK: No dangling ADR references.")
sys.exit(0)
print("FAIL: Dangling ADR references:\n")
for v in all_violations:
print(v)
print(f"\nTotal: {len(all_violations)} violation(s)")
sys.exit(1)
if __name__ == "__main__":
main()