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:
parent
66d11cc352
commit
f566fd17eb
2 changed files with 83 additions and 0 deletions
|
|
@ -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**
|
(roles, ADRs, runbooks, playbooks, scripts — your shard list) and **exact findings**
|
||||||
(markers, broken refs, unencrypted vaults). Fold these into the report verbatim.
|
(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
|
### Phase 1 — fan-out judgement review
|
||||||
Scale to repo size:
|
Scale to repo size:
|
||||||
- **Small** (≤ ~10 roles, like boma today): a few sub-agents, or one pass per area.
|
- **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).
|
- Merge and dedupe all findings (deterministic + reviewer).
|
||||||
- Run **one cross-cutting reviewer** over the full ADR set + `STATUS.md` + `CLAUDE.md`
|
- 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).
|
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
|
- Diff against the previous run's `docs/reviews/<prev>-findings.json` and tag each
|
||||||
finding **new / recurring / resolved**.
|
finding **new / recurring / resolved**.
|
||||||
- Prioritise by severity; split into auto-fixable vs report-only.
|
- Prioritise by severity; split into auto-fixable vs report-only.
|
||||||
|
|
|
||||||
|
|
@ -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./-]+")
|
PATH_REF_RE = re.compile(r"(?:docs|scripts|roles|inventories|terraform|playbooks)/[\w./-]+")
|
||||||
PLACEHOLDER = set("<>*${}")
|
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():
|
def walk_files():
|
||||||
for dirpath, dirnames, filenames in os.walk(ROOT):
|
for dirpath, dirnames, filenames in os.walk(ROOT):
|
||||||
|
|
@ -81,6 +142,9 @@ def adr_numbers():
|
||||||
def scan():
|
def scan():
|
||||||
findings = []
|
findings = []
|
||||||
adrs = adr_numbers()
|
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():
|
for path in walk_files():
|
||||||
rpath = rel(path)
|
rpath = rel(path)
|
||||||
if rpath.startswith(SKIP_PREFIX):
|
if rpath.startswith(SKIP_PREFIX):
|
||||||
|
|
@ -108,7 +172,13 @@ def scan():
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if rpath.startswith(decisions_dir) and rpath.endswith(".md"):
|
||||||
|
adr_files[rpath] = lines
|
||||||
|
|
||||||
for i, line in enumerate(lines, 1):
|
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)))
|
markers = sorted(set(m.group(1) for m in MARKER_RE.finditer(line)))
|
||||||
if markers:
|
if markers:
|
||||||
findings.append({"check": "marker", "severity": "low", "path": rpath,
|
findings.append({"check": "marker", "severity": "low", "path": rpath,
|
||||||
|
|
@ -131,6 +201,7 @@ def scan():
|
||||||
if not os.path.exists(os.path.join(ROOT, ref)):
|
if not os.path.exists(os.path.join(ROOT, ref)):
|
||||||
findings.append({"check": "broken-path-ref", "severity": "medium", "path": rpath,
|
findings.append({"check": "broken-path-ref", "severity": "medium", "path": rpath,
|
||||||
"line": i, "detail": f"references '{ref}' which does not exist"})
|
"line": i, "detail": f"references '{ref}' which does not exist"})
|
||||||
|
findings.extend(deferred_findings(adr_files, defer_refs))
|
||||||
return findings
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue