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) <noreply@anthropic.com>
This commit is contained in:
sjat 2026-06-05 18:13:49 +02:00
parent 66d11cc352
commit f566fd17eb
2 changed files with 83 additions and 0 deletions

View file

@ -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/<prev>-findings.json` and tag each
finding **new / recurring / resolved**.
- Prioritise by severity; split into auto-fixable vs report-only.

View file

@ -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