Files
claude/lib/tests/doctrine-citers.test.sh
T
bchanot 0efdff0d55 fix(portability): make test green on macOS, GNU-only idioms replaced
The suite and seven libs assumed a GNU userland: `cmd | grep -q` under
pipefail (grep exits at the first match, the producer takes SIGPIPE,
rc 141 → 15 false "merged into" FAILs in gitflow-test), `sed -i` with no
suffix, `wc -l` padding compared as a string, `stat -c`, `touch -d`,
`realpath -m` (gstack-links refusal never fired), bare `timeout` off the
sanitized PATH (design gate READY BUT UNVERIFIED), `grep -oP` (update-all
emptied the plugin list). Thirteen suites were red on this machine.

Portable forms on the native userland of both OS: producer captured out
of the pipeline, `sed -i.bak` in tests and a temp-sibling `sed_profile`
on the user dotfile, `tr -d ' '`, python3 perms, `touch -t`, a
`realpath -m` emulation that refuses `..`, perl `alarm` for the 15 s
bound, `sed -n` token extraction. Regression tests: gstack-links T4b,
doctrine-citers Alphabet/Alpha flip, profile-set-managed T18 (failing CLI
no longer aborts `set`), new portability-census suite over the tracked
shell files. Linux run deferred (see TODO).
2026-10-06 15:10:20 +02:00

79 lines
4.0 KiB
Bash

#!/usr/bin/env bash
# lib/tests/doctrine-citers.test.sh — every citation of a CLAUDE.global.md
# section or bold label from skills/agents/lib/rules/hooks must resolve
# (BDR-100). Born from the 2026-09-24 density pass: a bold label was reworded
# and five skills kept pointing at "§ Language" for a day. A flip-test proves
# the checker bites before the real census runs (LRN-096).
#
# Only citations that NAME the doctrine count: `CLAUDE.md "Section"` and
# `CLAUDE.md … § Label`. Bare `section "…"` / `§ X` inside a skill refer to the
# skill's own sections and are out of scope.
set -u
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
pass=0; fail=0
check() { if [ "$2" = "$3" ]; then pass=$((pass+1)); else fail=$((fail+1));
printf 'FAIL %s: got[%s] want[%s]\n' "$1" "$2" "$3"; fi; }
# _citers_extract <file>… → "file:line:name" per cited section (quoted) or label (§)
_citers_extract() {
/usr/bin/grep -nHoE 'CLAUDE(\.global)?\.md[^"“]{0,12}["“][^"”[:space:]][^"”]{1,59}["”]' "$@" 2>/dev/null \
| sed -E 's/^([^:]+:[0-9]+):.*["“]([^"”]+)["”]$/\1:\2/'
/usr/bin/grep -nHoE 'CLAUDE(\.global)?\.md[^§]{0,60}§ ?[A-Z][A-Za-z][A-Za-z -]{1,40}' "$@" 2>/dev/null \
| sed -E 's/^([^:]+:[0-9]+):.*§ ?([A-Za-z][A-Za-z -]+)$/\1:\2/; s/[[:space:]]+$//'
}
# _citers_resolve <doctrine> <name> → rc 0 when a heading starts with <name>
# (then end, space, `:`, `(` or `—`) or a bold label `**<name>` appears.
# Fixed-string awk: the name is never read as a regex (BSD grep -E rejects
# some, and "Alpha" must not resolve against "## Alphabet").
_citers_resolve() {
awk -v n="$2" '
/^#+ / {
t = $0; sub(/^#+ /, "", t)
if (substr(t, 1, length(n)) == n) {
r = substr(t, length(n) + 1)
if (r == "" || r ~ /^[ :(]/ || index(r, "—") == 1) found = 1
}
}
index($0, "**" n) { found = 1 }
END { exit !found }' "$1"
}
# citers_check <doctrine> <file>… → prints DANGLING lines; rc = their count (capped 99)
citers_check() {
local doctrine="$1" line name file lno n=0; shift
while IFS= read -r line; do
[ -n "$line" ] || continue
file="${line%%:*}"; lno="$(printf '%s' "$line" | cut -d: -f2)"; name="${line#*:*:}"
_citers_resolve "$doctrine" "$name" || { echo "DANGLING: $file:$lno → \"$name\""; n=$((n+1)); }
done < <(_citers_extract "$@")
return $(( n > 99 ? 99 : n ))
}
# ── flip-test: a synthetic dangling citation must be caught ──────────────────
FIX="$(mktemp -d)"; trap 'rm -rf "$FIX"' EXIT
# shellcheck disable=SC2016 # literal backticks in the fixture heading
printf '## Alpha\n\n**Always English, always caveman**: rule.\n\n## Memory registries (`x`)\n' > "$FIX/doctrine.md"
printf 'ok: see CLAUDE.md "Alpha" and CLAUDE.md "Memory registries" (Always English, always caveman)\n' > "$FIX/good.md"
printf 'bad: (see CLAUDE.md "Memory registries" § Language) and CLAUDE.md "Beta"\n' > "$FIX/bad.md"
printf '## Alphabet\n' > "$FIX/prefix.md"
printf 'prefix: see CLAUDE.md "Alpha"\n' > "$FIX/alpha.md"
citers_check "$FIX/prefix.md" "$FIX/alpha.md" >/dev/null
check T2d-name-prefix-of-heading-stays-dangling "$?" 1
citers_check "$FIX/doctrine.md" "$FIX/good.md" >/dev/null; check T1-resolving-citations-pass "$?" 0
out=$(citers_check "$FIX/doctrine.md" "$FIX/bad.md"); rc=$?
check T2-dangling-section-and-label-caught "$rc" 2
check T2b-names-the-culprit "$(printf '%s\n' "$out" | grep -c 'bad.md:1')" 2
check T2c-label-named "$(printf '%s\n' "$out" | grep -c '"Language"')" 1
# ── real census: the repo's own citers against CLAUDE.global.md ──────────────
mapfile -t FILES < <(cd "$ROOT" && find skills agents lib rules hooks -type f \( -name '*.md' -o -name '*.sh' \) \
-not -path 'skills/graphify/*' -not -path 'skills/impeccable/*' -not -path 'skills/synced/*' \
-not -path 'agents/impeccable-*' -not -path 'lib/tests/*' | sort)
out=$(cd "$ROOT" && citers_check CLAUDE.global.md "${FILES[@]}"); rc=$?
[ -n "$out" ] && printf '%s\n' "$out"
check T3-repo-citations-resolve "$rc" 0
echo "PASS=$pass FAIL=$fail"
[ "$fail" -eq 0 ]