From f566fd17eb6cad39e770a615220b65d2676f5d89 Mon Sep 17 00:00:00 2001 From: sjat Date: Fri, 5 Jun 2026 18:13:49 +0200 Subject: [PATCH] review-repo: add stale-deferred check for ADR Deferred entries repo-scan.py now enumerates open ADR "Deferred/Open" items and flags any that another file describes as resolved but which isn't marked resolved in place (the recurring miss in docs/FRICTION.md). review-repo.md's Phase 2 reviewer confirms each open item against later ADRs/STATUS. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/commands/review-repo.md | 12 ++++++ scripts/repo-scan.py | 71 +++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/.claude/commands/review-repo.md b/.claude/commands/review-repo.md index e887e63..b8eeaa0 100644 --- a/.claude/commands/review-repo.md +++ b/.claude/commands/review-repo.md @@ -27,6 +27,11 @@ Run `python3 scripts/repo-scan.py > /tmp/repo-scan.json`. It returns the **inven (roles, ADRs, runbooks, playbooks, scripts — your shard list) and **exact findings** (markers, broken refs, unencrypted vaults). Fold these into the report verbatim. +It also emits two deferral checks (see Phase 2): `open-deferred-item` (every still-open +ADR "Deferred/Open" entry — a checklist to confirm) and `stale-deferred` (an entry +another file describes as resolved but which isn't marked resolved in place — +high-confidence, usually auto-fixable by marking the source ADR's entry RESOLVED). + ### Phase 1 — fan-out judgement review Scale to repo size: - **Small** (≤ ~10 roles, like boma today): a few sub-agents, or one pass per area. @@ -42,6 +47,13 @@ location (file:line), description, suggested_fix, auto_fixable (bool)}`. - Merge and dedupe all findings (deterministic + reviewer). - Run **one cross-cutting reviewer** over the full ADR set + `STATUS.md` + `CLAUDE.md` to catch contradictions that span files (per-shard agents can't see these). +- **Resolve the deferral checklist.** For every `open-deferred-item` from Phase 0, + judge whether it is *genuinely* still open: search later ADRs / `STATUS.md` for a + decision on that subject (a deferred item often resolves silently when a later ADR + lands). If it has been decided, it is a stale-deferred finding — the fix is to mark + that entry RESOLVED in its **source ADR's** Deferred list (the spot the resolving + ADR's own change won't have touched). Treat every `stale-deferred` finding as + high-confidence. This is the recurring miss logged in `docs/FRICTION.md`. - Diff against the previous run's `docs/reviews/-findings.json` and tag each finding **new / recurring / resolved**. - Prioritise by severity; split into auto-fixable vs report-only. diff --git a/scripts/repo-scan.py b/scripts/repo-scan.py index daac708..6bea156 100644 --- a/scripts/repo-scan.py +++ b/scripts/repo-scan.py @@ -31,6 +31,67 @@ ADR_REF_RE = re.compile(r"\bADR-(\d{3})\b") PATH_REF_RE = re.compile(r"(?:docs|scripts|roles|inventories|terraform|playbooks)/[\w./-]+") PLACEHOLDER = set("<>*${}") +# Stale-deferred detection: ADR "Deferred/Open" entries that another file describes +# as resolved, but which aren't marked resolved in place. (See docs/FRICTION.md.) +RESOLVE_MARK_RE = re.compile(r"\b(?:RESOLVED|DECIDED)\b", re.I) +LIST_ITEM_RE = re.compile(r"^\s*(\d+\.|[-*+])\s+(.*)") +# An external "this resolves ADR-NNN deferred #K" style reference. +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) + + +def _is_defer_heading(text): + t = text.strip().lower() + return (t.startswith("deferred") or t.startswith("unresolved") + or "open question" in t or "open issue" in t) + + +def _defer_subject(item_text): + m = re.search(r"\*\*(.+?)\*\*", item_text) + s = m.group(1) if m else re.split(r"\s+[—–-]\s+|:", item_text, maxsplit=1)[0] + return re.sub(r"\s+", " ", s).strip(" *_`~—–-:.") + + +def deferred_findings(adr_files, defer_refs): + """adr_files: {rel_path: [lines]} for docs/decisions/*.md. + defer_refs: [(adr, ordinal, path, line, has_resolve_word)] gathered repo-wide. + Emits one informational `open-deferred-item` per open entry, and a `stale-deferred` + contradiction when another file describes that entry as resolved.""" + out = [] + for rpath, lines in sorted(adr_files.items()): + madr = re.match(r"(\d{3})-", os.path.basename(rpath)) + adr_num = madr.group(1) if madr else None + in_defer = False + for i, raw in enumerate(lines, 1): + hm = re.match(r"#{1,6}\s+(.*)", raw) + if hm: + in_defer = _is_defer_heading(hm.group(1)) + continue + if not in_defer: + continue + im = LIST_ITEM_RE.match(raw) + if not im: + continue + marker, item_text = im.group(1), im.group(2) + # self-marked resolved (inline RESOLVED/DECIDED or ~~strikethrough~~) → fine + if RESOLVE_MARK_RE.search(raw) or item_text.lstrip().startswith("~~"): + continue + ordinal = int(marker[:-1]) if marker[:-1].isdigit() else None + subject = _defer_subject(item_text) + tag = f" #{ordinal}" if ordinal else "" + out.append({"check": "open-deferred-item", "severity": "low", "path": rpath, + "line": i, "detail": f"open deferred item{tag} in ADR-{adr_num}: " + f"'{subject[:80]}' — confirm not resolved by a later ADR/STATUS"}) + if adr_num and ordinal: + for ra, rk, rp, rl, has_res in defer_refs: + if ra == adr_num and rk == ordinal and rp != rpath and has_res: + out.append({"check": "stale-deferred", "severity": "medium", + "path": rpath, "line": i, + "detail": f"ADR-{adr_num} deferred #{ordinal} " + f"('{subject[:60]}') is described as resolved at " + f"{rp}:{rl}, but is not marked RESOLVED in place"}) + return out + def walk_files(): for dirpath, dirnames, filenames in os.walk(ROOT): @@ -81,6 +142,9 @@ def adr_numbers(): def scan(): findings = [] adrs = adr_numbers() + adr_files = {} # docs/decisions/*.md → lines, for deferred-section parsing + defer_refs = [] # repo-wide "resolves ADR-NNN deferred #K" references + decisions_dir = os.path.join("docs", "decisions") for path in walk_files(): rpath = rel(path) if rpath.startswith(SKIP_PREFIX): @@ -108,7 +172,13 @@ def scan(): except OSError: continue + if rpath.startswith(decisions_dir) and rpath.endswith(".md"): + adr_files[rpath] = lines + for i, line in enumerate(lines, 1): + for m in DEFER_REF_RE.finditer(line): + defer_refs.append((m.group(1), int(m.group(2)), rpath, i, + bool(RESOLVE_WORD_RE.search(line)))) markers = sorted(set(m.group(1) for m in MARKER_RE.finditer(line))) if markers: findings.append({"check": "marker", "severity": "low", "path": rpath, @@ -131,6 +201,7 @@ def scan(): if not os.path.exists(os.path.join(ROOT, ref)): 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)) return findings