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
167 lines
6.9 KiB
Shell
Executable file
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
|