feat(kyberforge): ADR-0020 context contract for skills and agents #103
@@ -810,3 +810,136 @@ EOF
|
|||||||
assert_success
|
assert_success
|
||||||
refute_output --partial "hooks"
|
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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -332,6 +332,76 @@ EOF
|
|||||||
)"
|
)"
|
||||||
expect "a fenced references/example-file.md does not ERROR" "$F_REF_FENCED" silent
|
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 ""
|
||||||
echo "--- a references/ pointer in a same-line removal context is history, not dispatch ---"
|
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
|
# Narrow on purpose: a live dispatch table never describes its own target as
|
||||||
|
|||||||
@@ -15,14 +15,20 @@
|
|||||||
# ready to ship and the commit hook then rejects it, or worse, the reverse. So the
|
# 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.
|
# comparison here is over VERDICTS on files, not over source text.
|
||||||
#
|
#
|
||||||
# Scope: the ADR-0020 axes the two scripts share — description length and tier,
|
# Scope: every axis the two scripts share. The ADR-0020 ones — description
|
||||||
# body word count and tier, dangling routing targets, missing references/
|
# length and tier, body word count and tier, dangling routing targets, missing
|
||||||
# pointers, the two Gotchas suggestions, the missing-boundary-clause suggestion,
|
# references/ pointers, the two Gotchas suggestions, the missing-boundary-clause
|
||||||
# a declined resolution, and an empty description. The two scripts legitimately
|
# suggestion, a declined resolution, an empty description — plus the two
|
||||||
# differ elsewhere (validate.sh also checks name/directory agreement, script
|
# agentskills.io spec ceilings, MAX_LINES and MAX_WORDS.
|
||||||
# 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
|
# Those last two were EXCLUDED from this comparison until a real divergence
|
||||||
# shape they were never meant to have.
|
# 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
|
# 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
|
# 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))"
|
python3 -c "print(' '.join(['word'] * 70))"
|
||||||
} >> "$FX/gotchas-fraction/SKILL.md"
|
} >> "$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.
|
# Empty description — the shape that used to exit 0 in silence.
|
||||||
mkdir -p "$FX/empty-desc"
|
mkdir -p "$FX/empty-desc"
|
||||||
printf -- '---\nname: empty-desc\ndescription:\nmodel: sonnet\n---\n\nDo the thing.\n' \
|
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)')),
|
('NO_BOUNDARY_CLAUSE', re.compile(r'(description has no boundary clause)')),
|
||||||
('RESOLUTION_DECLINED', re.compile(r'(boundary-target resolution DID NOT RUN)')),
|
('RESOLUTION_DECLINED', re.compile(r'(boundary-target resolution DID NOT RUN)')),
|
||||||
('DESC_EMPTY', re.compile(r'(description field is missing or empty)')),
|
('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
|
Lines that match no rule are dropped rather than compared: the two scripts
|
||||||
legitimately check different things outside ADR-0020 (name/directory
|
legitimately check different things outside ADR-0020 (name/directory
|
||||||
agreement, script executability, the 1024-char spec backstop, whole-file
|
agreement, script executability, the 1024-char spec backstop), and forcing
|
||||||
line and word ceilings), and forcing those into the comparison would report
|
those into the comparison would report a difference that is not a
|
||||||
a difference that is not a disagreement.
|
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()
|
found = set()
|
||||||
for raw in output.splitlines():
|
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')
|
problems.append('the hook reported an ADR-0020 ERROR but exited 0')
|
||||||
if audit_err and audit_rc == 0:
|
if audit_err and audit_rc == 0:
|
||||||
problems.append('skill-audit reported an ADR-0020 FAIL but exited 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):
|
# No escape hatch here any more. There used to be one — a
|
||||||
problems.append('the hook exited %d with no ADR-0020 ERROR and no spec-ceiling ERROR'
|
# `_non_adr_hook_error()` helper that waved through a non-zero hook exit
|
||||||
% hook_rc)
|
# 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:
|
if problems:
|
||||||
bad('%s: %s' % (label, '; '.join(problems)))
|
bad('%s: %s' % (label, '; '.join(problems)))
|
||||||
@@ -289,16 +361,6 @@ def compare(label, skill_dir):
|
|||||||
return False
|
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 -------------------------------------------------------
|
# --- The real corpus -------------------------------------------------------
|
||||||
corpus = sorted(glob.glob(os.path.join(repo_root, 'plugins', '*', '.apm', 'skills', '*')))
|
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'))]
|
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'
|
ok('every one of the %d compared axes was exercised by at least one fixture'
|
||||||
% len(expected_tokens))
|
% 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("")
|
||||||
print("Results: %d passed, %d failed" % (passes, failures))
|
print("Results: %d passed, %d failed" % (passes, failures))
|
||||||
sys.exit(1 if failures else 0)
|
sys.exit(1 if failures else 0)
|
||||||
|
|||||||
@@ -19,7 +19,22 @@
|
|||||||
# NEXT key. The value then looked present (so "missing or empty" never
|
# 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).
|
# 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
|
# 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
|
# 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
|
# 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':
|
elif kind == 'yaml-string':
|
||||||
fm_lines = ['just a bare scalar, not a mapping']
|
fm_lines = ['just a bare scalar, not a mapping']
|
||||||
elif kind == 'yaml-none':
|
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 = []
|
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':
|
elif kind == 'yaml-malformed':
|
||||||
fm_lines = ['name: ' + name, 'description: "unterminated', 'tabs:\t- a']
|
fm_lines = ['name: ' + name, 'description: "unterminated', 'tabs:\t- a']
|
||||||
elif kind == 'desc-no-value':
|
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" \
|
probe_all "CRLF line endings do not hide the findings" \
|
||||||
crlf "description is $DESC_CHARS char" "@skills:body is $BODY_WORDS words"
|
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
|
# 1b. Unparseable frontmatter is a hard ERROR, never a quiet skip
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo ""
|
echo ""
|
||||||
echo "--- genuinely unparseable frontmatter exits non-zero with a message, rather than passing quietly ---"
|
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"
|
build_subjects "$kind"
|
||||||
done
|
done
|
||||||
probe_all "frontmatter with no closing --- is reported, not skipped" \
|
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" \
|
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" \
|
probe_all "frontmatter that parses to a STRING is reported, not skipped" \
|
||||||
yaml-string "frontmatter"
|
yaml-string "frontmatter is not a YAML mapping"
|
||||||
probe_all "frontmatter that parses to None (empty block) is reported, not skipped" \
|
probe_all "frontmatter that parses to None (a comment-only block) is reported, not skipped" \
|
||||||
yaml-none "frontmatter"
|
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" \
|
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
|
# 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" \
|
probe_all "'description: >' with nothing folded under it FAILs" \
|
||||||
desc-empty-fold "description field is missing or empty"
|
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
|
# 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
|
# 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.
|
# made this un-diagnosable, so the output is asserted non-empty independently.
|
||||||
|
|||||||
@@ -13,6 +13,14 @@
|
|||||||
# identical with and without a deployed tree — on a synthetic fixture AND on
|
# identical with and without a deployed tree — on a synthetic fixture AND on
|
||||||
# the real 39-skill corpus.
|
# 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
|
# 2. THE BARE-TARGET GRAMMAR RULE. A hyphenated token used as a compound
|
||||||
# MODIFIER ("pre-commit hooks", "pull-request template") is prose, not a
|
# MODIFIER ("pre-commit hooks", "pull-request template") is prose, not a
|
||||||
# route; a terminal one is a real target. Getting this wrong in either
|
# 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>}"
|
fail "the consumer path did not resolve through the deployed tree (exit $CONSUMER_RC): ${CONSUMER_OUT:-<empty>}"
|
||||||
fi
|
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
|
# 1b. Machine independence — the real corpus
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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"
|
"Unchecked target(s): some-other-skill"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "--- the three live dangling routing targets are caught (issue #100) ---"
|
echo "--- the live dangling routing targets are caught (issue #100) ---"
|
||||||
# ADR-0020 records four broken routing targets and splits fixing them into its
|
# ADR-0020 records the broken routing targets and splits fixing them into its own
|
||||||
# own issue. Three are detectable from the description text alone; this asserts
|
# issue. This asserts the gate actually sees them rather than the check being
|
||||||
# the gate actually sees them rather than the check being vacuous in the corpus
|
# vacuous in the corpus it was written against.
|
||||||
# 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 \
|
for probe in \
|
||||||
"plugins/bin/.apm/skills/research/SKILL.md:neuledge-context" \
|
"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
|
"plugins/gitea/.apm/skills/gitea-issues/SKILL.md:gitea-labels"; do
|
||||||
probe_file="$REPO_ROOT/${probe%%:*}"
|
probe_file="$REPO_ROOT/${probe%%:*}"
|
||||||
probe_name="${probe##*:}"
|
probe_name="${probe##*:}"
|
||||||
if [[ ! -f "$probe_file" ]]; then
|
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
|
continue
|
||||||
fi
|
fi
|
||||||
# Captured, not piped: the script exits non-zero on these files and
|
# Captured, not piped: the script exits non-zero on these files and
|
||||||
@@ -558,10 +570,8 @@ for probe in \
|
|||||||
set -e
|
set -e
|
||||||
if [[ "$probe_out" == *"routes to '$probe_name'"* ]]; then
|
if [[ "$probe_out" == *"routes to '$probe_name'"* ]]; then
|
||||||
pass "detects the dangling '$probe_name' target in ${probe%%:*}"
|
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
|
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
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user