feat(kyberforge): ADR-0020 context contract for skills and agents #103

Merged
Defame1297 merged 18 commits from refactor/trim-skills-agents-context into main 2026-08-16 21:20:02 +00:00
6 changed files with 673 additions and 42 deletions
Showing only changes of commit b0d6d08239 - Show all commits

View File

@@ -810,3 +810,136 @@ EOF
assert_success
refute_output --partial "hooks"
}
# ---------------------------------------------------------------------------
# tools: — both YAML spellings
# ---------------------------------------------------------------------------
# The subagent-unavailable-tool SUGGESTION is read off the `tools` field, and
# `tools` has two legal spellings: an inline scalar and a block sequence. The
# field used to be pulled out with a line regex whose capture is newline-bounded
# on purpose, so a block sequence captured NOTHING and the check silently
# stopped firing — on the shape Copilot agent files actually use, which is to say
# on the files it was written for. Both spellings are pinned, and they are pinned
# together: the inline case alone was green throughout.
# make_pair <root> <tools-frontmatter> — a project-scope CC + Copilot pair
# carrying the same `tools` value in both files. `tools` is on neither the
# claude-code-only nor the copilot-only list, so it is legal in both and the pair
# stays otherwise clean; the description carries a boundary clause so the only
# SUGGESTION that can fire is the one under test.
make_tools_pair() {
local root="$1" tools="$2"
mkdir -p "$root/.git" "$root/.claude/agents" "$root/.github/agents"
local f
for f in "$root/.claude/agents/my-agent.md" "$root/.github/agents/my-agent.agent.md"; do
{
echo "---"
echo "name: my-agent"
echo "description: A valid agent description. Do not use for anything else."
echo "$tools"
echo "---"
echo ""
echo "You are a test agent. When invoked, do the thing."
} > "$f"
done
}
@test "a subagent-unavailable tool in an INLINE tools scalar raises a SUGGESTION" {
make_tools_pair "$TMPDIR/inline" "tools: Read ExitPlanMode"
run bash "$SCRIPT" "$TMPDIR/inline/.claude/agents/my-agent.md"
assert_success
assert_output --partial "'ExitPlanMode' is listed in tools but is never available to subagents"
}
@test "a subagent-unavailable tool in a BLOCK SEQUENCE tools field raises the same SUGGESTION" {
make_tools_pair "$TMPDIR/block" "$(printf 'tools:\n - Read\n - ExitPlanMode')"
run bash "$SCRIPT" "$TMPDIR/block/.claude/agents/my-agent.md"
assert_success
assert_output --partial "'ExitPlanMode' is listed in tools but is never available to subagents"
}
@test "a tools list with no subagent-unavailable tool stays silent in both spellings" {
# The control. Without it both cases above are satisfied by a check that
# fires on every tools field it can see, which would be the opposite defect.
make_tools_pair "$TMPDIR/inline-clean" "tools: Read Edit"
run bash "$SCRIPT" "$TMPDIR/inline-clean/.claude/agents/my-agent.md"
assert_success
refute_output --partial "never available to subagents"
make_tools_pair "$TMPDIR/block-clean" "$(printf 'tools:\n - Read\n - Edit')"
run bash "$SCRIPT" "$TMPDIR/block-clean/.claude/agents/my-agent.md"
assert_success
refute_output --partial "never available to subagents"
}
# ---------------------------------------------------------------------------
# A file that cannot be read
# ---------------------------------------------------------------------------
# scripts/check-apm-agents-valid.sh derives its expected agent-file set from
# `git ls-files`, so it hands this script paths that are tracked but absent from
# the worktree — a real and expected state, not a corner case. That used to exit
# 1 with a bare FileNotFoundError traceback and no FAIL line at all: non-zero, so
# the gate blocked, but with an interpreter stack instead of a diagnostic naming
# the file. Both scope paths are covered because they are separate call sites
# (check_apm_agent_file and check_file) and each needed its own handler.
#
# `is-a-dir.agent.md` is a DIRECTORY rather than a chmod 000 file on purpose:
# these tests run as root in CI, where mode bits do not deny anything and a
# permissions fixture would be silently readable and prove nothing.
@test "a nonexistent plugin/APM-scope agent file gets a FAIL naming the path, not a traceback" {
local root="$TMPDIR/pkg"
mkdir -p "$root/.apm/agents"
cat > "$root/apm.yml" <<EOF
name: test-package
version: 0.1.0
type: skill
EOF
run bash "$SCRIPT" "$root/.apm/agents/absent.agent.md"
assert_failure
assert_output --partial "FAIL"
assert_output --partial "could not be read"
assert_output --partial "absent.agent.md"
refute_output --partial "Traceback"
refute_output --partial "FileNotFoundError"
}
@test "an unreadable plugin/APM-scope agent file gets a FAIL naming the path, not a traceback" {
local root="$TMPDIR/pkg-dir"
mkdir -p "$root/.apm/agents/is-a-dir.agent.md"
cat > "$root/apm.yml" <<EOF
name: test-package
version: 0.1.0
type: skill
EOF
run bash "$SCRIPT" "$root/.apm/agents/is-a-dir.agent.md"
assert_failure
assert_output --partial "FAIL"
assert_output --partial "could not be read"
assert_output --partial "is-a-dir.agent.md"
refute_output --partial "Traceback"
refute_output --partial "IsADirectoryError"
}
@test "a nonexistent project-scope agent file gets a FAIL naming the path, not a traceback" {
# The counterpart is pre-checked before either file is opened, so this
# exercises the OTHER call site: the counterpart exists, the named file does
# not, and check_file is what has to report it.
local root="$TMPDIR/proj-missing"
mkdir -p "$root/.git" "$root/.claude/agents" "$root/.github/agents"
cat > "$root/.github/agents/my-agent.agent.md" <<EOF
---
name: my-agent
description: A valid agent description. Do not use for anything else.
---
You are a test agent. When invoked, do the thing.
EOF
run bash "$SCRIPT" "$root/.claude/agents/my-agent.md"
assert_failure
assert_output --partial "FAIL"
assert_output --partial "could not be read"
assert_output --partial "my-agent.md"
refute_output --partial "Traceback"
refute_output --partial "FileNotFoundError"
}

