#!/usr/bin/env bats setup() { REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../../../../" && pwd)" load "$REPO_ROOT/tests/test_helper/bats-support/load" load "$REPO_ROOT/tests/test_helper/bats-assert/load" SCRIPT="$(cd "$BATS_TEST_DIRNAME/../scripts" && pwd)/validate.sh" TMPDIR="$(mktemp -d)" # Helper: create an APM package root at (apm.yml with a top-level # type: line, marking it a real package manifest — not marketplace-only) # plus a single vendor-neutral agent file at # /.apm/agents/.agent.md. , if given, is # inserted as additional raw frontmatter lines (used to inject fields # under test). make_apm_agent() { local root="$1" local name="$2" local extra_frontmatter="${3:-}" mkdir -p "$root/.apm/agents" cat > "$root/apm.yml" < "$root/.apm/agents/${name}.agent.md" < characters that carries a boundary # clause and names no routing target. ADR-0020's missing-boundary-clause # SUGGESTION fires on any description without one, so a fixture that omits it # is never "otherwise clean" and a test refuting SUGGESTION would be asserting # the boundary check's absence instead of the thing it names. The clause is # paid for out of the measured budget rather than appended to it, because # these tests measure the description LENGTH. "anything else" is not # hyphenated, so no routing target comes with it. desc_of_length() { python3 - "$1" <<'PY' import sys n = int(sys.argv[1]) prefix = 'Use when doing the thing. Do not use for anything else. ' assert n >= len(prefix), 'requested description shorter than the boundary clause' print(prefix + 'x' * (n - len(prefix))) PY } # Helper: same shape as make_apm_agent, but the description is supplied # verbatim — used by the ADR-0020 description-budget tests. make_apm_agent_with_desc() { local root="$1" name="$2" desc="$3" mkdir -p "$root/.apm/agents" cat > "$root/apm.yml" < "$root/.apm/agents/${name}.agent.md" < "$root/.claude/agents/my-agent.md" < "$root/.github/agents/my-agent.agent.md" < "$fake_home/.claude/agents/my-agent.md" < "$fake_home/.copilot/agents/my-agent.agent.md" < "$fake_home/my-agent.md" < "$fake_home/.copilot/agents/my-agent.agent.md" < "$nested/.claude/agents/my-agent.md" < "$nested/.github/agents/my-agent.agent.md" < "$nested/.claude/agents/my-agent.md" < "$fake_home/.copilot/agents/my-agent.agent.md" < "$root/.claude/agents/my-agent.md" < "$root/.github/agents/my-agent.agent.md" < one level below a .git ancestor resolves scope to , not to wherever .git was found (subdirectory of a larger git-tracked tree)" { local repo="$TMPDIR/repo-with-subdir" local root="$repo/subdir" mkdir -p "$repo/.git" "$root/.claude/agents" "$root/.github/agents" cat > "$root/.claude/agents/my-agent.md" < "$root/.github/agents/my-agent.agent.md" < as its root argument, would place the # counterpart at /.github/agents — not at the repo root's # .github/agents, even though .git lives at the repo root one level up. run bash "$SCRIPT" "$root/.claude/agents/my-agent.md" assert_success refute_output --partial "FAIL" refute_output --partial "counterpart" } @test "project scope: a non-conventional path (agent file not directly under a literal 'agents' dir) falls back to the nearest .git boundary instead of two-segments-up arithmetic" { local outer="$TMPDIR/outer-repo" local pkg="$outer/pkgA" mkdir -p "$pkg/.git" "$pkg/.github/agents" "$pkg/extra" # Misplaced file: sits two path segments below $outer (pkgA/extra), which # matches the conventional_root arithmetic by coincidence, but its # immediate parent dir is "extra", not "agents" — conventional_shape is # false, so the fix must fall back to the nearest .git boundary (pkgA), # not trust $outer. cat > "$pkg/extra/my-agent.md" < "$pkg/.github/agents/my-agent.agent.md" < "$root/.claude/agents/my-agent.md" < "$root/.github/agents/my-agent.agent.md" < "$root/.claude/agents/my-agent.md" < "$root/.github/agents/my-agent.agent.md" < "$root/.claude/agents/my-agent.md" < "$root/apm.yml" < "$root/.apm/agents/my-agent.agent.md" < "$root/apm.yml" < "$root/.apm/agents/my-agent.agent.md" < "$root/apm.yml" < "$root/.apm/agents/my-agent.agent.md" < "$root/apm.yml" < "$root/.apm/agents/my-agent.agent.md" < "$root/apm.yml" < "$root/.apm/agents/my-agent.agent.md" < --- You are a test agent. When invoked, do the thing. EOF run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md" assert_failure assert_output --partial "template HTML comments" } @test "fails when body contains unfilled FILL IN: placeholder in a plugin/APM-scope agent file" { local root="$TMPDIR/pkg" mkdir -p "$root/.apm/agents" cat > "$root/apm.yml" < "$root/.apm/agents/my-agent.agent.md" < "$root/apm.yml" < "$root/.apm/agents/nested/apm.yml" < "$root/.apm/agents/nested/deeper/my-agent.agent.md" < "$root/apm.yml" < "$root/.claude/agents/my-agent.md" < "$root/.github/agents/my-agent.agent.md" < "$root/apm.yml" <` line it is 1 character and passes. { echo "---" echo "name: my-agent" echo "description: >" python3 -c "print('\n'.join([' ' + 'x' * 40] * 11))" echo "---" echo "" echo "You are a test agent." } > "$root/.apm/agents/my-agent.agent.md" run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md" assert_failure assert_output --partial "description is 450 chars" } @test "ADR-0020: the description gate applies at project scope too" { local root="$TMPDIR/project" local desc desc="$(python3 -c "print('x' * 401)")" mkdir -p "$root/.git" "$root/.claude/agents" "$root/.github/agents" cat > "$root/.claude/agents/my-agent.md" < "$root/.github/agents/my-agent.agent.md" < "$root/apm.yml" < "$root/.apm/agents/my-agent.agent.md" run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md" assert_success refute_output --partial "FAIL" # A 1,500-word body is 667% of the skill ceiling. Nothing may be said about # it at any tier: not a FAIL, not a SUGGESTION, and not the word-count # wording either tier would use if a gate were quietly added later. refute_output --partial "SUGGESTION" refute_output --partial "1500 words" refute_output --partial "900-word" refute_output --partial "body is" } @test "a bare plugin.json with no apm.yml is no longer plugin scope — falls through to project scope" { local root="$TMPDIR/proj-legacy-plugin-json" mkdir -p "$root/.git" "$root/.claude/agents" "$root/.github/agents" echo '{}' > "$root/plugin.json" # 'hooks' is plugin-silently-ignored only at (old) plugin scope; at # project scope it's a legitimate CC field. If this directory were # mis-detected as plugin scope (old plugin.json-based logic), this would # FAIL with a plugin-silently-ignored-fields finding on 'hooks'. cat > "$root/.claude/agents/my-agent.md" < "$root/.github/agents/my-agent.agent.md" < — 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" < "$root/apm.yml" < "$root/.github/agents/my-agent.agent.md" <