hyperhive/scripts/check-doc-refs.sh
atlas 8bf21b6d0c ci: check every repo-relative path resolves, not just doc pointers
mara on #3923: "any gate we add should not have false positives". This is
the gate, and the reason it can exist without a skip-list is the prose fix
in the previous commit -- every false positive this arm would have raised
was a sentence naming where something used to live.

A third arm on the existing doc-pointer lint rather than a fourth CI job:
it inherits the job that already runs on every PR and in pre-push, the
`lint:allow` hatch the sibling arms honour, and the ::error annotation
format. A new job is a thing that can ship without ever running.

Scope is any repo-relative source path, not only `docs/**.md`. A comment
naming a moved .rs or .nix file misleads exactly as much as a dead doc
link, and nothing was checking it: 1123 such paths resolve on this tree
and none were verified before now.

Both ends of the pattern are anchored, each because its absence invented a
path -- an unanchored extension matched the prefix of a longer one, and an
unanchored prefix matched mid-path inside a nested directory, conjuring a
top-level twin of a real file. Those failures now surface as dead paths and
fail the build rather than passing silently, so they need no separate
control.

Written with placeholders, because the first draft named the two phantom
paths literally and the new arm immediately reported the script to itself.
The header already warned about exactly that, two lines above where I put
them.

Refs #3923
2026-09-07 15:06:12 +02:00

159 lines
6.7 KiB
Shell
Executable file