View File

@@ -332,6 +332,76 @@ EOF
)"
expect "a fenced references/example-file.md does not ERROR" "$F_REF_FENCED" silent
echo ""
echo "--- an UNTERMINATED fence does not blank the rest of the body ---"
# The fenced-block exemptions above all rest on mask_fenced(), and an unclosed
# fence used to run to EOF: everything after it was blanked, so the ERROR-tier
# references/ check and both Gotchas counts silently stopped seeing any of it.
# That is the worst shape a masking bug can take — a stray ``` line, which is a
# typo an author makes while writing the very examples the masking exists for,
# turned the rest of the file invisible and the gate green. Masking may narrow
# what a check reads; it may never delete content from every check at once.
#
# Both suppressed checks are asserted, because they are separate call sites and
# a fix that restored only one would leave the other silent.
F_FENCE_REF="$(make_skill fence-unclosed-ref "$CLEAN_DESC" <<EOF
Here is how it is invoked:
\`\`\`bash
some-command --all
If the caller needs the long form, read references/behind-the-fence.md first.
EOF
)"
expect "an absent references/ pointer after an unclosed fence still ERRORs" \
"$F_FENCE_REF" errors "points at references/behind-the-fence.md"
F_FENCE_GOTCHAS="$(make_skill fence-unclosed-gotchas "$CLEAN_DESC" <<EOF
Here is how it is invoked:
\`\`\`bash
some-command --all
## Common gotchas
- first trap here
- second trap here
- third trap here
- fourth trap here
- fifth trap here
- sixth trap here
- seventh trap here
## Notes
$(filler 200)
EOF
)"
expect "a Gotchas section after an unclosed fence is still counted" \
"$F_FENCE_GOTCHAS" suggests "Gotchas section has 7 entries"
# The control. Closing the fence must still mask, or the fix above would have
# been "stop masking", which re-breaks every false-positive case in this file.
F_FENCE_CLOSED="$(make_skill fence-closed-ref "$CLEAN_DESC" <<EOF
Here is how it is invoked:
\`\`\`bash
some-command --all
\`\`\`
Dispatch tables look like this:
\`\`\`markdown
If X, read references/behind-the-fence.md.
\`\`\`
EOF
)"
expect "control: the same pointer inside a CLOSED fence is still masked" \
"$F_FENCE_CLOSED" silent
echo ""
echo "--- a references/ pointer in a same-line removal context is history, not dispatch ---"
# Narrow on purpose: a live dispatch table never describes its own target as

View File

@@ -15,14 +15,20 @@
# ready to ship and the commit hook then rejects it, or worse, the reverse. So the
# comparison here is over VERDICTS on files, not over source text.
#
# Scope: the ADR-0020 axes the two scripts share — description length and tier,
# body word count and tier, dangling routing targets, missing references/
# pointers, the two Gotchas suggestions, the missing-boundary-clause suggestion,
# a declined resolution, and an empty description. The two scripts legitimately
# differ elsewhere (validate.sh also checks name/directory agreement, script
# executability and the 1024-char spec backstop; the hook checks whole-file lines
# and words), and those lines are ignored rather than being forced into a shared
# shape they were never meant to have.
# Scope: every axis the two scripts share. The ADR-0020 ones — description
# length and tier, body word count and tier, dangling routing targets, missing
# references/ pointers, the two Gotchas suggestions, the missing-boundary-clause
# suggestion, a declined resolution, an empty description — plus the two
# agentskills.io spec ceilings, MAX_LINES and MAX_WORDS.
#
# Those last two were EXCLUDED from this comparison until a real divergence
# shipped behind the exclusion. The header used to say "the hook checks
# whole-file lines and words" as if the auditor did not; it does, from its own
# copy of the same two constants, and the two implementations disagreed on
# Unicode whitespace for as long as nobody compared them. An axis both scripts
# measure is in scope by definition — the only lines still ignored are the ones
# a single script owns outright (validate.sh's name/directory agreement, script
# executability and 1024-char description backstop).
#
# Run over the real 39-skill corpus AND over purpose-built fixtures that sit ON
# each boundary. The corpus alone is not enough — it happens not to contain a
@@ -145,6 +151,48 @@ make_fx gotchas-fraction "$CLEAN" 0
python3 -c "print(' '.join(['word'] * 70))"
} >> "$FX/gotchas-fraction/SKILL.md"
# The agentskills.io spec ceilings, measured over Unicode whitespace.
#
# These two are in the comparison at all because they used to be excluded from
# it — `_non_adr_hook_error()` waved a spec-ceiling exit through as "not a
# disagreement", and that exclusion is exactly why the divergence below stayed
# invisible. The hook counted lines and words in a single awk pass (NR / NF)
# while skill-audit counted them with Python's splitlines() / split(). The two
# primitives do not agree: splitlines() also breaks on U+2028, U+2029, \x0b,
# \x0c, \x1c-\x1e and \x85, and split() breaks on every Unicode space. Same
# constants, same file, different verdict — hook green, audit FAIL, which is the
# precise failure mode ("passes its own audit, blocked by the commit hook",
# inverted) this whole suite exists to catch.
#
# One fixture per primitive, each sitting just past its ceiling on the Python
# measurement and nowhere near it on the awk one.
python3 - "$FX" <<'PY'
import os
import sys
fx = sys.argv[1]
# Spelled as escapes, never as literals. An invisible separator pasted into a
# source file is unreviewable and one editor round-trip away from becoming an
# ordinary space, which would silently turn both fixtures into nothing.
SEP_LINE = '\u2028' # LINE SEPARATOR: splitlines() breaks on it, awk's NR does not
SEP_WORD = '\u00a0' # NO-BREAK SPACE: split() breaks on it, awk's NF does not
head = ('---\nname: %s\n'
'description: Use when doing the thing. Do not use for anything else.\n'
'---\n\n')
# 600 U+2028-separated segments: 605 lines to splitlines(), 6 to awk's NR.
# Word count stays far below the 2,770 ceiling, so this fixture isolates lines.
cases = {
'spec-lines-u2028': SEP_LINE.join(['word'] * 600),
# 2,800 U+00A0-separated words: 2,816 words to split(), 17 to awk's NF.
'spec-words-u00a0': SEP_WORD.join(['word'] * 2800),
}
for name, body in cases.items():
d = os.path.join(fx, name)
os.makedirs(d, exist_ok=True)
with open(os.path.join(d, 'SKILL.md'), 'w', encoding='utf-8') as fh:
fh.write(head % name + body + '\n')
PY
# Empty description — the shape that used to exit 0 in silence.
mkdir -p "$FX/empty-desc"
printf -- '---\nname: empty-desc\ndescription:\nmodel: sonnet\n---\n\nDo the thing.\n' \
@@ -219,6 +267,18 @@ RULES = (
('NO_BOUNDARY_CLAUSE', re.compile(r'(description has no boundary clause)')),
('RESOLUTION_DECLINED', re.compile(r'(boundary-target resolution DID NOT RUN)')),
('DESC_EMPTY', re.compile(r'(description field is missing or empty)')),
# The agentskills.io spec ceilings. These were EXCLUDED from the comparison
# until the awk/Python divergence shipped, on the reasoning that "the hook
# checks whole-file lines and words" and the auditor did not. It does — with
# the same two constants — so the exclusion was never a scope decision, only
# an untested assumption, and it hid a real disagreement. Both scripts spell
# the finding differently, so the patterns match either wording and capture
# only the MEASUREMENT:
# hook: "... has 605 lines, exceeding the 500-line ceiling ..."
# audit: "SKILL.md line count 605 — exceeds 500-line limit"
('SPEC_LINES', re.compile(r'(?:has|line count) (\d+)(?: lines,)? (?:exceeding|—)')),
('SPEC_WORDS', re.compile(
r'(?:has|word count) (\d+)(?: words \(proxy for tokens\),)? (?:exceeding|—)')),
)
@@ -227,9 +287,15 @@ def verdict(output):
Lines that match no rule are dropped rather than compared: the two scripts
legitimately check different things outside ADR-0020 (name/directory
agreement, script executability, the 1024-char spec backstop, whole-file
line and word ceilings), and forcing those into the comparison would report
a difference that is not a disagreement.
agreement, script executability, the 1024-char spec backstop), and forcing
those into the comparison would report a difference that is not a
disagreement.
The whole-file line and word ceilings are NOT in that list. They were
excluded once, on the untested assumption that awk and splitlines() agree;
they do not, and the divergence was invisible for exactly as long as the
exclusion stood. SPEC_LINES/SPEC_WORDS are compared like any other rule —
see the file header. Do not re-add an exclusion for them.
"""
found = set()
for raw in output.splitlines():
@@ -278,9 +344,15 @@ def compare(label, skill_dir):
problems.append('the hook reported an ADR-0020 ERROR but exited 0')
if audit_err and audit_rc == 0:
problems.append('skill-audit reported an ADR-0020 FAIL but exited 0')
if not hook_err and hook_rc != 0 and not _non_adr_hook_error(hook_out):
problems.append('the hook exited %d with no ADR-0020 ERROR and no spec-ceiling ERROR'
% hook_rc)
# No escape hatch here any more. There used to be one — a
# `_non_adr_hook_error()` helper that waved through a non-zero hook exit
# explained by MAX_LINES / MAX_WORDS, on the grounds that those two were
# outside the comparison. They are inside it now (see SPEC_LINES /
# SPEC_WORDS in RULES), so every ERROR the hook can raise is a token this
# comparison holds both scripts to.
if not hook_err and hook_rc != 0:
problems.append('the hook exited %d with no compared ERROR at all — it has an '
'ERROR source this comparison does not know about' % hook_rc)
if problems:
bad('%s: %s' % (label, '; '.join(problems)))
@@ -289,16 +361,6 @@ def compare(label, skill_dir):
return False
def _non_adr_hook_error(output):
"""True if the hook failed on a spec ceiling rather than an ADR-0020 gate.
MAX_LINES / MAX_WORDS are the hook's other ERROR sources and are outside
this comparison, so a non-zero exit explained by one of them is not a
disagreement.
"""
return bool(re.search(r'ERROR: .*(-line ceiling|-word ceiling \(~5,000 tokens)', output))
# --- The real corpus -------------------------------------------------------
corpus = sorted(glob.glob(os.path.join(repo_root, 'plugins', '*', '.apm', 'skills', '*')))
corpus = [d for d in corpus if os.path.isfile(os.path.join(d, 'SKILL.md'))]
@@ -359,6 +421,31 @@ else:
ok('every one of the %d compared axes was exercised by at least one fixture'
% len(expected_tokens))
# --- The Unicode-whitespace fixtures, named and asserted directly -----------
# The two comparisons above would catch this divergence, but only as "fixture
# spec-lines-u2028 disagreed" — one line among 65. Spelled out here so the
# failure names the primitive, and so the ceiling is asserted to FIRE in both
# scripts rather than merely to be reported the same way by both.
print("")
print("--- both scripts break the spec ceilings on the same Unicode whitespace ---")
for name, token, expected in (('spec-lines-u2028', 'SPEC_LINES', '605'),
('spec-words-u00a0', 'SPEC_WORDS', '2816')):
skill_dir = os.path.join(fixture_dir, name)
_, h_out = run(['bash', hook, os.path.join(skill_dir, 'SKILL.md')])
_, a_out = run(['bash', validate, skill_dir])
want = ('ERROR', token, expected)
missing = [who for who, v in (('the hook', verdict(h_out)),
('skill-audit', verdict(a_out)))
if want not in v]
if missing:
bad('%s: %s did not report %s=%s. The two scripts must count with the '
'same primitive — Python splitlines()/split(), not awk NR/NF, which '
'does not break on this character' % (name, ' and '.join(missing),
token, expected))
else:
ok('%s: both scripts measure %s=%s and raise the ceiling ERROR'
% (name, token, expected))
print("")
print("Results: %d passed, %d failed" % (passes, failures))
sys.exit(1 if failures else 0)

View File

@@ -19,7 +19,22 @@
# NEXT key. The value then looked present (so "missing or empty" never
# fired) and was empty once folded (so every ADR-0020 gate early-returned).
# An agent file with one exited 0 with zero output through a BLOCKING
# pre-push gate. All five spellings of "no value" are pinned here.
# pre-push gate. All five spellings of "no value" are pinned here, plus the
# three shapes where the value is present but is not TEXT — a list, a
# mapping, a bool. Those used to be `str()`-coerced and then measured as a
# Python repr, so `description: true` was the four-character "True" and
# passed the 400-character gate.
#
# 3. THE INDENTED CLOSING MARKER. The mirror image of (1): content the pattern
# was too LOOSE to reject. `\r?\n[ \t]*---` matched an indented `---` inside
# a `>`-folded description, truncating the frontmatter mid-value — the
# description gate then measured a fragment and the body gate measured the
# discarded description text.
#
# Every needle names the specific branch or measurement the case is about. A
# needle loose enough to match two branches is how the yaml-none fixture spent
# its life asserting the wrong one: it emitted `---\n---\n`, which never matched
# the frontmatter pattern at all, and passed on the bare word "frontmatter".
#
# Both fixtures carry an over-ceiling description AND an over-ceiling body on
# purpose: asserting a non-zero exit alone would be satisfied by the "cannot
@@ -81,7 +96,48 @@ elif kind == 'yaml-list':
elif kind == 'yaml-string':
fm_lines = ['just a bare scalar, not a mapping']
elif kind == 'yaml-none':
# A comment-only block, NOT an empty one. `---\n---\n` does not match
# FRONTMATTER_RE at all (the pattern needs a `\n` between the markers), so
# it lands on the "no parseable frontmatter" branch and never reaches the
# `data is None` -> "not a YAML mapping" branch this fixture is named for.
# It passed anyway because the needle used to be the bare word
# "frontmatter", which both messages contain. A comment is real frontmatter
# text that yaml.safe_load() returns None for, which is the branch.
fm_lines = ['# nothing but a comment']
elif kind == 'yaml-empty-block':
# The shape the fixture above USED to have, kept as its own case so the
# "no parseable frontmatter block" branch is covered on purpose rather than
# by accident.
fm_lines = []
elif kind == 'desc-folded-indented':
# A `>`-folded description whose CONTENT contains an indented `---` line.
# YAML block-scalar content must be indented deeper than its key, so this is
# a value, not a document marker — but the closing pattern used to be
# `\r?\n[ \t]*---`, which matched it, truncated the frontmatter mid-value
# and silently reclassified the rest of the description as body. Both halves
# of that are vacuous greens: the description gate measured a fragment, and
# the body gate measured description text.
#
# The value is padded to exactly desc_chars AFTER folding, and the boundary
# clause naming a target sits in the part the truncation used to discard.
head = 'Use when doing the thing. '
tail = ' Do not use for improvements — use no-such-folded-target instead.'
span = int(desc_chars) - len(head) - len(tail) - len(' --- ')
if span < 2:
raise SystemExit('desc_chars too small for the folded fixture')
fm_lines = [
'name: ' + name,
'description: >',
' ' + head + 'x' * (span // 2),
' ---',
' ' + 'x' * (span - span // 2) + tail,
]
elif kind == 'desc-list':
fm_lines = ['name: ' + name, 'description:', ' - one', ' - two']
elif kind == 'desc-mapping':
fm_lines = ['name: ' + name, 'description:', ' text: a description']
elif kind == 'desc-bool':
fm_lines = ['name: ' + name, 'description: true']
elif kind == 'yaml-malformed':
fm_lines = ['name: ' + name, 'description: "unterminated', 'tabs:\t- a']
elif kind == 'desc-no-value':
@@ -222,24 +278,58 @@ probe_all "trailing whitespace after either --- marker does not hide the finding
probe_all "CRLF line endings do not hide the findings" \
crlf "description is $DESC_CHARS char" "@skills:body is $BODY_WORDS words"
# ---------------------------------------------------------------------------
# 1a-bis. An indented `---` inside a block scalar is CONTENT, not a marker
# ---------------------------------------------------------------------------
# The mirror image of the four shapes above. Those were markers the pattern was
# too strict to accept; this is content the pattern was too loose to reject. The
# closing marker used to be `\r?\n[ \t]*---`, so an indented `---` inside a
# `>`-folded description ended the frontmatter early: the description gate then
# measured a truncated fragment (under every ceiling, so silent) and the body
# gate measured the discarded description text as body. Measured on the fixture
# below, the old code exited 0 with nothing but a spurious "no boundary clause"
# SUGGESTION — the clause is in the half it threw away.
#
# The needle is the full-value length, so a script that merely rejected the file
# would not satisfy it.
echo ""
echo "--- an indented --- inside a >-folded description is content, not the end of the frontmatter ---"
build_subjects desc-folded-indented
probe_all "a folded description containing an indented '---' is measured whole" \
desc-folded-indented "description is $DESC_CHARS char"
# ---------------------------------------------------------------------------
# 1b. Unparseable frontmatter is a hard ERROR, never a quiet skip
# ---------------------------------------------------------------------------
echo ""
echo "--- genuinely unparseable frontmatter exits non-zero with a message, rather than passing quietly ---"
for kind in no-close yaml-list yaml-string yaml-none yaml-malformed; do
# Each needle names the BRANCH the fixture is supposed to reach, not the word
# "frontmatter" — which every one of these messages contains, and which is why
# the yaml-none fixture below passed for years while landing on the wrong branch
# entirely.
for kind in no-close yaml-list yaml-string yaml-none yaml-empty-block yaml-malformed; do
build_subjects "$kind"
done
probe_all "frontmatter with no closing --- is reported, not skipped" \
no-close "frontmatter"
no-close "parseable YAML frontmatter block"
probe_all "frontmatter that parses to a LIST is reported, not skipped" \
yaml-list "frontmatter"
yaml-list "frontmatter is not a YAML mapping"
probe_all "frontmatter that parses to a STRING is reported, not skipped" \
yaml-string "frontmatter"
probe_all "frontmatter that parses to None (empty block) is reported, not skipped" \
yaml-none "frontmatter"
yaml-string "frontmatter is not a YAML mapping"
probe_all "frontmatter that parses to None (a comment-only block) is reported, not skipped" \
yaml-none "frontmatter is not a YAML mapping"
probe_all "a completely empty '---/---' block is reported, not skipped" \
yaml-empty-block "parseable YAML frontmatter block"
# Two needles, both naming the SYNTAX branch specifically. "frontmatter is not
# valid YAML" is now exclusive to it — the wrong-typed-description failures reach
# the same wrapper and no longer borrow that phrase (see 2c below) — and the
# scanner context proves the parser's own diagnostic survives the wrapper rather
# than being replaced by a generic one. Do not needle the tail of PyYAML's
# message: an earlier attempt used "could not find expected", which PyYAML 6.0.3
# does not emit for this fixture at all, so the case failed on the assertion
# rather than on the behaviour.
probe_all "malformed YAML in the frontmatter is reported, not skipped" \
yaml-malformed "frontmatter"
yaml-malformed "frontmatter is not valid YAML" "while scanning a quoted scalar"
# ---------------------------------------------------------------------------
# 2. A valueless description is a hard FAIL in all three scripts
@@ -265,6 +355,70 @@ probe_all "'description: \"\"' FAILs" \
probe_all "'description: >' with nothing folded under it FAILs" \
desc-empty-fold "description field is missing or empty"
# ---------------------------------------------------------------------------
# 2b. A description that is not a STRING is a parse failure, not a measurement
# ---------------------------------------------------------------------------
# The other half of the same family, and the reason it belongs beside the five
# above: all eight shapes are "the description is not a description", and seven
# of them used to be handled while this one was silently coerced. A non-string
# value went through `str()` and was then measured as a Python repr —
# `description: true` became the four-character "True" and sailed through the
# 400-character gate, a list became "['one', 'two']", a mapping its dict repr.
# None of those is text a host can preload, so measuring one is a green verdict
# on a file that was never measured.
echo ""
echo "--- a description that is a list, a mapping or a bool hard-FAILs in all three scripts ---"
for kind in desc-list desc-mapping desc-bool; do
build_subjects "$kind"
done
probe_all "a LIST description FAILs rather than being measured as its repr" \
desc-list "description is a list, not a string"
probe_all "a MAPPING description FAILs rather than being measured as its repr" \
desc-mapping "description is a dict, not a string"
probe_all "a BOOL description FAILs rather than being measured as the 4-char 'True'" \
desc-bool "description is a bool, not a string"
# ---------------------------------------------------------------------------
# 2c. The FAILURE CLASS reported has to be the one that happened
# ---------------------------------------------------------------------------
# The three fixtures above reach the same wrapper as a genuine YAML syntax
# error, and that wrapper used to prefix a hard-coded "frontmatter is not valid
# YAML (...)" onto all of them. For a non-string description that is false: the
# block parses, only the field's TYPE is wrong. On a blocking gate with no
# baseline it sent the author hunting for a syntax error that is not there. The
# assertion runs in both directions, because fixing it by dropping the phrase
# everywhere would trade one wrong diagnosis for another.
echo ""
echo "--- 'not valid YAML' is said for a syntax error and NOT for a wrong-typed description ---"
YAML_CLASS_PROBLEMS=""
for spec in "yaml-malformed|yes" "desc-list|no" "desc-mapping|no" "desc-bool|no"; do
kind="${spec%%|*}"
want="${spec#*|}"
build_subjects "$kind"
for target in \
"hook|$HOOK|$TMPDIR_T/$kind/skill/my-skill/SKILL.md" \
"skill-audit|$SKILL_VALIDATE|$TMPDIR_T/$kind/skill/my-skill" \
"agent-audit|$AGENT_VALIDATE|$TMPDIR_T/$kind/agent/.apm/agents/my-agent.agent.md"
do
who="${target%%|*}"; rest="${target#*|}"
script="${rest%%|*}"; arg="${rest#*|}"
set +e
out="$(bash "$script" "$arg" 2>&1)"
set -e
if [[ "$want" == yes && "$out" != *"frontmatter is not valid YAML"* ]]; then
YAML_CLASS_PROBLEMS="$YAML_CLASS_PROBLEMS [$who did not call $kind a YAML syntax error: $out]"
fi
if [[ "$want" == no && "$out" == *"not valid YAML"* ]]; then
YAML_CLASS_PROBLEMS="$YAML_CLASS_PROBLEMS [$who called $kind invalid YAML, but the frontmatter parsed: $out]"
fi
done
done
if [[ -z "$YAML_CLASS_PROBLEMS" ]]; then
pass "a type error is reported as a type error and a syntax error as a syntax error"
else
fail "wrong failure class reported —$YAML_CLASS_PROBLEMS"
fi
# The specific regression, spelled out: the valueless-description agent file must
# not merely fail — it must not be SILENT. Zero output on a blocking gate is what
# made this un-diagnosable, so the output is asserted non-empty independently.

View File

@@ -13,6 +13,14 @@
# identical with and without a deployed tree — on a synthetic fixture AND on
# the real 39-skill corpus.
#
# Three further ways the universe can be built out of the wrong directory,
# each of which shipped: a `.git` at the CONSUMER root (the fallback is
# truthy in any git repo, which made the deployed-tree branch dead code), a
# `.git` INSIDE a plugin (the walk-up is two passes precisely so this cannot
# capture the root), and glob metacharacters in the checkout path (which
# turned the directory name into a character class matching nothing, and the
# resolver into a no-op that still reported green).
#
# 2. THE BARE-TARGET GRAMMAR RULE. A hyphenated token used as a compound
# MODIFIER ("pre-commit hooks", "pull-request template") is prose, not a
# route; a terminal one is a real target. Getting this wrong in either
@@ -138,6 +146,175 @@ else
fail "the consumer path did not resolve through the deployed tree (exit $CONSUMER_RC): ${CONSUMER_OUT:-<empty>}"
fi
# ---------------------------------------------------------------------------
# 1a-bis. The consumer case with the one thing every real consumer has: .git
# ---------------------------------------------------------------------------
# The fixture immediately above has no .git, and that is precisely why it could
# never catch this. _authoring_root() falls back to the nearest .git ancestor, so
# it returns truthy in ANY git repo — a consumer checkout included. The branch
# that reads the deployed trees was guarded by `else`, so in every consumer
# checkout the fallback won, _collect_authoring_root() contributed nothing
# (there is no plugins/ directory to collect), and _deployed_roots() was dead
# code in exactly the case it exists for.
#
# The pair below is the whole test: the SAME tree, once with .git and once
# without. Old behaviour was rc=1 with .git and rc=0 without; a test covering
# only the no-.git shape reports green on both.
#
# `deployed-only-agent` lives ONLY in .agents/agents/, so it can be reached
# through no route but _deployed_roots(). `sibling-skill` sits in .claude/skills/
# beside the subject, which the sibling-collection block above reaches on its own
# — it is the corroborator that makes the dangling target BLOCKING rather than a
# SUGGESTION, so the old failure shows up in the exit code and not only in prose.
echo ""
echo "--- a consumer checkout resolves through its deployed trees even though it is a git repo ---"
build_consumer() {
local root="$1"
mkdir -p "$root/.agents/agents"
write_skill "$root/.claude/skills/sibling-skill" sibling-skill \
"Use when doing the other thing. Do not use for anything else."
write_skill "$root/.claude/skills/my-skill" my-skill \
"Use when doing the thing. Do not use for the other thing — use sibling-skill or deployed-only-agent instead."
: > "$root/.agents/agents/deployed-only-agent.agent.md"
}
build_consumer "$TMPDIR_T/consumer-git"
mkdir -p "$TMPDIR_T/consumer-git/.git"
build_consumer "$TMPDIR_T/consumer-nogit"
# consumer_case <label> <root>
consumer_case() {
local label="$1" root="$2" out status=0
set +e
out="$(bash "$HOOK" "$root/.claude/skills/my-skill/SKILL.md" 2>&1)"
status=$?
set -e
if [[ $status -eq 0 && "$out" != *"routes to"* && "$out" != *"DID NOT RUN"* ]]; then
pass "$label"
else
fail "$label (exit $status, output: ${out:-<empty>})"
fi
}
consumer_case "an agent in .agents/agents/ resolves in a consumer checkout that HAS a .git directory" \
"$TMPDIR_T/consumer-git"
consumer_case "control: the same tree without .git resolves too (the shape that always passed)" \
"$TMPDIR_T/consumer-nogit"
# ---------------------------------------------------------------------------
# 1a-ter. A monorepo with ONE plugin is still a monorepo
# ---------------------------------------------------------------------------
# The first attempt at the fix above conditioned the deployed branch on whether
# the authoring root had CONTRIBUTED a name — `if len(names) == before:`. That
# reads as "the .git fallback collected nothing, so fall through", and it is
# wrong: _collect_authoring_root() re-collects the subject's OWN plugin, whose
# names the sibling and package blocks have already added. With two plugins
# (fixture 1) the cross-plugin name makes the delta non-zero and the guard stays
# shut. With ONE plugin the delta is zero, the guard fires in a genuine
# monorepo, and _deployed_roots() walks up to ten levels — reaching the user's
# global ~/.claude/skills. That is install-dependence again, in the shape
# ADR-0020 lines 118-127 exist to forbid.
#
# So the predicate is which PROBE matched, not how many names arrived. The
# assertion is the same shape as fixture 1 — identical verdict either way — but
# on a single-plugin tree, which fixture 1 cannot express.
echo ""
echo "--- a SINGLE-plugin monorepo does not fall through to the deployed trees ---"
build_single() {
local root="$1"
write_skill "$root/plugins/only-plugin/.apm/skills/my-skill" my-skill \
"Use when doing the thing. Do not use for the other thing — use /deployed-only-skill instead."
}
build_single "$TMPDIR_T/single-no-claude"
build_single "$TMPDIR_T/single-with-claude"
write_skill "$TMPDIR_T/single-with-claude/.claude/skills/deployed-only-skill" deployed-only-skill \
"Use when doing the other thing. Do not use for anything else."
run_single() {
local root="$1" out
set +e
out="$(bash "$HOOK" "$root/plugins/only-plugin/.apm/skills/my-skill/SKILL.md" 2>&1)"
set -e
printf '%s\n' "$out" | sed "s#$root#<ROOT>#g"
}
SINGLE_NO_OUT="$(run_single "$TMPDIR_T/single-no-claude")"
SINGLE_WITH_OUT="$(run_single "$TMPDIR_T/single-with-claude")"
if [[ "$SINGLE_NO_OUT" == "$SINGLE_WITH_OUT" ]]; then
pass "a single-plugin monorepo gets the same verdict with and without a deployed .claude/ tree"
else
fail "the deployed tree changed the verdict in a single-plugin monorepo — without: [$SINGLE_NO_OUT] with: [$SINGLE_WITH_OUT]"
fi
# Identical-but-wrong guard, as in fixture 1: the deployed-only name must DANGLE,
# not resolve. Written as `/deployed-only-skill` so it blocks on its own without
# needing a second target in the sentence to corroborate it.
if [[ "$SINGLE_WITH_OUT" == *"routes to 'deployed-only-skill'"* ]]; then
pass "the deployed-only target dangles in a single-plugin monorepo (~/.claude/skills is not in the universe)"
else
fail "the deployed-only target resolved — the single-plugin tree fell through to _deployed_roots(): $SINGLE_WITH_OUT"
fi
# ---------------------------------------------------------------------------
# 1c. A nested .git inside a plugin must not beat the monorepo root
# ---------------------------------------------------------------------------
# ADR-0020 records the walk-up as TWO passes — plugins/*/.apm/{skills,agents}
# first, .git only afterwards — specifically so a .git inside a plugin (a
# submodule, or a sub-package with its own worktree) cannot capture the root.
# Nothing anywhere placed a .git inside a plugin, so the second pass was
# structural claim only. Collapsing the two probes into one interleaved walk
# passes every other fixture in this repo and fails here.
echo ""
echo "--- a .git INSIDE a plugin does not shadow the monorepo root above it ---"
NESTED="$TMPDIR_T/nested-git"
write_skill "$NESTED/plugins/other-plugin/.apm/skills/cross-plugin-skill" cross-plugin-skill \
"Use when doing the other thing. Do not use for anything else."
write_skill "$NESTED/plugins/subject-plugin/.apm/skills/sibling-skill" sibling-skill \
"Use when doing the other thing. Do not use for anything else."
write_skill "$NESTED/plugins/subject-plugin/.apm/skills/my-skill" my-skill \
"Use when doing the thing. Do not use for the other thing — use sibling-skill or cross-plugin-skill instead."
# The trap: a git checkout one level BELOW the monorepo root and above the skill.
mkdir -p "$NESTED/plugins/subject-plugin/.git"
set +e
NESTED_OUT="$(bash "$HOOK" "$NESTED/plugins/subject-plugin/.apm/skills/my-skill/SKILL.md" 2>&1)"
NESTED_RC=$?
set -e
# The sibling-plugin name is the discriminator: it is reachable ONLY from the
# monorepo root. If the nested .git won, subject-plugin would be the root, its
# plugins/ glob would collect nothing, and cross-plugin-skill would dangle —
# corroborated by sibling-skill in the same sentence, so it would BLOCK.
if [[ $NESTED_RC -eq 0 && "$NESTED_OUT" != *"routes to"* && "$NESTED_OUT" != *"DID NOT RUN"* ]]; then
pass "a sibling-plugin target still resolves with a .git directory inside the subject's own plugin"
else
fail "the nested .git captured the authoring root (exit $NESTED_RC): ${NESTED_OUT:-<empty>}"
fi
# ---------------------------------------------------------------------------
# 1d. Glob metacharacters in the checkout path
# ---------------------------------------------------------------------------
# The universe is built with glob.glob() against paths that begin with the
# checkout directory. A `[`, `]`, `*` or `?` anywhere in that prefix — a worktree
# named `feature[2]`, a CI workspace named `build[1]` — turned the literal
# directory name into a character class that matched nothing. The resolver then
# found no universe at all and degraded to the "DID NOT RUN" INFO with rc=0:
# every routing target in the tree silently unchecked, on a gate that reports
# green. Same monorepo as above, one directory renamed.
echo ""
echo "--- glob metacharacters in the checkout path do not silently disable the resolver ---"
GLOBDIR="$TMPDIR_T/gl[1]?x/mono"
write_skill "$GLOBDIR/plugins/other-plugin/.apm/skills/cross-plugin-skill" cross-plugin-skill \
"Use when doing the other thing. Do not use for anything else."
write_skill "$GLOBDIR/plugins/subject-plugin/.apm/skills/sibling-skill" sibling-skill \
"Use when doing the other thing. Do not use for anything else."
write_skill "$GLOBDIR/plugins/subject-plugin/.apm/skills/my-skill" my-skill \
"Use when doing the thing. Do not use for the other thing — use sibling-skill or cross-plugin-skill instead."
set +e
GLOB_OUT="$(bash "$HOOK" "$GLOBDIR/plugins/subject-plugin/.apm/skills/my-skill/SKILL.md" 2>&1)"
GLOB_RC=$?
set -e
if [[ $GLOB_RC -eq 0 && "$GLOB_OUT" != *"DID NOT RUN"* && "$GLOB_OUT" != *"routes to"* ]]; then
pass "a monorepo under a directory named 'gl[1]?x' resolves exactly like any other"
else
fail "glob metacharacters in the path changed the verdict (exit $GLOB_RC): ${GLOB_OUT:-<empty>}"
fi
# ---------------------------------------------------------------------------
# 1b. Machine independence — the real corpus
# ---------------------------------------------------------------------------

View File

@@ -535,19 +535,31 @@ expect_gate "a fixture with no authoring root reports DID NOT RUN and exits 0" \
"Unchecked target(s): some-other-skill"
echo ""
echo "--- the three live dangling routing targets are caught (issue #100) ---"
# ADR-0020 records four broken routing targets and splits fixing them into its
# own issue. Three are detectable from the description text alone; this asserts
# the gate actually sees them rather than the check being vacuous in the corpus
# it was written against.
echo "--- the live dangling routing targets are caught (issue #100) ---"
# ADR-0020 records the broken routing targets and splits fixing them into its own
# issue. This asserts the gate actually sees them rather than the check being
# vacuous in the corpus it was written against.
#
# There used to be a third probe here, for `skill-improve` in skill-audit's
# description. It was already stale: that target was fixed, so the iteration
# permanently took a `pass "SKIP: ..."` branch — an assertion-free result counted
# in the totals, which is worse than no probe at all because it makes the suite
# look one test stronger than it is. It also contradicted
# tests/test-adr0020-targets.sh, which pins the live dangling set as EXACTLY
# {gitea-labels, neuledge-context}; that file is the authority on the set, this
# one only checks the two are individually detected.
#
# Both SKIP branches are gone with it, for the same reason. A probe whose fixture
# has been retrofitted is not "still passing" — it is a pin that needs updating,
# here and in the exact-set assertion in test-adr0020-targets.sh, and it should
# say so out loud rather than quietly agreeing with whatever it finds.
for probe in \
"plugins/bin/.apm/skills/research/SKILL.md:neuledge-context" \
"plugins/kyberforge/.apm/skills/skill-audit/SKILL.md:skill-improve" \
"plugins/gitea/.apm/skills/gitea-issues/SKILL.md:gitea-labels"; do
probe_file="$REPO_ROOT/${probe%%:*}"
probe_name="${probe##*:}"
if [[ ! -f "$probe_file" ]]; then
pass "SKIP: ${probe%%:*} no longer exists (retrofitted)"
fail "the probe fixture ${probe%%:*} no longer exists — this pin has become vacuous; update it and EXPECTED_DANGLING in tests/test-adr0020-targets.sh together"
continue
fi
# Captured, not piped: the script exits non-zero on these files and
@@ -558,10 +570,8 @@ for probe in \
set -e
if [[ "$probe_out" == *"routes to '$probe_name'"* ]]; then
pass "detects the dangling '$probe_name' target in ${probe%%:*}"
elif ! grep -q "$probe_name" "$probe_file"; then
pass "SKIP: '$probe_name' no longer appears in ${probe%%:*} (fixed by issue #100)"
else
fail "did not detect the dangling '$probe_name' target in ${probe%%:*}"
fail "did not detect the dangling '$probe_name' target in ${probe%%:*}. If issue #100 retrofitted it, drop this probe and update EXPECTED_DANGLING in tests/test-adr0020-targets.sh; if a false-positive fix took a true positive with it, that is the regression this asserts."
fi
done