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:
@@ -414,6 +414,56 @@ and rises to a blocking ERROR the moment a resolving sibling joins it. The reaso
|
||||
the point of enforcement in `_add()`'s docstring in `scripts/skill-size-check.sh` and its two
|
||||
mirrored copies, and the verdict table in `docs/spec/gates.md` states the corrected shape.
|
||||
|
||||
## Amendment (2026-09-22): body-level routing targets are resolved too
|
||||
|
||||
The Decision section's routing-target resolver (`boundary_targets()` / `unresolved_targets()`) reads
|
||||
the **description** only. 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: `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 were found by reading, not by a gate, during
|
||||
the #99 retrofit and its follow-up audit; both were fixed in `03abcff`. **The fix this amendment
|
||||
records is the gate, not those two edits** (issue #124).
|
||||
|
||||
The body gate is a **separate, narrower** extractor (`body_targets()` /
|
||||
`unresolved_body_targets()`), not the description resolver reused at wider scope. The description
|
||||
resolver'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
|
||||
and procedure prose in both directions: under-firing on a table row that carries no "do not" /
|
||||
"instead", over-firing on a procedure step naming a file, a CLI verb or a config key exactly the way
|
||||
a route names a skill. Retuning those heuristics for the body genre was considered and rejected as
|
||||
the harder half of the problem, with a materially worse cost of getting it wrong (a body is loaded
|
||||
on every invocation, so a false-positive-prone body gate is felt far more often than a
|
||||
false-positive-prone description gate).
|
||||
|
||||
So the body gate reads **only** explicit route notation — `/name` and backticked-or-slash-prefixed
|
||||
`-> name` / `→ name` — already the description gate's own unconditionally-blocking tier, and nothing
|
||||
softer: no SUGGESTION tier, no bare-word forms, no corroboration. Two further restrictions, both
|
||||
earned by a real corpus false positive rather than assumed up front:
|
||||
|
||||
- **the target must be hyphenated**, even in notation. `` `/fork` `` (`forge/SKILL.md`, citing
|
||||
Claude Code's own `/fork` subagent command) and `` `/name` `` (`skill-author/SKILL.md`, a
|
||||
placeholder for the skill's own name) are real corpus citations of a tool or a placeholder, not
|
||||
routes, and both hard-FAILed with no escape hatch before this restriction. This is the same
|
||||
"single-word targets are ordinary English" trade the Decision section already makes for the bare
|
||||
form, extended to notation because the body genre has no boundary-sentence signal to fall back on;
|
||||
- **a bare hyphenated word after any arrow is not notation.** The description gate's own bare-arrow
|
||||
sweep (`NOTATION_ARROW`) reads ordinary process-chain prose as a route: `caveman`'s "Inline obj
|
||||
prop -> new ref -> re-render." dangled to `re-render` under it. The body gate uses `ARROW_MARKED`
|
||||
instead, which requires the target to be backticked or slash-prefixed — true of the one real
|
||||
historical target (`` -> `to-prd` ``, confirmed against `03abcff`'s diff), so this costs no real
|
||||
coverage;
|
||||
- a target immediately preceded by `<` is a closing tag (`</what-to-do>`, `<supporting-info>` — this
|
||||
repo's own `grill-with-docs/SKILL.md` uses these as prompt section delimiters), not `/name`
|
||||
notation, and is discarded on that basis alone.
|
||||
|
||||
Both consumers — `scripts/skill-size-check.sh` and `factory-audit/scripts/lib-checks-skill.sh` —
|
||||
call the shared functions independently over the same `known_targets()` universe the description
|
||||
check already computed, so a body target folds into the existing "DID NOT RUN" INFO tier rather than
|
||||
adding a second one. `tests/test-adr0020-targets.sh` pins the two live true positives, all three
|
||||
guards above, and the fenced-code-block mask; the corpus-wide dangling assertion now covers body
|
||||
targets the same way it already covered description ones. `docs/spec/gates.md`'s "Body-level routing
|
||||
targets" section states the enforced shape in full.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Editing any non-compliant skill now requires retrofitting it first.** At decision time, 30 of 39
|
||||
|
||||
@@ -360,6 +360,68 @@ at a real sentence end. **Read the second bullet forward as well as back:** a ba
|
||||
after a dotted filename is now extracted, resolved, and a blocking ERROR when it dangles, where the
|
||||
same clause used to pass unchecked in silence.
|
||||
|
||||
### Body-level routing targets (issue #124)
|
||||
|
||||
Everything above resolves targets named in the **description** — the one field `boundary_targets()`
|
||||
and `unresolved_targets()` read. Until issue #124, a target named in the **body** — a dispatch table
|
||||
or a "run X" step, both routine in a 900-word procedure — was checked by nothing: `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`, and both were found by reading, not by any gate (fixed in `03abcff`;
|
||||
the gate itself is the ask this section documents).
|
||||
|
||||
`body_targets()` / `unresolved_body_targets()` (`lib-boundary-resolver.sh`) are a **separate,
|
||||
narrower** extractor, not a reuse of the description one at wider scope. A body is dispatch-table
|
||||
and procedure prose, not a one-to-three-sentence routing clause, so `BOUNDARY_MARKER`, the follower
|
||||
test and in-sentence corroboration all misfire on it in both directions — under-firing on a table
|
||||
row that carries no "do not"/"instead", over-firing on a procedure step that names a file, a CLI verb
|
||||
or a config key exactly the way a route names a skill. So the body gate reads only **notation**,
|
||||
already the description gate's own "always blocks" tier, and nothing softer:
|
||||
|
||||
| Form | Pattern | Requires |
|
||||
|---|---|---|
|
||||
| `/name` | `NOTATION_SLASH` | a hyphen in `name`; not preceded by `<` |
|
||||
| `-> name` / `→ name` | `ARROW_MARKED` | the name **backticked or slash-prefixed** — `NOTATION_ARROW`'s bare form is not used here |
|
||||
|
||||
Both constraints exist because the corpus, not intuition, said so — each is a real false positive
|
||||
this gate produced once and was narrowed to remove:
|
||||
|
||||
- **No SUGGESTION tier, no continuation, one arrow per target.** Both forms are notation, and
|
||||
notation is unconditionally blocking — there is no ambiguous prose reading left to soften, so
|
||||
there is nothing to report at a softer tier. `CONT_MARKED`/`CONT_ANY` are not run either, so
|
||||
`-> \`a\` or \`b\`` resolves only `a`, same as the one-arrow-one-target convention **#107** already
|
||||
states for descriptions — enforced here by construction instead of by a second SUGGESTION.
|
||||
- **A bare hyphenated word after any arrow is not notation here.** `NOTATION_ARROW` (used for the
|
||||
description gate's own `Not X -> name` sweep) matches a bare `-> name` unconditionally, and a body
|
||||
is full of ordinary arrow prose that is not a route: `caveman`'s own `Inline obj prop -> new ref ->
|
||||
re-render.` read as a dangling route to `re-render` under that pattern. `ARROW_MARKED` requires the
|
||||
target to be backticked or slash-prefixed, which the one real historical target (`` -> `to-prd` ``,
|
||||
per `03abcff`'s diff) already was, so the narrowing costs no real coverage.
|
||||
- **A single-word target is discarded, even in notation.** `` `/fork` `` (`forge/SKILL.md`,
|
||||
contrasting `context: fork` with Claude Code's own `/fork` subagent command) and `` `/name` ``
|
||||
(`skill-author/SKILL.md`, "the user types `/name`" — a placeholder for the skill's *own* name, not
|
||||
a route) are both real corpus citations of a tool or a placeholder, not routes, and both hard-FAILed
|
||||
with no escape hatch before the hyphen requirement was added. This is a real, accepted recall loss:
|
||||
a body dispatch entry to a genuinely single-word skill (`forge`, `research`, `triage`, `tdd`,
|
||||
`prototype`) cannot be checked through this extractor. Same trade the description gate already
|
||||
makes for the *bare* form (the known gap above), extended here to notation as well because the body
|
||||
genre has no boundary-sentence signal to lean on instead.
|
||||
- **A name immediately preceded by `<` is a closing tag, not a route.** `grill-with-docs/SKILL.md`
|
||||
uses XML-style prompt delimiters (`<what-to-do>...</what-to-do>`, `<supporting-info>...`), and
|
||||
`</what-to-do>` is indistinguishable from `/what-to-do` notation by every other rule above. No route
|
||||
is ever written directly after `<` in this corpus, so the guard costs nothing else.
|
||||
|
||||
Fenced code blocks are masked first (`mask_fenced()`, the same masking `gotcha_stats()` and the
|
||||
references/-pointer check already use): an illustrative ` ```/some-skill``` ` in `skill-author` or
|
||||
`factory-audit` — which document this very notation — is not a live dispatch entry.
|
||||
|
||||
Both consumers agree by construction: `scripts/skill-size-check.sh` and
|
||||
`factory-audit/scripts/lib-checks-skill.sh` each call `body_targets()`/`unresolved_body_targets()`
|
||||
independently, over the same `known_targets()` universe the description check already computed, so
|
||||
the "DID NOT RUN" INFO tier covers both description and body targets in one message rather than
|
||||
firing twice. `tests/test-adr0020-targets.sh`'s "body-level routing targets (issue #124)" section
|
||||
pins both the two live true positives and every guard above; the corpus-wide dangling assertion
|
||||
(`EXPECTED_DANGLING`) covers body targets the same way it already covered description ones.
|
||||
|
||||
### SUGGESTION-only checks
|
||||
|
||||
Deterministic to measure, judgment to act on:
|
||||
|
||||
@@ -7,7 +7,7 @@ description: >
|
||||
fixes -> agent-author.
|
||||
allowed-tools: Bash Read
|
||||
metadata:
|
||||
version: "1.0.4"
|
||||
version: "1.0.5"
|
||||
category: factory
|
||||
source_keys:
|
||||
- agentskills-home
|
||||
|
||||
@@ -898,6 +898,103 @@ def unresolved_targets(description, known):
|
||||
reported.add(name)
|
||||
return sorted(blocking), sorted(reported - blocking)
|
||||
|
||||
|
||||
# --- Body-level routing targets (issue #124) -------------------------------
|
||||
# boundary_targets()/unresolved_targets() above are tuned for a description:
|
||||
# one to three sentences, where BOUNDARY_MARKER, the follower test and
|
||||
# in-sentence corroboration all exist to tell a routing sentence apart from
|
||||
# ordinary prose about a hyphenated tool. A SKILL.md body is a different
|
||||
# genre — up to 900 words of procedure and dispatch tables — where those same
|
||||
# heuristics would misfire in both directions: a dispatch table rarely reads
|
||||
# as a "boundary sentence" (under-fire), and a procedure step naming a file, a
|
||||
# CLI verb or a config key looks exactly like a route (over-fire). Retuning
|
||||
# the sentence-level heuristics for that genre is the hard half of this gate
|
||||
# and is deliberately NOT attempted here — see the issue for why.
|
||||
#
|
||||
# So the body extractor takes the narrow route instead: only two EXPLICIT
|
||||
# ROUTE NOTATION forms count, and each is measured against the real corpus
|
||||
# (39 SKILL.md bodies) rather than assumed correct from the description gate's
|
||||
# behaviour — a body is dense with prose that LOOKS like this notation and
|
||||
# genuinely is not, in ways a one-to-three-sentence description never is:
|
||||
#
|
||||
# * ARROW_MARKED — `-> name` / `→ name` where the target is BACKTICKED or
|
||||
# slash-prefixed (MARKED_TARGET). NOT NOTATION_ARROW, which matches a bare
|
||||
# hyphenated word after any arrow: the corpus's own process-chain prose
|
||||
# ("Inline obj prop -> new ref -> re-render.", caveman/SKILL.md) reads as
|
||||
# a route under that pattern and does not under this one, because a
|
||||
# process chain is never itself backticked or slash-prefixed. The one
|
||||
# live true positive this was filed over, write-docs' "-> `to-prd`", IS
|
||||
# backticked (03abcff's diff shows the original), so ARROW_MARKED still
|
||||
# catches it losslessly.
|
||||
# * NOTATION_SLASH — free-standing `/name`, unconditionally, the same
|
||||
# pattern the description gate sweeps with. Two guards narrow it for body
|
||||
# text specifically, each one measured against a real corpus false
|
||||
# positive rather than hypothesised:
|
||||
# - a name with NO hyphen is discarded. A real dispatch entry in this
|
||||
# corpus always names a multi-word skill (`to-prd`,
|
||||
# `setup-matt-pocock-skills`); a single bare or backticked word after
|
||||
# a `/` is prose citing a CLI command, a Claude Code built-in or a
|
||||
# placeholder — `` `/fork` `` (forge/SKILL.md, contrasting
|
||||
# `context: fork` with Claude Code's own /fork subagent command) and
|
||||
# `` `/name` `` (skill-author/SKILL.md, "the user types `/name`" —
|
||||
# `name` is a placeholder for the skill's OWN name, not a route) are
|
||||
# both real corpus hits this guard removes. This is a real recall
|
||||
# loss — `/forge`, `/triage` and other single-word skill names are
|
||||
# unreachable through this extractor — accepted deliberately, the
|
||||
# same "start narrow" trade the issue itself recommends.
|
||||
# - a name immediately preceded by `<` is discarded. An XML/HTML-style
|
||||
# closing tag used as a prompt section delimiter — `</what-to-do>`,
|
||||
# `</supporting-info>` (grill-with-docs/SKILL.md) — is indistinguishable
|
||||
# from `/what-to-do` notation by every other rule in this pattern; no
|
||||
# route is ever written directly after `<` in this corpus, so the
|
||||
# guard costs nothing else.
|
||||
#
|
||||
# Every surviving hit is unconditionally blocking: both forms are explicit
|
||||
# notation with the ambiguous single-word and closing-tag readings already
|
||||
# removed, so there is no SUGGESTION tier here — that tier exists to soften
|
||||
# an ambiguous prose form, and none is admitted at this point.
|
||||
#
|
||||
# No conjunction continuation (CONT_*) either: `-> \`to-prd\` or \`grill-me\``
|
||||
# resolves only `to-prd`, the same one-arrow-one-target convention
|
||||
# multi_target_arrow_clauses() already enforces on descriptions (issue #107),
|
||||
# applied here by construction instead of by a second SUGGESTION.
|
||||
def body_targets(body):
|
||||
"""Every /name or -> `name` routing target named in a SKILL.md body.
|
||||
|
||||
Fenced code blocks are masked first, the same way gotcha_stats() and
|
||||
missing_reference_pointers() mask them: a ```-fenced example quoting
|
||||
`/some-skill` or `-> \`some-skill\`` as illustration is not a live
|
||||
dispatch entry, and skill-author/factory-audit — which document this
|
||||
very notation — are exactly the skills most likely to carry one.
|
||||
"""
|
||||
masked = mask_fenced(body)
|
||||
names = set()
|
||||
for match in NOTATION_SLASH.finditer(masked):
|
||||
if match.start() > 0 and masked[match.start() - 1] == '<':
|
||||
continue # </closing-tag>, not /route-notation
|
||||
name = match.group(1)
|
||||
if '-' in name:
|
||||
names.add(name)
|
||||
for match in ARROW_MARKED.finditer(masked):
|
||||
name, _, _ = _first(match)
|
||||
if name and '-' in name:
|
||||
names.add(name)
|
||||
return sorted(names)
|
||||
|
||||
|
||||
def unresolved_body_targets(body, known):
|
||||
"""Body routing targets (notation only) that resolve to nothing.
|
||||
|
||||
Unlike unresolved_targets(), this has one outcome, not two: every name
|
||||
body_targets() finds is already route notation, and notation always
|
||||
blocks. `known` is the resolved universe from known_targets(); passing an
|
||||
empty set is not meaningful — callers check for that first and decline
|
||||
out loud instead, exactly as they do for the description gate.
|
||||
"""
|
||||
return sorted(name for name in body_targets(body)
|
||||
if normalize_target(name) not in known)
|
||||
|
||||
|
||||
# --- Frontmatter ----------------------------------------------------------
|
||||
# Tolerant on the way in, HARD-FAILING on the way out. A UTF-8 BOM, a leading
|
||||
# blank line, trailing whitespace after either `---`, or CRLF line endings all
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -961,6 +961,117 @@ else
|
||||
fail "an attributive target naming a REAL skill produced output (exit $ATTR_RC): $ATTR_OUT"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Body-level routing targets (issue #124)
|
||||
# ---------------------------------------------------------------------------
|
||||
# boundary_targets()/unresolved_targets() are the DESCRIPTION gate, exercised
|
||||
# above. body_targets()/unresolved_body_targets() are the separate, narrower
|
||||
# extractor added for issue #124: a SKILL.md body is dispatch-table and
|
||||
# procedure prose, not a one-to-three-sentence routing clause, so the body
|
||||
# extractor takes only /name and -> `name` NOTATION (never the bare-prose
|
||||
# forms the description gate also reads), and even within notation, a target
|
||||
# must be hyphenated and must not be a `<tag` immediately before the `/`.
|
||||
# Every fixture is built inside a real plugin tree (BODY_ROOT), same as
|
||||
# section 2 above, so the resolver actually runs instead of declining.
|
||||
echo ""
|
||||
echo "--- body-level routing targets (issue #124) ---"
|
||||
BODY_ROOT="$TMPDIR_T/body"
|
||||
write_skill "$BODY_ROOT/plugins/p/.apm/skills/sibling-skill" sibling-skill \
|
||||
"Use when doing the other thing. Do not use for anything else."
|
||||
|
||||
# write_skill_body <skill-dir> <name> <body>
|
||||
write_skill_body() {
|
||||
mkdir -p "$1"
|
||||
{
|
||||
echo "---"
|
||||
echo "name: $2"
|
||||
echo "description: Use when doing the thing. Do not use for anything else."
|
||||
echo "metadata:"
|
||||
echo " version: \"1.0.0\""
|
||||
echo "---"
|
||||
echo ""
|
||||
printf '%s\n' "$3"
|
||||
} > "$1/SKILL.md"
|
||||
}
|
||||
|
||||
# body_case <slug> <expect: silent|errors> <needle> <body>
|
||||
body_case() {
|
||||
local slug="$1" mode="$2" needle="$3" body="$4" out status=0
|
||||
write_skill_body "$BODY_ROOT/plugins/p/.apm/skills/$slug" "$slug" "$body"
|
||||
set +e
|
||||
out="$(bash "$HOOK" "$BODY_ROOT/plugins/p/.apm/skills/$slug/SKILL.md" 2>&1)"
|
||||
status=$?
|
||||
set -e
|
||||
if [[ "$out" == *"DID NOT RUN"* ]]; then
|
||||
fail "body \"$body\" — the resolver declined, so this case asserts nothing about extraction: $out"
|
||||
return
|
||||
fi
|
||||
case "$mode" in
|
||||
silent)
|
||||
if [[ $status -eq 0 && -z "$out" ]]; then
|
||||
pass "not a dangling body target: \"$body\""
|
||||
else
|
||||
fail "body \"$body\" (exit $status, output: ${out:-<empty>})"
|
||||
fi
|
||||
;;
|
||||
errors)
|
||||
if [[ $status -ne 0 && "$out" == *"$needle"* ]]; then
|
||||
pass "dangling body target caught: \"$body\""
|
||||
else
|
||||
fail "body \"$body\" should have ERRORed with $needle (exit $status, output: ${out:-<empty>})"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# The two live true positives the issue was filed over, at fixture scale:
|
||||
# a bare/backticked `/name` and a backticked `-> \`name\``.
|
||||
body_case body-slash-dangling errors "body routes to 'no-such-body-skill'" \
|
||||
"Run \`/no-such-body-skill\` if the config is missing."
|
||||
body_case body-slash-resolves silent "" \
|
||||
"Run \`/sibling-skill\` if the config is missing."
|
||||
body_case body-arrow-dangling errors "body routes to 'no-such-arrow-body'" \
|
||||
"- User wants X -> \`no-such-arrow-body\`"
|
||||
body_case body-arrow-resolves silent "" \
|
||||
"- User wants X -> \`sibling-skill\`"
|
||||
|
||||
# No conjunction continuation: only the FIRST target after an arrow is ever
|
||||
# read, so a dangling SECOND name is silently uncounted rather than reported
|
||||
# — the same one-arrow-one-target convention issue #107 enforces on
|
||||
# descriptions (there, at SUGGESTION tier; here, by construction, since the
|
||||
# body gate has no SUGGESTION tier at all).
|
||||
body_case body-arrow-no-continuation silent "" \
|
||||
"- User wants X -> \`sibling-skill\` or \`no-such-uncounted-target\`"
|
||||
|
||||
# The single-word guard: a real corpus false positive removed by requiring a
|
||||
# hyphen. `` `/fork` `` (forge/SKILL.md) and `` `/name` `` (skill-author/SKILL.md)
|
||||
# are both single-word citations of a tool or a placeholder, not routes, and
|
||||
# both would otherwise have hard-FAILed with no escape hatch.
|
||||
body_case body-slash-single-word-guard silent "" \
|
||||
"See \`/fork\` for how the two differ."
|
||||
|
||||
# The closing-tag guard: an XML/HTML-style section delimiter used as a prompt
|
||||
# marker (grill-with-docs/SKILL.md's <what-to-do>...</what-to-do>) is
|
||||
# indistinguishable from /route notation by every other rule in the pattern —
|
||||
# a `<` immediately before the `/` is the one signal that tells them apart.
|
||||
body_case body-closing-tag-guard silent "" \
|
||||
$'<what-to-do>\nDo the thing.\n</what-to-do>'
|
||||
|
||||
# The bare-arrow guard: NOTATION_ARROW (bare hyphenated word after any arrow)
|
||||
# is deliberately NOT used here, only ARROW_MARKED (backticked or
|
||||
# slash-prefixed). caveman/SKILL.md's own process chain, "Inline obj prop ->
|
||||
# new ref -> re-render.", is real corpus prose this guard exists for — an
|
||||
# unbacked, unresolvable name after an arrow must stay silent, not become a
|
||||
# hard-blocking dangling-target FAIL with no suppression mechanism.
|
||||
body_case body-arrow-bare-not-notation silent "" \
|
||||
"Reproduce -> minimise -> no-such-bare-chain-target."
|
||||
|
||||
# Fenced code blocks are masked, same as gotcha_stats() and
|
||||
# missing_reference_pointers() mask them: an illustrative example is not a
|
||||
# live dispatch entry.
|
||||
body_case body-fenced-example silent "" \
|
||||
$'```\nRun /no-such-fenced-skill instead.\n```'
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
|
||||
Reference in New Issue
Block a user