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