diff --git a/docs/adr/0020-skill-description-and-body-context-contract.md b/docs/adr/0020-skill-description-and-body-context-contract.md index c6bb937..ca6e1ae 100644 --- a/docs/adr/0020-skill-description-and-body-context-contract.md +++ b/docs/adr/0020-skill-description-and-body-context-contract.md @@ -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 (``, `` — 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 diff --git a/docs/spec/gates.md b/docs/spec/gates.md index 7af78c4..b19986e 100644 --- a/docs/spec/gates.md +++ b/docs/spec/gates.md @@ -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 (`...`, `...`), and + `` 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: diff --git a/plugins/kyberforge/.apm/skills/factory-audit/SKILL.md b/plugins/kyberforge/.apm/skills/factory-audit/SKILL.md index 1a1f961..399c336 100644 --- a/plugins/kyberforge/.apm/skills/factory-audit/SKILL.md +++ b/plugins/kyberforge/.apm/skills/factory-audit/SKILL.md @@ -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 diff --git a/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-boundary-resolver.sh b/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-boundary-resolver.sh index be8168c..cf099e8 100755 --- a/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-boundary-resolver.sh +++ b/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-boundary-resolver.sh @@ -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 — ``, +# `` (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 # , 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 diff --git a/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-checks-skill.sh b/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-checks-skill.sh index f7770c6..3bd3c3e 100755 --- a/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-checks-skill.sh +++ b/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-checks-skill.sh @@ -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) diff --git a/scripts/skill-size-check.sh b/scripts/skill-size-check.sh index 9210604..295d892 100755 --- a/scripts/skill-size-check.sh +++ b/scripts/skill-size-check.sh @@ -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 diff --git a/tests/test-adr0020-targets.sh b/tests/test-adr0020-targets.sh index f4b5563..a760086 100755 --- a/tests/test-adr0020-targets.sh +++ b/tests/test-adr0020-targets.sh @@ -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 ` +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 +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:-})" + 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:-})" + 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 ...) 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 "" \ + $'\nDo the thing.\n' + +# 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 ]]