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:
@@ -444,30 +444,42 @@ for path in files:
|
||||
"\"Not X -> %s. Not Y -> %s.\"" % (path, first, second, first, second))
|
||||
|
||||
targets = boundary_targets(desc)
|
||||
if targets:
|
||||
# Body-level targets (issue #124): notation only (`/name`, `-> name`), so
|
||||
# every hit is unconditionally blocking — see body_targets()'s header for
|
||||
# why the description gate's SUGGESTION tier has no counterpart here.
|
||||
body_route_names = body_targets(body)
|
||||
if targets or body_route_names:
|
||||
known = known_targets(skill_dir)
|
||||
if known:
|
||||
blocking, reported = unresolved_targets(desc, known)
|
||||
for target in blocking:
|
||||
error("%s: description routes to '%s', which does not resolve to a skill "
|
||||
"or agent in this monorepo, in this package, or in a package it "
|
||||
"declares in apm.yml dependencies.apm (ADR-0020). A boundary clause "
|
||||
"that names a non-existent target sends the router nowhere."
|
||||
% (path, target))
|
||||
for target in reported:
|
||||
suggest("%s: description routes to '%s', which does not resolve to a skill "
|
||||
"or agent in this monorepo, in this package, or in a package it "
|
||||
"declares in apm.yml dependencies.apm (ADR-0020). SUGGESTION rather "
|
||||
"than a hard failure because nothing else in the sentence resolves, "
|
||||
"so this is equally likely to be a tool, a file format or an English "
|
||||
"compound. If it IS a route, write it as `/%s` or `-> %s` and it will "
|
||||
"be checked properly." % (path, target, target, target))
|
||||
if targets:
|
||||
blocking, reported = unresolved_targets(desc, known)
|
||||
for target in blocking:
|
||||
error("%s: description routes to '%s', which does not resolve to a skill "
|
||||
"or agent in this monorepo, in this package, or in a package it "
|
||||
"declares in apm.yml dependencies.apm (ADR-0020). A boundary clause "
|
||||
"that names a non-existent target sends the router nowhere."
|
||||
% (path, target))
|
||||
for target in reported:
|
||||
suggest("%s: description routes to '%s', which does not resolve to a skill "
|
||||
"or agent in this monorepo, in this package, or in a package it "
|
||||
"declares in apm.yml dependencies.apm (ADR-0020). SUGGESTION rather "
|
||||
"than a hard failure because nothing else in the sentence resolves, "
|
||||
"so this is equally likely to be a tool, a file format or an English "
|
||||
"compound. If it IS a route, write it as `/%s` or `-> %s` and it will "
|
||||
"be checked properly." % (path, target, target, target))
|
||||
for target in unresolved_body_targets(body, known):
|
||||
error("%s: body routes to '%s' (`/%s` or `-> %s` notation), which does not "
|
||||
"resolve to a skill or agent in this monorepo, in this package, or in a "
|
||||
"package it declares in apm.yml dependencies.apm (ADR-0020). A dispatch "
|
||||
"table or \"run X\" step naming a non-existent target sends the agent "
|
||||
"nowhere." % (path, target, target, target))
|
||||
else:
|
||||
unchecked = sorted(set(targets) | set(body_route_names))
|
||||
info("%s: boundary-target resolution DID NOT RUN — no skill universe "
|
||||
"could be determined for this path (no authoring root above it, no "
|
||||
"apm package root, no declared apm dependencies, no deployed "
|
||||
".claude/ or .agents/ tree). Unchecked target(s): %s"
|
||||
% (path, ", ".join(targets)))
|
||||
% (path, ", ".join(unchecked)))
|
||||
|
||||
sys.exit(1 if failed else 0)
|
||||
SSC_CHECKS_PY
|
||||
|
||||
Reference in New Issue
Block a user