#!/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 a minimal valid skill directory make_valid_skill() { local dir="$1" local name name="$(basename "$dir")" mkdir -p "$dir/scripts" cat > "$dir/SKILL.md" < is used verbatim; "word" # tokens follow the frontmatter. Used by the ADR-0020 boundary tests. make_sized_skill() { local dir="$1" desc="$2" body_words="$3" local name name="$(basename "$dir")" mkdir -p "$dir" { echo "---" echo "name: $name" echo "description: $desc" echo "---" echo "" python3 -c "print(' '.join(['word'] * $body_words))" } > "$dir/SKILL.md" } # Helper: build a self-contained fixture plugin tree so the boundary-target # resolver has a real authoring source to resolve against, independent of # this repo's live skills. Echoes the subject skill's directory. # # /plugins/fixture-plugin/.apm/skills//SKILL.md # /plugins/fixture-plugin/.apm/skills/fixture-sibling-skill/ # /plugins/fixture-plugin/.apm/agents/fixture-sibling-agent.agent.md make_fixture_tree() { local root="$1" subject="$2" local apm="$root/plugins/fixture-plugin/.apm" mkdir -p "$apm/skills/$subject" "$apm/skills/fixture-sibling-skill" "$apm/agents" touch "$apm/agents/fixture-sibling-agent.agent.md" echo "$apm/skills/$subject" } } teardown() { rm -rf "$TMPDIR" } # --------------------------------------------------------------------------- # Passing cases # --------------------------------------------------------------------------- @test "passes on a valid minimal skill" { local skill="$TMPDIR/my-skill" make_valid_skill "$skill" run bash "$SCRIPT" "$skill" assert_success } @test "--help exits 0" { run bash "$SCRIPT" --help assert_success assert_output --partial "Usage:" } @test "passes when scripts/ directory is absent" { local skill="$TMPDIR/my-skill" make_valid_skill "$skill" rmdir "$skill/scripts" run bash "$SCRIPT" "$skill" assert_success } @test "FILL IN: inside backticks does not fail" { local skill="$TMPDIR/my-skill" make_valid_skill "$skill" echo "Use \`FILL IN: value\` as an example." >> "$skill/SKILL.md" run bash "$SCRIPT" "$skill" assert_success } @test "the 1024-char agentskills.io spec backstop is unchanged and separate from the ADR-0020 ceiling" { local skill="$TMPDIR/my-skill" local name name="$(basename "$skill")" mkdir -p "$skill" local desc desc="$(python3 -c "print('x' * 1024)")" cat > "$skill/SKILL.md" <> "$skill/SKILL.md" run bash "$SCRIPT" "$skill" assert_success } # --------------------------------------------------------------------------- # Failing cases # --------------------------------------------------------------------------- @test "fails when SKILL.md is missing" { local skill="$TMPDIR/my-skill" mkdir -p "$skill" run bash "$SCRIPT" "$skill" assert_failure } @test "fails when name does not match directory" { local skill="$TMPDIR/my-skill" make_valid_skill "$skill" sed -i 's/^name: .*/name: wrong-name/' "$skill/SKILL.md" run bash "$SCRIPT" "$skill" assert_failure } @test "fails when description exceeds 1024 chars" { local skill="$TMPDIR/my-skill" local name name="$(basename "$skill")" mkdir -p "$skill" local desc desc="$(python3 -c "print('x' * 1025)")" cat > "$skill/SKILL.md" <> "$skill/SKILL.md" run bash "$SCRIPT" "$skill" assert_failure } @test "fails when body contains unfilled FILL IN: placeholder" { local skill="$TMPDIR/my-skill" make_valid_skill "$skill" echo "FILL IN: replace this" >> "$skill/SKILL.md" run bash "$SCRIPT" "$skill" assert_failure } @test "fails when a script is not executable" { local skill="$TMPDIR/my-skill" make_valid_skill "$skill" echo "#!/usr/bin/env bash" > "$skill/scripts/helper.sh" chmod -x "$skill/scripts/helper.sh" run bash "$SCRIPT" "$skill" assert_failure } @test "fails when a script has an interactive prompt" { local skill="$TMPDIR/my-skill" make_valid_skill "$skill" printf '#!/usr/bin/env bash\nread -p "Enter value: " VAL\n' > "$skill/scripts/helper.sh" chmod +x "$skill/scripts/helper.sh" run bash "$SCRIPT" "$skill" assert_failure } @test "fails when a script reads a variable with no redirect" { local skill="$TMPDIR/my-skill" make_valid_skill "$skill" printf '#!/usr/bin/env bash\nread -r ANSWER\n' > "$skill/scripts/helper.sh" chmod +x "$skill/scripts/helper.sh" run bash "$SCRIPT" "$skill" assert_failure } @test "fails when an interactive prompt string contains an angle bracket" { local skill="$TMPDIR/my-skill" make_valid_skill "$skill" printf '#!/usr/bin/env bash\nread -p "enter : " NAME\n' > "$skill/scripts/helper.sh" chmod +x "$skill/scripts/helper.sh" run bash "$SCRIPT" "$skill" assert_failure } @test "passes when a script reads from a here-string" { local skill="$TMPDIR/my-skill" make_valid_skill "$skill" printf '#!/usr/bin/env bash\nLINE="a b"\nread -r X Y <<< "$LINE"\n' \ > "$skill/scripts/helper.sh" chmod +x "$skill/scripts/helper.sh" run bash "$SCRIPT" "$skill" assert_success } @test "passes when a script reads from a here-doc" { local skill="$TMPDIR/my-skill" make_valid_skill "$skill" printf '#!/usr/bin/env bash\nread -r X < "$skill/scripts/helper.sh" chmod +x "$skill/scripts/helper.sh" run bash "$SCRIPT" "$skill" assert_success } @test "passes when a script reads from a file redirect" { local skill="$TMPDIR/my-skill" make_valid_skill "$skill" printf '#!/usr/bin/env bash\nread -r LINE < "$1"\n' > "$skill/scripts/helper.sh" chmod +x "$skill/scripts/helper.sh" run bash "$SCRIPT" "$skill" assert_success } @test "passes when a script reads from a pipe continued onto the next line" { local skill="$TMPDIR/my-skill" make_valid_skill "$skill" printf '#!/usr/bin/env bash\nprintf %%s "$1" |\n read -r X\n' > "$skill/scripts/helper.sh" chmod +x "$skill/scripts/helper.sh" run bash "$SCRIPT" "$skill" assert_success } @test "fails when name contains consecutive hyphens" { local skill="$TMPDIR/my--skill" make_valid_skill "$skill" run bash "$SCRIPT" "$skill" assert_failure } @test "fails when name has a leading hyphen" { local skill="$TMPDIR/-my-skill" make_valid_skill "$skill" run bash "$SCRIPT" "$skill" assert_failure } @test "fails when no frontmatter block is present" { local skill="$TMPDIR/my-skill" mkdir -p "$skill" echo "Just some content with no frontmatter." > "$skill/SKILL.md" run bash "$SCRIPT" "$skill" assert_failure } @test "fails when no arguments are given" { run bash "$SCRIPT" assert_failure } # --------------------------------------------------------------------------- # ADR-0020 — description budget (250 SUGGESTION / 400 FAIL) # # These sit UNDER the agentskills.io 1024-character spec backstop above, which # is unchanged. Both ceilings are inclusive: exactly at the number passes that # tier, one past it trips. # --------------------------------------------------------------------------- @test "ADR-0020: description of exactly 250 chars raises no suggestion" { local skill="$TMPDIR/my-skill" make_sized_skill "$skill" "$(python3 -c "print('x' * 250)")" 10 run bash "$SCRIPT" "$skill" assert_success refute_output --partial "SUGGESTION" } @test "ADR-0020: description of 251 chars raises a SUGGESTION and still exits 0" { local skill="$TMPDIR/my-skill" make_sized_skill "$skill" "$(python3 -c "print('x' * 251)")" 10 run bash "$SCRIPT" "$skill" assert_success assert_output --partial "SUGGESTION" assert_output --partial "description is 251 chars" assert_output --partial "All checks passed (1 suggestion(s))." } @test "ADR-0020: description of exactly 400 chars is a SUGGESTION, not a FAIL" { local skill="$TMPDIR/my-skill" make_sized_skill "$skill" "$(python3 -c "print('x' * 400)")" 10 run bash "$SCRIPT" "$skill" assert_success assert_output --partial "SUGGESTION" } @test "ADR-0020: description of 401 chars FAILs and exits non-zero" { local skill="$TMPDIR/my-skill" make_sized_skill "$skill" "$(python3 -c "print('x' * 401)")" 10 run bash "$SCRIPT" "$skill" assert_failure assert_output --partial "description is 401 chars" assert_output --partial "400-character ADR-0020 ceiling" } @test "ADR-0020: description length is measured after YAML folding is resolved" { local skill="$TMPDIR/my-skill" mkdir -p "$skill" # A >-folded block scalar: 11 lines of 40 chars folded with 10 joining # spaces = 450 characters. Measured off its raw `description: >` line it is # 1 character and passes; measured as the folded VALUE it must FAIL. This # is exactly the case a line-wise regex gets wrong. { echo "---" echo "name: my-skill" echo "description: >" python3 -c "print('\n'.join([' ' + 'x' * 40] * 11))" echo "---" echo "" echo "Do the thing." } > "$skill/SKILL.md" run bash "$SCRIPT" "$skill" assert_failure assert_output --partial "description is 450 chars" assert_output --partial "400-character ADR-0020 ceiling" } # --------------------------------------------------------------------------- # ADR-0020 — body budget (600 SUGGESTION / 900 FAIL), body ONLY # # Distinct from the 2,770-word whole-file spec ceiling above, which counts # frontmatter too and is unchanged. Do not unify them. # --------------------------------------------------------------------------- @test "ADR-0020: body of exactly 600 words raises no suggestion" { local skill="$TMPDIR/my-skill" make_sized_skill "$skill" "A short valid description." 600 run bash "$SCRIPT" "$skill" assert_success refute_output --partial "SUGGESTION" } @test "ADR-0020: body of 601 words raises a SUGGESTION and still exits 0" { local skill="$TMPDIR/my-skill" make_sized_skill "$skill" "A short valid description." 601 run bash "$SCRIPT" "$skill" assert_success assert_output --partial "body is 601 words" assert_output --partial "All checks passed (1 suggestion(s))." } @test "ADR-0020: body of exactly 900 words is a SUGGESTION, not a FAIL" { local skill="$TMPDIR/my-skill" make_sized_skill "$skill" "A short valid description." 900 run bash "$SCRIPT" "$skill" assert_success assert_output --partial "body is 900 words" } @test "ADR-0020: body of 901 words FAILs and exits non-zero" { local skill="$TMPDIR/my-skill" make_sized_skill "$skill" "A short valid description." 901 run bash "$SCRIPT" "$skill" assert_failure assert_output --partial "body is 901 words" assert_output --partial "900-word ADR-0020 ceiling" } @test "ADR-0020: the body gate counts the body only — frontmatter words do not count toward it" { local skill="$TMPDIR/my-skill" # 895 body words plus a description long enough that the WHOLE FILE is well # over 900 words. The body gate must stay silent; the 2,770-word whole-file # ceiling is a separate measurement and is nowhere near tripping. make_sized_skill "$skill" "$(python3 -c "print(' '.join(['w'] * 100))")" 895 run bash "$SCRIPT" "$skill" assert_success refute_output --partial "900-word ADR-0020 ceiling" } # --------------------------------------------------------------------------- # ADR-0020 — resolvable boundary targets # # Resolved against the AUTHORING SOURCE (plugins/*/.apm/skills/ and # plugins/*/.apm/agents/), never .claude/skills/, so the check works offline and # before an apm install. Every fixture below builds its own plugin tree rather # than leaning on this repo's live skills. # --------------------------------------------------------------------------- @test "ADR-0020: a boundary target naming an existing sibling skill resolves" { local skill skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")" make_sized_skill "$skill" "Use when doing the thing. Do not use for the other thing — use fixture-sibling-skill instead." 10 run bash "$SCRIPT" "$skill" assert_success assert_output --partial "boundary target(s) resolve" } @test "ADR-0020: a boundary target naming a non-existent skill FAILs" { local skill skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")" make_sized_skill "$skill" "Use when doing the thing. Do not use for the other thing — use fixture-missing-skill instead." 10 run bash "$SCRIPT" "$skill" assert_failure assert_output --partial "routes to 'fixture-missing-skill'" } @test "ADR-0020: a boundary target naming an AGENT file resolves (agents are valid routing targets)" { local skill skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")" make_sized_skill "$skill" "Use when doing the thing. Do not use when the caller is an agent — invoke fixture-sibling-agent instead." 10 run bash "$SCRIPT" "$skill" assert_success assert_output --partial "boundary target(s) resolve" } @test "ADR-0020: a /slash-command boundary target that does not resolve FAILs" { local skill skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")" make_sized_skill "$skill" "Use when doing the thing. Do not use when improvements are wanted — use /fixture-missing-improve instead." 10 run bash "$SCRIPT" "$skill" assert_failure assert_output --partial "routes to 'fixture-missing-improve'" } @test "ADR-0020: a backticked name that does not resolve FAILs" { local skill skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")" make_sized_skill "$skill" "Use when doing the thing. Composes \`fixture-missing-helper\` for the shared part." 10 run bash "$SCRIPT" "$skill" assert_failure assert_output --partial "routes to 'fixture-missing-helper'" } @test "ADR-0020: a bare hyphenated word outside a boundary sentence is not read as a routing target" { local skill skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")" # "run pre-commit hooks" is pc-run's real phrasing. A naive extractor reads # it as a route to a non-existent `pre-commit` skill. make_sized_skill "$skill" "Use when the user wants to run pre-commit hooks or install git hooks." 10 run bash "$SCRIPT" "$skill" assert_success refute_output --partial "pre-commit" } @test "ADR-0020: an arrow chain outside a boundary clause is not read as a routing target" { local skill skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")" # diagnose's real process chain. Only ADR-0020's `Not -> ` # form makes a bare arrow target a route. make_sized_skill "$skill" "Reproduce → minimise → instrument → fix → regression-test. Use when a bug is reported." 10 run bash "$SCRIPT" "$skill" assert_success refute_output --partial "regression-test" } @test "ADR-0020: MCP tool names and capitalised tool names are not read as routing targets" { local skill skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")" make_sized_skill "$skill" "Use when writing issues. Do not use for local files (use Read/Write/Edit) — that write goes through \`issue_write\`/\`pull_request_write\` instead." 10 run bash "$SCRIPT" "$skill" assert_success refute_output --partial "routes to" } @test "ADR-0020: ADR's compressed boundary form (Not -> ) is checked" { local skill skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")" make_sized_skill "$skill" "Use when doing the thing. Not the other thing → fixture-missing-target." 10 run bash "$SCRIPT" "$skill" assert_failure assert_output --partial "routes to 'fixture-missing-target'" } @test "ADR-0020: the boundary check declines rather than false-FAILs when no authoring source is found" { local skill="$TMPDIR/orphan/my-skill" make_sized_skill "$skill" "Use when doing the thing. Do not use for the other thing — use some-other-skill instead." 10 run bash "$SCRIPT" "$skill" assert_success refute_output --partial "routes to" }