fix(gates): close the /name fail-open and stop the path guard inventing targets

Two defects in the routing-target resolver, both latent in the corpus but hot for
anything written next.

The free-standing `/name` sweep sat inside `if boundary:`, so route notation in a
sentence carrying no boundary marker was never extracted at all — not an ERROR, not
a SUGGESTION, not an INFO. That contradicted ADR-0020's amendment and gates.md,
which both promise `/name` blocks unconditionally. The sweep now runs over every
sentence. `-> name` and backticked forms stay gated deliberately: an arrow also
writes a process chain and a code span cites tools, files and skills alike, so
ungating either fires on ordinary prose.

The path guard used `\b`, which still holds after a hyphen, so the engine
backtracked to a shorter hyphen-terminated prefix whenever the lookahead rejected
the full segment. `/api-docs/v2.md` in a boundary clause raised blocking ERRORs for
'api' and 'api-docs' — names no author wrote, with no corroboration escape.
`(?![\w-])` forbids the shortened prefix outright; MARKED_TARGET, which had no
trailing guard at all, gained one.

Zero arguments now exits 2 rather than 0, so a mis-scoped `files:` pattern is no
longer indistinguishable from a clean corpus. Both hook manifests pass filenames
and pre-commit skips a filename-passing hook when nothing matches, so the hook
never sees an empty argv — that contract is now asserted by a test rather than
left in prose.

Deleting the sweep entirely used to leave every suite green. It now kills eight
assertions. The suite also gains its first slash-path and URL fixtures, in both
directions.

Refs: #107, #110, #124
ADR: 0020
This commit is contained in:
2026-09-01 12:37:12 +00:00
parent 971e148e19
commit 9fe734573d
9 changed files with 802 additions and 146 deletions

View File

