docs: three dangling links, and a gate so they cannot accumulate

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. Three were dangling:

- Dankclaw/Gitea.md pointed twice at dankclaw/compose.yaml — **which this
  session retired**. The clean cutover obligation from my own change: the page
  now describes the unit that publishes 3000 and the canonical Caddy unit, and
  records why the compose file is gone.
- Dankclaw/Hermes-Dashboard.md linked [[LAN DNS]]; the page is [[LAN-DNS]].
- LLM/frank-medium-radiance-mxfp4.md linked [[Observability on Dankclaw]]; the
  page is [[Observability]].

scripts/check-vault-links.py + a seam keep it that way. The checker models
Obsidian's rules, which took three corrections to get right — and each was a
false positive that would have sent me editing healthy docs:

  1. Wikilinks are case-insensitive. A case-sensitive matcher reported 45
     breaks; 39 were `[[qBittorrent]]` vs qbittorrent.md.
  2. `[[folder/Note]]` resolves by path suffix, not by stem.
  3. `%20` in a markdown link is URL-encoding, not a filename.

Validating the linter against the resolver it models is the prerequisite for
trusting any of its output — the same discipline that caught a bad assertion of
my own earlier tonight.
This commit is contained in:
Frank committed 2026-09-16 07:43:33 -07:00
1 parent 80a070d71b
commit 2e33ec36c1
5 files changed
+91 -5

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
#!/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):
pages = sorted(vault.rglob("*.md"))
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))
+2 -2
View File
@@ -103,9 +103,9 @@ Back up `/home/dank/Containers/gitea/` (tar) or `pg_dump` the database. See the
containers. Edit files as the owning user with containers. Edit files as the owning user with
`podman exec --user 1000 gitea-server ...`. `podman exec --user 1000 gitea-server ...`.
- **The host auto-reboots.** `unattended-upgrades` reboots Dankclaw when a package needs it. The quadlet units make Gitea survive that. See the 2026-08-16 incident below. - **The host auto-reboots.** `unattended-upgrades` reboots Dankclaw when a package needs it. The quadlet units make Gitea survive that. See the 2026-08-16 incident below.
- **Port 3000 vs 3001.** The live host port is 3000. The full-stack [`dankclaw/compose.yaml`](../../dankclaw/compose.yaml) maps 3001. Align the two before deploying the full stack. - **Port 3000, and one owner.** The live host port is 3000, published by the `gitea` Quadlet unit. The `dankclaw/compose.yaml` that mapped 3001 was **retired on 2026-09-16** (staged in `.AGENTTRASH/`) once an ownership audit showed every container on this host reports a `PODMAN_SYSTEMD_UNIT` label — a second composition path could only spawn a duplicate fighting a unit for a name. Do not reintroduce a compose service for Gitea or the proxy.
- **Legacy compose retired.** `/opt/gitea/podman-compose.yml` is retired to `/opt/gitea/.deprecated/`. Do not run it. Its containers collide with the Quadlet units on ports 3000 and 2222. - **Legacy compose retired.** `/opt/gitea/podman-compose.yml` is retired to `/opt/gitea/.deprecated/`. Do not run it. Its containers collide with the Quadlet units on ports 3000 and 2222.
- **Canonical proxy.** `gitea.franksdank.cloud` is served by the Caddy service in `dankclaw/compose.yaml`, which proxies to `192.168.0.143:3000`. Do not recreate a Gitea-specific Caddy unit or publish another proxy on ports 80/443. During migration, the deployer stages any installed legacy unit under `~/.AGENTTRASH/YYYYMMDD-gitea-caddy/` before starting canonical Caddy. - **Canonical proxy.** `gitea.franksdank.cloud` is served by the canonical Caddy **unit** (`dankclaw/caddy/quadlet/caddy.container`, repo `dankclaw/Caddyfile`), which proxies to `192.168.0.143:3000`. Do not recreate a Gitea-specific Caddy unit or publish another proxy on ports 80/443. If a legacy unit is ever installed again, the deployer stages it under `~/.AGENTTRASH/YYYYMMDD-gitea-caddy/` before starting canonical Caddy.
## Incident: 2026-08-16 — down after unattended reboot ## Incident: 2026-08-16 — down after unattended reboot
@@ -54,7 +54,7 @@ login — there is no unauthenticated bind:
reverse-proxies `192.168.0.143:9119` directly, with **no** Anubis/Outpost guard reverse-proxies `192.168.0.143:9119` directly, with **no** Anubis/Outpost guard
chain in front of it (exempt-by-construction: the app carries its own complete chain in front of it (exempt-by-construction: the app carries its own complete
auth). The name deliberately has no public DNS record, so the vhost resolves on auth). The name deliberately has no public DNS record, so the vhost resolves on
the LAN only; WAN clients get NXDOMAIN. See [[LAN DNS]]. the LAN only; WAN clients get NXDOMAIN. See [[LAN-DNS]].
2. **`http://192.168.0.143:9119`** — the raw published port. Still reachable from 2. **`http://192.168.0.143:9119`** — the raw published port. Still reachable from
any LAN host (the container binds `0.0.0.0` inside its namespace and publishes any LAN host (the container binds `0.0.0.0` inside its namespace and publishes
`9119:9119`), so treat the LAN as a trusted boundary here, not as a gate. `9119:9119`), so treat the LAN as a trusted boundary here, not as a gate.
@@ -426,8 +426,8 @@ scraped — Prometheus has `frank-medium` and `frank-large` jobs
`vllm:kv_cache_usage_perc` (per-card pool fill), `vllm:num_requests_waiting` `vllm:kv_cache_usage_perc` (per-card pool fill), `vllm:num_requests_waiting`
(queue depth), `rate(vllm:prefix_cache_hits_total[5m]) / (queue depth), `rate(vllm:prefix_cache_hits_total[5m]) /
rate(vllm:prefix_cache_queries_total[5m])` (whether pinned conversations are rate(vllm:prefix_cache_queries_total[5m])` (whether pinned conversations are
served warm), and the request-latency histograms. See served warm), and the request-latency histograms. See [[Observability]], and one
[[Observability on Dankclaw]], and one Grafana dashboard reads them Grafana dashboard reads them
(**LLM fleet — capacity and reuse**). (**LLM fleet — capacity and reuse**).
Five rules watch this service (`shared/prometheus/rules/llm-fleet.yml`): pool Five rules watch this service (`shared/prometheus/rules/llm-fleet.yml`): pool
+10
View File
@@ -33,3 +33,13 @@ load helpers
} }
done < <(find "$REPO_ROOT/serverAppConfigs-docs" -name '*.md' -not -name 'index.md' -not -path '*/.obsidian/*' | sort) done < <(find "$REPO_ROOT/serverAppConfigs-docs" -name '*.md' -not -name 'index.md' -not -path '*/.obsidian/*' | sort)
} }
@test "no unresolved links in the docs vault" {
# A wikilink to a page that does not exist is a promise the repo cannot
# keep. Fixing the three that were dangling (2026-09-16) also meant
# retiring a reference to dankclaw/compose.yaml, which no longer exists.
# The checker models Obsidian's resolution rules; see its docstring for
# the three false-positive classes a naive version produces.
run "$REPO_ROOT/scripts/check-vault-links.py" "$REPO_ROOT/serverAppConfigs-docs"
[ "$status" -eq 0 ] || { echo "$output"; false; }
}