#!/usr/bin/env bash # Regression test for the two properties of ADR-0020 boundary-target resolution # that decide whether the gate can be trusted at all. # # 1. MACHINE INDEPENDENCE. The resolution universe is derived by walking up # FROM THE TARGET FILE to an authoring root, and when one is found the # deployed .claude/ and .agents/ trees are deliberately NOT consulted. Those # trees are `apm install` output — gitignored, and present only on a machine # that has run it. Four cross-plugin targets in this repo resolved through # .claude/skills/ alone, so the same commit measured 2 dangling targets on a # developer machine and 6 on a fresh clone. A gate shipping hot with no # baseline cannot give two answers, so this file asserts the verdict is # 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 # direction is fatal: firing on prose makes the gate untrustworthy and it # gets turned off, while suppressing too much deletes the only two true # positives the corpus has. Both live true positives are BARE, which is why # the rule keys on the FOLLOWER TOKEN rather than on marking, and why they # are pinned by name below — a future false-positive fix must not be able to # quietly take them with it. # # 3. IN-SENTENCE CORROBORATION. Terminal position alone is not evidence of a # route: "run `pre-commit` instead", "see `commit-msg`", "use the clean-up # instead" and "run unit-tests" are all terminal, all prose, and all were # hard FAILs with no suppression mechanism anywhere in the gate. A # prose-form target therefore blocks only when its own sentence names # another target that RESOLVES; otherwise it is reported at SUGGESTION tier # and the commit proceeds. Route NOTATION (`/name`, `-> name`) is exempt # and always blocks. Both halves are asserted below: the prose class must # report-not-block, and the notation and corroborated forms must still # ERROR, or the fix would have eaten the gate rather than narrowed it. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" HOOK="$REPO_ROOT/scripts/skill-size-check.sh" PASS=0 FAIL=0 pass() { echo " PASS: $1"; PASS=$((PASS + 1)); } fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } TMPDIR_T="$(mktemp -d)" trap 'rm -rf "$TMPDIR_T"' EXIT # write_skill write_skill() { mkdir -p "$1" { echo "---" echo "name: $2" echo "description: $3" echo "metadata:" echo " version: \"1.0.0\"" echo "---" echo "" echo "Do the thing." } > "$1/SKILL.md" } # --------------------------------------------------------------------------- # 1. Machine independence — synthetic fixture # --------------------------------------------------------------------------- # Two trees, identical except that one also carries a deployed .claude/ tree # holding a skill and an agent that exist NOWHERE in plugins/. The subject routes # to one name that lives in a sibling plugin (must resolve in both) and one that # lives only in .claude/ (must DANGLE in both — an authoring root exists, so the # deployed tree is not part of the universe). # # If the deployed tree were consulted, the second target would resolve on the # machine that has run `apm install` and dangle on a fresh clone. That is the # 2-vs-6 defect exactly, at fixture scale. echo "" echo "--- the same file gets the same verdict with and without a deployed .claude/ tree ---" build_tree() { local root="$1" write_skill "$root/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 "$root/plugins/subject-plugin/.apm/skills/my-skill" my-skill \ "Use when doing the thing. Do not use for the other thing — use cross-plugin-skill or deployed-only-skill instead." } build_tree "$TMPDIR_T/no-claude" build_tree "$TMPDIR_T/with-claude" # The deployed tree, present only in the second root. Both a skill and an agent, # because both are valid routing targets and both would leak. # # The skill gets a real SKILL.md. That is not decoration: a directory under # skills/ is a resolvable name only when it HOLDS one, so an empty directory # would dangle for the wrong reason and the assertion below would pass without # testing the deployed-tree rule at all. mkdir -p "$TMPDIR_T/with-claude/.claude/agents" write_skill "$TMPDIR_T/with-claude/.claude/skills/deployed-only-skill" deployed-only-skill \ "Use when doing the deployed thing. Do not use for anything else." : > "$TMPDIR_T/with-claude/.claude/agents/deployed-only-agent.md" run_subject() { local root="$1" out set +e out="$(bash "$HOOK" "$root/plugins/subject-plugin/.apm/skills/my-skill/SKILL.md" 2>&1)" set -e # Normalise the tree root out of the paths so the two runs are comparable. printf '%s\n' "$out" | sed "s#$root##g" } NO_CLAUDE_OUT="$(run_subject "$TMPDIR_T/no-claude")" WITH_CLAUDE_OUT="$(run_subject "$TMPDIR_T/with-claude")" if [[ "$NO_CLAUDE_OUT" == "$WITH_CLAUDE_OUT" ]]; then pass "identical output with and without a deployed .claude/ tree" else fail "the deployed .claude/ tree changed the verdict — without: [$NO_CLAUDE_OUT] with: [$WITH_CLAUDE_OUT]" fi # Identical-but-wrong is still possible (both could resolve everything, or # neither could resolve anything), so the CONTENT is asserted too: the # sibling-plugin name must resolve and the deployed-only name must not. if [[ "$WITH_CLAUDE_OUT" == *"routes to 'deployed-only-skill'"* ]]; then pass "a name that exists only in .claude/ still dangles when an authoring root is present" else fail "the deployed-only target did not dangle — the deployed tree is being read into the universe: $WITH_CLAUDE_OUT" fi if [[ "$WITH_CLAUDE_OUT" != *"routes to 'cross-plugin-skill'"* ]]; then pass "a name in a SIBLING PLUGIN resolves, so the comparison above is not 'nothing resolves'" else fail "the sibling-plugin target dangled — the monorepo universe is not being built: $WITH_CLAUDE_OUT" fi # The other half of the rule: with NO authoring root, deployed trees ARE the # universe. That is the consumer case, and without this the rule above could be # implemented as "never read .claude/", which would leave consumers with no # resolution at all. echo "" echo "--- with no authoring root, a deployed .claude/ tree IS the universe ---" CONSUMER="$TMPDIR_T/consumer" write_skill "$CONSUMER/.claude/skills/deployed-only-skill" deployed-only-skill \ "Use when doing the deployed thing. Do not use for anything else." write_skill "$CONSUMER/.claude/skills/my-skill" my-skill \ "Use when doing the thing. Do not use for the other thing — use deployed-only-skill instead." set +e CONSUMER_OUT="$(bash "$HOOK" "$CONSUMER/.claude/skills/my-skill/SKILL.md" 2>&1)" CONSUMER_RC=$? set -e if [[ $CONSUMER_RC -eq 0 && "$CONSUMER_OUT" != *"routes to"* && "$CONSUMER_OUT" != *"DID NOT RUN"* ]]; then pass "a sibling in a deployed .claude/skills/ tree resolves when there is no authoring root" else fail "the consumer path did not resolve through the deployed tree (exit $CONSUMER_RC): ${CONSUMER_OUT:-}" 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