`scripts/skill-check.sh` matched one regex against one line, so a mention the file hard-wraps -- `` `make `` at the end of one line and the target at the start of the next -- was invisible to it. These docs are mostly hard-wrapped prose, so the wrap is what the author does not think about, and `CONTRIBUTING.md:80` is already that shape. Lines are now joined while the inline span is still open, which an odd number of backticks means. The fence and line-start halves are untouched: a fenced command is already whole, and joining inside one would break the rule that made this awk rather than a grep. Joining is bounded three ways -- a fence, a blank line (CommonMark allows no blank line inside a code span) and a file boundary -- so a stray backtick costs one paragraph of over-matching rather than the rest of the file. The reporting loop needed the other half of the same fix: it named the offending file with `grep -ln "make $t"`, which cannot see a wrapped mention either, so a target the new parser found reported no file at all and `set -o pipefail` turned the empty grep into exit 123 before the line telling the author what to do. It falls back to the bare name. Verified by planting the report's own wrapped `make no-such-wrapped-target` into `CONTRIBUTING.md`: the old script reports 48 targets and exits 0, the new one names the target and the file and exits 1. Plant removed afterwards. Closes #228
178 lines
7.0 KiB
Bash
Executable File
178 lines
7.0 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# Every command in the agent-facing docs is a `make` target on purpose:
|
|
# the Makefile is the source of truth for *how* to invoke something, and
|
|
# the docs only decide *which* and *in what order*. This check keeps
|
|
# that honest — a renamed or deleted target turns into a failing commit
|
|
# rather than into an agent confidently running a command that no longer
|
|
# exists.
|
|
#
|
|
# It checks two things. Usage: scripts/skill-check.sh
|
|
#
|
|
# **Every `make <target>` named in an agent-facing doc exists.** The
|
|
# scanned set is `.pi/` *and* CLAUDE.md, which is the half that was
|
|
# missing: CLAUDE.md names 27 targets and nothing verified one of them,
|
|
# so the file the agents trust most was the file least checked.
|
|
#
|
|
# README.md and CONTRIBUTING.md are in it too, and the header sentence
|
|
# above is why: a person who has *not* read the Makefile goes looking in
|
|
# the contributor-facing doc, so a renamed target sends them off the
|
|
# same cliff it sends an agent off. CONTRIBUTING.md names 21 targets.
|
|
#
|
|
# **AGENTS.md is a symlink to CLAUDE.md.** This repo is worked on by
|
|
# two agent harnesses that read different files by convention — Claude
|
|
# Code reads CLAUDE.md, others read AGENTS.md — and two harnesses
|
|
# reading two descriptions of one project is how they come to hold
|
|
# different beliefs about it. A symlink makes that impossible by
|
|
# construction; a *copy* would pass every other check in this repo while
|
|
# silently drifting, which is exactly the failure being prevented, so
|
|
# the symlink itself is asserted rather than its contents compared.
|
|
set -euo pipefail
|
|
|
|
cd "$(dirname "$0")/.."
|
|
|
|
# The symlink half runs even without .pi/, since it is not about .pi/.
|
|
if [ -e AGENTS.md ] || [ -L AGENTS.md ]; then
|
|
if [ ! -L AGENTS.md ]; then
|
|
echo "skill-check: AGENTS.md is a regular file, not a symlink to CLAUDE.md." >&2
|
|
echo " Two harnesses would read two descriptions of one project." >&2
|
|
echo " Fix: rm AGENTS.md && ln -s CLAUDE.md AGENTS.md" >&2
|
|
exit 1
|
|
fi
|
|
|
|
target="$(readlink AGENTS.md)"
|
|
|
|
if [ "$target" != "CLAUDE.md" ]; then
|
|
echo "skill-check: AGENTS.md points at '$target', expected CLAUDE.md." >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# The scan is over the docs that are actually there: a checkout without
|
|
# .pi/ still has README.md and CONTRIBUTING.md to check, and gating the
|
|
# whole run on .pi/ would have made the human-facing half conditional on
|
|
# the agent-facing one. This list is used twice — once to read the
|
|
# mentions out and once to say which file a missing target came from —
|
|
# because a second list is a second thing to forget.
|
|
# `ls` exits non-zero when *any* of its arguments is missing while still
|
|
# printing the ones that are there, and under `set -e` that would sink
|
|
# the assignment rather than scanning what exists, so swallow it.
|
|
docs="$({ find .pi -name '*.md' 2>/dev/null
|
|
ls CLAUDE.md README.md CONTRIBUTING.md 2>/dev/null || true; })"
|
|
|
|
[ -n "$docs" ] || exit 0
|
|
|
|
# `make -pq` prints the database including every rule, without running
|
|
# anything. It exits non-zero when a target is out of date, and under
|
|
# `pipefail` that would sink the whole assignment, so swallow it.
|
|
targets="$({ make -pqRr 2>/dev/null || true; } |
|
|
awk '/^[a-zA-Z0-9][^$#\/\t=]*:([^=]|$)/ {sub(/:.*/, "", $0); print}' |
|
|
sort -u)"
|
|
|
|
# A mention counts only when it is code: backticked (`make ui-test`)
|
|
# anywhere, or at the start of a line **inside a fenced block**. Bare
|
|
# prose is not scanned, because English says things like "a renamed make
|
|
# target".
|
|
#
|
|
# The fence is why this is awk rather than one grep. Line-start alone is
|
|
# not evidence of code in a file that is mostly hard-wrapped prose: the
|
|
# sentence "Two green branches do not / make a green merge" wrapped onto
|
|
# a line beginning `make a`, and the check duly failed on a target called
|
|
# `a`. Inside a fence it is code; outside one it is a sentence that
|
|
# happened to break there, and a check that fails on reflow gets
|
|
# disabled rather than fixed.
|
|
#
|
|
# **An inline span may be hard-wrapped, and then the mention is split
|
|
# across two lines.** `make` at the end of one line and its target at
|
|
# the start of the next is one code span to Markdown and two strings to
|
|
# a per-line regex, so the target was invisible — and these docs are
|
|
# mostly hard-wrapped prose, so the wrap is what the author does not
|
|
# think about. Lines are therefore joined while the span is still open,
|
|
# which is what an odd number of backticks means.
|
|
#
|
|
# Joining re-opens the reflow trap above unless it is bounded, so it is
|
|
# bounded three ways: a fence flushes first (a fenced command is already
|
|
# whole, and joining inside one would break the line-start rule), a
|
|
# blank line flushes (CommonMark does not allow a blank line inside a
|
|
# code span, so nothing legitimate is split by one), and so does a file
|
|
# boundary. A stray odd backtick in prose therefore costs one paragraph
|
|
# of over-matching rather than the rest of the file.
|
|
#
|
|
# AGENTS.md is deliberately not in this list: it is a symlink to
|
|
# CLAUDE.md, asserted above, so scanning it would report every failure
|
|
# twice under two names.
|
|
mentioned="$(printf '%s\n' "$docs" |
|
|
xargs awk '
|
|
function scan(text, rest) {
|
|
rest = text
|
|
while (match(rest, /`make [a-z][a-z0-9-]*/)) {
|
|
print substr(rest, RSTART + 6, RLENGTH - 6)
|
|
rest = substr(rest, RSTART + RLENGTH)
|
|
}
|
|
}
|
|
|
|
function lineStart(text) {
|
|
if (match(text, /^make [a-z][a-z0-9-]*/)) {
|
|
print substr(text, 6, RLENGTH - 5)
|
|
}
|
|
}
|
|
|
|
function ticks(s, n, i) {
|
|
n = 0
|
|
for (i = 1; i <= length(s); i++) {
|
|
if (substr(s, i, 1) == "`") n++
|
|
}
|
|
return n
|
|
}
|
|
|
|
function flush() {
|
|
if (buf == "") return
|
|
scan(buf)
|
|
if (fence) lineStart(buf)
|
|
buf = ""
|
|
}
|
|
|
|
FNR == 1 { flush(); fence = 0 }
|
|
|
|
/^```/ { flush(); fence = !fence; next }
|
|
|
|
/^[[:space:]]*$/ { flush(); next }
|
|
|
|
{
|
|
if (fence) { scan($0); lineStart($0); next }
|
|
buf = (buf == "" ? $0 : buf " " $0)
|
|
if (ticks(buf) % 2 == 0) flush()
|
|
}
|
|
|
|
END { flush() }
|
|
' | sort -u)"
|
|
|
|
missing=""
|
|
|
|
for t in $mentioned; do
|
|
if ! printf '%s\n' "$targets" | grep -qx -- "$t"; then
|
|
missing="$missing $t"
|
|
fi
|
|
done
|
|
|
|
if [ -n "$missing" ]; then
|
|
echo "skill-check: the docs name make targets that do not exist:" >&2
|
|
for t in $missing; do
|
|
echo " make $t" >&2
|
|
# `make <t>` on one line first, because that is where a target is
|
|
# normally named and it is the precise answer. The bare name is the
|
|
# fallback, and it exists because the parser above can now find a
|
|
# mention that *this* grep cannot: a wrapped span has `make` and its
|
|
# target on different lines. Without it a missing target reported no
|
|
# file at all, and `set -o pipefail` turned the empty grep into exit
|
|
# 123, before the line telling the author what to do.
|
|
hits="$(printf '%s\n' "$docs" | xargs grep -ln "make $t" 2>/dev/null || true)"
|
|
[ -n "$hits" ] || hits="$(printf '%s\n' "$docs" | xargs grep -ln -- "$t" 2>/dev/null || true)"
|
|
[ -n "$hits" ] && printf '%s\n' "$hits" | sed 's/^/ /' >&2
|
|
done
|
|
echo "Fix the docs, or restore the target." >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "skill-check: $(printf '%s\n' "$mentioned" | wc -w) documented make targets, all present"
|