* 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>
96 lines
3.1 KiB
Python
Executable file
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()
|