build(scripts): add a documentation link integrity check (DR-208)

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.
This commit is contained in:
2026-08-20 19:30:59 +02:00
parent 4567c63797
commit bf72f9869a
+172
View File
@@ -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 "lineno<TAB>target" 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.)"