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:
@@ -443,39 +443,56 @@ elif desc:
|
||||
# derived from this script's own path, and — when an authoring root exists — it
|
||||
# never reads a deployed .claude/ tree, so a fresh clone and a machine that has
|
||||
# run `apm install` return the same verdict. See the shared resolver's header.
|
||||
if desc:
|
||||
routing_targets = boundary_targets(desc)
|
||||
known = known_targets(skill_dir) if routing_targets else set()
|
||||
if routing_targets and not known:
|
||||
routing_targets = boundary_targets(desc) if desc else []
|
||||
# Body-level targets (issue #124): notation only (`/name`, `-> name`), so
|
||||
# every hit is unconditionally blocking — see the shared resolver's
|
||||
# body_targets() header for why the description gate's SUGGESTION tier has
|
||||
# no counterpart here. Read regardless of `desc`: a body dispatch table can
|
||||
# carry a broken route even when the description carries none.
|
||||
body_routing_targets = body_targets(body)
|
||||
if routing_targets or body_routing_targets:
|
||||
known = known_targets(skill_dir)
|
||||
if not known:
|
||||
unchecked = sorted(set(routing_targets) | set(body_routing_targets))
|
||||
info(f"boundary-target resolution DID NOT RUN — no skill universe could be "
|
||||
f"determined for this path (no authoring root above it, no apm package "
|
||||
f"root, no declared apm dependencies, no deployed .claude/ or .agents/ "
|
||||
f"tree). Unchecked target(s): {', '.join(routing_targets)}")
|
||||
elif routing_targets:
|
||||
# blocking vs reported: a target only earns a FAIL when it is written in
|
||||
# route notation or its own sentence corroborates it by naming another
|
||||
# target that resolves. See the shared resolver's CORROBORATION note.
|
||||
unresolved, soft = unresolved_targets(desc, known)
|
||||
for target in unresolved:
|
||||
fail(f"description routes to '{target}', which resolves to no skill or agent "
|
||||
f"in this monorepo, in this package, or in a package it declares in "
|
||||
f"apm.yml dependencies.apm — a boundary clause naming a non-existent "
|
||||
f"target sends the router nowhere")
|
||||
for target in soft:
|
||||
suggest(f"description routes to '{target}', which resolves to no skill or agent "
|
||||
f"in this monorepo, in this package, or in a package it declares in "
|
||||
f"apm.yml dependencies.apm — SUGGESTION rather than FAIL because nothing "
|
||||
f"else in that sentence resolves, so it is equally likely to be a tool, a "
|
||||
f"file format or an English compound. If it IS a route, write it as "
|
||||
f"`/{target}` or `-> {target}` and it will be checked properly")
|
||||
if not unresolved:
|
||||
# Counts the targets that ACTUALLY resolve, not every target found:
|
||||
# a confirm-only target (one used attributively — see the resolver's
|
||||
# ATTRIBUTIVE USE note) is exempt from the failure above, so
|
||||
# reporting it as resolved would be a false claim.
|
||||
resolved = [t for t in routing_targets if normalize_target(t) in known]
|
||||
ok(f"{len(resolved)} of {len(routing_targets)} boundary target(s) resolve: "
|
||||
f"{', '.join(resolved) if resolved else '(none)'}")
|
||||
f"tree). Unchecked target(s): {', '.join(unchecked)}")
|
||||
else:
|
||||
if routing_targets:
|
||||
# blocking vs reported: a target only earns a FAIL when it is written in
|
||||
# route notation or its own sentence corroborates it by naming another
|
||||
# target that resolves. See the shared resolver's CORROBORATION note.
|
||||
unresolved, soft = unresolved_targets(desc, known)
|
||||
for target in unresolved:
|
||||
fail(f"description routes to '{target}', which resolves to no skill or agent "
|
||||
f"in this monorepo, in this package, or in a package it declares in "
|
||||
f"apm.yml dependencies.apm — a boundary clause naming a non-existent "
|
||||
f"target sends the router nowhere")
|
||||
for target in soft:
|
||||
suggest(f"description routes to '{target}', which resolves to no skill or agent "
|
||||
f"in this monorepo, in this package, or in a package it declares in "
|
||||
f"apm.yml dependencies.apm — SUGGESTION rather than FAIL because nothing "
|
||||
f"else in that sentence resolves, so it is equally likely to be a tool, a "
|
||||
f"file format or an English compound. If it IS a route, write it as "
|
||||
f"`/{target}` or `-> {target}` and it will be checked properly")
|
||||
if not unresolved:
|
||||
# Counts the targets that ACTUALLY resolve, not every target found:
|
||||
# a confirm-only target (one used attributively — see the resolver's
|
||||
# ATTRIBUTIVE USE note) is exempt from the failure above, so
|
||||
# reporting it as resolved would be a false claim.
|
||||
resolved = [t for t in routing_targets if normalize_target(t) in known]
|
||||
ok(f"{len(resolved)} of {len(routing_targets)} boundary target(s) resolve: "
|
||||
f"{', '.join(resolved) if resolved else '(none)'}")
|
||||
unresolved_body = unresolved_body_targets(body, known)
|
||||
for target in unresolved_body:
|
||||
fail(f"body routes to '{target}' (`/{target}` or `-> {target}` notation), which "
|
||||
f"resolves to no skill or agent in this monorepo, in this package, or in a "
|
||||
f"package it declares in apm.yml dependencies.apm — a dispatch table or "
|
||||
f"\"run X\" step naming a non-existent target sends the agent nowhere")
|
||||
if body_routing_targets and not unresolved_body:
|
||||
ok(f"{len(body_routing_targets)} of {len(body_routing_targets)} body routing "
|
||||
f"target(s) resolve: {', '.join(body_routing_targets)}")
|
||||
|
||||
# Body unfilled placeholders
|
||||
fill_matches = PLACEHOLDER_RE.findall(body)
|
||||
|
||||
Reference in New Issue
Block a user