fix(skill-frontmatter): check every file, scope checks to frontmatter
The hook is `entry: bash` with `args: ['-c', <script>]`. pre-commit appends filenames after the script string, so the first becomes `$0` and never enters `"$@"` — on a single-file commit, the common case, the loop body never ran and the hook reported Passed having measured nothing. ADR-0022 leans on this hook as the enforcement for a mandatory `metadata.version`, so the vacuous green was the whole gate. Implementation notes: - An arg0 placeholder absorbs `$0` so every filename lands in `"$@"`. - Checks now run against the YAML frontmatter block only, extracted with awk. The old `grep -A10 "^metadata:"` matched a `metadata:` inside a body code fence, spanned past the block into a following `source:` entry's `version:`, accepted any indentation, and missed a `version:` more than ten lines in. An unreadable frontmatter block is now an error, never a pass. - The value is asserted against three-part semver. `write-docs` carried "1.0" through the entire ADR-0022 retrofit undetected, which a presence-only check cannot catch. Impact: `tests/test-skill-frontmatter.sh` is the first test this hook has ever had. It drives the real `entry`/`args` composition read out of the config rather than a copy of the script, which is the only shape that catches the arg0 bug; against the pre-fix hook it scores 7/20. gates.md described the hook wrongly in both directions and is rewritten, with a carve-out explaining why this one stays a shell parser next to the "python3 and PyYAML are hard requirements" reasoning that argues otherwise. Refs: #127 ADR: 0022 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeH8SCbcrCAQrtymkNuhKP
This commit is contained in:
366
tests/test-skill-frontmatter.sh
Normal file
366
tests/test-skill-frontmatter.sh
Normal file
@@ -0,0 +1,366 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression test for the `skill-frontmatter` pre-commit hook.
|
||||
#
|
||||
# The hook is `entry: bash` with `args: ['-c', <script>, <arg0>]`, and this file
|
||||
# drives that exact call shape -- read out of .pre-commit-config.yaml, never
|
||||
# re-implemented here. That is the whole point. `grep -rn skill-frontmatter
|
||||
# tests/` returned nothing before this file existed, and the two defect classes
|
||||
# below are both invisible to a test that copies the script body and calls it
|
||||
# some other way:
|
||||
#
|
||||
# 1. THE POSITIONAL DROP. `bash -c <script> fileA fileB` puts fileA in $0, not
|
||||
# in "$@". The hook had no arg0 placeholder, so pre-commit's FIRST filename
|
||||
# was swallowed -- and a single-file commit, the normal case, ran the loop
|
||||
# body zero times and reported Passed having checked nothing. A test that
|
||||
# sources or inlines the script never sees this; only the real invocation
|
||||
# shape does. Cases 2 and 3 below are that regression.
|
||||
#
|
||||
# 2. THE UNSCOPED GREP. The checks used to run over the WHOLE file, and the
|
||||
# version check was `grep -A10 "^metadata:" | grep -q " version:"`. Four
|
||||
# confirmed ways to pass while measuring nothing, all pinned below:
|
||||
# * `metadata:` in a BODY code fence satisfies it (skill-author's own
|
||||
# docs quote exactly such a block);
|
||||
# * `-A10` runs past the end of the metadata block, so a `version:`
|
||||
# belonging to a following `source:` list entry satisfies it
|
||||
# (write-docs and research both have `source:` right after `metadata:`);
|
||||
# * `" version:"` is an unanchored substring, so ` version:` at a
|
||||
# deeper nesting satisfies it;
|
||||
# * and the mirror image, a false NEGATIVE: a metadata block with more
|
||||
# than 10 lines before `version:` was reported missing.
|
||||
#
|
||||
# Plus the semver assertion, which is not redundant with presence: write-docs
|
||||
# carried `version: "1.0"` -- present, well-nested, and not a version -- through
|
||||
# an entire PR under a check that only ever asked whether the key was there.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CONFIG="$REPO_ROOT/.pre-commit-config.yaml"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
# Same guard shape as tests/test-check-executables-allow-sync.sh: the hook
|
||||
# definition is YAML and reading it any other way is guessing. README.md lists
|
||||
# python3/PyYAML as a pre-push prerequisite, and `run-tests.sh --strict` turns
|
||||
# this skip into a failure, which is the correct reading when it runs as a gate.
|
||||
command -v python3 > /dev/null 2>&1 || { echo "SKIP: python3 is required to read the hook definition out of .pre-commit-config.yaml"; exit 77; }
|
||||
python3 -c 'import yaml' > /dev/null 2>&1 || { echo "SKIP: PyYAML is required to read the hook definition out of .pre-commit-config.yaml"; exit 77; }
|
||||
|
||||
TMPDIR_T="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMPDIR_T"' EXIT
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The hook, as pre-commit will run it
|
||||
# ---------------------------------------------------------------------------
|
||||
# ENTRY and ARGS come straight out of the config. run_hook() then reproduces
|
||||
# pre-commit's own composition -- entry, then args, then the filenames appended
|
||||
# LAST -- so the positional handling under test is the real one.
|
||||
HOOK_JSON="$TMPDIR_T/hook.json"
|
||||
python3 - "$CONFIG" "$HOOK_JSON" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
config_path, out_path = sys.argv[1:3]
|
||||
with open(config_path, encoding='utf-8') as fh:
|
||||
cfg = yaml.safe_load(fh) or {}
|
||||
|
||||
found = None
|
||||
for repo in cfg.get('repos') or []:
|
||||
for hook in repo.get('hooks') or []:
|
||||
if hook.get('id') == 'skill-frontmatter':
|
||||
found = hook
|
||||
|
||||
if found is None:
|
||||
sys.exit("skill-frontmatter hook not found in " + config_path)
|
||||
|
||||
with open(out_path, 'w', encoding='utf-8') as fh:
|
||||
json.dump(
|
||||
{
|
||||
'entry': found.get('entry'),
|
||||
'args': found.get('args') or [],
|
||||
'files': found.get('files'),
|
||||
'pass_filenames': found.get('pass_filenames', True),
|
||||
'always_run': found.get('always_run', False),
|
||||
},
|
||||
fh,
|
||||
)
|
||||
PY
|
||||
|
||||
# Reads NUL-delimited fields on stdin into the named array. bash 3.2 (macOS) has
|
||||
# no `mapfil[e]`/`readarra[y]` builtin, and tests/test-vale-wrap.sh's static scan
|
||||
# rejects both — the bracket classes above match what a bare spelling would while
|
||||
# keeping one out of this file, the same trick that file uses for `npro[c]`.
|
||||
read_nul_array() {
|
||||
local __var="$1"
|
||||
shift
|
||||
local __item
|
||||
eval "$__var=()"
|
||||
while IFS= read -r -d '' __item; do
|
||||
eval "$__var+=(\"\$__item\")"
|
||||
done
|
||||
}
|
||||
|
||||
ENTRY="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["entry"])' "$HOOK_JSON")"
|
||||
ARGS=()
|
||||
read_nul_array ARGS < <(python3 -c '
|
||||
import json
|
||||
import sys
|
||||
|
||||
data = json.load(open(sys.argv[1]))
|
||||
for arg in data["args"]:
|
||||
sys.stdout.write(arg + "\0")
|
||||
' "$HOOK_JSON")
|
||||
|
||||
RUN_OUT=""
|
||||
RUN_RC=0
|
||||
# run_hook <file>... -- exactly `entry args... files...`, pre-commit's ordering.
|
||||
run_hook() {
|
||||
RUN_RC=0
|
||||
RUN_OUT="$("$ENTRY" ${ARGS[@]+"${ARGS[@]}"} "$@" 2>&1)" || RUN_RC=$?
|
||||
}
|
||||
|
||||
# write_skill <path> <frontmatter-body> [markdown-body]
|
||||
write_skill() {
|
||||
local path="$1" frontmatter="$2" body="${3:-# Heading
|
||||
|
||||
Body text.}"
|
||||
mkdir -p "$(dirname "$path")"
|
||||
{
|
||||
printf -- '---\n'
|
||||
printf '%s\n' "$frontmatter"
|
||||
printf -- '---\n\n'
|
||||
printf '%s\n' "$body"
|
||||
} > "$path"
|
||||
}
|
||||
|
||||
VALID_FM='name: valid-skill
|
||||
description: A skill whose frontmatter is complete and well formed.
|
||||
metadata:
|
||||
version: "1.0.0"'
|
||||
|
||||
assert_passes() { # <label> <file>...
|
||||
local label="$1"
|
||||
shift
|
||||
run_hook "$@"
|
||||
if [[ $RUN_RC -eq 0 ]]; then
|
||||
pass "$label"
|
||||
else
|
||||
fail "$label -- expected exit 0, got $RUN_RC. Output: $RUN_OUT"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_fails_with() { # <label> <needle> <file>...
|
||||
local label="$1" needle="$2"
|
||||
shift 2
|
||||
run_hook "$@"
|
||||
if [[ $RUN_RC -eq 0 ]]; then
|
||||
fail "$label -- exited 0, so the hook reported Passed having checked nothing. Output: $RUN_OUT"
|
||||
elif [[ "$RUN_OUT" != *"$needle"* ]]; then
|
||||
fail "$label -- exited $RUN_RC but the message lacked '$needle'. Output: $RUN_OUT"
|
||||
else
|
||||
pass "$label"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. The call shape itself
|
||||
# ---------------------------------------------------------------------------
|
||||
# Asserted as a contract as well as behaviourally, because the behavioural
|
||||
# symptom of losing arg0 is a GREEN run -- the least likely thing to be noticed.
|
||||
echo "--- the hook passes an arg0 placeholder so pre-commit's filenames land in \"\$@\" ---"
|
||||
if [[ "$ENTRY" != "bash" ]]; then
|
||||
fail "entry is '$ENTRY', not bash -- the arg0 reasoning below assumes bash -c"
|
||||
elif [[ ${#ARGS[@]} -lt 3 ]]; then
|
||||
fail "args has ${#ARGS[@]} entries; a 'bash -c <script>' hook needs a third, the arg0 placeholder, or the first filename is dropped from \"\$@\""
|
||||
elif [[ "${ARGS[0]}" != "-c" ]]; then
|
||||
fail "args[0] is '${ARGS[0]}', not -c"
|
||||
elif [[ "${ARGS[2]}" == -* ]]; then
|
||||
fail "args[2] is '${ARGS[2]}', which bash will read as a flag rather than as \$0"
|
||||
else
|
||||
pass "args is [-c, <script>, '${ARGS[2]}'] -- the third entry becomes \$0 and every filename reaches \"\$@\""
|
||||
fi
|
||||
|
||||
FILES_PATTERN="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["files"] or "")' "$HOOK_JSON")"
|
||||
PASS_FILENAMES="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["pass_filenames"])' "$HOOK_JSON")"
|
||||
echo ""
|
||||
echo "--- the hook is filename-driven, so the loop is the only thing that ever runs ---"
|
||||
if [[ "$PASS_FILENAMES" != "True" ]]; then
|
||||
fail "pass_filenames is $PASS_FILENAMES; with no filenames the loop body never executes and the hook is a permanent no-op"
|
||||
elif [[ "$FILES_PATTERN" != '^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$' ]]; then
|
||||
fail "files: is '$FILES_PATTERN', not the .apm/ skill scope -- ADR-0020's gates and this one must agree on scope"
|
||||
else
|
||||
pass "pass_filenames is on and files: is the .apm/ skill scope"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. A single bad file, on its own -- THE regression
|
||||
# ---------------------------------------------------------------------------
|
||||
# This is the shipped bug in one line: one file, which is what a commit
|
||||
# touching one skill hands the hook. Before the arg0 fix the file landed in $0,
|
||||
# "$@" was empty, and this exited 0.
|
||||
echo ""
|
||||
echo "--- a SINGLE bad file fails (before the arg0 fix this exited 0 having read nothing) ---"
|
||||
write_skill "$TMPDIR_T/single/SKILL.md" 'name: single
|
||||
description: Missing its metadata block entirely.'
|
||||
assert_fails_with "one file with no metadata block is rejected" "missing required frontmatter fields" "$TMPDIR_T/single/SKILL.md"
|
||||
|
||||
echo ""
|
||||
echo "--- and a single GOOD file still passes, so the case above is not failing for some other reason ---"
|
||||
write_skill "$TMPDIR_T/single-ok/SKILL.md" "$VALID_FM"
|
||||
assert_passes "one valid file passes" "$TMPDIR_T/single-ok/SKILL.md"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Position within the argument list must not matter
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- the bad file is caught wherever it sits in the argument list ---"
|
||||
write_skill "$TMPDIR_T/ok-a/SKILL.md" "$VALID_FM"
|
||||
write_skill "$TMPDIR_T/ok-b/SKILL.md" "$VALID_FM"
|
||||
write_skill "$TMPDIR_T/bad/SKILL.md" 'name: bad
|
||||
description: No metadata block.'
|
||||
|
||||
assert_fails_with "bad file FIRST is caught" "$TMPDIR_T/bad/SKILL.md" \
|
||||
"$TMPDIR_T/bad/SKILL.md" "$TMPDIR_T/ok-a/SKILL.md" "$TMPDIR_T/ok-b/SKILL.md"
|
||||
assert_fails_with "bad file LAST is caught (the loop reaches the end of \"\$@\")" "$TMPDIR_T/bad/SKILL.md" \
|
||||
"$TMPDIR_T/ok-a/SKILL.md" "$TMPDIR_T/ok-b/SKILL.md" "$TMPDIR_T/bad/SKILL.md"
|
||||
assert_passes "three valid files pass" "$TMPDIR_T/ok-a/SKILL.md" "$TMPDIR_T/ok-b/SKILL.md" "$TMPDIR_T/single-ok/SKILL.md"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. The four grep defects
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- defect 1: a \`metadata:\` block in a BODY code fence is documentation, not frontmatter ---"
|
||||
# skill-author's docs quote a metadata block verbatim. Under the old whole-file
|
||||
# grep that quotation satisfied the check for the file quoting it.
|
||||
write_skill "$TMPDIR_T/fence/SKILL.md" 'name: fence
|
||||
description: Frontmatter has no metadata block; the body quotes one.' '# Fence
|
||||
|
||||
Skills declare their version like this:
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
version: "1.0.0"
|
||||
```'
|
||||
assert_fails_with "a quoted metadata block in the body does not satisfy metadata.version" "missing required frontmatter fields" "$TMPDIR_T/fence/SKILL.md"
|
||||
|
||||
echo ""
|
||||
echo "--- ... and the same for name: and description: quoted in the body ---"
|
||||
write_skill "$TMPDIR_T/fence-keys/SKILL.md" 'metadata:
|
||||
version: "1.0.0"' '# Fence keys
|
||||
|
||||
```yaml
|
||||
name: not-the-real-name
|
||||
description: not the real description
|
||||
```'
|
||||
assert_fails_with "body-fenced name:/description: do not satisfy the presence checks" "name: description:" "$TMPDIR_T/fence-keys/SKILL.md"
|
||||
|
||||
echo ""
|
||||
echo "--- defect 2: a \`version:\` under a following \`source:\` list is not metadata.version ---"
|
||||
# `-A10` ran ten lines past `metadata:` regardless of where the block ended.
|
||||
# write-docs and research both carry a `source:` list immediately after it.
|
||||
write_skill "$TMPDIR_T/source-list/SKILL.md" 'name: source-list
|
||||
description: metadata has no version; the next top-level key does.
|
||||
metadata:
|
||||
author: someone
|
||||
source:
|
||||
- name: upstream
|
||||
version: "2.3.4"'
|
||||
assert_fails_with "a version: belonging to source[] does not satisfy metadata.version" "missing required frontmatter fields" "$TMPDIR_T/source-list/SKILL.md"
|
||||
|
||||
echo ""
|
||||
echo "--- defect 3: a deeper-nested \` version:\` is not metadata.version ---"
|
||||
# `grep -q " version:"` was an unanchored substring match, so any indentation
|
||||
# of two spaces or more matched.
|
||||
write_skill "$TMPDIR_T/deep-indent/SKILL.md" 'name: deep-indent
|
||||
description: The only version: key sits one level too deep.
|
||||
metadata:
|
||||
provenance:
|
||||
version: "1.0.0"'
|
||||
assert_fails_with "a four-space-indented version: does not satisfy metadata.version" "missing required frontmatter fields" "$TMPDIR_T/deep-indent/SKILL.md"
|
||||
|
||||
echo ""
|
||||
echo "--- defect 4: a version: more than ten lines into the metadata block is FOUND ---"
|
||||
# The mirror image: the old check reported this one missing.
|
||||
write_skill "$TMPDIR_T/long-metadata/SKILL.md" 'name: long-metadata
|
||||
description: A long metadata block whose version sits well past line ten.
|
||||
metadata:
|
||||
a: 1
|
||||
b: 2
|
||||
c: 3
|
||||
d: 4
|
||||
e: 5
|
||||
f: 6
|
||||
g: 7
|
||||
h: 8
|
||||
i: 9
|
||||
j: 10
|
||||
k: 11
|
||||
version: "1.0.0"'
|
||||
assert_passes "a version: 13 lines into the metadata block is found" "$TMPDIR_T/long-metadata/SKILL.md"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Present is not the same as well formed
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- a present-but-non-semver version is rejected, with its own message ---"
|
||||
# plugins/bin/.apm/skills/write-docs/SKILL.md carried exactly this through a
|
||||
# whole PR: the key was present, so a presence-only check had nothing to say.
|
||||
write_skill "$TMPDIR_T/two-part/SKILL.md" 'name: two-part
|
||||
description: Its version is two-part, which is a float in YAML, not a version.
|
||||
metadata:
|
||||
version: "1.0"'
|
||||
assert_fails_with "\"1.0\" is rejected as malformed, not reported as missing" "malformed frontmatter metadata.version" "$TMPDIR_T/two-part/SKILL.md"
|
||||
|
||||
echo ""
|
||||
echo "--- ... and the malformed message quotes the offending value ---"
|
||||
run_hook "$TMPDIR_T/two-part/SKILL.md"
|
||||
if [[ "$RUN_OUT" == *'"1.0"'* ]]; then
|
||||
pass "the message names the value it rejected"
|
||||
else
|
||||
fail "the message did not quote the rejected value. Output: $RUN_OUT"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- other non-semver shapes ---"
|
||||
write_skill "$TMPDIR_T/empty-version/SKILL.md" 'name: empty-version
|
||||
description: The key is present with no value at all.
|
||||
metadata:
|
||||
version:'
|
||||
assert_fails_with "a valueless version: is rejected" "metadata.version" "$TMPDIR_T/empty-version/SKILL.md"
|
||||
|
||||
write_skill "$TMPDIR_T/word-version/SKILL.md" 'name: word-version
|
||||
description: A non-numeric version.
|
||||
metadata:
|
||||
version: latest'
|
||||
assert_fails_with "version: latest is rejected" "malformed frontmatter metadata.version" "$TMPDIR_T/word-version/SKILL.md"
|
||||
|
||||
echo ""
|
||||
echo "--- an unquoted three-part version is accepted (both YAML spellings are legal) ---"
|
||||
write_skill "$TMPDIR_T/unquoted/SKILL.md" 'name: unquoted
|
||||
description: An unquoted semver, which YAML reads as a string.
|
||||
metadata:
|
||||
version: 0.1.4'
|
||||
assert_passes "version: 0.1.4 unquoted passes" "$TMPDIR_T/unquoted/SKILL.md"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. A file that cannot be read as frontmatter must not report green
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- a file with no frontmatter block fails loudly rather than passing vacuously ---"
|
||||
printf '# Just a document\n\nname: not-frontmatter\ndescription: nor this\n' > "$TMPDIR_T/no-fm.md"
|
||||
assert_fails_with "a file with no --- block is an error" "no closing YAML frontmatter block" "$TMPDIR_T/no-fm.md"
|
||||
|
||||
printf -- '---\nname: unterminated\ndescription: the block is never closed\nmetadata:\n version: "1.0.0"\n' > "$TMPDIR_T/unterminated.md"
|
||||
assert_fails_with "an unterminated frontmatter block is an error" "no closing YAML frontmatter block" "$TMPDIR_T/unterminated.md"
|
||||
|
||||
echo ""
|
||||
echo "--- a path that does not exist is skipped, not crashed on ---"
|
||||
assert_passes "a nonexistent path is ignored" "$TMPDIR_T/nope/SKILL.md"
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
Reference in New Issue
Block a user