#!/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//.md` # rel — a markdown link resolved against the linking file, [x](..//.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///`, 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