@@ -780,6 +780,150 @@ else
fail "a populated skills/ghost-skill/ directory still did not resolve (exit $GHOST_RC): ${GHOST_OUT:-<empty>}"
fi
# ---------------------------------------------------------------------------
# 2g. The FREE-STANDING /name sweep, and its reach beyond a boundary sentence
# ---------------------------------------------------------------------------
# NOTATION_SLASH's own sweep in _extract_sentence() is what sees `/name` when no
# route verb and no arrow precedes it. Nothing pinned it: every `/name` fixture
# in this suite before these cases ALSO carried a route verb ("use
# /no-such-slash-skill instead"), which ROUTE_ANY/ROUTE_MARKED extract on their
# own, so deleting the sweep outright left the whole suite green. The eight
# phrasings below carry no route verb in front of the target, so each of them is
# invisible without the sweep — which is exactly the silence the sweep exists to
# repair, and the shape the SUGGESTION tier's own remedy ("write it as `/name`
# and it will be checked properly") used to teach an author to produce.
echo ""
echo "--- /name with no route verb in front of it is still extracted ---"
grammar_case sweep-dash errors "routes to 'no-such-skill'" \
"Use when doing the thing. Do not use for Y — /no-such-skill instead."
grammar_case sweep-semicolon errors "routes to 'no-such-skill'" \
"Use when doing the thing. Do not use for Y; /no-such-skill handles that."
grammar_case sweep-paren errors "routes to 'no-such-skill'" \
"Use when doing the thing. Do not use for Y (/no-such-skill covers it)."
grammar_case sweep-possessive errors "routes to 'no-such-skill'" \
"Use when doing the thing. Do not use for Y — that is /no-such-skill's job."
grammar_case sweep-defer errors "routes to 'no-such-skill'" \
"Use when doing the thing. Do not use for Y — defer to /no-such-skill."
grammar_case sweep-terminal errors "routes to 'no-such-skill'" \
"Use when doing the thing. Do not use for Y — /no-such-skill."
# The arrow twin. `;` ends CLAUSE_BODY, so ARROW_BOUNDARY cannot reach across it
# from `not`; only NOTATION_ARROW's own sweep sees this one.
grammar_case sweep-arrow-after-semicolon errors "routes to 'no-such-skill'" \
"Use when doing the thing. Do not use for Y; -> no-such-skill covers it."
# THE SWEEP IS NOT SCOPED TO A BOUNDARY SENTENCE, and this is the case that
# proves it. Extraction is per-sentence (corroboration is scoped to one
# sentence), so gating the `/name` sweep on the sentence carrying a
# BOUNDARY_MARKER meant a route written one sentence AFTER the boundary clause
# was never looked at: exit 0, no ERROR, no SUGGESTION, not even the name. That
# contradicts ADR-0020's amendment and docs/spec/gates.md, which both promise
# `/name` blocks unconditionally, for any name.
#
# The first sentence's `/sibling-skill` is deliberate: it resolves, so the
# fixture is not "the gate fires on any slash it sees" — it fires on the one
# that dangles, in the sentence that carries no boundary marker at all.
echo ""
echo "--- /name is checked in a sentence that carries no boundary marker ---"
grammar_case sweep-outside-boundary errors "routes to 'no-such-skill'" \
"Use for X. Do not use for Z — use /sibling-skill instead. For W, /no-such-skill is the right entry point."
# Control, so the case above is not satisfied by a gate that fires on every
# unresolvable-looking token in a non-boundary sentence: the same shape with a
# name that RESOLVES stays silent.
grammar_case sweep-outside-boundary-control silent "" \
"Use for X. Do not use for Z — use /sibling-skill instead. For W, /sibling-skill is the right entry point."
# ---------------------------------------------------------------------------
# 2h. A slash PATH is not a route (the trailing guard, and its backtracking)
# ---------------------------------------------------------------------------
# There was no path or URL fixture anywhere in this suite, and the guard was
# defeated by ordinary regex backtracking. `/(NAME_ANY)\b(?!/|\.\S)` looks like
# it refuses a path, and does not: when the lookahead rejects the FULL segment
# the engine backtracks to a shorter hyphen-terminated prefix, `\b` still holds
# after a hyphen, and the phantom is reported as a hard BLOCKING ERROR naming a
# skill nobody wrote:
# /opt-tools/bin/thing -> ERROR: routes to 'opt'
# /api-docs/v2.md -> ERROR: routes to 'api' AND to 'api-docs'
# /no-such-skill.md -> ERROR: routes to 'no-such'
# `(?![\w-])` is the guard that actually holds, because it forbids the shortened
# prefix instead of merely disliking the full one. MARKED_TARGET carries it too:
# that pattern had NO trailing lookahead at all, which is where the second
# 'api-docs' error above came from.
#
# These are `silent`, not `suggests`. A path is not a routing target at any
# tier — reporting one would be the same false positive one notch quieter, on
# the skills most likely to name a path in a boundary clause.
echo ""
echo "--- a slash PATH in a boundary sentence is not a routing target ---"
grammar_case path-absolute silent "" \
"Use when doing the thing. Do not use for Y; the config lives at /opt-tools/bin/thing."
grammar_case path-dotted-file silent "" \
"Use when doing the thing. Do not use for Y — see /api-docs/v2.md for the schema."
grammar_case path-dotted-backticked silent "" \
"Use when doing the thing. Do not use for Y — see \`/api-docs/v2.md\` for the schema."
grammar_case path-md-suffix silent "" \
"Use when doing the thing. Do not use for Y — the file /no-such-skill.md documents it."
# The two suppressions that were already working and must keep working: a URL
# (the `/` is preceded by a word character or by another `/`) and a relative
# references/ pointer. Asserted explicitly because the guard above is a change to
# the same lookarounds, and a fix that traded one silence for another would look
# identical from the corpus.
grammar_case path-url silent "" \
"Use when doing the thing. Do not use for Y — see https://example.com/no-such-skill for details."
grammar_case path-relative silent "" \
"Use when doing the thing. Do not use for Y — see references/no-such-skill.md for details."
# The other direction, which is what stops the guard from becoming a hole: a
# name whose only follower is the SENTENCE-ENDING dot is still a route. A
# closing `.` is not followed by a non-space, so `(?!\.\S)` does not reject it.
# Without these, "refuse every /name near a dot or a slash" would pass every
# case above and silently delete the notation tier.
echo ""
echo "--- the path guard does not swallow a /name at a real sentence end ---"
grammar_case path-guard-sentence-end errors "routes to 'no-such-skill'" \
"Use when doing the thing. Do not use for Y — defer to /no-such-skill."
grammar_case path-guard-mid-sentence errors "routes to 'no-such-skill'" \
"Use when doing the thing. Do not use for Y — use /no-such-skill for that instead."
# ---------------------------------------------------------------------------
# 2i. A DIRECTORY named <something>.md is not an agent
# ---------------------------------------------------------------------------
# The skills branch of _collect_package() tests for a SKILL.md; the agents
# branch takes every `*.md` glob hit on trust, and glob does not distinguish a
# file from a directory. A leftover directory named `ghost-agent.md` — a botched
# `mkdir`, an editor's stray save, a half-deleted agent — is untracked by git, so
# it exists on the machine that made it and nowhere else, and it resolved a
# routing target there and dangled everywhere else. That is exactly the
# install-dependence fixture 2f pins one directory over, and the isfile() guard
# closing it had no test at all: deleting it left every suite green.
echo ""
echo "--- an agents/<name>.md DIRECTORY does not make a routing target resolve ---"
GHOST_AGENT="$TMPDIR_T/ghost-agent-dir"
write_skill "$GHOST_AGENT/plugins/p/.apm/skills/my-skill" my-skill \
"Use when doing the thing. Do not use for the other thing — use /ghost-agent instead."
mkdir -p "$GHOST_AGENT/plugins/p/.apm/agents/ghost-agent.md"
set +e
GHOST_AGENT_OUT="$(bash "$HOOK" "$GHOST_AGENT/plugins/p/.apm/skills/my-skill/SKILL.md" 2>&1)"
GHOST_AGENT_RC=$?
set -e
if [[ $GHOST_AGENT_RC -ne 0 && "$GHOST_AGENT_OUT" == *"routes to 'ghost-agent'"* ]]; then
pass "a DIRECTORY named ghost-agent.md is not a resolvable agent name"
else
fail "a directory named agents/ghost-agent.md resolved a routing target (exit $GHOST_AGENT_RC): ${GHOST_AGENT_OUT:-<empty>}"
fi
# The confirming half, exactly as in 2f: replace the directory with a real file
# and the identical description resolves. Without it the rule could be
# implemented as "agents/ never contributes anything" and still pass above.
rmdir "$GHOST_AGENT/plugins/p/.apm/agents/ghost-agent.md"
: > "$GHOST_AGENT/plugins/p/.apm/agents/ghost-agent.md"
set +e
GHOST_AGENT_OUT="$(bash "$HOOK" "$GHOST_AGENT/plugins/p/.apm/skills/my-skill/SKILL.md" 2>&1)"
GHOST_AGENT_RC=$?
set -e
if [[ $GHOST_AGENT_RC -eq 0 && -z "$GHOST_AGENT_OUT" ]]; then
pass "the same path as a FILE resolves, so the rule is 'not a file' and not 'never'"
else
fail "a real agents/ghost-agent.md file still did not resolve (exit $GHOST_AGENT_RC): ${GHOST_AGENT_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.