hyperhive/scripts/check-doc-refs.sh
atlas 484b8b1129 check-doc-refs: catch bare relative markdown links, and only judge a file rendered where it is stored
The rel arm required a leading `./` or `../`, so `[x](swarm/services.md)` —
the form a sibling link is usually written in — matched none of the three
arms. That is exactly the shape a directory move breaks: during #3911 a link
in that form moved into a new directory, resolved to nothing, and the lint
stayed green. Reproduced by putting the broken form back with `sed`
(MUTATED_DIRTY=1) and re-running: exit 0, a control that fails to fail.

Widening the pattern surfaces 116 links the arm never saw (76 -> 192 matches).
111 are in `.md` files and all 111 resolve today, so this adds coverage
without a single new failure in the tree; the other 5 are the interesting
part, and they are why the arm now selects on file type.

A link is only resolvable against its own directory when the file is
*rendered where it is stored*. The four in `nix/docs/default.nix` are inside
a markdown string this derivation writes to `$out`, and the one in
`hive-c0re/src/workers/knowledge.rs` is template text for another repo's
README — resolving either from the source directory certifies a path no
reader ever requests. `lint:allow` is the wrong tool for them: the marker
lives inside the generated text, so it would ship in the published page.

The `.rs` arm keeps its error, narrowed to doc-comment lines. Its premise
still holds — the repo has zero relative links in `.rs` doc comments — but
without the narrowing the widened pattern reports the knowledge.rs template
string with a message about rustdoc that is wrong for it.

Measured: the old arm's 76 hits are all in `.md` files, so restricting
resolution to `.md` removes no existing coverage.

Closes #4076
2026-09-08 13:31:33 +02:00

167 lines
6.9 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.
#
# Emits a CI error annotation per hit; exit 1 if any pointer is dead.
#
# 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, dot-prefixed
# `[x](../<dir>/<f>.md)` or bare `[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._][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#](}"
# This arm resolves against the directory of the file the link is written
# in, so it can only judge a file that is rendered where it is stored — a
# tracked `.md`. A link inside a nix or rust string literal is text for a
# document generated somewhere else (`$out`, another repo), and resolving
# it from the source directory certifies a path no reader ever requests.
# `lint:allow` is not the answer for those: the marker would sit inside
# the generated text and ship in the published page.
#
# rustdoc is the one non-md renderer that earns an error instead of a
# skip. It emits the href verbatim onto a page at
# `target/doc/<crate>/<module…>/`, so a relative link in a doc comment
# resolves against *that* directory and renders broken; the backticked
# repo-root form the root arm gates works under both renderers. Doc
# comments only — inferring module nesting to resolve one properly would
# be machinery in service of a form nothing uses.
case "$file" in
*.md) ;;
*.rs)
case "$(sed -n "${lineno}p" "$file")" in
*///* | *//!*)
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"
;;
esac
continue
;;
*) 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