hyperhive/scripts/check-doc-refs.sh
atlas 8f56f86df5 ci: refuse relative markdown links in rust doc comments
check-doc-refs.sh's relative arm resolved every link against the linking
file's directory. That is right for markdown and wrong for a .rs doc
comment: rustdoc emits the href verbatim onto a page under
target/doc/<crate>/<module...>/, so the link resolves against THAT
directory instead.

The consequence was a false pass, not a miss -- a link resolving from the
source dir got certified while rendering broken in the published docs.
Reproduced against main's script: a `](../../docs/README.md)` planted in
hive-forge/src/client.rs resolves from hive-forge/src/ and the old rule
exits 0.

Refused rather than resolved rustdoc-aware. The repo has zero relative
links in .rs doc comments (measured on 54daf9ac), so nothing needs the
second reader, and the backticked repo-root form the root arm already
gates renders correctly under both. Inferring module nesting would be
machinery in service of a form nothing uses.

Closes #3928
2026-09-02 12:29:13 +02:00

118 lines
4.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.
#
# Two arms, because a pointer is written two 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)
# (spelled with placeholders on purpose: a literal example path here would be
# a dead pointer this script then reports against itself)
#
# 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.
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/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