oc_chat
Loading...
Searching...
No Matches
py_link_check.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/scripts/py_link_check.py
4
5# OCT-LINT: disable=tests_exist reason="Calibrated at authoring via the plan-151 T0 RED/GREEN fixture run; a unit-test module is tracked post-migration"
6"""
7Relative-link checker for NON-``.md`` link targets (the class ``tools/link_check.py`` is blind to).
8
9Purpose
10-------
11``tools/link_check.py`` only validates markdown links whose target ends in ``.md``
12(``_is_md_ref``), so it is BLIND to dead relative links pointing at ``.py`` (or any
13other non-``.md``) files — exactly the class a code move breaks (e.g. the many
14``.py``-target links in ``CONTRACTS.md`` / ``TOOL_CONTRACT.md``). This companion
15sweep resolves every relative, non-URL, non-anchor markdown link whose target is a
16file with a non-``.md`` extension and reports the ones that do not exist on disk.
17Usage: ``python oc_chat/scripts/py_link_check.py [--root DIR] [--json]``. ``--root``
18defaults to the repository root inferred from this file's location, so the script
19scans the tree it travels with (post-move it is invoked from the monorepo root as
20``python oc_chat/oc_chat/scripts/py_link_check.py``). Exit ``0`` when no broken
21non-``.md`` relative file links are found; ``1`` otherwise. The §2.2 gate is
22baseline-relative: fail only on NEW entries vs the recorded pre-move baseline.
23
24Responsibilities
25----------------
26- Enumerate ``*.md`` files under a scan root (skipping VCS internals).
27- Extract inline-link targets and normalise them (strip titles, fragments,
28 queries, ``:NN`` line locators, percent-encoding).
29- Resolve each non-``.md`` relative file target and report the unresolved ones.
30
31Diagnostics
32-----------
33Domain: OC_CHAT.MAIN
34Levels:
35 L2 - CLI lifecycle (scan start / result)
36 L3 - per-root resolution details
37 L4 - per-file / per-link tracing
38
39Contracts
40---------
41- Imports ONLY ``oc_diagnostics`` (structured logging) — never ``oc_chat`` — so it
42 is immune to the ``sys.path[0] = scripts/`` ``import oc_chat`` editable-install
43 shadowing trap (plan 151 §2.3); the scan logic is driven entirely by the ``--root``
44 filesystem path, not by imports, so it runs identically pre- and post-move.
45- Read-only: never writes to the scanned tree.
46- Deterministic: results are sorted; identical tree -> identical output.
47
48Dependencies
49------------
50- oc_diagnostics (structured debug logging via _dbg)
51"""
52from __future__ import annotations
53
54import argparse
55import json
56import re
57import sys
58from dataclasses import dataclass
59from pathlib import Path
60from urllib.parse import unquote
61
62from oc_diagnostics import _dbg
63
64
65_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
66
67# Markdown inline link body: the target portion of ``[text](target)``.
68_LINK_RE = re.compile(r"\‍]\‍(([^)]+)\‍)")
69# Targets we never resolve as on-disk relative file paths.
70_SKIP_PREFIXES = ("http://", "https://", "mailto:", "ftp://", "tel:", "#", "//")
71# A trailing source locator appended to a code ref: ``:123``, ``:123-456``,
72# ``:123:4`` (line:col), or ``:123:4-125`` (editor line:col-line spans).
73_LINE_LOCATOR_RE = re.compile(r"(?::\d+)+(?:-\d+(?::\d+)?)?$")
74
75
76@dataclass(frozen=True)
78 """One unresolved non-``.md`` relative link."""
79
80 source: str
81 target: str
82 resolved: str
83
84
85def _iter_markdown_files(root: Path):
86 """Yield every ``*.md`` file under *root*, skipping VCS internals."""
87
88 for path in sorted(root.rglob("*.md")):
89 if ".git" in path.parts:
90 continue
91 yield path
92
93
94def _clean_target(raw: str) -> str | None:
95 """Normalise a link body to a bare relative file path, or ``None`` to skip.
96
97 Strips ``<...>`` wrappers, an optional ``"title"`` suffix, ``#anchor`` /
98 ``?query`` fragments, a trailing ``:NN`` source-line locator, and percent
99 encoding. Returns ``None`` for URLs, pure anchors, absolute paths, extension-less
100 targets (directory/section references), and ``.md`` targets (owned by
101 ``tools/link_check.py``).
102 """
103
104 func = "_clean_target"
105 target = raw.strip()
106 if target.startswith("<") and target.endswith(">"):
107 target = target[1:-1].strip()
108 for quote in ('"', "'"):
109 idx = target.find(f" {quote}")
110 if idx != -1:
111 target = target[:idx].strip()
112 if not target or target.startswith(_SKIP_PREFIXES) or "://" in target:
113 return None
114 target = target.split("#", 1)[0].split("?", 1)[0]
115 if not target:
116 return None
117 target = _LINE_LOCATOR_RE.sub("", unquote(target))
118 if not target or target.startswith("/") or re.match(r"^[A-Za-z]:[\\/]", target):
119 return None
120 last = target.replace("\\", "/").rstrip("/").rsplit("/", 1)[-1]
121 if "." not in last or last.lower().endswith(".md"):
122 return None
123 _dbg("OC_CHAT.MAIN", func, f"candidate target {target}", 4)
124 return target
125
126
127def find_broken_links(root: Path) -> list[BrokenLink]:
128 """Return every broken non-``.md`` relative file link under *root*, sorted."""
129
130 func = "find_broken_links"
131 root = root.resolve()
132 broken: list[BrokenLink] = []
133 for md_path in _iter_markdown_files(root):
134 try:
135 text = md_path.read_text(encoding="utf-8", errors="replace")
136 except OSError:
137 continue
138 for match in _LINK_RE.finditer(text):
139 target = _clean_target(match.group(1))
140 if target is None:
141 continue
142 resolved = (md_path.parent / target).resolve()
143 if not resolved.exists():
144 broken.append(
146 source=md_path.relative_to(root).as_posix(),
147 target=target,
148 resolved=str(resolved),
149 )
150 )
151 _dbg("OC_CHAT.MAIN", func, f"{len(broken)} broken under {root}", 2)
152 return sorted(broken, key=lambda item: (item.source, item.target))
153
154
155def _build_parser() -> argparse.ArgumentParser:
156 """Construct the CLI argument parser."""
157
158 parser = argparse.ArgumentParser(
159 prog="python oc_chat/scripts/py_link_check.py",
160 description="Report broken relative links whose target is a non-.md file.",
161 )
162 default_root = Path(__file__).resolve().parent.parent.parent
163 parser.add_argument(
164 "--root",
165 default=str(default_root),
166 help="Scan root (default: repo root inferred from this file's location).",
167 )
168 parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
169 return parser
170
171
172def main(argv: list[str] | None = None) -> int:
173 """CLI entry point: scan ``--root`` and report broken non-``.md`` file links."""
174
175 func = "main"
176 args = _build_parser().parse_args(argv)
177 root = Path(args.root)
178 if not root.is_dir():
179 print(f"py_link_check: --root is not a directory: {root}", file=sys.stderr)
180 return 2
181 broken = find_broken_links(root)
182 if args.json:
183 print(
184 json.dumps(
185 {
186 "root": str(root.resolve()),
187 "broken_count": len(broken),
188 "broken": [
189 {"source": item.source, "target": item.target, "resolved": item.resolved}
190 for item in broken
191 ],
192 },
193 indent=2,
194 sort_keys=True,
195 )
196 )
197 else:
198 print(f"py_link_check: scanned {root.resolve()}")
199 print(f"broken non-.md relative file links: {len(broken)}")
200 for item in broken:
201 print(f" {item.source} -> {item.target}")
202 _dbg("OC_CHAT.MAIN", func, f"exit {1 if broken else 0}", 2)
203 return 1 if broken else 0
204
205
206if __name__ == "__main__":
207 sys.exit(main())