#!/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.
#
# Two arms, because a pointer is written two 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)
# (spelled with placeholders on purpose: a literal example path here would be
# a dead pointer this script then reports against itself)
#
# 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#](}"
# 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.
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/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