mirror of
https://github.com/Druthulu/BFM-decomp
synced 2026-09-26 13:33:34 -04:00
105 lines
4.7 KiB
Python
105 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
|
"""doc_links.py — every relative Markdown link in the public-facing docs resolves (P33 D5; in tools-health).
|
|
|
|
tools/doc_links.py # the default document set; exit 1 on a broken link
|
|
tools/doc_links.py --strict # also fail on links listed as PENDING (for gate 2 / the PhaseEnd)
|
|
tools/doc_links.py FILE… # an explicit set
|
|
|
|
A link `[text](target)` whose target is not a URL/mailto/anchor must resolve relative to the linking file (or to the
|
|
repository root when it starts with `/`); `#fragment`s are stripped. Targets listed in docs/doc_links_pending.txt
|
|
(`path<TAB>the task that creates it`) are reported as PENDING instead of BROKEN — a README may promise a page that a
|
|
later task of the same phase writes, but the promise is tracked, and `--strict` (gate 2) refuses any pending entry.
|
|
Coverage is printed with its denominator (R41): files scanned, links checked, pending, broken.
|
|
"""
|
|
import argparse
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
REPO = pathlib.Path(__file__).resolve().parent.parent
|
|
DEFAULT = ["README.md", "THIRD_PARTY.md", "CLAUDE.md", "src/NOTICE.md", "tools/README.md", "docs/SETUP.md",
|
|
"docs/verification.md", "docs/public-flip-runbook.md", "docs/decision-log.md", "docs/accelerators.md",
|
|
"docs/story.md", "docs/story-timeline.md", "docs/retrospective.md", "phase-ends/README.md", "phase-ends/DIGEST.md",
|
|
"docs/gcc-2.7.2-map/README.md", "tools/xsig/README.md",
|
|
"docs/permuter-ils.md", "docs/matching-drafter-pipeline.md",
|
|
"docs/gen3-handoff.md"]
|
|
# whole directories in the default set (P33 F3): the wiki pages and the how-to chapters — every file, so a new page is
|
|
# checked the moment it exists (the glob is expanded at run time; the count is printed with the rest, R41)
|
|
DEFAULT_GLOBS = ["docs/wiki/*.md", "docs/how-to-ai-decomp/*.md"]
|
|
|
|
|
|
def default_files():
|
|
out = list(DEFAULT)
|
|
for g in DEFAULT_GLOBS:
|
|
out += [p.relative_to(REPO).as_posix() for p in sorted(REPO.glob(g))]
|
|
return out
|
|
PENDING_FILE = REPO / "docs" / "doc_links_pending.txt"
|
|
LINK_RE = re.compile(r"(?<!\!)\[[^\]]*\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
|
|
SKIP_PREFIX = ("http://", "https://", "mailto:", "#", "ftp://")
|
|
|
|
|
|
def pending():
|
|
out = {}
|
|
if PENDING_FILE.exists():
|
|
for ln in PENDING_FILE.read_text(encoding="utf-8").splitlines():
|
|
ln = ln.strip()
|
|
if ln and not ln.startswith("#"):
|
|
path, _, task = ln.partition("\t")
|
|
out[path.strip()] = task.strip() or "?"
|
|
return out
|
|
|
|
|
|
def check(files, strict):
|
|
pend = pending()
|
|
n_links, broken, pend_hits = 0, [], []
|
|
for f in files:
|
|
fp = REPO / f
|
|
if not fp.exists():
|
|
if f in pend:
|
|
pend_hits.append((f, "<the document itself>", pend[f])) # promised by a later task, tracked
|
|
else:
|
|
broken.append((f, "<file itself>", "document missing"))
|
|
continue
|
|
text = fp.read_text(encoding="utf-8", errors="replace")
|
|
for m in LINK_RE.finditer(text):
|
|
tgt = m.group(1)
|
|
if tgt.startswith(SKIP_PREFIX):
|
|
continue
|
|
n_links += 1
|
|
path = tgt.split("#", 1)[0]
|
|
if not path:
|
|
continue
|
|
resolved = (REPO / path.lstrip("/")) if path.startswith("/") else (fp.parent / path)
|
|
if resolved.exists():
|
|
continue
|
|
rel = str(resolved.resolve().relative_to(REPO)) if resolved.resolve().is_relative_to(REPO) else str(resolved)
|
|
if rel in pend:
|
|
pend_hits.append((f, tgt, pend[rel]))
|
|
else:
|
|
broken.append((f, tgt, "does not exist"))
|
|
print(f"doc_links: {len(files)} documents, {n_links} relative links checked, {len(pend_hits)} pending "
|
|
f"({len(pend)} planned targets on the list), {len(broken)} broken")
|
|
for f, t, why in broken:
|
|
print(f" BROKEN {f}: ({t}) {why}")
|
|
for f, t, task in sorted(set(pend_hits)):
|
|
print(f" PENDING {f}: ({t}) — created by {task}")
|
|
if broken:
|
|
return 1
|
|
if strict and pend_hits:
|
|
print("doc_links --strict: pending links remain — the promised pages must exist before gate 2")
|
|
return 1
|
|
print("doc_links: OK" + (" (with pending, allowed outside --strict)" if pend_hits else ""))
|
|
return 0
|
|
|
|
|
|
def main(argv):
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("files", nargs="*")
|
|
ap.add_argument("--strict", action="store_true")
|
|
a = ap.parse_args(argv)
|
|
return check(a.files or default_files(), a.strict)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|