#!/usr/bin/env python3 """Mechanical link/anchor + convention checks for the ai-back-office PRD tree.""" import re import difflib from pathlib import Path BASE = Path("/Users/gabrielradureau/Work/Arcodange/factory/.claude/worktrees/client-dossier-synergy/vibe/PRD/ai-back-office") FILES = ["README.md", "task-inventory.md", "agent-architecture.md", "model-fleet.md", "compliance.md", "roadmap.md", "agent-catalog.md", "challenges.md", "poc-plan.md", "qa-strategy.md", "STATUS.md"] LINK_RE = re.compile(r'\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)') HEADING_RE = re.compile(r'^(#{1,6})\s+(.+?)\s*$') def strip_md(text: str) -> str: text = re.sub(r'\[([^\]]*)\]\([^)]*\)', r'\1', text) # links -> text text = text.replace('`', '') return text def slugify(text: str) -> str: """GitHub-style slug per the given spec: lowercase; keep alnum/space/hyphen; space->hyphen.""" t = strip_md(text).strip().lower() kept = ''.join(ch for ch in t if ch.isalnum() or ch in ' -') return kept.replace(' ', '-') def load(path: Path): return path.read_text(encoding='utf-8').splitlines() def headings_and_slugs(path: Path): """Return ordered list of (level, text) headings outside code fences, plus slug set with GitHub dup handling.""" slugs = {} heads = [] in_fence = False for line in load(path): if line.strip().startswith('```'): in_fence = not in_fence continue if in_fence: continue m = HEADING_RE.match(line) if m: text = m.group(2) heads.append((len(m.group(1)), text)) base = slugify(text) if base in slugs: slugs[base] += 1 slugs[f"{base}-{slugs[base]}"] = 0 else: slugs[base] = 0 return heads, set(slugs.keys()) def extract_links(path: Path): """(lineno, text, target) for every markdown link.""" out = [] for i, line in enumerate(load(path), 1): for m in LINK_RE.finditer(line): out.append((i, m.group(1), m.group(2))) return out def main(): slug_cache = {} def slugs_for(p: Path): rp = p.resolve() if rp not in slug_cache: slug_cache[rp] = headings_and_slugs(rp)[1] if rp.exists() else set() return slug_cache[rp] broken = [] total_links = 0 for fname in FILES: fpath = BASE / fname for lineno, text, target in extract_links(fpath): total_links += 1 t = target.strip('<>') if t.startswith(('http://', 'https://', 'mailto:')): continue if '#' in t: pathpart, anchor = t.split('#', 1) else: pathpart, anchor = t, None if pathpart: resolved = (fpath.parent / pathpart).resolve() if not resolved.exists(): broken.append(f"{fname}:{lineno} -> {target} [MISSING FILE {resolved}]") continue else: resolved = fpath.resolve() if anchor is not None: if resolved.suffix != '.md': broken.append(f"{fname}:{lineno} -> {target} [ANCHOR ON NON-MD]") continue sl = slugs_for(resolved) if anchor not in sl: близ = difflib.get_close_matches(anchor, sl, n=2) broken.append(f"{fname}:{lineno} -> {target} [UNRESOLVED ANCHOR; close: {близ}]") print("=== JOB 1: LINK & ANCHOR CHECK ===") print(f"total links scanned: {total_links}") if broken: for b in broken: print("BROKEN:", b) else: print("all relative file links + anchors resolve: NONE BROKEN") # ---------------- Job 2 mechanical parts ---------------- print("\n=== JOB 2a: BREADCRUMBS ===") crumb_re = re.compile(r'^(\[[^\]]+\]\([^)]+\) > )+\*\*[^*]+\*\*$') for fname in FILES: first = load(BASE / fname)[0] ok = bool(crumb_re.match(first)) print(f"{fname}:1 breadcrumb {'OK' if ok else 'VIOLATION: ' + first!r}") print("\n=== JOB 2b: HEADER BLOCKQUOTE (Status + Last Updated) ===") for fname in FILES: lines = load(BASE / fname) bq = [l for l in lines[:12] if l.startswith('>')] joined = '\n'.join(bq) has_status = '**Status:**' in joined mdate = re.search(r'\*\*Last Updated:\*\*\s*(\S+)', joined) date = mdate.group(1) if mdate else None ok = has_status and date == '2026-07-11' print(f"{fname}: Status={'Y' if has_status else 'N'} LastUpdated={date} -> {'OK' if ok else 'VIOLATION'}") print("\n=== JOB 2c: TOMBSTONE SCAN (eyeball hits) ===") tomb = re.compile(r'(?i)previously|formerly|renamed from|was renamed|no longer|used to be|correction \(|changelog|superseded|deprecated|instead of the old|updated? on \d{4}') hits = 0 for fname in FILES: for i, line in enumerate(load(BASE / fname), 1): if tomb.search(line): hits += 1 print(f"{fname}:{i}: {line.strip()[:140]}") if not hits: print("no tombstone-pattern hits") print("\n=== JOB 2d: BIDIRECTIONAL LINKS ===") hub_text = (BASE / "README.md").read_text() leaves = [f for f in FILES if f != "README.md"] for leaf in leaves: in_hub = f']({leaf})' in hub_text or f']({leaf}#' in hub_text leaf_text = (BASE / leaf).read_text() back = '](README.md)' in leaf_text or '](README.md#' in leaf_text print(f"{leaf}: hub->leaf {'OK' if in_hub else 'MISSING'} | leaf->hub {'OK' if back else 'MISSING'}") print("\n=== JOB 2e: MERMAID CONVENTION ===") for fname in FILES: lines = load(BASE / fname) i = 0 while i < len(lines): if lines[i].strip().startswith('```mermaid'): start = i first_inner = lines[i + 1].strip() if i + 1 < len(lines) else '' has_init = first_inner.startswith('%%{init') j = i + 1 while j < len(lines) and not lines[j].strip().startswith('```'): j += 1 k = j + 1 while k < len(lines) and lines[k].strip() == '': k += 1 followed = k < len(lines) and re.match(r'^1[.)]\s', lines[k].strip()) print(f"{fname}:{start+1} mermaid: init={'OK' if has_init else 'MISSING'} numbered-list-after={'OK' if followed else 'MISSING'}") i = j i += 1 print("\n=== JOB 2f: ID CONSISTENCY SCAN ===") # definitions from headings def defined_ids(fname, pat): heads, _ = headings_and_slugs(BASE / fname) out = set() for _, h in heads: m = re.match(pat, h) if m: out.add(m.group(1)) return out tasks_def = defined_ids("task-inventory.md", r'^(T\d{2})\b') chal_def = defined_ids("challenges.md", r'^(C\d{1,2})\b') poc_def = defined_ids("poc-plan.md", r'^(POC-\d)\b') refs = {'T': {}, 'C': {}, 'POC': {}, 'D': {}, 'A': {}, 'phase': {}} for fname in FILES: text = (BASE / fname).read_text() for m in re.finditer(r'\bT\d{2}\b', text): refs['T'].setdefault(m.group(0), set()).add(fname) for m in re.finditer(r'\bC\d{1,2}\b', text): refs['C'].setdefault(m.group(0), set()).add(fname) for m in re.finditer(r'\bPOC-\d\b', text): refs['POC'].setdefault(m.group(0), set()).add(fname) for m in re.finditer(r'\bD\d\b', text): refs['D'].setdefault(m.group(0), set()).add(fname) for m in re.finditer(r'\bA\d\b', text): refs['A'].setdefault(m.group(0), set()).add(fname) for m in re.finditer(r'(?i)\bphase\s+(\d)\b', text): refs['phase'].setdefault(m.group(1), set()).add(fname) print(f"tasks defined: {sorted(tasks_def)}") print(f"task refs outside defined set: {sorted(set(refs['T']) - tasks_def)}") print(f"challenges defined: {sorted(chal_def, key=lambda x: int(x[1:]))}") print(f"challenge refs outside defined set: {sorted(set(refs['C']) - chal_def)}") print(f"POCs defined: {sorted(poc_def)}") print(f"POC refs outside defined set: {sorted(set(refs['POC']) - poc_def)}") print(f"D refs: {sorted(refs['D'])} (defined D1-D6 in agent-architecture open-decisions table)") print(f"A-token refs: {sorted(refs['A'])} <- note A4 is a CA3 box, not an autonomy level") print(f"phase numbers referenced: {sorted(refs['phase'])}") print("\n=== JOB 2g: DATE SCAN (regulatory) ===") date_pat = re.compile(r'20\d{2}-\d{2}(?:-\d{2})?') for fname in FILES: for i, line in enumerate(load(BASE / fname), 1): for m in date_pat.finditer(line): d = m.group(0) if d.startswith(('2026-09', '2027-09', '2027-01', '2026-12', '2027-05')): print(f"{fname}:{i}: {d} | {line.strip()[:100]}") print("\n=== BONUS: parent PRD hub backlink ===") parent = BASE.parent / "README.md" if parent.exists(): ptext = parent.read_text() print(f"vibe/PRD/README.md links ai-back-office: {'YES' if 'ai-back-office' in ptext else 'NO — tree not registered in parent hub'}") else: print("parent PRD hub missing") if __name__ == '__main__': main()