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
118 lines
4.7 KiB
Shell
Executable file
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
|