From 8bf21b6d0c4c7215a6e8bc1b36542eab8e0cc88a Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 2 Sep 2026 23:04:15 +0200 Subject: [PATCH] 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 --- scripts/check-doc-refs.sh | 43 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/scripts/check-doc-refs.sh b/scripts/check-doc-refs.sh index 24894438..d6ae0a8f 100755 --- a/scripts/check-doc-refs.sh +++ b/scripts/check-doc-refs.sh @@ -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//.md` # rel — a markdown link resolved against the linking file, [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) # +# 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 `/` matched mid-path inside `nix//…`, +# 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