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:
@@ -56,11 +56,33 @@ set -euo pipefail
|
||||
# `hooks/hooks.json` "at the plugin root, not inside .claude-plugin/"
|
||||
# (plugins/kyberforge/docs/research/docs/claude-code-plugins/configuration.md's "Plugin
|
||||
# Directory Layout" table; quoted verbatim in ADR-0017's own root-cause analysis), and
|
||||
# the compiled plugin.json carries no `hooks` pointer to override that -- apm's
|
||||
# build_plugin_manifest strips pointer fields unconditionally, and re-injecting one is
|
||||
# the option ADR-0017 explicitly rejected. A root-level hooks.json (this script's own
|
||||
# pre-fix output shape) is therefore scanned by nothing at all, and is deleted as stale
|
||||
# by a real sync / reported as drift by --check.
|
||||
# the compiled plugin.json carries no `hooks` pointer to override that. apm emits none:
|
||||
# `hooks` is not in build_plugin_manifest's strip list at all (that list is
|
||||
# agents/skills/commands/instructions, and it is dead code besides -- see ADR-0017's
|
||||
# "Considered options"); apm.yml simply has no key that produces one. A root-level
|
||||
# hooks.json (this script's own pre-fix output shape) is therefore scanned by nothing at
|
||||
# all, and is deleted as stale by a real sync / reported as drift by --check.
|
||||
#
|
||||
# NO `hooks` POINTER IS RE-INJECTED into .github/plugin/plugin.json, deliberately, and
|
||||
# this is NOT the same call as mcpServers above. Copilot types `hooks` "string or object"
|
||||
# with no default, exactly like mcpServers, so Copilot resolves no hooks from any plugin
|
||||
# here -- but the two ecosystems' hooks FILE FORMATS are mutually incompatible (Claude:
|
||||
# `{"hooks": {"PreToolUse": [{matcher, hooks:[...]}]}}`; Copilot: `{"version": 1,
|
||||
# "hooks": {"sessionStart": [{type, bash, powershell, ...}]}}`), and apm's exporter
|
||||
# merges .apm/hooks/*.json into exactly ONE hooks.json with no per-target shaping
|
||||
# (_collect_hooks_from_apm, apm_cli/bundle/plugin_exporter.py). A pointer would therefore
|
||||
# assert that a Claude-shaped file is Copilot-shaped. .mcp.json carries no such claim --
|
||||
# it is one host-agnostic format both ecosystems read. See ADR-0017's 2026-08-14
|
||||
# "no `hooks` pointer" amendment; plugins/kyberforge/docs/hooks.md carries the
|
||||
# author-facing version.
|
||||
#
|
||||
# SYMLINKS UNDER .apm/ ARE NOT MIRRORED and cannot be: apm's bundle exporter filters
|
||||
# every symlink out of the bundle it produces (`f.is_file() and not f.is_symlink()` in
|
||||
# _collect_flat/_collect_recursive, and the same test in _collect_hooks_from_apm), with
|
||||
# no warning. Nothing downstream of the bundle can see the omission -- both sides of
|
||||
# --check's diff are built from that same bundle, so sync and --check agree the symlink
|
||||
# never existed. check_apm_symlinks below therefore reads the .apm/ SOURCE tree directly,
|
||||
# which is the only place the loss is visible, and reports it in both modes.
|
||||
#
|
||||
# tests/ subdirectories (e.g. .apm/skills/<name>/tests/*.bats) are excluded from the
|
||||
# mirror -- they are dev-time fixtures a plugin host never needs to discover, and several
|
||||
@@ -131,6 +153,14 @@ source "$SCRIPT_DIR/lib/batch-run.sh"
|
||||
# directory apm never emits and no plugin host ever scans.
|
||||
MIRROR_DIRS=(agents skills commands instructions extensions)
|
||||
|
||||
# The .apm/ SOURCE directories apm's exporter reads to build the bundle -- the input
|
||||
# side of MIRROR_DIRS, and deliberately a different list: `prompts` is here because
|
||||
# apm reads it (folding it into commands/), and `hooks` is here because
|
||||
# _collect_hooks_from_apm reads it. Used only by check_apm_symlinks, which needs to
|
||||
# know which parts of .apm/ are mirror INPUT: a symlink under a directory apm never
|
||||
# reads loses nothing and must not be reported as loss.
|
||||
APM_SOURCE_DIRS=(agents skills prompts commands instructions extensions hooks)
|
||||
|
||||
# The generated hooks directory, the merged hooks file inside it (Claude Code's
|
||||
# convention-scanned path), and the pre-fix root-level path a real sync now cleans
|
||||
# up as stale.
|
||||
@@ -241,6 +271,49 @@ normalize_trailing_newline() {
|
||||
printf '%s\n' "$(cat "$1")" >"$2"
|
||||
}
|
||||
|
||||
# Reports every symlink under the .apm/ directories apm's exporter reads. Runs in
|
||||
# BOTH modes, and is the one check here that reads the .apm/ source tree rather than
|
||||
# comparing two bundle-derived trees.
|
||||
#
|
||||
# It has to: apm drops symlinks from the bundle silently (see the header), so by the
|
||||
# time either mode has a bundle the symlink is already gone from both sides of every
|
||||
# comparison. check_dir, check_file and check_path_modes all diff the live mirror
|
||||
# against a freshly synced copy -- neither side has the file, they agree, and --check
|
||||
# exits 0 while the author's content is simply not there. That is the entire failure
|
||||
# mode: not a mismatch, an absence with nothing left to mismatch against. Verified on
|
||||
# a fixture -- `ln -s real.md link.md` under .apm/skills/hello/ produced a mirror with
|
||||
# no link.md and a --check at exit 0.
|
||||
#
|
||||
# Reported rather than resolved (no dereference-and-copy): the mirror's contract is
|
||||
# that it is `apm pack`'s output, and materializing a file apm chose not to export
|
||||
# would make a real sync produce content the bundle does not contain -- exactly the
|
||||
# "reimplement the mapping outside apm" that ADR-0017 rejects. Telling the author is
|
||||
# the cheap, in-contract half.
|
||||
#
|
||||
# <name>/tests is carved out to match sync_dir's own depth-scoped exclusion: that
|
||||
# subtree is not mirrored whether or not it holds a symlink, so nothing is lost there.
|
||||
# The carve-out is on the SECOND path segment specifically, mirroring sync_dir's
|
||||
# `-mindepth 2 -maxdepth 2`; a `tests` deeper than that (assets/templates/tests) IS
|
||||
# mirrored, so a symlink in it is real loss and is reported.
|
||||
check_apm_symlinks() {
|
||||
local apm_dir="$1"
|
||||
local d src link rel rest
|
||||
|
||||
for d in "${APM_SOURCE_DIRS[@]}"; do
|
||||
src="$apm_dir/$d"
|
||||
[[ -d "$src" ]] || continue
|
||||
while IFS= read -r link; do
|
||||
rel="${link#"$src"/}"
|
||||
rest="${rel#*/}"
|
||||
if [[ "$rest" != "$rel" ]] && { [[ "$rest" == "tests" ]] || [[ "$rest" == tests/* ]]; }; then
|
||||
continue
|
||||
fi
|
||||
echo "FAIL $link: symlink under .apm/ -- apm's bundle exporter drops symlinks from the bundle entirely, so this content never reaches the mirror and no diff can see it missing. Replace it with a regular file." >&2
|
||||
FAIL=1
|
||||
done < <(find "$src" -type l -print 2>/dev/null | LC_ALL=C sort)
|
||||
done
|
||||
}
|
||||
|
||||
# Real-mode mirror write. There is no --check branch here on purpose: check mode
|
||||
# calls this same function against a throwaway copy of the plugin root and diffs
|
||||
# the result (see sync_one), so the tests/ exclusion below is the only copy of
|
||||
@@ -553,9 +626,16 @@ sync_plugin_manifest() {
|
||||
# FAIL here is that subshell's own copy -- it never touches the parent's FAIL
|
||||
# and must be handed back via status_file instead.
|
||||
sync_one() {
|
||||
local plugin_dir="${1%/}" status_file="$2"
|
||||
local plugin_dir="${1%/}" status_file="$2" verified_file="$3"
|
||||
local apm_dir="$plugin_dir/.apm"
|
||||
FAIL=0
|
||||
# "This plugin's mirror was actually synced/checked." Flipped to 1 only at the
|
||||
# very bottom, so every early return below -- nonexistent directory, no .apm/,
|
||||
# apm pack failure, no bundle -- leaves it 0. --all compares the count of these
|
||||
# against the number of plugins marketplace.json declared (see the dispatch
|
||||
# loop's aggregation); an exit-status-only handshake cannot express "ran, but
|
||||
# verified nothing", which is exactly what the SKIP below is.
|
||||
echo 0 >"$verified_file"
|
||||
|
||||
if [[ ! -d "$plugin_dir" ]]; then
|
||||
echo "FAIL $plugin_dir: plugin directory does not exist" >&2
|
||||
@@ -570,6 +650,10 @@ sync_one() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Before the pack, not after: this reads the .apm/ source tree, and it is the
|
||||
# only report that survives apm's silent symlink filtering (see the header).
|
||||
check_apm_symlinks "$apm_dir"
|
||||
|
||||
local name scratch bundle_dir pack_log pack_cwd
|
||||
name="$(basename "$plugin_dir")"
|
||||
scratch="$SCRATCH_ROOT/$name"
|
||||
@@ -656,6 +740,7 @@ sync_one() {
|
||||
sync_plugin_manifest "$plugin_dir" "$pack_cwd" ".claude-plugin/plugin.json"
|
||||
sync_plugin_manifest "$plugin_dir" "$pack_cwd" ".github/plugin/plugin.json"
|
||||
fi
|
||||
echo 1 >"$verified_file"
|
||||
echo "$FAIL" >"$status_file"
|
||||
}
|
||||
|
||||
@@ -670,18 +755,50 @@ sync_one() {
|
||||
declare -a batch_args=()
|
||||
for plugin_dir in ${plugin_dirs[@]+"${plugin_dirs[@]}"}; do
|
||||
name="$(basename "${plugin_dir%/}")"
|
||||
cmd="$(printf 'sync_one %q %q' "$plugin_dir" "$SCRATCH_ROOT/$name.status")"
|
||||
cmd="$(printf 'sync_one %q %q %q' "$plugin_dir" "$SCRATCH_ROOT/$name.status" \
|
||||
"$SCRATCH_ROOT/$name.verified")"
|
||||
batch_args+=("$name" "$cmd")
|
||||
done
|
||||
batch_run "$SCRATCH_ROOT" ${batch_args[@]+"${batch_args[@]}"}
|
||||
|
||||
declare -a unverified=()
|
||||
for plugin_dir in ${plugin_dirs[@]+"${plugin_dirs[@]}"}; do
|
||||
name="$(basename "${plugin_dir%/}")"
|
||||
cat "$SCRATCH_ROOT/$name.log" >&2
|
||||
status="$(cat "$SCRATCH_ROOT/$name.status" 2>/dev/null || echo 1)"
|
||||
[[ "$status" -ne 0 ]] && FAIL=1
|
||||
# Missing file reads as 0 (unverified), matching the status file's `|| echo 1`
|
||||
# default: a job whose marker never got written did not verify anything.
|
||||
if [[ "$(cat "$SCRATCH_ROOT/$name.verified" 2>/dev/null || echo 0)" != "1" ]]; then
|
||||
unverified+=("$plugin_dir")
|
||||
fi
|
||||
done
|
||||
|
||||
# --all's second floor, and the one the zero-plugin floor above cannot express.
|
||||
# That floor rejects "the marketplace yielded no plugins"; this rejects "the
|
||||
# marketplace yielded N and only M were actually verified". The gap between them
|
||||
# is sync_one's SKIP path: a plugin directory with no .apm/ reports status 0 and
|
||||
# checks nothing, so --all printed one SKIP line among the noise and exited 0
|
||||
# having verified fewer plugins than it listed. --all is a pre-push gate over a
|
||||
# GENERATED work list, so "checked fewer than declared" has to be a failure.
|
||||
#
|
||||
# There is no legitimate state in this repo where a listed local plugin lacks
|
||||
# .apm/: ADR-0015 made .apm/ the sole authoring source for every plugin here, and
|
||||
# ADR-0017's mirror is defined as that directory's compiled output, so a local
|
||||
# marketplace entry without one is drift in one of the two -- either the directory
|
||||
# lost its .apm/, or marketplace.json still lists a package that is no longer one.
|
||||
# Both need a human, and neither is fixed by re-running the sync, so this is
|
||||
# reported separately from the drift hint below rather than folded into it.
|
||||
#
|
||||
# SKIP stays a skip when plugin directories are named EXPLICITLY on the command
|
||||
# line: there the caller chose the work list and a non-apm directory is their
|
||||
# business, not a generated file's drift.
|
||||
UNVERIFIED=0
|
||||
if [[ "$ALL" -eq 1 ]] && [[ ${#unverified[@]} -gt 0 ]]; then
|
||||
echo "Error: --all verified $(( ${#plugin_dirs[@]} - ${#unverified[@]} )) of the ${#plugin_dirs[@]} local plugin entries $MARKETPLACE declares; unverified: ${unverified[*]}. A listed plugin that cannot be checked (typically: its .apm/ is gone, which sync_one skips) is drift, not a pass -- restore its .apm/, or drop the entry from root apm.yml's marketplace.packages[] and recompile." >&2
|
||||
UNVERIFIED=1
|
||||
fi
|
||||
|
||||
if [[ "$FAIL" -ne 0 ]]; then
|
||||
if [[ "$CHECK" -eq 1 ]]; then
|
||||
if [[ "$ALL" -eq 1 ]]; then
|
||||
@@ -692,3 +809,5 @@ if [[ "$FAIL" -ne 0 ]]; then
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[[ "$UNVERIFIED" -eq 0 ]] || exit 1
|
||||
|
||||
Reference in New Issue
Block a user