fix(scripts): detect the .apm/ content the mirror loses silently
apm's bundle exporter drops symlinks entirely, so a symlink under .apm/ never
reaches the mirror -- and no gate could see it, because every existing check
diffs the live mirror against a bundle-derived copy and both sides lack the
file. It is an absence with nothing left to mismatch against, the only class of
.apm/ content that vanishes without a trace. check_apm_symlinks reads the .apm/
source tree, where the loss is visible, and fails both modes. Reported rather
than resolved: dereferencing would make a real sync emit content the bundle does
not contain, which is the reimplementation ADR-0017 rejects.
--check --all could also pass having verified fewer plugins than the marketplace
lists: a plugin whose .apm/ had gone was SKIPped rather than counted, and the
earlier floor only caught zero. The count is now checked against the marketplace's
own local-package list. There is no exempt state -- ADR-0015 makes .apm/ the sole
authoring source for every local plugin, so a listed plugin without one is drift.
On the Copilot hooks gap, the decision is to document, not implement. Copilot
declares no hooks path and apm emits none, which looks like the mcpServers case
-- but that exception holds because .mcp.json is one host-agnostic format both
ecosystems read, so a pointer to it is true whatever it contains. Hooks have no
shared format: Claude expects nested matcher groups under PascalCase events,
Copilot requires version: 1, camelCase, and a bash/powershell split. apm merges
.apm/hooks/*.json into exactly one file, at Claude's convention path. A pointer
would assert a Claude-shaped file is Copilot-shaped -- an incomplete manifest
traded for a wrong one -- and it is not inert today either, since {"hooks": {}}
lacks Copilot's mandatory version key. A test pins the decision, so restoring the
pointer fails until someone confronts the schema mismatch.
Tests: 77 -> 92 assertions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
This commit is contained in:
@@ -1090,6 +1090,202 @@ else
|
||||
esac
|
||||
fi
|
||||
|
||||
# --- 29. A symlink under .apm/ is reported, in both modes ---
|
||||
# apm's bundle exporter filters every symlink out of the bundle it builds
|
||||
# (`f.is_file() and not f.is_symlink()` in _collect_flat/_collect_recursive,
|
||||
# apm_cli/bundle/plugin_exporter.py) with no warning. Every other check here diffs
|
||||
# the live mirror against a freshly synced copy, and BOTH are built from that same
|
||||
# bundle — so the symlink is absent on both sides, they agree, and --check exits 0
|
||||
# while the author's content is simply gone. Not a mismatch: an absence with nothing
|
||||
# left to mismatch against. Verified before the fix: `ln -s real.md link.md` under
|
||||
# .apm/skills/hello/ produced a mirror with no link.md and a --check at exit 0.
|
||||
#
|
||||
# Message-asserted, not exit-code-asserted: an unsynced fixture exits 1 anyway, so a
|
||||
# bare non-zero would pass with the detection deleted.
|
||||
echo ""
|
||||
echo "--- a symlink under .apm/ is reported as lost content in both modes ---"
|
||||
FIXTURE29="$(make_fixture)"; track "$FIXTURE29"
|
||||
printf 'real content\n' > "$FIXTURE29/.apm/skills/hello/real.md"
|
||||
ln -s real.md "$FIXTURE29/.apm/skills/hello/link.md"
|
||||
RCSYNC29=0
|
||||
SYNC29="$(bash "$SCRIPT" "$FIXTURE29" 2>&1)" || RCSYNC29=$?
|
||||
CHECK29="$(bash "$SCRIPT" --check "$FIXTURE29" 2>&1 || true)"
|
||||
for MODE29 in sync check; do
|
||||
case "$MODE29" in
|
||||
sync) OUT29="$SYNC29" ;;
|
||||
check) OUT29="$CHECK29" ;;
|
||||
esac
|
||||
case "$OUT29" in
|
||||
*"$FIXTURE29/.apm/skills/hello/link.md: symlink under .apm/"*)
|
||||
pass "$MODE29 mode reports the symlink under .apm/ by path" ;;
|
||||
*)
|
||||
fail "$MODE29 mode did not report the symlink under .apm/ — apm drops it silently and no diff can see it: $OUT29" ;;
|
||||
esac
|
||||
done
|
||||
# The loss is real, not theoretical: assert the mirror genuinely lacks it, so this
|
||||
# case still means something if apm ever starts exporting symlinks.
|
||||
if [[ ! -e "$FIXTURE29/skills/hello/link.md" ]]; then
|
||||
pass "the symlink is indeed absent from the mirror (nothing else could have caught it)"
|
||||
else
|
||||
fail "the symlink reached the mirror — apm's exporter no longer drops it, so this report is now wrong"
|
||||
fi
|
||||
# The real sync above exited non-zero too (its rc, not a fresh run). Sync and --check
|
||||
# agreeing is this script's core contract, and a real sync that "succeeds" while
|
||||
# dropping content breaks it.
|
||||
if [[ "$RCSYNC29" -ne 0 ]]; then
|
||||
pass "a real sync exits non-zero rather than reporting success over dropped content"
|
||||
else
|
||||
fail "a real sync exited 0 while silently dropping .apm/ content — sync and --check must agree"
|
||||
fi
|
||||
rm -f "$FIXTURE29/.apm/skills/hello/link.md"
|
||||
if bash "$SCRIPT" "$FIXTURE29" > /dev/null 2>&1 && bash "$SCRIPT" --check "$FIXTURE29" > /dev/null 2>&1; then
|
||||
pass "removing the symlink clears the report in both modes"
|
||||
else
|
||||
fail "the symlink report survived its removal"
|
||||
fi
|
||||
|
||||
# --- 29b. The report is scoped to content the mirror would actually carry ---
|
||||
# sync_dir strips <category>/<name>/tests from the mirror outright, so a symlink in
|
||||
# there loses nothing and reporting it would be a false alarm demanding a pointless
|
||||
# edit. A `tests` DEEPER than that is a template asset the mirror does carry (case
|
||||
# 3b), so a symlink in it is real loss. Same depth boundary, both directions —
|
||||
# a carve-out asserted in only one direction passes on "report nothing, ever".
|
||||
echo ""
|
||||
echo "--- the symlink report follows the mirror's own tests/ depth boundary ---"
|
||||
FIXTURE29B="$(make_fixture)"; track "$FIXTURE29B"
|
||||
printf 'x\n' > "$FIXTURE29B/.apm/skills/hello/tests/real.txt"
|
||||
ln -s real.txt "$FIXTURE29B/.apm/skills/hello/tests/link.txt"
|
||||
bash "$SCRIPT" "$FIXTURE29B" > /dev/null 2>&1
|
||||
RC29B=0
|
||||
OUT29B="$(bash "$SCRIPT" --check "$FIXTURE29B" 2>&1)" || RC29B=$?
|
||||
case "$OUT29B" in
|
||||
*"tests/link.txt: symlink under .apm/"*)
|
||||
fail "a symlink under the un-mirrored <name>/tests/ was reported — nothing is lost there" ;;
|
||||
*)
|
||||
pass "a symlink under <name>/tests/ is not reported (that subtree is not mirrored)" ;;
|
||||
esac
|
||||
# Exit code as well as message, from that same run: a carve-out that suppresses the
|
||||
# line but still fails the gate is not a carve-out.
|
||||
if [[ "$RC29B" -eq 0 ]]; then
|
||||
pass "--check is clean with a symlink confined to the un-mirrored tests/ fixture dir"
|
||||
else
|
||||
fail "--check reported drift for a symlink under the un-mirrored <name>/tests/: $OUT29B"
|
||||
fi
|
||||
printf 'y\n' > "$FIXTURE29B/.apm/skills/hello/assets/templates/tests/real.txt"
|
||||
ln -s real.txt "$FIXTURE29B/.apm/skills/hello/assets/templates/tests/link.txt"
|
||||
OUT29B2="$(bash "$SCRIPT" --check "$FIXTURE29B" 2>&1 || true)"
|
||||
case "$OUT29B2" in
|
||||
*"assets/templates/tests/link.txt: symlink under .apm/"*)
|
||||
pass "a symlink under the mirrored assets/templates/tests/ IS reported" ;;
|
||||
*)
|
||||
fail "a symlink under the mirrored assets/templates/tests/ was not reported — the carve-out is depth-agnostic and swallows real loss: $OUT29B2" ;;
|
||||
esac
|
||||
|
||||
# --- 30. --all fails when it verified fewer plugins than the marketplace declares ---
|
||||
# The zero-plugin floor (case 28) rejects "the marketplace yielded nothing"; it cannot
|
||||
# see "it yielded N and only M were checked". sync_one SKIPs a plugin directory with no
|
||||
# .apm/ at status 0, so --all printed one SKIP line and exited 0 having verified one
|
||||
# plugin fewer than it listed — a pre-push gate over a GENERATED work list silently
|
||||
# covering less than it claims.
|
||||
echo ""
|
||||
echo "--- --check --all fails when a listed plugin cannot be verified ---"
|
||||
# A repo fixture with real plugin directories, unlike case 28's marketplace-only one.
|
||||
make_repo_with_plugins() {
|
||||
local dir
|
||||
dir="$(mktemp -d)"
|
||||
dir="$(cd "$dir" && pwd -P)"
|
||||
if ! env -u GIT_DIR -u GIT_WORK_TREE git -C "$dir" init -q >/dev/null 2>&1; then
|
||||
echo "make_repo_with_plugins: 'git init' failed in $dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$dir/.claude-plugin" "$dir/plugins"
|
||||
printf '%s' '{"plugins":[{"name":"good","source":"./plugins/good"},{"name":"bare","source":"./plugins/bare"}]}' \
|
||||
> "$dir/.claude-plugin/marketplace.json"
|
||||
local p
|
||||
for p in good bare; do
|
||||
local src
|
||||
src="$(make_fixture)"; track "$src"
|
||||
mv "$src" "$dir/plugins/$p"
|
||||
done
|
||||
echo "$dir"
|
||||
}
|
||||
REPO30="$(make_repo_with_plugins)"; track "$REPO30"
|
||||
# Sync both first, so the ONLY thing --all can complain about below is the count.
|
||||
bash "$SCRIPT" "$REPO30/plugins/good" "$REPO30/plugins/bare" > /dev/null 2>&1
|
||||
if (cd "$REPO30" && env -u GIT_DIR -u GIT_WORK_TREE bash "$SCRIPT" --check --all > /dev/null 2>&1); then
|
||||
pass "--check --all is clean when every declared plugin is verifiable (baseline)"
|
||||
else
|
||||
fail "--check --all reported drift on a freshly synced two-plugin repo — cannot test the count case"
|
||||
fi
|
||||
# Now take one listed plugin's .apm/ away: it is still declared, still on disk, and
|
||||
# now unverifiable. Its mirror is left in place, so no other check has anything to say.
|
||||
rm -rf "$REPO30/plugins/bare/.apm"
|
||||
RC30=0
|
||||
OUT30="$(cd "$REPO30" && env -u GIT_DIR -u GIT_WORK_TREE bash "$SCRIPT" --check --all 2>&1)" || RC30=$?
|
||||
case "$RC30:$OUT30" in
|
||||
0:*)
|
||||
fail "--check --all exited 0 having verified 1 of the 2 plugins its marketplace declares" ;;
|
||||
*"verified 1 of the 2 local plugin entries"*)
|
||||
pass "--check --all fails and names how many of the declared plugins it actually verified" ;;
|
||||
*)
|
||||
fail "--check --all exited $RC30 but not for the under-count reason: $OUT30" ;;
|
||||
esac
|
||||
# The message must name the plugin, not just the arithmetic — a count alone leaves the
|
||||
# reader diffing marketplace.json against a directory listing by hand.
|
||||
case "$OUT30" in
|
||||
*"unverified: $REPO30/plugins/bare"*)
|
||||
pass "the failure names the unverified plugin directory" ;;
|
||||
*)
|
||||
fail "the failure did not name the unverified plugin directory: $OUT30" ;;
|
||||
esac
|
||||
# ...and an explicitly-named plugin dir with no .apm/ stays a skip (case 6): there the
|
||||
# caller chose the work list, so a non-apm directory is their business, not drift in a
|
||||
# generated file.
|
||||
if bash "$SCRIPT" --check "$REPO30/plugins/bare" > /dev/null 2>&1; then
|
||||
pass "the same directory named explicitly is still a clean skip, not a failure"
|
||||
else
|
||||
fail "an explicitly-named plugin dir with no .apm/ now fails — case 6's skip contract is broken"
|
||||
fi
|
||||
|
||||
# --- 31. No `hooks` pointer is injected into the Copilot manifest ---
|
||||
# Copilot types `hooks` "string or object" with NO default (github-copilot-plugins/
|
||||
# configuration.md:47), exactly like mcpServers — so Copilot resolves no hooks from any
|
||||
# plugin here, and re-injecting a pointer the way reinject_mcp_servers() does for
|
||||
# mcpServers looks like the obvious twin fix. It is not, and this case pins the
|
||||
# difference: apm merges .apm/hooks/*.json into exactly ONE hooks.json with no
|
||||
# per-target shaping, while the two ecosystems' hook file formats are mutually
|
||||
# incompatible (Claude: `{"hooks":{"PreToolUse":[{matcher,hooks}]}}`; Copilot:
|
||||
# `{"version":1,"hooks":{"sessionStart":[{type,bash,powershell}]}}`). A pointer would
|
||||
# assert that a Claude-shaped file is Copilot-shaped — a wrong manifest in place of an
|
||||
# incomplete one. .mcp.json carries no such claim: it is one format both hosts read.
|
||||
# See ADR-0017's "no `hooks` pointer" amendment and plugins/kyberforge/docs/hooks.md.
|
||||
echo ""
|
||||
echo "--- the generated Copilot manifest carries no hooks pointer ---"
|
||||
FIXTURE31="$(make_fixture_with_mcp '{"mcpServers":{"demo":{"command":"demo-server","type":"stdio"}}}')"; track "$FIXTURE31"
|
||||
mkdir -p "$FIXTURE31/.apm/hooks"
|
||||
cat > "$FIXTURE31/.apm/hooks/hooks.json" <<'EOF'
|
||||
{"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "true"}]}]}}
|
||||
EOF
|
||||
bash "$SCRIPT" "$FIXTURE31" > /dev/null 2>&1
|
||||
if [[ ! -f "$FIXTURE31/hooks/hooks.json" ]]; then
|
||||
fail "sync produced no hooks/hooks.json — cannot test the hooks-pointer decision"
|
||||
elif [[ ! -f "$FIXTURE31/.github/plugin/plugin.json" ]]; then
|
||||
fail "sync produced no .github/plugin/plugin.json — cannot test the hooks-pointer decision"
|
||||
else
|
||||
pass "a non-empty .apm/hooks/ produces hooks/hooks.json (Claude Code's convention path)"
|
||||
if jq -e 'has("hooks") | not' "$FIXTURE31/.github/plugin/plugin.json" > /dev/null 2>&1; then
|
||||
pass "no hooks pointer in .github/plugin/plugin.json, even with a real hook present"
|
||||
else
|
||||
fail "a hooks pointer was injected into the Copilot manifest (got: $(jq -c '.hooks' "$FIXTURE31/.github/plugin/plugin.json" 2>/dev/null)) — it would point Copilot at a Claude-shaped hooks file. Reconcile the two hook schemas first; see ADR-0017"
|
||||
fi
|
||||
# The Claude manifest needs none either: hooks/hooks.json IS its convention path.
|
||||
if jq -e 'has("hooks") | not' "$FIXTURE31/.claude-plugin/plugin.json" > /dev/null 2>&1; then
|
||||
pass "no hooks pointer in .claude-plugin/plugin.json either — the convention path needs none"
|
||||
else
|
||||
fail "a hooks pointer appeared in the Claude manifest, which convention-scans hooks/hooks.json already"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
|
||||
Reference in New Issue
Block a user