fix(gates): check body-level routing targets, not just descriptions

The ADR-0020 boundary resolver (boundary_targets()/unresolved_targets())
only ever read a SKILL.md's description. A target named in the BODY -- a
dispatch table row, a "run X" step, both routine in a 900-word procedure
-- was checked by nothing. Two real instances shipped before either was
caught by reading rather than by a gate: bin/write-docs routed twice to a
deleted `to-prd` skill, and bin/triage told an agent to run a nonexistent
`/setup-matt-pocock-skills` (both fixed in 03abcff; that fix was the
symptom, this gate is the actual ask per #124).

Added a separate, narrower extractor -- body_targets() /
unresolved_body_targets() in the shared lib-boundary-resolver.sh -- rather
than reusing the description resolver at wider scope. The description
gate's sentence-level heuristics (BOUNDARY_MARKER, the follower test,
in-sentence corroboration) are tuned for a one-to-three-sentence routing
clause and misfire on dispatch-table/procedure prose in both directions,
so the body gate reads only explicit route notation (`/name`,
backticked-or-slash-prefixed `-> name` / `-> name`), already the
description gate's own unconditionally-blocking tier.

Three guards were added after running the extractor over the real
39-skill corpus and reading every hit rather than assuming the design was
correct:

- a target must be hyphenated, even in notation -- single-word citations
  like `/fork` (forge, citing Claude Code's own /fork command) and
  `/name` (skill-author, a placeholder) are not routes.
- a bare hyphenated word after any arrow is not notation -- only
  ARROW_MARKED (backticked/slash-prefixed) is used, not NOTATION_ARROW's
  bare form, so ordinary process-chain prose ("prop -> new ref ->
  re-render", caveman) is not read as a route.
- a name immediately preceded by `<` is a closing tag
  (`</what-to-do>`, grill-with-docs), not /name notation.

Wired into both consumers that must agree by contract: scripts/
skill-size-check.sh (the pre-commit hook) and factory-audit's
lib-checks-skill.sh (the audit). Verified identical findings across both
over the whole corpus.

tests/test-adr0020-targets.sh gains a dedicated section pinning the two
live true positives and all three guards. docs/spec/gates.md and
ADR-0020 get a matching amendment.

Fixes: #124
ADR: 0020

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGHFJextYtVQseaHPDDhxB
This commit is contained in:
2026-09-22 15:09:21 +00:00
parent 3ea057794c
commit c5f754d3ad
7 changed files with 397 additions and 48 deletions

View File

@@ -961,6 +961,117 @@ else
fail "an attributive target naming a REAL skill produced output (exit $ATTR_RC): $ATTR_OUT"
fi
# ---------------------------------------------------------------------------
# 3. Body-level routing targets (issue #124)
# ---------------------------------------------------------------------------
# boundary_targets()/unresolved_targets() are the DESCRIPTION gate, exercised
# above. body_targets()/unresolved_body_targets() are the separate, narrower
# extractor added for issue #124: a SKILL.md body is dispatch-table and
# procedure prose, not a one-to-three-sentence routing clause, so the body
# extractor takes only /name and -> `name` NOTATION (never the bare-prose
# forms the description gate also reads), and even within notation, a target
# must be hyphenated and must not be a `<tag` immediately before the `/`.
# Every fixture is built inside a real plugin tree (BODY_ROOT), same as
# section 2 above, so the resolver actually runs instead of declining.
echo ""
echo "--- body-level routing targets (issue #124) ---"
BODY_ROOT="$TMPDIR_T/body"
write_skill "$BODY_ROOT/plugins/p/.apm/skills/sibling-skill" sibling-skill \
"Use when doing the other thing. Do not use for anything else."
# write_skill_body <skill-dir> <name> <body>
write_skill_body() {
mkdir -p "$1"
{
echo "---"
echo "name: $2"
echo "description: Use when doing the thing. Do not use for anything else."
echo "metadata:"
echo " version: \"1.0.0\""
echo "---"
echo ""
printf '%s\n' "$3"
} > "$1/SKILL.md"
}
# body_case <slug> <expect: silent|errors> <needle> <body>
body_case() {
local slug="$1" mode="$2" needle="$3" body="$4" out status=0
write_skill_body "$BODY_ROOT/plugins/p/.apm/skills/$slug" "$slug" "$body"
set +e
out="$(bash "$HOOK" "$BODY_ROOT/plugins/p/.apm/skills/$slug/SKILL.md" 2>&1)"
status=$?
set -e
if [[ "$out" == *"DID NOT RUN"* ]]; then
fail "body \"$body\" — the resolver declined, so this case asserts nothing about extraction: $out"
return
fi
case "$mode" in
silent)
if [[ $status -eq 0 && -z "$out" ]]; then
pass "not a dangling body target: \"$body\""
else
fail "body \"$body\" (exit $status, output: ${out:-<empty>})"
fi
;;
errors)
if [[ $status -ne 0 && "$out" == *"$needle"* ]]; then
pass "dangling body target caught: \"$body\""
else
fail "body \"$body\" should have ERRORed with $needle (exit $status, output: ${out:-<empty>})"
fi
;;
esac
}
# The two live true positives the issue was filed over, at fixture scale:
# a bare/backticked `/name` and a backticked `-> \`name\``.
body_case body-slash-dangling errors "body routes to 'no-such-body-skill'" \
"Run \`/no-such-body-skill\` if the config is missing."
body_case body-slash-resolves silent "" \
"Run \`/sibling-skill\` if the config is missing."
body_case body-arrow-dangling errors "body routes to 'no-such-arrow-body'" \
"- User wants X -> \`no-such-arrow-body\`"
body_case body-arrow-resolves silent "" \
"- User wants X -> \`sibling-skill\`"
# No conjunction continuation: only the FIRST target after an arrow is ever
# read, so a dangling SECOND name is silently uncounted rather than reported
# — the same one-arrow-one-target convention issue #107 enforces on
# descriptions (there, at SUGGESTION tier; here, by construction, since the
# body gate has no SUGGESTION tier at all).
body_case body-arrow-no-continuation silent "" \
"- User wants X -> \`sibling-skill\` or \`no-such-uncounted-target\`"
# The single-word guard: a real corpus false positive removed by requiring a
# hyphen. `` `/fork` `` (forge/SKILL.md) and `` `/name` `` (skill-author/SKILL.md)
# are both single-word citations of a tool or a placeholder, not routes, and
# both would otherwise have hard-FAILed with no escape hatch.
body_case body-slash-single-word-guard silent "" \
"See \`/fork\` for how the two differ."
# The closing-tag guard: an XML/HTML-style section delimiter used as a prompt
# marker (grill-with-docs/SKILL.md's <what-to-do>...</what-to-do>) is
# indistinguishable from /route notation by every other rule in the pattern —
# a `<` immediately before the `/` is the one signal that tells them apart.
body_case body-closing-tag-guard silent "" \
$'<what-to-do>\nDo the thing.\n</what-to-do>'
# The bare-arrow guard: NOTATION_ARROW (bare hyphenated word after any arrow)
# is deliberately NOT used here, only ARROW_MARKED (backticked or
# slash-prefixed). caveman/SKILL.md's own process chain, "Inline obj prop ->
# new ref -> re-render.", is real corpus prose this guard exists for — an
# unbacked, unresolvable name after an arrow must stay silent, not become a
# hard-blocking dangling-target FAIL with no suppression mechanism.
body_case body-arrow-bare-not-notation silent "" \
"Reproduce -> minimise -> no-such-bare-chain-target."
# Fenced code blocks are masked, same as gotcha_stats() and
# missing_reference_pointers() mask them: an illustrative example is not a
# live dispatch entry.
body_case body-fenced-example silent "" \
$'```\nRun /no-such-fenced-skill instead.\n```'
echo ""
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]]