#!/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. # # 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. # # 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. # 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 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