fix(gates): block a dangling /name route with no preceding verb

The header promised explicit route notation always blocks. It did not: /name
reached extraction only behind a ROUTE_VERB, so a target with no verb before it
was never extracted at all -- exit 0, no output. Taking the SUGGESTION's own
advice ('write it as /name and it will be checked properly') was the one edit
that blinded the gate.

Adds two notation sweeps gated on BOUNDARY_MARKER and routed through _add, plus
a path guard so file paths and URLs are not read as routes. Also excises the
matched pointer span before the REFERENCE_PAST sweep, so a reference file can no
longer exempt itself by its own filename, and guards the agent branch with the
isfile test the skills branch already had.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJJrm5YmacbwMdzZpXcoti
This commit is contained in:
2026-08-31 19:45:45 +00:00
parent 5b80f305e9
commit c232e69645
5 changed files with 370 additions and 5 deletions

View File

@@ -292,6 +292,16 @@ def _collect_package(pkg_dir, names):
names.add(os.path.basename(path.rstrip('/')).lower())
for sub in ('.apm/agents/*.md', 'agents/*.md'):
for path in glob.glob(os.path.join(safe_dir, sub)):
# The same rule one directory over, which until now had no
# counterpart here at all: the skills branch above tests for a
# SKILL.md, the agents branch took every glob hit on trust. A
# DIRECTORY named `ghost-agent.md` matches `*.md` and glob does not
# tell the two apart, so a leftover of that shape resolved a routing
# target on the machine holding it and dangled everywhere else —
# identical install-dependence, arriving through the one door
# nobody guarded.
if not os.path.isfile(path):
continue
base = os.path.basename(path)
if base.endswith('.agent.md'):
base = base[:-len('.agent.md')]
@@ -570,6 +580,34 @@ def known_targets(start_dir):
# ambiguity to resolve, and an author who wants a route checked unconditionally
# has two ways to say so.
#
# BOTH FORMS ARE SWEPT FOR ON THEIR OWN inside a boundary sentence, and that is
# a repair of the promise above rather than a widening of it. Until the sweeps
# existed, notation was only ever seen as the OBJECT OF A ROUTE VERB (`use
# /name`) or as the tail of a `not ... ->` clause with no `;` or sentence end in
# between. Every one of these therefore exited 0 in total silence — no ERROR, no
# SUGGESTION, not even the target's name:
# Do not use for Y — /no-such-skill instead.
# Do not use for Y; /no-such-skill handles that.
# Do not use for Y (/no-such-skill covers it).
# Do not use for Y — that is /no-such-skill's job.
# Do not use for Y — defer to /no-such-skill.
# Do not use for Y — /no-such-skill.
# Do not use for Y; -> no-such-skill covers it.
# The target was never EXTRACTED, so the notation-first rule in _add() had
# nothing to apply itself to and the "always blocks" promise was false for the
# ordinary way an author writes the thing. The SUGGESTION tier made it worse
# than a gap: its printed remedy tells the author to "write it as `/name` or
# `-> name` and it will be checked properly", and taking that advice turned a
# visible SUGGESTION into silence — the gate teaching the one edit that blinds
# it.
#
# The sweeps are gated on the sentence carrying a BOUNDARY_MARKER, the same gate
# the backtick sweep uses, and NOTATION_SLASH refuses a token that is part of a
# PATH: a following `/`, or a `.` followed by a non-space, means
# `references/foo.md`, `docs/a/b.md` or `https://x/y`, not a route. A sentence's
# closing `.` is not followed by a non-space, so `— /no-such-skill.` still
# counts.
#
# NAMESPACE: `plugin:skill` is live in this repo (native user-scope installs
# still resolve `gitea:gitea-prs`), so the patterns admit an optional
# `<plugin>:` prefix and normalize_target() strips it before resolution.
@@ -593,6 +631,23 @@ ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET)
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I)
# The two EXPLICIT ROUTE NOTATION sweeps, scoped to a boundary sentence by their
# caller. NOTATION_SLASH is deliberately not a reuse of MARKED_TARGET's `/name`
# alternative: that one only ever runs behind a route verb or an arrow, and the
# trailing lookahead here is the part that makes a FREE-STANDING sweep safe.
# NOTATION_ARROW is ARROW_BOUNDARY minus its leading `\bnot\b%s*?`, which is
# what made `Do not use for Y; -> no-such-skill covers it.` invisible:
# CLAUSE_BODY cannot cross the `;`, so the clause's own punctuation disarmed the
# check. Dropping that prefix costs the one false positive the bare-arrow bullet
# above names — a process chain ending in a hyphenated word, `Instead, reproduce
# -> minimise -> regression-test.` — and costs it only in a sentence that already
# carries a BOUNDARY_MARKER. That exposure is neither new nor larger: the same
# chain written `Do not use for X — reproduce -> regression-test.` was already a
# hard ERROR under ARROW_BOUNDARY, so this changes which boundary words reach the
# arrow, not whether prose can. An author who means the chain and not a route
# writes it in its own sentence, where neither pattern looks.
NOTATION_SLASH = re.compile(r"(?<![\w./*-])/(%s)\b(?!/|\.\S)" % NAME_ANY, re.I)
NOTATION_ARROW = re.compile(r"(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I)
# CLAUSE_BODY is what may sit between `Not` and the arrow, and it is NOT
# `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a
# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`,
@@ -768,6 +823,16 @@ def _extract_sentence(sentence):
_add(out, sentence, match.group(1), match.start(1), match.end(1),
strict=True, arrow=True)
if boundary:
# Route notation wherever it sits in the clause, not only where a route
# verb or an arrow happens to precede it. See the EXPLICIT ROUTE
# NOTATION note in the header for the seven phrasings this recovers and
# for why silence was the failure mode. Neither sweep takes the follower
# test: _add() reads the notation first and both forms reach it marked.
for match in NOTATION_SLASH.finditer(sentence):
_add(out, sentence, match.group(1), match.start(1), match.end(1))
for match in NOTATION_ARROW.finditer(sentence):
_add(out, sentence, match.group(1), match.start(1), match.end(1),
strict=True, arrow=True)
for match in BACKTICK.finditer(sentence):
_add(out, sentence, match.group(1), match.start(1), match.end(1))
return out
@@ -1138,7 +1203,15 @@ def missing_reference_pointers(body, skill_dir):
end = masked.find('\n', match.end())
if end < 0:
end = len(masked)
if REFERENCE_PAST.search(masked[start:end]):
# The pointer's OWN SPAN is excised before the sweep. Run over the
# whole line, the past-tense test matched the very path it was judging,
# so a file exempted itself by its NAME: `references/deprecated-api.md`,
# `references/removed-flags.md` and `references/gone.md` produced no
# ERROR at all, while `references/missing.md` — an identical break —
# errored. The exemption is about what the SENTENCE says about the
# pointer, never about what the pointer is called.
line = masked[start:match.start()] + masked[match.end():end]
if REFERENCE_PAST.search(line):
continue
if REFERENCE_QUALIFIER.search(masked[start:match.start()]):
continue