feat(index): generate the skills table from frontmatter
CI / Check / Test (push) Failing after 1s
CI / Tag release (push) Skipped

Second half of #29. The index was hand-maintained and had already drifted
once: dream and experiment-spec shipped with no row, their rows lost to a
squash-merge after a directory reorg. bin/check-index caught that after the
fact; generating removes the class.

The curated summary and trigger move into each SKILL.md as `summary:` and
`use_when:`, carried over verbatim from the existing table rather than derived
from `description:` — description is a paragraph written for model selection,
and truncating it would have produced a worse table than the one it replaced.
Knowledge now lives once, next to the skill it describes, which is the argument
that made a hand-maintained index the wrong shape to begin with.

The table is written between markers, so the wiring docs, the relationships
tree and the trigger table below it are untouched — those are prose, not data.
Rows are alphabetical now; the conceptual grouping the old order carried lives
in the relationships tree, which is the better place for it. A fourth column
shows each skill's surfaces, which is what makes #29's field visible rather
than merely enforced.

bin/check-index and its test are deleted: a generated table cannot drift in
either direction, so `generate-index --check` subsumes both of its assertions.
Verified by mutation — deleting the tdd row turns `task check` red with
"SKILLS_INDEX.md is out of date".

Closes #29
Bump-Type: minor
This commit is contained in:
2026-08-29 11:23:14 +02:00
parent 90a58f0b9e
commit c6787e04d7
48 changed files with 343 additions and 161 deletions
+113
View File
@@ -0,0 +1,113 @@
#!/bin/sh
# Tests for bin/generate-index.
#
# SKILLS_INDEX.md was maintained by hand and drifted: two skills shipped with
# no row at all (#15), because their rows lived on branches squash-merged after
# a directory reorg. A gate caught drift after the fact; generating the table
# from frontmatter removes the class entirely (#29) — the curated summary and
# trigger now live next to the skill they describe, which is the same argument
# that made a hand-maintained index the wrong shape.
set -u
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
SUT="$ROOT/bin/generate-index"
pass=0
fail=0
WORK=${TMPDIR:-/tmp}/generate_index_test.$$
if [ ! -f "$SUT" ]; then
printf 'FAIL: %s does not exist\n' "$SUT"
exit 1
fi
cleanup() { rm -rf "$WORK"; }
trap cleanup EXIT INT TERM
ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; }
no() { fail=$((fail + 1)); printf ' FAIL %s\n%s\n' "$1" "${2:-}"; }
# skill <root> <name> <surfaces> [summary] [use_when]
skill() {
mkdir -p "$1/skills/$2"
{
printf -- '---\nname: %s\nsurfaces: %s\n' "$2" "$3"
[ $# -lt 4 ] || printf 'summary: "%s"\n' "$4"
[ $# -lt 5 ] || printf 'use_when: "%s"\n' "$5"
printf -- 'description: d\n---\n'
} > "$1/skills/$2/SKILL.md"
}
# fixture <name> — a root with an index carrying the generated-table markers.
fixture() {
_f="$WORK/$1"
mkdir -p "$_f"
cat > "$_f/SKILLS_INDEX.md" <<'IDX'
# Engineering Skills Index
<!-- BEGIN skills-table -->
<!-- END skills-table -->
## Wiring
prose that must survive regeneration
IDX
printf '%s' "$_f"
}
f=$(fixture basic)
skill "$f" beta '[claude-code, library]' 'B summary' 'when B'
skill "$f" alpha '[chat]' 'A summary' 'when A'
out=$(sh "$SUT" "$f" 2>&1); rc=$?
if [ "$rc" -ne 0 ]; then
no 'generates a table' "$out"
elif ! grep -q '^| `alpha` | A summary | when A |' "$f/SKILLS_INDEX.md"; then
no 'generates a table' "$(cat "$f/SKILLS_INDEX.md")"
elif ! grep -q 'prose that must survive regeneration' "$f/SKILLS_INDEX.md"; then
no 'content outside the markers survives' "$(cat "$f/SKILLS_INDEX.md")"
elif [ "$(grep -n '`alpha`' "$f/SKILLS_INDEX.md" | cut -d: -f1)" -gt "$(grep -n '`beta`' "$f/SKILLS_INDEX.md" | cut -d: -f1)" ]; then
no 'rows are sorted' "$(cat "$f/SKILLS_INDEX.md")"
else
ok 'generates a sorted table and leaves the rest of the file alone'
fi
# The surfaces a skill ships to are the point of #29, so they are visible.
grep -q 'chat' "$f/SKILLS_INDEX.md" \
&& ok 'the row names the surfaces' \
|| no 'the row names the surfaces' "$(cat "$f/SKILLS_INDEX.md")"
# Idempotent: a second run changes nothing.
before=$(cat "$f/SKILLS_INDEX.md")
sh "$SUT" "$f" >/dev/null 2>&1
[ "$before" = "$(cat "$f/SKILLS_INDEX.md")" ] \
&& ok 'regeneration is idempotent' \
|| no 'regeneration is idempotent'
# --check is the CI gate: clean tree passes.
sh "$SUT" --check "$f" >/dev/null 2>&1 \
&& ok '--check passes on a generated index' \
|| no '--check passes on a generated index'
# ...and fails once a skill appears without regenerating.
skill "$f" gamma '[library]' 'G summary' 'when G'
if sh "$SUT" --check "$f" >/dev/null 2>&1; then
no '--check fails on a stale index'
else
ok '--check fails on a stale index'
fi
# A skill with no curated summary is an error, not an empty cell: the table
# cell is what a reader scans, and a blank one is worse than a missing row.
f=$(fixture nosummary)
skill "$f" alpha '[library]'
out=$(sh "$SUT" "$f" 2>&1); rc=$?
if [ "$rc" -eq 0 ]; then
no 'a missing summary is an error' "$out"
elif ! printf '%s' "$out" | grep -q alpha; then
no 'a missing summary is an error' "$out"
else
ok 'a missing summary is an error naming the skill'
fi
printf '\ngenerate_index: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]