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:
Executable
+172
@@ -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.)"
|
||||||
Reference in New Issue
Block a user