Files
serverAppConfigs/scripts/check-vault-links.py
T
Frank 3880503057 feat(scripts): OKF conformance linter, trash-aware vault seams
Add scripts/check-okf.py: enforces the OKF v0.1 hard rules the repo had
been carrying by convention — parseable frontmatter with type/title/
description/timestamp, description shape (length, not-equals-title, no
restated quantity), the reserved-name and index.md rules, log.md's date
heading shape (bare ISO date, one entry per date, newest first), the
500-line hint, and the doubled-page signature (a second `type:` key in
the body). Citations are reported as a soft tally, not a failure. The
500-line ceiling is escapable two ways, both explainable: `okf-exempt:
length` in a page's own frontmatter, and pages derived as one-per-
endpoint — live endpoints from scripts/, retired-endpoint era pages from
their tag (AGENTS.md lets a campaign ledger live on the page that
measured it).

scripts/gen-vault-index.py: sibling-presence was tested as a bare stem
substring, so a page whose name is a prefix of another page's name
(`frank-medium` inside `frank-medium-rocmfpx`) silently never got an
index row. Test the wikilink form instead.

scripts/check-vault-links.py: `.AGENTTRASH` is deleted-but-held state,
not vault content. It is now excluded from both the scan set and the
target index, so a live link into the trash reads as unresolved instead
of resolved.

tests/docs_completeness.bats: the same exclusion in both page walks —
trashed pages need no index row, and a trashed page must not satisfy the
docs requirement for a live compose service. Adds the OKF conformance
and index-freshness tests.

scripts/port-census.py: the port-before-cut orphan census used to prove
a merge lost no fact, kept so the next collapse can re-run it.
2026-10-19 04:54:06 -07:00

82 lines
3.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""Report unresolved links in the docs vault (serverAppConfigs-docs).
The vault is the project's source of truth, so a link to a page that does not
exist is a promise the repo cannot keep. This models **Obsidian's** resolution
rules rather than a naive one — three separate false-positive classes were found
while writing it, and a linter that cries wolf is worse than no linter:
1. Wikilinks are **case-insensitive** (`[[qBittorrent]]` -> `qbittorrent.md`).
A case-sensitive matcher reported 45 breaks; 39 were this.
2. A path-qualified wikilink (`[[folder/Note]]`) resolves by **path suffix**,
not by stem, and must not be stem-matched. Treating the whole string as a
stem reported the PP pages as missing when they exist one directory down.
3. `%20` in a markdown link is URL-encoding. Dealing in raw text reported
`Network%20Architecture.md` as missing beside the real file.
Exit status is 1 when any link is unresolved, so it gates a test.
"""
import pathlib
import re
import sys
import urllib.parse
WIKI = re.compile(r"\[\[([^\]\|#]+)(?:#[^\]\|]*)?(?:\|[^\]]*)?\]\]")
MD = re.compile(r"(?<!\!)\[[^\]]*\]\(([^)]+)\)")
EXTERNAL = ("http://", "https://", "mailto:", "#", "~")
def strip_md(path: str) -> str:
return path[:-3] if path.endswith(".md") else path
def build_index(vault: pathlib.Path):
# `.AGENTTRASH` is deleted-but-held state, not vault content: it must not be
# scanned (its relative links resolve against the trash dir, not the vault)
# and must not answer as a link target — a live page pointing into the trash
# is an unresolved link, not a resolved one.
pages = sorted(p for p in vault.rglob("*.md")
if ".AGENTTRASH" not in str(p))
stems: dict[str, list[pathlib.Path]] = {}
for page in pages:
stems.setdefault(page.stem.casefold(), []).append(page)
relative = [strip_md(str(p.relative_to(vault)).casefold()) for p in pages]
return pages, stems, relative
def wiki_resolves(target: str, stems, relative) -> bool:
want = target.strip().rstrip("\\").casefold()
if "/" in want:
return any(r == want or r.endswith("/" + want) for r in relative)
return want in stems
def md_resolves(base: pathlib.Path, target: str) -> bool:
text = urllib.parse.unquote(target.strip())
if text.startswith(EXTERNAL):
return True
text = text.split("#")[0].strip()
return not text or (base / text).exists()
def main(argv: list[str]) -> int:
vault = pathlib.Path(argv[1] if len(argv) > 1 else "serverAppConfigs-docs")
pages, stems, relative = build_index(vault)
broken: list[str] = []
for page in pages:
text = page.read_text(errors="replace")
for target in WIKI.findall(text):
if not wiki_resolves(target, stems, relative):
broken.append(f"[wiki] {page.relative_to(vault)} -> {target.strip()}")
for target in MD.findall(text):
if not md_resolves(page.parent, target):
broken.append(f"[md] {page.relative_to(vault)} -> {target.strip()}")
print(f"{len(pages)} pages, {len(broken)} unresolved links")
for line in broken:
print(" " + line)
return 1 if broken else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))