fix(gates): make the ADR-0020 boundary check parse what skills actually write

The routing-target check understood only a single-arrow clause naming a bare
skill, so most real boundary prose was silently skipped rather than verified.
Two of those silences were fail-open: an unrecognised token following a target
dropped that target from the check entirely, and a skill directory with no
SKILL.md still resolved as a valid routing target, so a broken route passed.

Multi-target arrow clauses now draw a SUGGESTION instead of being ignored,
hand-invocation phrasing is carved out so it is not read as a route, and a
dotted filename parses into a new `unparsed` status rather than disappearing.
Three test fixtures had been relying on the SKILL.md-less directory resolving
as a target; they are corrected alongside the check.

Addresses #107, #108, #110.
This commit is contained in:
2026-08-31 08:01:07 +00:00
parent 095929142f
commit db5a426416
8 changed files with 1688 additions and 90 deletions

View File

@@ -92,8 +92,14 @@ build_tree "$TMPDIR_T/no-claude"
build_tree "$TMPDIR_T/with-claude"
# The deployed tree, present only in the second root. Both a skill and an agent,
# because both are valid routing targets and both would leak.
mkdir -p "$TMPDIR_T/with-claude/.claude/skills/deployed-only-skill" \
"$TMPDIR_T/with-claude/.claude/agents"
#
# The skill gets a real SKILL.md. That is not decoration: a directory under
# skills/ is a resolvable name only when it HOLDS one, so an empty directory
# would dangle for the wrong reason and the assertion below would pass without
# testing the deployed-tree rule at all.
mkdir -p "$TMPDIR_T/with-claude/.claude/agents"
write_skill "$TMPDIR_T/with-claude/.claude/skills/deployed-only-skill" deployed-only-skill \
"Use when doing the deployed thing. Do not use for anything else."
: > "$TMPDIR_T/with-claude/.claude/agents/deployed-only-agent.md"
run_subject() {
@@ -133,7 +139,8 @@ fi
echo ""
echo "--- with no authoring root, a deployed .claude/ tree IS the universe ---"
CONSUMER="$TMPDIR_T/consumer"
mkdir -p "$CONSUMER/.claude/skills/deployed-only-skill"
write_skill "$CONSUMER/.claude/skills/deployed-only-skill" deployed-only-skill \
"Use when doing the deployed thing. Do not use for anything else."
write_skill "$CONSUMER/.claude/skills/my-skill" my-skill \
"Use when doing the thing. Do not use for the other thing — use deployed-only-skill instead."
set +e
@@ -384,8 +391,12 @@ for bait_root in "$BAIT_FRESH" "$BAIT_DEPLOYED"; do
write_skill "$bait_root/plugins/bin/.apm/skills/deployed-tree-probe" deployed-tree-probe \
"Use when doing the probe thing. Do not use for the other thing — use $BAIT_NAME instead."
done
# Only the deployed copy gets the name planted where `apm install` would put it.
mkdir -p "$BAIT_DEPLOYED/.claude/skills/$BAIT_NAME" "$BAIT_DEPLOYED/.claude/agents"
# Only the deployed copy gets the name planted where `apm install` would put it,
# as a REAL skill directory holding a SKILL.md — an empty directory is not a
# resolvable name, so baiting with one would make the A/B pass vacuously.
mkdir -p "$BAIT_DEPLOYED/.claude/agents"
write_skill "$BAIT_DEPLOYED/.claude/skills/$BAIT_NAME" "$BAIT_NAME" \
"Use when doing the bait thing. Do not use for anything else."
BAIT_FRESH_DANGLING="$(dangling_set "$BAIT_FRESH/plugins")"
BAIT_DEPLOYED_DANGLING="$(dangling_set "$BAIT_DEPLOYED/plugins")"
@@ -511,13 +522,24 @@ grammar_case() {
# The four phrasings that were hard dangling FAILs with no suppression. All four
# are lifted from real descriptions in this corpus.
grammar_case fp-precommit-hooks silent "" \
#
# THEY ARE `suggests`, NOT `silent`, AND THE DIFFERENCE IS THE POINT. The
# follower rule takes away the power to BLOCK a commit on a compound modifier;
# it does not take away visibility, and it used to. FOLLOWER_OK is a closed
# whitelist of about eighty words, so a non-terminal verdict means "the next
# token is outside a list someone maintains by hand", not "this is prose" — and
# `continue`ing on it made the gate fail OPEN on its own unfamiliarity: any
# target followed by an unlisted word was neither blocked nor mentioned at any
# tier. Asserting silence here pinned that hole in place. The assertion that
# still matters is `!= ERROR`, which `suggests` checks, and which is what keeps
# a false positive from stopping a commit.
grammar_case fp-precommit-hooks suggests "routes to 'pre-commit'" \
"Use when running the linter. Use pre-commit hooks instead of ad-hoc scripts."
grammar_case fp-pull-request silent "" \
grammar_case fp-pull-request suggests "routes to 'pull-request'" \
"Use when opening changes. Invoke the pull-request template instead of writing one by hand."
grammar_case fp-conventional silent "" \
grammar_case fp-conventional suggests "routes to 'conventional-commits'" \
"Use when writing history. Use conventional-commits formatting rather than free-form messages."
grammar_case fp-prepush-backticked silent "" \
grammar_case fp-prepush-backticked suggests "routes to 'pre-push'" \
"Use when checking a branch. Do not use for local edits — run the \`pre-push\` hooks instead."
echo ""
@@ -599,6 +621,165 @@ grammar_case lowercase-start suggests "routes to 'no-such-lower-skill'" \
grammar_case backtick-start suggests "routes to 'no-such-tick-skill'" \
"Use when doing the thing. Use sibling-skill for the main case. \`no-such-tick-skill\` is not for this — do not use it instead."
# ---------------------------------------------------------------------------
# 2a. Route NOTATION always blocks, whatever token follows it
# ---------------------------------------------------------------------------
# FOLLOWER_OK is a closed whitelist of about eighty words. A target followed by
# anything outside it was non-terminal, and `/name` reached _add() with
# strict=None, so it fell to the follower test and lost the power to block —
# contradicting the header's own promise that route notation "always blocks",
# for the one form Claude Code actually uses. Combined with the old `continue`
# in unresolved_targets(), `use /no-such-skill afterwards.` exited 0 with no
# output at all: the gate failed OPEN on a word nobody had thought to enumerate.
#
# "afterwards" is the probe in every case below. It is ordinary English, it is
# not in FOLLOWER_OK, and it is not going to be added to it.
echo ""
echo "--- route notation blocks even when the following token is outside FOLLOWER_OK ---"
grammar_case notation-slash-unlisted errors "routes to 'no-such-slash-skill'" \
"Use when doing the thing. Do not use for improvements — use /no-such-slash-skill afterwards."
grammar_case notation-arrow-unlisted errors "routes to 'no-such-arrow-skill'" \
"Use when doing the thing. Not the other thing -> no-such-arrow-skill afterwards."
echo ""
echo "--- a target the follower rule cannot vouch for is REPORTED, never invisible ---"
# The other half of the same defect, and the one that cost visibility rather
# than enforcement: a PROSE-form target with an unlisted follower may not block
# (that is what the follower rule is for) but it must still be named. Silence
# here is the vacuous-green shape the whole script forbids itself.
grammar_case follower-unlisted-bare suggests "routes to 'no-such-modifier-skill'" \
"Use when doing the thing. Do not use for improvements — use no-such-modifier-skill afterwards."
grammar_case follower-unlisted-backticked suggests "routes to 'no-such-ticked-skill'" \
"Use when doing the thing. Do not use for improvements — use \`no-such-ticked-skill\` afterwards."
# ---------------------------------------------------------------------------
# 2b. Capitalised abbreviations do not over-split a sentence
# ---------------------------------------------------------------------------
# SENTENCE_SPLIT was the one pattern in the resolver built without re.I, so its
# five abbreviation lookbehinds only covered the lowercase spelling. `E.g.` and
# `I.e.` — the SENTENCE-INITIAL spellings, which is exactly where an
# abbreviation lands — matched none of them. The clause split at the
# abbreviation, the corroborating target was stranded on the far side of the
# cut, and a genuinely dangling target silently demoted from blocking ERROR to
# SUGGESTION. The lowercase twin of each case below is `abbrev-split` above and
# already passed, which is precisely why the gap survived.
echo ""
echo "--- a CAPITALISED abbreviation does not strand the corroborator ---"
grammar_case abbrev-split-caps-eg errors "routes to 'no-such-caps-eg-skill'" \
"Use when doing the thing. Do not use for improvements — use sibling-skill first, E.g. \"run the audit\", then use no-such-caps-eg-skill instead."
grammar_case abbrev-split-caps-ie errors "routes to 'no-such-caps-ie-skill'" \
"Use when doing the thing. Do not use for improvements — use sibling-skill first, I.e. \"run the audit\", then use no-such-caps-ie-skill instead."
# ---------------------------------------------------------------------------
# 2c. A boundary clause naming a dotted filename (issue #110)
# ---------------------------------------------------------------------------
# `[^.;]` cannot cross the `.` in `AGENTS.md` or `.pre-commit-config.yaml`, so a
# clause naming a dotted file between "Not" and the arrow was invisible to both
# BOUNDARY_ARROW and ARROW_BOUNDARY. Two different failures came out of that:
# with a backticked target the clause was merely MISDIAGNOSED as missing, and
# with a BARE target it was never extracted at all, so the dangling check
# silently did not run on it. Both directions are pinned.
echo ""
echo "--- a boundary clause naming a dotted filename is seen, and its target is checked ---"
grammar_case dotted-bare-target errors "routes to 'no-such-dotted-skill'" \
"Use when doing the thing. Not AGENTS.md -> no-such-dotted-skill."
grammar_case dotted-clause-seen silent "" \
"Use when doing the thing. Not .pre-commit-config.yaml -> sibling-skill."
# The guard that makes the fix a fix and not a hole: a REAL sentence end still
# ends the clause. A `.` followed by whitespace terminates it exactly as before,
# so "Not applicable here." plus an arrow two sentences later is not a boundary
# clause and is still reported as one missing.
grammar_case dotted-sentence-end-guard suggests "has no boundary clause" \
"Use when doing the thing. Not applicable here. Reproduce -> minimise."
# ---------------------------------------------------------------------------
# 2d. "Present but unparsed" is a different finding from "missing"
# ---------------------------------------------------------------------------
# Issue #110's standing request. An arrow clause ALWAYS names a target, so one
# that yields none is a parse failure and must say so — telling the author the
# clause is missing sends them to add a second copy of a clause that is already
# there. The live shape is a single-word target, which is deliberately not
# matchable bare because `research`, `triage` and `forge` are all skill names
# AND ordinary English.
echo ""
echo "--- an arrow clause that yields no target is reported as unparsed, not as missing ---"
grammar_case arrow-single-word-target suggests "no target could be read" \
"Use when doing the thing. Not the other thing -> forge."
# Control, so the case above is not satisfied by a check that fires on every
# arrow clause: the same clause with the target written in a shape the extractor
# can see produces nothing at all.
grammar_case arrow-single-word-marked silent "" \
"Use when doing the thing. Not the other thing -> \`sibling-skill\`."
# ---------------------------------------------------------------------------
# 2e. One arrow, one target (issue #107)
# ---------------------------------------------------------------------------
# Only the first target after an arrow is resolved: the conjunction continuation
# is wired to the prose route verbs and never to arrows. So the second name in
# `Not X -> a or b` was resolved by nothing and reported by nothing, and the
# audit then printed "1 of 1 boundary target(s) resolve" on a clause naming two.
# A typo in the second target shipped through a green gate.
#
# The fix rejects the shape rather than widening the extractor. The case below
# is the exact failure: a bare `Not ... ->` sentence carries no BOUNDARY_MARKER,
# so the backtick sweep does not run and the second target is genuinely
# invisible to every other rule in the resolver.
echo ""
echo "--- an arrow clause naming two targets is rejected, so the unchecked one is visible ---"
grammar_case multi-arrow-second-target suggests "names more than one target" \
"Use when doing the thing. Not the other thing -> \`sibling-skill\` or \`no-such-second-target\`."
grammar_case multi-arrow-comma suggests "names more than one target" \
"Use when doing the thing. Not the other thing -> \`sibling-skill\`, \`no-such-comma-target\`."
# Control: one arrow, one target — the convention the SUGGESTION is asking for —
# stays silent. Without this the case above is satisfied by a check that fires
# on every arrow clause in the corpus.
grammar_case multi-arrow-control silent "" \
"Use when doing the thing. Not the other thing -> \`sibling-skill\`."
# ---------------------------------------------------------------------------
# 2f. A skill directory with no SKILL.md is not a skill
# ---------------------------------------------------------------------------
# _collect_package() added a name for every directory matching skills/*/, with
# no check that anything was in it. A leftover empty directory — a deleted skill
# whose directory survived, a scaffolding stub, an editor's stray mkdir — is
# untracked by git, so it exists on the machine that made it and nowhere else.
# The hook went green locally and red in a fresh clone: the same
# install-dependence the deployed-tree rule exists to remove, arriving through a
# different door. Both directions are asserted, because "never resolve" would
# also satisfy the first half.
echo ""
echo "--- an empty skills/<name>/ directory does not make a routing target resolve ---"
GHOST="$TMPDIR_T/ghost-dir"
write_skill "$GHOST/plugins/p/.apm/skills/my-skill" my-skill \
"Use when doing the thing. Do not use for the other thing — use /ghost-skill instead."
mkdir -p "$GHOST/plugins/p/.apm/skills/ghost-skill"
# NOT wrapped in a helper function: command substitution runs the body in a
# subshell, so an exit status assigned inside one never reaches the caller —
# under `set -u` the second read of it aborts the suite.
set +e
GHOST_OUT="$(bash "$HOOK" "$GHOST/plugins/p/.apm/skills/my-skill/SKILL.md" 2>&1)"
GHOST_RC=$?
set -e
if [[ $GHOST_RC -ne 0 && "$GHOST_OUT" == *"routes to 'ghost-skill'"* ]]; then
pass "a directory with no SKILL.md in it is not a resolvable name"
else
fail "an empty skills/ghost-skill/ directory resolved a routing target (exit $GHOST_RC): ${GHOST_OUT:-<empty>}"
fi
# The confirming half: drop a SKILL.md into the same directory and the identical
# description resolves. Without this the rule could be implemented as "skills/
# never contributes anything" and still pass above.
write_skill "$GHOST/plugins/p/.apm/skills/ghost-skill" ghost-skill \
"Use when doing the other thing. Do not use for anything else."
set +e
GHOST_OUT="$(bash "$HOOK" "$GHOST/plugins/p/.apm/skills/my-skill/SKILL.md" 2>&1)"
GHOST_RC=$?
set -e
if [[ $GHOST_RC -eq 0 && -z "$GHOST_OUT" ]]; then
pass "the same directory WITH a SKILL.md resolves, so the rule is 'no SKILL.md' and not 'never'"
else
fail "a populated skills/ghost-skill/ directory still did not resolve (exit $GHOST_RC): ${GHOST_OUT:-<empty>}"
fi
# And the confirming half of the grammar rule: a compound-modifier target is
# CONFIRM-ONLY, not ignored. When the name does exist it still counts as a route
# — the rule suppresses the ERROR, it does not delete the target.

View File

@@ -331,13 +331,32 @@ PY
#
# The sibling plugin is what makes "every plugin in the monorepo contributes its
# names" testable; without it a cross-plugin target and a typo are the same.
#
# Both sibling skill directories get a real SKILL.md, and that is load-bearing
# rather than tidiness: a skill directory is a resolvable name only if it HOLDS
# a SKILL.md. An empty leftover directory is untracked by git, so counting one
# made a target resolve on the machine that made it and dangle in a fresh clone
# — the same install-dependence the deployed-tree rule exists to remove. This
# fixture used to `mkdir` the two siblings and write nothing into them, so it
# was itself relying on the behaviour the resolver no longer has.
make_tree_fixture() {
local label="$1" desc="$2" body_words="$3" root apm
local label="$1" desc="$2" body_words="$3" root apm sib
root="$TMPDIR/tree-$label"
apm="$root/plugins/subject-plugin/.apm"
mkdir -p "$apm/skills/$label" "$apm/skills/sibling-skill" "$apm/agents" \
"$root/plugins/other-plugin/.apm/skills/cross-plugin-skill"
: > "$apm/agents/sibling-agent.agent.md"
for sib in "$apm/skills/sibling-skill" \
"$root/plugins/other-plugin/.apm/skills/cross-plugin-skill"; do
{
echo "---"
echo "name: $(basename "$sib")"
echo "description: Use when doing the other thing. Do not use for anything else."
echo "---"
echo ""
echo "Do the thing."
} > "$sib/SKILL.md"
done
{
echo "---"
echo "name: $label"
@@ -364,8 +383,17 @@ expect_gate() {
fail "$label (exit $status, output: ${out:-<empty>})"
fi
;;
# The tier and the needle are matched ADJACENTLY — `*"SUGGESTION"*"$needle"*`
# — not as two independent substring tests. Independently, any output
# carrying a SUGGESTION anywhere and the needle anywhere satisfied the
# assertion, so a needle emitted at the WRONG TIER still passed: a finding
# that moved from SUGGESTION to a blocking ERROR line would be caught only
# by the exit-status test, and one that moved from SUGGESTION to INFO would
# not be caught at all. grammar_case's `suggests` branch in
# tests/test-adr0020-targets.sh has always matched them adjacently; this is
# the same rule.
suggest)
if [[ $status -eq 0 && "$out" == *"SUGGESTION"* && "$out" == *"$needle"* ]]; then
if [[ $status -eq 0 && "$out" == *"SUGGESTION"*"$needle"* ]]; then
pass "$label"
else
fail "$label (exit $status, output: ${out:-<empty>})"
@@ -450,9 +478,14 @@ expect_gate "body at $((BODY_MAX_WORDS + 1)) words fails" \
echo ""
echo "--- the body gate and the whole-file gate are independent measurements ---"
BODY_ONLY_DESC="$(python3 -c "print(' '.join(['w'] * 100))")"
# The needle pins the COUNT, not the bare word "words". "words" appears in the
# whole-file ceiling message, in the body ceiling message and in the body target
# message alike, so it was satisfied by any of the three — including the one
# this case exists to prove does NOT fire. Naming the number is what makes the
# assertion about the body-only measurement.
expect_gate "frontmatter words do not count toward the $BODY_MAX_WORDS-word body ceiling" \
suggest "$(make_budget_fixture body-independent "$BODY_ONLY_DESC" "$((BODY_MAX_WORDS - 5))")" \
"words"
"body is $((BODY_MAX_WORDS - 5)) words"
BIG_BODY="$(make_budget_fixture body-over-not-whole-file "$CLEAN_DESC" "$((BODY_MAX_WORDS + 1))")"
BIG_BODY_WORDS="$(wc -w < "$BIG_BODY")"
if [[ "$BIG_BODY_WORDS" -le "$MAX_WORDS" ]]; then
@@ -461,6 +494,96 @@ else
fail "the body-gate fixture is $BIG_BODY_WORDS whole-file words, which also trips MAX_WORDS=$MAX_WORDS — the test no longer isolates the body gate"
fi
# ---------------------------------------------------------------------------
# ADR-0020's hand-invocation carve-out (issue #108)
# ---------------------------------------------------------------------------
# A skill carrying `disable-model-invocation: true` is removed from the
# model-visible listing entirely — it is not preloaded, and the Skill tool
# refuses to call it — so its description is never matched against user intent.
# ADR-0020, skill-author Step 2 and skill-audit's own Step 0 all give it ONE
# plain human-facing sentence: no trigger list, no boundary clause. No validator
# knew the field existed, so the boundary-clause SUGGESTION fired on exactly the
# shape the contract mandates, and its remedy — "so the router knows where NOT
# to send this skill" — named a router that cannot see the skill at all.
#
# The carve-out is NARROW and the half it does not cover is the half worth
# testing: the body is still loaded on invocation, so the body budget stands,
# and the 400-character ceiling stands because it is an outlier stop rather than
# a routing-quality target. Every case below asserts one of those two halves.
make_hand_invoked_fixture() {
local name="$1" desc="$2" body_words="$3" file
file="$TMPDIR/$name.md"
{
echo "---"
echo "name: $name"
echo "description: $desc"
echo "disable-model-invocation: true"
echo "---"
echo ""
python3 -c "print(' '.join(['word'] * $body_words))"
} > "$file"
echo "$file"
}
# A description over the 250-character target, carrying no boundary clause and
# no routing target — the exact shape `zoom-out` and `caveman` ship. Built with
# no hyphens so nothing in it reads as a target.
HAND_DESC="$(python3 -c "
prefix = 'Tell the agent to zoom out and give broader context. '
print(prefix + 'x' * (300 - len(prefix)))")"
echo ""
echo "--- a hand-invoked skill is exempt from the routing rules, and only those ---"
expect_gate "a hand-invoked skill with a 300-char description and no boundary clause is silent" \
pass "$(make_hand_invoked_fixture hand-quiet "$HAND_DESC" 10)"
# The control that makes the case above mean something. Same description, same
# body, only the frontmatter flag removed: both findings must appear, or the
# exemption is being credited for silence it did not cause.
expect_gate "control: the SAME description without the flag is over the 250-char target" \
suggest "$(make_budget_fixture hand-control "$HAND_DESC" 10)" \
"description is 300 characters"
expect_gate "control: the SAME description without the flag has no boundary clause" \
suggest "$(make_budget_fixture hand-control "$HAND_DESC" 10)" \
"has no boundary clause"
echo ""
echo "--- the carve-out lifts the routing rules ONLY: both size gates still bite ---"
# The description ceiling is not a routing budget: a hand-invoked description is
# still the one line a human reads in the `/` menu, and 400 characters is the
# outlier stop either way.
HAND_OVER_MAX="$(python3 -c "
prefix = 'Tell the agent to zoom out and give broader context. '
print(prefix + 'x' * (401 - len(prefix)))")"
expect_gate "a hand-invoked description over $DESC_MAX_CHARS chars still FAILS" \
fail "$(make_hand_invoked_fixture hand-over-max "$HAND_OVER_MAX" 10)" \
"$DESC_MAX_CHARS-character ceiling"
# The body is loaded on invocation like any other body and competes with the
# caller's live conversation exactly the same way, so neither body tier moves.
expect_gate "a hand-invoked body over $BODY_MAX_WORDS words still FAILS" \
fail "$(make_hand_invoked_fixture hand-over-body "$HAND_DESC" "$((BODY_MAX_WORDS + 1))")" \
"$BODY_MAX_WORDS-word ceiling"
expect_gate "a hand-invoked body over $BODY_SUGGEST_WORDS words is still suggested" \
suggest "$(make_hand_invoked_fixture hand-over-body-suggest "$HAND_DESC" "$((BODY_SUGGEST_WORDS + 1))")" \
"body is $((BODY_SUGGEST_WORDS + 1)) words"
echo ""
echo "--- the flag is read as a BOOLEAN, not as any mention of the key ---"
# `disable-model-invocation: false` is the model-invoked case written out
# longhand. Reading the key's presence instead of its value would hand every
# routing exemption to anyone who typed the field at all.
HAND_FALSE="$TMPDIR/hand-false.md"
{
echo "---"
echo "name: hand-false"
echo "description: $HAND_DESC"
echo "disable-model-invocation: false"
echo "---"
echo ""
echo "Do the thing."
} > "$HAND_FALSE"
expect_gate "disable-model-invocation: false is NOT the carve-out" \
suggest "$HAND_FALSE" "has no boundary clause"
echo ""
echo "--- resolvable boundary targets ---"
# Resolution is against the AUTHORING SOURCE (plugins/*/.apm/skills/ and