oc_chat
Loading...
Searching...
No Matches
link_check.py
Go to the documentation of this file.
1#!/usr/bin/env python
2"""Repo-wide stale-link checker for Markdown docs (OC_Chat maintenance tool).
3
4Scans every ``.md`` file under the repo, extracts ``[](...)`` links and backtick
5path code-spans, resolves each against the filesystem, and reports DEAD file
6references. The output is filtered so the noise (illustrative example paths,
7forward-refs to not-yet-authored TARGET files, generated views, preserved
8receipts, frozen golden fixtures) is separated from genuine broken navigation.
9
10The high-signal section ("A") is: markdown ``[](...)`` nav-links in *active*
11docs whose target no longer exists but whose basename exists *elsewhere* (i.e.
12the target moved) — with the new location. Those are the actionable stale links.
13
14Usage: python tools/link_check.py
15
16Origin: written 2026-07-04 to sweep for stale links after the Plan-142 archival
17(the archival moved 17 plan files to plans/completed/ and left dead references
18in lint config, specs, and cross-links). Caught 3 partial-scope misses that
19several scoped greps had missed; kept as a reusable check.
20
21TODO(oct-migration): this checker is generic, not OC_Chat-specific. Migrate it
22into the `oct` linter as a repo-wide ``stale_links`` check when the monorepo
23lands, respecting the exempt buckets below (receipts / old / archive /
24completed / generated views / golden fixtures). Kept here meanwhile.
25"""
26import os, re
27
28REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # tools/ -> repo root
29SCAN_ROOTS = ["oc_chat", "tools"]
30
31
32def _n(p):
33 return p.replace("/", os.sep)
34
35
36# A dead link in one of these is EXPECTED (preserved receipt / superseded snapshot /
37# generated view / frozen test oracle) — never reported as actionable.
38EXEMPT = [_n(s) for s in [
39 "historical-archive-DO-NOT-DELETE", "/old/", "/reviews/", "/reports/reports/",
40 "DESIGN_DECISIONS_2026-06-08", "report-phase2", "/completed/",
41 "/golden/", # frozen render-oracle fixtures — must byte-match; fix the source, not these
42]]
43# Active-ish design/research/portable docs, heavy with ILLUSTRATIVE example paths;
44# demoted to CONTEXT so the actionable "A" section stays clean.
45CONTEXT = [_n(s) for s in [
46 "/docs/Ollama/", "/LLM-API-support/", "/best-practices/", "/benchmarks/",
47 "/architecture/memory/", "/architecture/roadmap-render/", "/architecture/agent_runtime/",
48 "/architecture/security/", "/version-semantics/", "/process/", "/option_c_specs/",
49 "/docs/research/", "/docs/audits/", # dated research/audit snapshots -> archived versions
50]]
51
52
53def _generated(fp):
54 return fp == os.path.join(REPO, "CHANGELOG.md") or \
55 fp.startswith(os.path.join(REPO, "oc_chat", "roadmap") + os.sep)
56
57
58def bucket(fp):
59 if any(s in fp for s in EXEMPT) or _generated(fp):
60 return "EXEMPT"
61 if any(s in fp for s in CONTEXT):
62 return "CONTEXT"
63 return "CORE"
64
65
66_md_link = re.compile(r'\‍[[^\‍]]*\‍]\‍(([^)\s]+)')
67_code_span = re.compile(r'`([^`\n]+)`')
68
69
70def _clean(t):
71 t = t.strip().strip("<>").strip().split("#", 1)[0]
72 return re.sub(r'(:\d+(-\d+)?)+$', '', t).strip()
73
74
76 if not t or any(c in t for c in " \t~*{}<>|"):
77 return False
78 if "://" in t or t.startswith(("http", "mailto:")):
79 return False
80 return "/" in t and t.lower().endswith(".md")
81
82
83def _resolve(target, d):
84 return any(os.path.exists(os.path.normpath(os.path.join(b, target))) for b in (REPO, d))
85
86
88 for root in SCAN_ROOTS:
89 for dp, _, fs in os.walk(os.path.join(REPO, root)):
90 if ".git" in dp:
91 continue
92 for f in fs:
93 if f.endswith(".md"):
94 yield os.path.join(dp, f)
95 for f in os.listdir(REPO):
96 if f.endswith(".md"):
97 yield os.path.join(REPO, f)
98
99
100def main():
101 files = list(_walk_md())
102 index = {}
103 for fp in files:
104 index.setdefault(os.path.basename(fp).lower(), []).append(os.path.relpath(fp, REPO))
105
106 dead, checked = [], 0
107 for fp in files:
108 d = os.path.dirname(fp)
109 text = open(fp, encoding="utf-8", errors="replace").read()
110 src = {}
111 for m in _code_span.finditer(text):
112 src[m.group(1)] = "code-span"
113 for m in _md_link.finditer(text):
114 src[m.group(1)] = "LINK"
115 for raw, s in src.items():
116 t = _clean(raw)
117 if not _is_md_ref(t):
118 continue
119 checked += 1
120 if not _resolve(t, d):
121 dead.append((os.path.relpath(fp, REPO), t, bucket(fp), s))
122
123 def cands(t):
124 base = t.rstrip("/").split("/")[-1].lower()
125 return [c for c in index.get(base, []) if c.replace("\\", "/") != t]
126
127 core = sorted(set((d[0], d[1], d[3]) for d in dead if d[2] == "CORE"))
128 nav_moved = [(fp, t, cands(t)) for fp, t, s in core if s == "LINK" and cands(t)]
129 print(f"scanned {len(files)} .md files; checked {checked} .md nav-links")
130 print(f"\nA. GENUINE BROKEN NAV LINKS (markdown [](...) links in active docs, target moved): {len(nav_moved)}")
131 for fp, t, c in nav_moved:
132 print(f" {fp}\n [](link): {t}\n now at: {', '.join(c[:2])}")
133 if not nav_moved:
134 print(" (none)")
135 print("\n(CONTEXT / EXEMPT / never-existed buckets suppressed -- tweak the script to surface them.)")
136
137
138if __name__ == "__main__":
139 main()