From bf72f9869ab0c6cc2db1a5111b710f5ae5cd079b Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 20 Aug 2026 19:30:59 +0200 Subject: [PATCH] build(scripts): add a documentation link integrity check (DR-208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walks every tracked .md file, resolves each relative inline link against the directory the file lives in, and fails with the file:line and the unresolved target if it is not on disk. Skips http(s)/mailto, pure anchors, and links inside fenced code blocks (a template being shown to the reader is sample text, not a live link). This is the check that would have caught the 2,793 dead links in the generated traceability matrix at the commit that introduced them, and the handful of hand-written ones repaired alongside it. Nobody clicks 2,800 links, which is why the defect survived for months. Two documented exceptions rather than silent ones: docs-site/SUMMARY.md is copied into docs/ by publish-docs before rendering, so its links are resolved from docs/ — which is what makes it catch a nav entry pointing at a page that does not exist; and docs/README.md and docs/api-redirect.md are generated by that same job and so are absent from the repo by design. The header states what it deliberately cannot see, in the house style of check-frontend-boundary.sh: it validates paths, not anchors. Resolving a fragment needs a renderer's heading-slug rules, which differ between Gitea, GitHub and mdBook, so a link to a renamed heading still passes. The package.json script and CI wiring are added separately. --- scripts/check-doc-links.sh | 172 +++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100755 scripts/check-doc-links.sh diff --git a/scripts/check-doc-links.sh b/scripts/check-doc-links.sh new file mode 100755 index 00000000..1c60fd1d --- /dev/null +++ b/scripts/check-doc-links.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# Documentation link integrity: every relative markdown link must point at a +# file that exists. +# +# Implements DR-208 (see docs/requirements.md). +# +# Why this exists: docs/traceability.md is generated into docs/ while its file +# links were emitted repo-root-relative, so all ~2,800 of them resolved to +# docs/src-tauri/… and 404'd — in the Gitea repo browser and on the published +# mdBook site alike. Nobody clicks 2,800 links, so it went unnoticed for months. +# Several hand-written docs had the same defect at smaller scale: links to files +# that had been deleted, and links written as if the doc lived at the repo root. +# A link that does not resolve is a documentation defect of the same kind as a +# compile error, and a grep is enough to catch the whole class. +# +# What it checks: for every tracked `.md` file, every inline markdown link +# `[text](target)` whose target is a *path* — the target is resolved relative to +# the directory of the file containing it, and must exist on disk. +# +# ⚠️ It validates PATHS, NOT ANCHORS. A green run does not mean the links land +# where the text claims. +# +# 🔴 What it deliberately CANNOT see (do not read a green run as proof): +# - **Anchor fragments.** `foo.md#some-heading` is checked only as `foo.md`. +# Resolving the fragment needs a markdown renderer's heading-slug rules +# (which differ between Gitea, GitHub and mdBook), so a link to a heading +# that was renamed still passes here. That is a deliberate scope cut, not an +# oversight. +# - **External URLs.** http(s):// and mailto: are skipped. Checking them means +# network I/O in a gate, which makes the gate flaky and slow; link rot in an +# external URL is also not something a commit can break. +# - **Reference-style links** (`[text][ref]` with a separate `[ref]: target` +# definition) and bare autolinks. This project writes inline links; add the +# pattern here if that changes. +# - **Links inside fenced code blocks**, which are intentionally skipped — +# a template being *shown* to the reader (e.g. the release-notes template in +# docs/release-checklist.md) is sample text, not a live link, and its targets +# are resolved wherever it is eventually pasted, not from the docs tree. +# - **A link that resolves to the wrong existing file.** Existence is not +# correctness. +# +# Usage: bash scripts/check-doc-links.sh +# Exits non-zero, listing file:line and the unresolved target, on any failure. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +# Generated, vendored or build-output trees. Their markdown is not authored here +# and their link targets are not ours to fix. +EXCLUDES=( + "./node_modules/*" + "./.svelte-kit/*" + "./build/*" + "./dist/*" + "./src-tauri/gen/*" + "./src-tauri/target/*" + "./.git/*" +) + +# Targets that do not exist in the repo *by design* because the publish-docs job +# writes them into docs/ at build time (see .gitea/workflows/publish-docs.yml). +# Keep this list to genuinely generated pages — anything else here is a broken +# link being hidden. +GENERATED_TARGETS=( + "./docs/README.md" # the site's landing page, written by publish-docs + "./docs/api-redirect.md" # the rustdoc redirect stub, likewise +) + +is_generated() { + local candidate="$1" + for generated in "${GENERATED_TARGETS[@]}"; do + [[ "$candidate" == "$generated" ]] && return 0 + done + return 1 +} + +echo "🔎 Checking relative markdown links resolve to files on disk…" + +# Build the find(1) prune expression from EXCLUDES. +find_args=(. ) +for pattern in "${EXCLUDES[@]}"; do + find_args+=(-path "$pattern" -prune -o) +done +find_args+=(-name "*.md" -type f -print) + +mapfile -t md_files < <(find "${find_args[@]}" | sort) + +echo " ${#md_files[@]} markdown files" + +broken="" +checked=0 + +for md in "${md_files[@]}"; do + dir="$(dirname "$md")" + + # One documented exception: docs-site/SUMMARY.md is mdBook's table of + # contents, and the publish-docs job copies it *into* docs/ before rendering + # (book.toml sets src = "../docs"). Its links are therefore written relative + # to docs/, not to the directory the file is stored in. Resolving it from + # docs/ is what actually validates it — and it is the check that catches a + # SUMMARY entry pointing at a page that does not exist, which mdBook itself + # only warns about. + if [[ "$md" == "./docs-site/SUMMARY.md" ]]; then + dir="./docs" + fi + + # Strip fenced code blocks (``` and ~~~) before extracting links, so sample + # markdown shown to the reader is not checked as if it were a live link. + # Line numbers are preserved by blanking the lines rather than deleting them. + # + # Then emit "linenotarget" for each inline link on each surviving line. + while IFS=$'\t' read -r lineno target; do + [[ -z "${target:-}" ]] && continue + + # Skip external schemes and pure-anchor links. + case "$target" in + http://*|https://*|mailto:*|ftp://*|"#"*|"") continue ;; + # A protocol-relative or scheme-ish target we do not resolve. + //*) continue ;; + esac + + # Drop any anchor fragment and query string — we check the path only. + path="${target%%#*}" + path="${path%%\?*}" + [[ -z "$path" ]] && continue + + # Percent-decode: SvelteKit route directories are literally named `[id]`, + # which docs link as `%5Bid%5D`, and spaces appear as `%20`. + if [[ "$path" == *%* ]]; then + path="$(printf '%b' "${path//%/\\x}")" + fi + + checked=$((checked + 1)) + + if is_generated "$dir/$path"; then + continue + fi + + if [[ ! -e "$dir/$path" ]]; then + broken+="${md}:${lineno} -> ${target}"$'\n' + fi + done < <( + awk ' + /^[[:space:]]*(```|~~~)/ { fence = !fence; print ""; next } + fence { print ""; next } + { print } + ' "$md" | + grep -noE '\]\([^)[:space:]]+' | + sed -E 's/^([0-9]+):\]\(/\1\t/' + ) +done + +echo " $checked relative links checked" + +if [[ -n "$broken" ]]; then + echo "" + echo "❌ Broken documentation links — these targets do not exist on disk:" + echo "" + echo "$broken" | sed 's/^/ /' + echo " Each link is resolved relative to the directory of the file it is in." + echo " The usual causes:" + echo " • the target file was moved or deleted — update or drop the link;" + echo " • the link was written as if the doc lived at the repo root — a doc" + echo " in docs/ needs '../' to reach src/, scripts/ or CHANGELOG.md;" + echo " • a generated doc emits repo-root-relative hrefs — fix the" + echo " generator, not the output (see scripts/extract-traces.ts)." + exit 1 +fi + +echo "✅ All relative documentation links resolve." +echo " (Reminder: paths only — anchors and external URLs are NOT checked.)"