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
This commit is contained in:
atlas 2026-09-08 13:22:21 +02:00 committed by mara
commit 484b8b1129

View file

@ -3,15 +3,14 @@
#
# 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.
# resolves, and nothing was checking.
#
# 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.
# 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, [x](../<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)
@ -31,7 +30,7 @@
set -eu
root_pattern='docs/[A-Za-z0-9._/-]*\.md'
rel_pattern='\]\(\.\.*/[A-Za-z0-9._/-]*\.md'
rel_pattern='\]\([A-Za-z0-9._][A-Za-z0-9._/-]*\.md'
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
@ -66,25 +65,34 @@ 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.
# 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.
#
# 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.
# 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)
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"
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