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.
82 lines
3.2 KiB
Python
Executable File
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))
|