ci: check every repo-relative path resolves, not just doc pointers

mara on #3923: "any gate we add should not have false positives". This is
the gate, and the reason it can exist without a skip-list is the prose fix
in the previous commit -- every false positive this arm would have raised
was a sentence naming where something used to live.

A third arm on the existing doc-pointer lint rather than a fourth CI job:
it inherits the job that already runs on every PR and in pre-push, the
`lint:allow` hatch the sibling arms honour, and the ::error annotation
format. A new job is a thing that can ship without ever running.

Scope is any repo-relative source path, not only `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: 1123 such paths resolve on this tree
and none were verified before now.

Both ends of the pattern are anchored, each because its absence invented a
path -- an unanchored extension matched the prefix of a longer one, and an
unanchored prefix matched mid-path inside a nested directory, conjuring a
top-level twin of a real file. Those failures now surface as dead paths and
fail the build rather than passing silently, so they need no separate
control.

Written with placeholders, because the first draft named the two phantom
paths literally and the new arm immediately reported the script to itself.
The header already warned about exactly that, two lines above where I put
them.

Refs #3923
This commit is contained in:
atlas 2026-09-02 23:04:15 +02:00 committed by mara
commit 8bf21b6d0c

View file

@ -9,12 +9,18 @@
# 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:
# 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)
# 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.
@ -98,10 +104,45 @@ done < "$tmp/rel"
# 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