#!/bin/sh
# CI lint: flags documentation pointers that no longer resolve.
#
# A pointer at a doc is the hive's preferred alternative to duplicating that
# doc's prose next to the code. That trade only pays while the pointer
# resolves, and nothing was checking. A `docs/` reorganisation left 15 distinct
# dead paths across nix, css, html, js, markdown and .prettierignore.
#
# Emits a CI error annotation per hit and exits 1 if any pointer is dead, 0
# otherwise. Runs as its own job; seconds, pure git+grep.
#
# Three arms, because a pointer is written three ways:
# root — a repo-root-relative path in prose or a comment, `docs/<dir>/<f>.md`
# rel — a markdown link resolved against the linking file, [x](../<dir>/<f>.md)
# path — any repo-relative source path, `<crate>/src/<f>.rs`, `nix/<f>.nix`
# (spelled with placeholders on purpose: a literal example path here would be
# a dead pointer this script then reports against itself)
#
# The third arm carries no skip-list, and could only be built after the prose
# it would have flagged was fixed: every false positive was a sentence naming
# where something *used to* live. Tolerating those needs a past-tense
# heuristic — the permanent carve-out that teaches readers to ignore a gate.
#
# Scope is every tracked file, deliberately unrestricted by type: prose
# describes a path in words, and a stale path in a config file (an ignore
# rule, a build input) is a live defect rather than a stale comment.
#
# Escape hatch: a line containing `lint:allow` is exempt. Reserve it for a
# path that is correct while absent — a file another derivation synthesizes,
# or a literal example — and keep the reason next to the marker.
set -eu
root_pattern='docs/[A-Za-z0-9._/-]*\.md'
rel_pattern='\]\(\.\.*/[A-Za-z0-9._/-]*\.md'
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
# Lines carrying the marker, as `file:line`, to skip below.
git ls-files -z | xargs -0 -r grep -n 'lint:allow' /dev/null 2>/dev/null \
| cut -d: -f1,2 > "$tmp/allow" || true
exempt() { grep -qxF "$1:$2" "$tmp/allow" 2>/dev/null; }
git ls-files -z | xargs -0 -r grep -nEo "$root_pattern" /dev/null 2>/dev/null \
> "$tmp/root" || true
git ls-files -z | xargs -0 -r grep -nEo "$rel_pattern" /dev/null 2>/dev/null \
> "$tmp/rel" || true
dead=0
alive=0
while IFS=: read -r file lineno ref; do
[ -n "${ref:-}" ] || continue
exempt "$file" "$lineno" && continue
if [ -f "$ref" ]; then
alive=$((alive + 1))
else
dead=$((dead + 1))
printf '::error file=%s,line=%s::doc pointer does not resolve: %s\n' \
"$file" "$lineno" "$ref"
fi
done < "$tmp/root"
while IFS=: read -r file lineno match; do
[ -n "${match:-}" ] || continue
exempt "$file" "$lineno" && continue
link="${match#](}"
# A `.rs` doc comment has a second renderer, and it does not count `../`
# the way the rest of this arm assumes. rustdoc emits the href verbatim
# onto a page at `target/doc/<crate>/<module…>/`, so the link resolves
# against *that* directory, not the source file's. Resolving from the
# source therefore certifies links that render broken — a false pass, not
# a miss, and worse than no check.
#
# Refused rather than resolved rustdoc-aware: the repo has zero relative
# links in `.rs` doc comments (measured on this commit), so nothing needs
# the second reader, and the backticked repo-root form the root arm above
# already gates works under both. Inferring module nesting would be
# machinery in service of a form nothing uses.
case "$file" in
*.rs)
dead=$((dead + 1))
printf '::error file=%s,line=%s::relative markdown link in a rust doc comment renders against the rustdoc output directory, not this file; use a backticked repo-root pointer instead: %s\n' \
"$file" "$lineno" "$link"
continue
;;
esac
# Resolved from the linking file's directory, so `../` counts the same way
# the renderer counts it. `-m` keeps a link that escapes the repo root
# resolvable enough to report rather than erroring out here.
target="$(cd "$(dirname "$file")" && realpath -m "$link")"
if [ -f "$target" ]; then
alive=$((alive + 1))
else
dead=$((dead + 1))
printf '::error file=%s,line=%s::relative doc link does not resolve: %s\n' \
"$file" "$lineno" "$link"
fi
done < "$tmp/rel"
# A lint whose extractor matches nothing passes forever and reports success
# while checking not one thing. Both arms therefore have to have found
# something that resolves before a clean run means anything: if a rename, a
# pattern edit or a tree move silences the search, that is a failure of this
# script, not a property of the tree.
# Third arm: repo-relative paths to any tracked file, not just `docs/**.md`.
# A comment naming a moved `.rs` or `.nix` file misleads exactly as much as a
# dead doc link, and nothing was checking it.
#
# Both ends are anchored, and each anchor is there because its absence
# invented a path: unanchored, a `.js` extension matched the prefix of a
# `.json` one, and a bare `<dir>/` matched mid-path inside `nix/<dir>/…`,
# conjuring a top-level twin of a real nested file. (Placeholders, per the
# header — a literal here would be a dead path this arm reports against
# itself.) `grep -o` has no lookaround, so the boundary char is stripped below.
prefix_pattern='(nix|scripts|docs|frontend|branding|hive-[a-z0-9-]+/src|hivectl/src|swarmctl/src|swarm-controller/src)'
ext_pattern='(nix|sh|rs|md|ts|tsx|js|css|toml)'
path_pattern="(^|[^A-Za-z0-9._/-])${prefix_pattern}/[A-Za-z0-9._/-]+\\.${ext_pattern}($|[^A-Za-z0-9])"
git ls-files -z | xargs -0 -r grep -nEo "$path_pattern" /dev/null 2>/dev/null \
> "$tmp/paths0" || true
sed -E 's/^([^:]*:[0-9]+:)[^A-Za-z0-9]?/\1/; s/[^A-Za-z0-9]$//' "$tmp/paths0" \
> "$tmp/paths" || true
while IFS=: read -r file lineno ref; do
[ -n "${ref:-}" ] || continue
exempt "$file" "$lineno" && continue
if [ -f "$ref" ]; then
alive=$((alive + 1))
else
dead=$((dead + 1))
printf '::error file=%s,line=%s::repo path does not resolve: %s\n' \
"$file" "$lineno" "$ref"
fi
done < "$tmp/paths"
if [ "$(wc -l < "$tmp/root")" -eq 0 ]; then
echo 'check-doc-refs: root-pointer search matched nothing — pattern or tree changed' >&2
exit 1
fi
if [ "$(wc -l < "$tmp/paths")" -eq 0 ]; then
echo 'check-doc-refs: repo-path search matched nothing — pattern or tree changed' >&2
exit 1
fi
if [ "$(wc -l < "$tmp/rel")" -eq 0 ]; then
echo 'check-doc-refs: relative-link search matched nothing — pattern or tree changed' >&2
exit 1
fi
if [ "$alive" -eq 0 ]; then
echo 'check-doc-refs: not one pointer resolved — the resolver is broken, not the tree' >&2
exit 1
fi
if [ "$dead" -gt 0 ]; then
printf 'check-doc-refs: %s dead doc pointer(s); %s resolved\n' "$dead" "$alive" >&2
exit 1
fi
exit 0