From 484b8b112966f0a100256181b0d935f423375dea Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 8 Sep 2026 13:22:21 +0200 Subject: [PATCH] check-doc-refs: catch bare relative markdown links, and only judge a file rendered where it is stored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/check-doc-refs.sh | 48 +++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/scripts/check-doc-refs.sh b/scripts/check-doc-refs.sh index d6ae0a8f..2e90f896 100755 --- a/scripts/check-doc-refs.sh +++ b/scripts/check-doc-refs.sh @@ -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//.md` -# rel — a markdown link resolved against the linking file, [x](..//.md) +# rel — a markdown link resolved against the linking file, dot-prefixed +# `[x](..//.md)` or bare `[x](/.md)` # path — any repo-relative source path, `/src/.rs`, `nix/.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///`, 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///`, 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