diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index b04ab946ca..fc367ab05d 100644 --- a/plugins/claude-ops/.claude-plugin/plugin.json +++ b/plugins/claude-ops/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-ops", - "version": "0.42.12", + "version": "0.42.13", "description": "Claude Code operations toolkit. Twelve skills: audit-skill-visibility (audit whether each installed skill is actually VISIBLE to the model, and diagnose why most of a fleet never gets used \u2014 a skill is invisible when its description is dropped by Claude Code's skill-listing context budget, which sheds descriptions lowest-score-first so an unused skill loses the keywords that would let it be matched, from skills genuinely not wanted, from skills the run cannot observe at all; computes whether the listing overflows from documented settings, and withholds every cold verdict the data cannot support rather than reporting absence of data as absence of use), inventory (read-only enumeration of the complete invocable surface \u2014 every built-in CLI command with aliases and hidden/gated status, every bundled skill, and every component of every installed plugin across all marketplaces; reads the shipped binary because upstream publishes no built-in command list, and carries an integrity verdict so a drifted build reports counts as floors rather than silently short totals), audit-install-state (read-only audit of the machine-scope ~/.claude installation directory and ~/.claude.json \u2014 full inventory split into an authored surface and rolled-up bulk trees, product-managed retention vs genuinely unmanaged state, filename-scheme resolution before any process-liveness check, and deliberate/mid-experiment detection; reports, never deletes), audit-performance (read-only slowness-diagnostic capture run at the moment the machine or a session feels slow: CLI version, retention-sweep health including the silent unparsable-settings pause, a timed census walk of the install tree as a sweep-cost proxy, active-session and plugin-fleet counts, a process census, and the fan-out layer, which covers a load-labelled no-op spawn baseline, every hook that will fire bucketed per-tool-call versus per-turn with its invocation shape, the configured statusline, subagent concurrency and spawn-depth ceilings against documented defaults, whether running sessions predate the settings file they are judged by, and orphan attribution by parent liveness rather than age, plus on Windows a kernel-object census (Token objects against uptime, paged pool) that names a host-level leak beneath all four suspects; read against a bundled known-performance-issues reference that also records the causes tested and cleared; separates the four documented suspects of accumulated state, version regression, component bloat, and per-spawn fan-out cost, and routes remediation out; reports, never mutates, and never executes a discovered hook or statusline command), audit-native-overlap (map native Claude Code surfaces \u2014 built-in CLI commands, bundled skills, plugin-backed built-ins, session-provided skills \u2014 against the current repo's plugin skills and agents, so a custom component never silently duplicates what Claude Code itself ships; bare invocation is a read-only overlap report carrying the extraction's integrity floors and a shared-listing-budget exposure section, verdicts are human-gated in a committed store rendered into a generated registry whose every row carries an observable recheck trigger, and only an explicit apply step bakes presence-gated native references into descriptions and Boundary sections), observability (read locally captured telemetry \u2014 OTEL store, collector, the per-session hook event log and hook-event JSONL, ccusage \u2014 with trend reports, a per-session report of what fired, what was blocked and the event timeline, and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand \u2014 marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view \u2014 queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort, a repo-pull + marketplace-refresh launch step, and a consume-restarts action \u2014 an OS-schedulable reader that relaunches stopped lanes whose telemetry carries a restart_request), and a re-runnable setup action that settles where the known-issues registry, the skill-usage log and the hook log root live, places the root's self-ignoring guard, and detects retired conventions. Plus an opt-in, default-off per-session hook event log (one JSON line per hook event on every event the generated registry marks observable, written to /sessions/.jsonl, with SessionEnd retention by session count or age and an optional detached pre-prune command), a family of eight advisory *-audit hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures, and unsurfaced hook failures \u2014 the last also warns the user via systemMessage, since a hook that fails to launch enforces nothing and Claude Code surfaces the failure to nobody) that emit the shared hook-telemetry envelope, and a reference sink that routes envelopes under the same root: per session when the envelope carries a session id, else into the shared hook-events.jsonl the observability skill reads.", "author": { "name": "Melodic Software", diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index b0b8e61e02..7d265019fd 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -3,6 +3,67 @@ All notable changes to the `claude-ops` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.42.13] + +### Added + +- **A standing cache-content check in the `plugins` skill, so a version-and-sha match is no + longer taken as proof that the files on disk are the build the record names (#3681).** + `claude plugin update` re-points an install record's `gitCommitSha` without rewriting the + plugin's cache directory when the manifest version number is unchanged across the two commits, + because the cache is keyed by version. The record then claims the new commit while the directory + still holds the older build, and every check the skill had passed in that state. On the reporting + machine six plugins were in it at once, twelve stale files in the worst case, including a reviewed + dispatcher and two `hooks.json` files. Any measurement or behaviour test against those caches was + a test of a different build. The new `cache-content-check.sh` byte-compares every file in a cache + directory against the recorded commit in the marketplace clone, in both directions: a changed + file, a file the commit has and the cache lacks, and a file deleted at the commit but still + sitting in the cache. It runs as Step 5b of `sync` and of `audit`, ungated, because an unchanged + version number is exactly the case in which every other step reports success. Cache-only files the + marketplace repo's own `.gitignore` covers are excluded: a cache directory is a live plugin root, + so it accumulates `__pycache__` and vendored dependencies that were never in any commit, and + without that filter three installs on the authoring machine reported stale on generated state + alone. The report gains a `Cache content:` row naming the affected ids and the remediation that + was proved to work, which is removing that version's directory and re-running the update. +- **The check reports and never repairs, and never reaches the network.** It writes no state file, + removes no cache directory, and does not `git fetch` a commit the marketplace clone lacks. A + missing commit is reported as `sha-not-local` and counted as unverifiable rather than as a pass, + because fetching it would be a mutation the audit does not perform and would erase the very + condition the verdict exists to report. A project-scope record whose `projectPath` is not on this + machine is counted as skipped rather than verdicted, on the same reasoning the skill already + applies to stale project records: absent is not dead. +- **The compare reads the plugin's source path from the RECORDED commit, not from the marketplace + clone's current checkout.** A plugin directory renamed or moved after the recorded commit was + otherwise looked up under its present-day path against an older tree, and `git ls-tree` treats a + pathspec that matches nothing as success with empty output rather than an error — so every file in + a perfectly healthy cache became an extra and the install reported `stale-content`. The manifest is + now read with `git show :.claude-plugin/marketplace.json`, with no fallback to the checkout, + so the source path and the expected tree describe the same revision. A pathspec that still matches + nothing at that sha gets its own `no-source-at-sha` verdict, counted unverifiable, so an empty + expected tree can never be reported as a content difference. The manifest read is cached per + distinct sha rather than paid per install record. +- **Pathnames travel NUL-separated end to end.** `ls-tree -z`, `find -print0` and `check-ignore -z` + replace their line-oriented forms. Without `-z`, git quotes and escapes any pathname carrying + non-ASCII, a tab, a newline or a backslash, and the compare then read that quoted spelling and the + raw path as two different files — an unchanged accented filename was reported as both + missing-from-cache and extra-in-cache. `git hash-object --stdin-paths` has no `-z` switch, so a + cache path containing a newline, and only that character, is hashed by its own process instead. +- **Tracked symlinks are compared mode-aware instead of reported missing.** A symlink is an ordinary + blob whose content is the link target text, but `find -type f` excluded it, so every tracked link + read as permanently missing from the cache. The cache walk now enumerates links as well as regular + files and hashes a link's `readlink` output, which is what git itself stores. +- **Install records are decoded with a US (0x1f) separator rather than a tab.** Bash treats tab as + IFS whitespace, so an empty middle column collapsed: a record carrying no `gitCommitSha` shifted + its `installPath` into the sha field and reported `install-path-missing` with a fabricated sha + instead of the honest `no-git-commit-sha`. This is the separator `fleet-state.sh` already uses for + its own internal records. +- **Route (b) of the three the issue offered, taken deliberately.** Route (a), a repo rule that + every plugin change bumps the version, prevents nothing already delivered and depends on every + future author remembering it; route (c), an upstream report, has no delivery date this repo + controls and leaves the machine undetected in the meantime. Route (b) is a check that runs on + every sync and audit, needs no author discipline, and holds whatever upstream does. It does not + preclude the other two. + ## [0.42.12] ### Changed diff --git a/plugins/claude-ops/skills/plugins/SKILL.md b/plugins/claude-ops/skills/plugins/SKILL.md index 25120823fb..65a47932d9 100644 --- a/plugins/claude-ops/skills/plugins/SKILL.md +++ b/plugins/claude-ops/skills/plugins/SKILL.md @@ -99,6 +99,22 @@ The third form projects that same id list from a report already on disk rather t fleet, and is the form `sync`'s steps use: each step re-reads the full report anyway, and every selector is derivable from it. Same script, same projection, so the `\r` protection is unchanged. +A second read-only script answers the question `fleet-state.sh` structurally cannot: whether the +files in a plugin's cache directory actually match the commit its install record claims. Run it as +Step 5b of `sync` and of `audit`: + +```bash +"${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/cache-content-check.sh --marketplace [--scope user|project|all] +"${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/cache-content-check.sh --marketplace --ids +``` + +`--ids` emits the stale ids alone, one per line, CR-free, the same contract and for the same reason +as `fleet-state.sh --ids`. The script never writes anything and never runs `git fetch`; a commit +that is not in the local marketplace clone is reported as `sha-not-local`, not fetched. See +[context/sync.md](context/sync.md) Step 5b, and +[context/scope-semantics.md](context/scope-semantics.md) for the mechanism that makes a cache +directory and its recorded sha disagree in the first place. + `sync` writes its run journal under this plugin's per-machine data directory. The path is substituted here because `${CLAUDE_PLUGIN_DATA}` resolves in skill content and **not** in a `context/*.md` spoke, which is read raw: @@ -168,6 +184,8 @@ Divergences: actionable ( newly created by this run — by the in-rep or listed here) Stale project records: record(s) across

path(s) not present on this machine (omit section entirely when K = 0; never counted in Divergences — see below for the row shape) +Cache content: install(s) whose cache files disagree with their recorded gitCommitSha + (omit the row entirely when N = 0; list the ids and the remediation — see below) Action needed: install(s) whose cache files disagree with their recorded gitCommitSha + - @ file(s) differ + Remediation: remove that version's directory under the plugin cache, then re-run + `claude plugin update @`, which recreates it from the clone. +``` + +**The check never repairs.** It does not delete a cache directory, does not re-run an update, and +does not `git fetch` a commit the marketplace clone lacks. A commit that is not local is reported as +`sha-not-local` and left alone: fetching is a network mutation this audit does not perform, and it +would also silently erase the condition the verdict exists to report. Every verdict other than +`match` and `stale-content` is counted as `unverifiable` — the audit looked and could not decide, +which is its own number and never folded into either side. + +**Expect a substantial `unverifiable` share, and never read it as a pass.** Claude Code clones a +marketplace shallow, so any install whose recorded commit predates that clone's window reports +`sha-not-local` through no fault of the fleet. On the machine this check was first run against, 11 +of 74 user-scope installs were unverifiable for exactly that reason. When the unverifiable count is +material, say so alongside the match count rather than leading with the match count alone. + ## userConfig: `install_new` Controls new-catalog-plugin install policy during `sync`. Ships as a plain `string` (the manifest diff --git a/plugins/claude-ops/skills/plugins/context/scope-semantics.md b/plugins/claude-ops/skills/plugins/context/scope-semantics.md index 2de8141bd7..0a1b9b659d 100644 --- a/plugins/claude-ops/skills/plugins/context/scope-semantics.md +++ b/plugins/claude-ops/skills/plugins/context/scope-semantics.md @@ -266,6 +266,41 @@ Code v2.1.193 or later." The `claude plugin prune` ≥ v2.1.121 gate is **not re the current docs describe `prune` without naming an introducing version, so the gate stands on its original source and nothing this pass found contradicts it. +## An unchanged version number keeps the old cache directory while `gitCommitSha` moves + +`claude plugin update -y @` re-points the install record's `gitCommitSha` in +`installed_plugins.json` without rewriting the plugin's cache directory when the manifest version +number is unchanged across the two commits. The cache is keyed by version, so an update that does +not move the version finds the directory already there and leaves the older build in it. The record +then names the new commit and the files on disk are the old one. + +**Consequence, and it is the reason the check exists.** The version-and-sha comparison every +delivery script relies on passes in exactly this state, so it is not proof that the files loaded are +the files delivered. Any measurement or behaviour test run against that cache directory is a test of +a different build than the one the record names, and nothing in the report says so. + +Observed on **Claude Code 2.1.259** (issue #3681 evidence, not re-run since). After a delivery, +six plugins reported the new sha while their cache directories still held files from an earlier +commit — twelve stale files in the worst case, including a reviewed dispatcher, three formatters, and +two `hooks.json` files. Removing those version directories and running the update again recreated +them correctly from the clone, which is both the confirmation and the remediation. **Recheck +trigger:** any minor-version bump touching plugin caching or the `plugin update` path — a date alone +is not a trigger. + +`cache-content-check.sh` is the standing detection: it byte-compares every file in a cache directory +against the recorded commit in the marketplace clone, which is the only check that separates this +state from a healthy one. It reports and never repairs; see `SKILL.md`'s "Cache content" section. + +**A marketplace clone is shallow, so most installs are unverifiable most of the time.** The clone +under `installLocation` carried a `.git/shallow` file and a three-commit history when this was +measured, so an install record naming any commit older than that window has no object to compare +against. Verified 2026-09-05 on **Claude Code 2.1.261**: 11 of 74 user-scope installs on the +authoring machine reported `sha-not-local` for exactly this reason, on a fleet with nothing wrong +with it. That is the steady state, not an edge case, and it caps how much any single run of the +check can establish. The check never fetches the missing commit: a fetch is a network mutation, and +it would repair the condition being reported. **Recheck trigger:** any change to how Claude Code +clones a marketplace, which would move the depth this number rests on. + ## `autoUpdate` is a background complement, not a substitute Official-Anthropic marketplaces default `autoUpdate: true`; third-party and local-dev marketplaces diff --git a/plugins/claude-ops/skills/plugins/context/sync.md b/plugins/claude-ops/skills/plugins/context/sync.md index 32f2e60a6c..1f0ae000ff 100644 --- a/plugins/claude-ops/skills/plugins/context/sync.md +++ b/plugins/claude-ops/skills/plugins/context/sync.md @@ -11,6 +11,7 @@ - [Step 2 — In-repo update (the primary value path)](#step-2--in-repo-update-the-primary-value-path) - [Step 3 — User-scope update sweep](#step-3--user-scope-update-sweep) - [Steps 4 and 5 — install and enable](#steps-4-and-5--install-and-enable) +- [Step 5b — Cache content check](#step-5b--cache-content-check) - [Step 6 — Report](#step-6--report) `sync` is the default action: bring the effective fleet current where you stand. Every step below @@ -545,6 +546,45 @@ cannot stand in for `pre-enable.$mp.json`. Do not collapse the two. When Step 1's refresh failed for this marketplace, both steps are deferred rather than run — the spoke carries what to say about that; see Step 1 above for why. +## Step 5b — Cache content check + +Read-only, runs after Step 5's enables and before the report, and is the same call in `sync` and in +`audit`. It is not gated on anything: an unchanged manifest version is exactly the case in which +every earlier step reports success, so a check that only ran when something else looked wrong would +never fire on the condition it exists to catch. + +```bash +"${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/cache-content-check.sh --marketplace "$mp" \ + >"$run_dir/cache-content.$mp.json" +``` + +In `sync` that redirect lands in the run journal beside the `fleet-state.sh` snapshots, so Step 6 +reads the finding rather than remembering it. In `audit` it lands in the throwaway scratch directory +that run deletes, the same way `audit` handles every other report it writes. + +Read the ids to act on with `--ids` rather than a hand-written `jq` extraction over the JSON, for +the reason [gotchas.md](gotchas.md) gives: on Windows a `jq -r … | while read` appends a `\r` to +every id but the last. + +```bash +while IFS=$'\t' read -r id; do + echo "cache content disagrees with recorded sha: $id" +done < <("${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/cache-content-check.sh --marketplace "$mp" --ids) +``` + +**Neither action repairs what this finds, and `sync` is no exception.** The remediation is a +directory removal under Claude Code's own plugin cache, which is outside the boundary the rest of +this skill keeps: `sync` mutates only through documented `claude plugin` CLI calls, and no CLI verb +rewrites a cache directory whose version number has not moved. So both actions report the ids and +the remediation and stop. Emit SKILL.md's `Cache content:` row, and omit it entirely when the check +found nothing. + +The check reads the marketplace clone at the recorded commit. It never fetches one it does not have +— that would be a network mutation, and it would repair the very condition being reported — so an +install whose sha is not in the clone is reported as `sha-not-local` and counted as unverifiable, +not as a pass. A report in which most installs are unverifiable has established very little; say so +rather than leading with the match count. + ## Step 6 — Report Emit the report per SKILL.md's "Report" section, filling each updated plugin's `` from diff --git a/plugins/claude-ops/skills/plugins/scripts/cache-content-check.sh b/plugins/claude-ops/skills/plugins/scripts/cache-content-check.sh new file mode 100755 index 0000000000..b197606115 --- /dev/null +++ b/plugins/claude-ops/skills/plugins/scripts/cache-content-check.sh @@ -0,0 +1,797 @@ +#!/usr/bin/env bash +# Read-only cache-CONTENT audit for the `plugins` skill. +# +# `fleet-state.sh` answers "does the recorded version/sha look right". This +# script answers the question that check cannot: "do the FILES in the cache +# directory actually match the commit the install record claims". The two are +# not the same, and the gap between them is a real observed failure — see the +# mechanism note below. NEVER writes anything: no state file, no cache +# directory, no `git fetch`, no `claude plugin` call. +# +# Usage: +# cache-content-check.sh --marketplace [--scope user|project|all] [--json] +# cache-content-check.sh --all [--scope user|project|all] [--json] +# cache-content-check.sh --marketplace [--scope …] --ids +# +# The mechanism this exists to catch (observed on Claude Code 2.1.259, not +# re-run since). `claude plugin update -y @` +# re-points a record's `gitCommitSha` in `installed_plugins.json` without +# rewriting the cache directory when the manifest version number is unchanged. +# The version directory keeps the older build while the metadata claims the new +# commit, so a version-and-sha check passes while the files on disk are a +# different build, and any measurement or behaviour test against that cache is +# a test of the wrong thing. Six plugins were found in that state on one +# machine, twelve stale files in the worst case. +# +# How the compare works, and why it is a fixed number of git processes per +# install rather than one per file. For each install record this script resolves +# the marketplace's `installLocation` (a git clone) and the plugin's `source` +# directory, then: +# 1. `git ls-tree -r -z -- ` — every blob id the commit says the +# plugin's files should have; +# 2. one `git hash-object --stdin-paths` over every regular file in the cache +# directory — the blob id each file on disk actually has. +# Set difference on the relative paths yields files present at the sha but +# missing from the cache and files in the cache but absent at the sha; hash +# inequality on the intersection yields changed files. No `git cat-file` pass +# is needed once ls-tree has carried the ids. +# +# The source directory is read from the marketplace.json AT THE RECORDED SHA +# (`git show :.claude-plugin/marketplace.json`), never from the clone's +# current checkout, and there is deliberately no fallback to the checked-out +# copy. A plugin whose source directory was renamed or moved after the recorded +# commit would otherwise be looked up under its CURRENT path against an OLDER +# tree, and `git ls-tree` treats a pathspec that matches nothing as success with +# empty output rather than an error — so every cache file becomes an extra and a +# perfectly matching cache reports `stale-content`. Reading both the source path +# and the expected tree from the same revision removes the mismatch; a pathspec +# that still matches nothing at that sha gets its own `no-source-at-sha` +# verdict, counted unverifiable, never reported as stale content. The per-sha +# manifest read is cached, so it costs one `git show` plus one `jq` per DISTINCT +# sha in the marketplace, not one per install record. +# +# Path transport is NUL-separated end to end (`ls-tree -z`, `find -print0`, +# `check-ignore -z`). Without `-z`, git QUOTES a pathname carrying non-ASCII, +# a tab, a newline, or a backslash — an accented filename comes back wrapped in +# double quotes with its bytes octal-escaped — and a line-oriented +# parser then compares that quoted spelling against the raw path `find` reports, +# so an unchanged file is reported as both missing-from-cache and extra-in-cache. +# The one transport that cannot carry NUL is `git hash-object --stdin-paths`, +# which has no `-z` switch at all: newline is therefore the only character that +# still breaks the batch, so a cache path containing one is hashed by its own +# `git hash-object` process instead of being fed to the batch. Every other +# character rides a raw line intact. +# +# Symlinks are compared MODE-AWARE rather than skipped. A tracked symlink is an +# ordinary blob in the tree whose content is the link target text, but +# `find -type f` excludes it and `hash-object` on the path would hash the file +# it points AT, so either omission reports every tracked symlink as permanently +# missing-from-cache. The cache walk therefore enumerates `-type f -o -type l`, +# and a link is hashed by feeding its `readlink` output to `git hash-object +# --stdin` — which is what git itself stores. On a Windows checkout with +# `core.symlinks=false` the link is materialized as a regular file holding that +# same target text, so the ordinary raw batch already produces the matching +# hash and no special case is reached. +# +# Line endings. A raw `hash-object` applies no clean filter, so a path that +# `.gitattributes` checks out CRLF (a marketplace pinning `*.cmd text eol=crlf` +# is the observed case) would hash differently from its blob even when the +# content is identical. Rather than pay a per-file `--path` hash for the whole +# fleet, the raw batch runs first and only the MISMATCHES are re-hashed with +# `git hash-object --path `, which does apply that path's +# attributes. A clean fleet therefore costs the two processes above; a dirty +# one pays one extra process per differing file, which is the population the +# operator is about to read one by one anyway. +# +# Excluded from the cache side, by name, never by guesswork: `.git` (a cache +# directory is a checkout, not a clone, but exclude it if one ever appears) and +# `.in_use` (Claude Code's own per-process refcount files, present in every +# cache version directory and never in any commit — folding them in would +# report the entire fleet as stale on their existence alone). +# +# Also excluded, and this one is load-bearing rather than cosmetic: a cache-only +# file that the marketplace repo's own `.gitignore` would ignore. A cache +# directory is a LIVE plugin root, so Python leaves `__pycache__` beside the +# scripts it runs and a plugin that vendors dependencies has a `node_modules` +# tree — generated state that was never in any commit. Without this filter the +# authoring machine reported three installs as stale purely on that state, one +# of them on 6,141 files, with zero genuinely differing bytes. The filter is one +# batched `git check-ignore` per plugin that has any cache-only file at all, and +# no process for a clean one. +# +# Verdicts, one per install record: +# match every tree file present in the cache with the same +# blob id, and no extra file in the cache +# stale-content at least one file differs, is missing from the cache, +# or is in the cache and not at the sha. This is the +# finding; the record's paths[] lists up to +# MAX_REPORTED_PATHS of them +# sha-not-local the recorded `gitCommitSha` is not an object in the +# installLocation clone. NOT an error and NEVER fetched: +# a fetch is a network mutation this audit does not +# perform. This is the ORDINARY case, not an edge one — +# Claude Code clones a marketplace SHALLOW (a depth of 3 +# observed on 2.1.261), so every record whose commit +# predates that window is legitimately unverifiable, and +# on the authoring machine 11 of 74 user-scope installs +# landed here. A report is only as strong as the share +# of installs it could actually compare +# no-git-commit-sha the record carries no `gitCommitSha` to compare against +# no-install-location the marketplace has no `installLocation`, or it is not +# a directory on this machine +# not-a-git-worktree the installLocation exists but is not a git work tree +# no-source the plugin id is absent from the marketplace.json AT +# THE RECORDED SHA, or its `source` is not a plain path +# string (a remote-source entry has no local tree here), +# or that commit carries no marketplace.json at all +# no-source-at-sha the plugin's recorded source directory names no path +# at that sha — `git ls-tree` matched nothing, which it +# reports as success with empty output. Distinct from +# stale-content on purpose: an empty expected tree makes +# every cache file an extra, and calling that stale +# would accuse a healthy cache of a defect it does not +# have +# install-path-missing the record's `installPath` is not a directory +# hash-batch-misaligned `git hash-object --stdin-paths` returned fewer hashes +# than it was given paths, so the two sides can no +# longer be lined up. Contract-breaking and not expected; +# reported rather than compared, because a shifted table +# reports healthy files as differing +# +# Output (stdout), default and with --json: one JSON object. +# {marketplace, scope, checked, match, stale_content, unverifiable, +# skipped_absent_project_paths, installs:[…]} +# `unverifiable` counts every install whose verdict is neither `match` nor +# `stale-content` — the audit looked and could not decide, which is reported +# as its own number rather than folded into either side. +# `skipped_absent_project_paths` counts project/local records whose +# `projectPath` is not a directory on this machine. Those records are not +# checked and not counted in `checked`. Absent is not dead: an unmounted +# volume, an offline share, and a removed worktree are indistinguishable to a +# directory test (see context/gotchas.md) — hence a count, never a verdict. +# --all: {marketplaces: {"": , …}}, and a +# per-marketplace failure appears as {"": {error: …}} inline rather +# than aborting the sweep. +# +# Output (stdout) with --ids: NOT JSON — the fully-qualified +# `@` id of every `stale-content` install, one per line, +# CR-free, and nothing else. Same contract as `fleet-state.sh --ids`: zero +# matches is success with empty output (exit 0), and a rejected invocation +# leaves stdout EMPTY so a `< <(…)` consumer can never read an error as an +# id. `--ids` with `--all` is refused: there is no single block to project. +# +# Exit codes: +# 0 ran to completion (per-install verdicts, including every unverifiable +# one, are reported in the body — a verdict is not an error) +# 2 fatal: jq or git missing, a usage error, a named marketplace that is not +# in known_marketplaces.json, or an internal state file that is present +# but does not match its expected shape (fail loud on schema drift) +# +# Deliberate divergence from fleet-state.sh's vocabulary, stated so it does +# not read as drift: fleet-state.sh exits 1 when a single marketplace cannot +# be resolved. Here an unknown marketplace is exit 2, because the name came +# from the caller's own argument and is a usage error, not a state-read +# failure. +# +# Env overrides (testing only; production uses the real paths). Same names as +# fleet-state.sh, so a fixture built for one runs against the other: +# CACHE_CONTENT_INSTALLED_JSON / FLEET_STATE_INSTALLED_JSON +# — path to installed_plugins.json +# CACHE_CONTENT_MARKETPLACES_JSON / FLEET_STATE_MARKETPLACES_JSON +# — path to known_marketplaces.json +# There is deliberately no catalog-directory override: this check needs a real +# git clone at the marketplace's installLocation, so a fixture points +# known_marketplaces.json at a throwaway repo instead of faking the catalog. + +set -uo pipefail + +# Resolve nothing through PATH-dependent helpers before the tool check; see +# fleet-state.sh's header for the environment-trust boundary this shares. +if ! command -v jq >/dev/null 2>&1; then + echo "ERROR: jq required (install with: winget install jqlang.jq | apt install jq | brew install jq)" >&2 + exit 2 +fi +if ! command -v git >/dev/null 2>&1; then + echo "ERROR: git required" >&2 + exit 2 +fi + +# --- jq capture --------------------------------------------------------------- +# Some native-Windows jq builds CRLF-terminate every line, including single-line +# compact output. `$(...)` strips only the trailing LF, so a stray CR survives +# and corrupts the value once re-parsed as JSON — and every id but the last in a +# line-oriented output arrives as `@\r`. Every jq call goes +# through this helper, which strips ALL carriage returns in the shell (no `tr` +# process). Identical to fleet-state.sh's; see context/gotchas.md. +jq_to() { + local __jq_var="$1" + shift + local __jq_out __jq_rc=0 + __jq_out=$(command jq "$@") || __jq_rc=$? + printf -v "$__jq_var" '%s' "${__jq_out//$'\r'/}" + return "$__jq_rc" +} + +# git output gets the same treatment, for the same reason: a Windows git build +# writing CRLF would put a CR inside a path or a blob id. +git_to() { + local __g_var="$1" + shift + local __g_out __g_rc=0 + __g_out=$(command git "$@") || __g_rc=$? + printf -v "$__g_var" '%s' "${__g_out//$'\r'/}" + return "$__g_rc" +} + +# JSON string literal built with builtins, so the strings this shell assembles +# (paths, ids, verdicts) get the same escaping jq's own encoder applies. +json_string_to() { + local __js_s="$2" __js_i __js_c __js_hex __js_out="" + __js_s="${__js_s//\\/\\\\}" + __js_s="${__js_s//\"/\\\"}" + __js_s="${__js_s//$'\n'/\\n}" + __js_s="${__js_s//$'\r'/\\r}" + __js_s="${__js_s//$'\t'/\\t}" + __js_s="${__js_s//$'\b'/\\b}" # portability-ok: JSON short escape for U+0008 in a parameter expansion, not a regex word boundary + __js_s="${__js_s//$'\f'/\\f}" + if [[ "$__js_s" == *[$'\x01'-$'\x1f']* ]]; then + for ((__js_i = 0; __js_i < ${#__js_s}; __js_i++)); do + __js_c="${__js_s:__js_i:1}" + if [[ "$__js_c" == [$'\x01'-$'\x1f'] ]]; then + printf -v __js_hex '\\u%04x' "'$__js_c" + __js_out+="$__js_hex" + else + __js_out+="$__js_c" + fi + done + __js_s="$__js_out" + fi + printf -v "$1" '"%s"' "$__js_s" +} + +# Native Windows paths (`C:\Users\…`) come out of installed_plugins.json and +# known_marketplaces.json verbatim. Backslash is an escape character in the +# shell, so every such value is folded to forward slashes before any `-d` test +# or `git -C`; Git Bash accepts the `C:/…` spelling for both. +to_slashes_to() { + printf -v "$1" '%s' "${2//\\//}" +} + +INSTALLED_JSON="${CACHE_CONTENT_INSTALLED_JSON:-${FLEET_STATE_INSTALLED_JSON:-$HOME/.claude/plugins/installed_plugins.json}}" +MARKETPLACES_JSON="${CACHE_CONTENT_MARKETPLACES_JSON:-${FLEET_STATE_MARKETPLACES_JSON:-$HOME/.claude/plugins/known_marketplaces.json}}" + +# How many differing paths a stale-content record lists. The count is always +# exact; the list is a sample, because a wholly-stale directory can differ in +# every file and a report naming all of them is not a report. +MAX_REPORTED_PATHS=10 + +# --- Arg parsing --------------------------------------------------------------- +# Parsed before any file is read, so a usage error costs no process. + +MODE="default" +TARGET="" +SCOPE="user" +IDS_MODE="" +while [[ $# -gt 0 ]]; do + case "$1" in + --marketplace) + MODE="single" + TARGET="${2:-}" + # Guard BEFORE `shift 2`: with no following arg only one positional param + # remains, `shift 2` fails (no set -e), $1 stays "--marketplace", and the + # loop spins forever. Same reasoning as fleet-state.sh. + if [[ -z "$TARGET" ]]; then + echo "ERROR: --marketplace requires a name" >&2 + exit 2 + fi + shift 2 + ;; + --scope) + SCOPE="${2:-}" + if [[ -z "$SCOPE" ]]; then + echo "ERROR: --scope requires user, project, or all" >&2 + exit 2 + fi + shift 2 + ;; + --all) + MODE="all" + shift + ;; + --ids) + IDS_MODE="yes" + shift + ;; + --json) + shift + ;; + *) + echo "ERROR: unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +case "$SCOPE" in +user | project | all) ;; +*) + echo "ERROR: unknown --scope: $SCOPE (expected user, project, or all)" >&2 + exit 2 + ;; +esac + +if [[ "$MODE" == "default" ]]; then + echo "ERROR: one of --marketplace or --all is required" >&2 + exit 2 +fi + +# --ids projects ONE marketplace's stale-content list; --all's envelope has no +# single block to project. Refused rather than invented. +if [[ -n "$IDS_MODE" && "$MODE" == "all" ]]; then + echo "ERROR: --ids cannot be combined with --all" >&2 + echo " Run --ids once per marketplace with --marketplace ." >&2 + exit 2 +fi + +# --- State files --------------------------------------------------------------- + +for f in "$INSTALLED_JSON" "$MARKETPLACES_JSON"; do + if [[ ! -f "$f" ]]; then + echo "ERROR: state file not found: $f" >&2 + exit 2 + fi +done + +# Shape validation is its own pass and it fails LOUD. A malformed state file +# read leniently produces an empty install list, which is indistinguishable +# from a clean fleet — the one wrong answer this check must never give. +if ! jq_to _shape_ok -e ' + (type == "object") + and ((.plugins // {}) | type == "object") + and ((.plugins // {}) | to_entries | all(.value | type == "array")) +' "$INSTALLED_JSON" >/dev/null 2>&1; then + echo "ERROR: $INSTALLED_JSON is not valid installed_plugins.json (expected an object with a .plugins map of arrays)" >&2 + exit 2 +fi +if ! jq_to _shape_ok -e 'type == "object"' "$MARKETPLACES_JSON" >/dev/null 2>&1; then + echo "ERROR: $MARKETPLACES_JSON is not valid known_marketplaces.json (expected an object)" >&2 + exit 2 +fi + +# --- Per-marketplace check ----------------------------------------------------- + +# Emits, on stdout, the single-marketplace JSON body for $1. Returns non-zero +# with a message on stderr only for a marketplace that cannot be named at all. +check_marketplace() { + local mp="$1" + local install_loc install_loc_native records + local checked=0 n_match=0 n_stale=0 n_unverifiable=0 n_skipped=0 + local records_json="" first="yes" + local stale_ids="" + + # shellcheck disable=SC2016 # a jq program: every $var is a jq variable + jq_to install_loc -r --arg mp "$mp" '.[$mp].installLocation // ""' "$MARKETPLACES_JSON" || return 1 + to_slashes_to install_loc_native "$install_loc" + + # Marketplace-wide precondition, resolved ONCE rather than per install: the + # worktree test is the same answer for every record. The source map is NOT + # marketplace-wide — it is read per recorded sha, below. + local loc_verdict="" + if [[ -z "$install_loc_native" || ! -d "$install_loc_native" ]]; then + loc_verdict="no-install-location" + elif ! git -C "$install_loc_native" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + loc_verdict="not-a-git-worktree" + fi + + # namesource for every plugin entry in the marketplace.json at one sha, + # cached so a marketplace whose records share a sha pays for it once. Only + # entries whose source is a plain path string are kept: an object source names + # a remote checkout with no local tree here, and is left out so the lookup + # miss becomes an honest `no-source` verdict. + local -A source_map_by_sha=() source_map_loaded=() + load_source_map() { + local s="$1" raw="" parsed="" + [[ -z "${source_map_loaded[$s]+set}" ]] || return 0 + source_map_loaded["$s"]=1 + source_map_by_sha["$s"]="" + git_to raw -C "$install_loc_native" show "$s:.claude-plugin/marketplace.json" 2>/dev/null || return 0 + jq_to parsed -r ' + (.plugins // []) + | map(select((.name | type == "string") and (.source | type == "string"))) + | map([.name, .source] | join("\u001f")) # delimiter is US (0x1f), as in fleet-state.sh + | .[] + ' <<<"$raw" 2>/dev/null || parsed="" + source_map_by_sha["$s"]="$parsed" + } + + # shellcheck disable=SC2016 # a jq program: every $var is a jq variable + jq_to records -r --arg mp "$mp" --arg scope "$SCOPE" ' + (.plugins // {}) + | to_entries + | map(select(.key | endswith("@" + $mp))) + | map(.key as $id | .value[] | { + id: $id, + scope: (.scope // "user"), + version: (.version // ""), + sha: (.gitCommitSha // ""), + installPath: (.installPath // ""), + projectPath: (.projectPath // "") + }) + | map(select($scope == "all" or (if $scope == "user" then .scope == "user" else .scope != "user" end))) + | map([.id, .scope, .version, .sha, .installPath, .projectPath] | join("\u001f")) + | .[] + ' "$INSTALLED_JSON" || return 1 + + # US (0x1f), not tab, and the reason is not cosmetic. Bash treats tab as IFS + # WHITESPACE, so consecutive tabs collapse into one separator and an EMPTY + # middle field disappears: a record with no `gitCommitSha` would shift its + # installPath left into `sha`, producing an `install-path-missing` verdict + # carrying a fabricated sha instead of the honest `no-git-commit-sha`. A + # non-whitespace separator preserves every empty column. Same choice, and the + # same character, as fleet-state.sh's internal record format. + local id scope version sha install_path project_path + while IFS=$'\x1f' read -r id scope version sha install_path project_path; do + [[ -n "$id" ]] || continue + + # A project/local record naming a directory that is not present is skipped + # and counted, never verdicted: the cache directory may be perfectly fine + # and the repo simply on an unmounted volume. + if [[ "$scope" != "user" && -n "$project_path" ]]; then + local project_path_native + to_slashes_to project_path_native "$project_path" + if [[ ! -d "$project_path_native" ]]; then + n_skipped=$((n_skipped + 1)) + continue + fi + fi + + checked=$((checked + 1)) + local verdict="" paths=() n_differ=0 n_missing=0 n_extra=0 + local install_path_native + to_slashes_to install_path_native "$install_path" + + local plugin_name="${id%@*}" + local source_dir="" + + if [[ -n "$loc_verdict" ]]; then + verdict="$loc_verdict" + elif [[ -z "$install_path_native" || ! -d "$install_path_native" ]]; then + verdict="install-path-missing" + elif [[ -z "$sha" ]]; then + verdict="no-git-commit-sha" + elif ! git -C "$install_loc_native" cat-file -e "${sha}^{commit}" >/dev/null 2>&1; then + # Never `git fetch` here. Fetching is a network mutation the audit does + # not perform, and it would also silently repair the very condition the + # verdict exists to report. + # + # This test comes BEFORE the source lookup, and must: the source path is + # now read out of the commit itself, so there is nothing to read until the + # commit is known to be present locally. + verdict="sha-not-local" + else + # Source directory as the RECORDED COMMIT spelled it, never as the current + # checkout spells it. A plugin renamed or moved after that commit would + # otherwise be looked up under a path the older tree does not contain, and + # a pathspec matching nothing is success-with-empty-output, not an error. + load_source_map "$sha" + local sm_name sm_source + while IFS=$'\x1f' read -r sm_name sm_source; do + if [[ "$sm_name" == "$plugin_name" ]]; then + source_dir="$sm_source" + break + fi + done <<<"${source_map_by_sha[$sha]}" + # marketplace.json spells a local source relative to the clone root and + # commonly with a leading `./`, which ls-tree's pathspec does not want. + source_dir="${source_dir#./}" + source_dir="${source_dir%/}" + fi + + if [[ -z "$verdict" && -z "$source_dir" ]]; then + verdict="no-source" + fi + + if [[ -z "$verdict" ]]; then + # `ls-tree -z` never goes through `git_to`: command substitution DROPS NUL + # bytes, which is the whole transport here. Read as NUL records instead, + # with the exit status appended as one final record so a process + # substitution's otherwise-invisible status is still checked. + local -a tree_recs=() + local tree_rc=0 + mapfile -d '' -t tree_recs < <( + git -C "$install_loc_native" ls-tree -r -z "$sha" -- "$source_dir" 2>/dev/null + printf '%d\0' "$?" + ) + tree_rc="${tree_recs[-1]}" + unset 'tree_recs[-1]' + if [[ "$tree_rc" != "0" ]]; then + verdict="no-source" + elif [[ ${#tree_recs[@]} -eq 0 ]]; then + # The pathspec matched nothing at this sha. Its own verdict, counted + # unverifiable: an empty expected tree makes every cache file an extra, + # and reporting that as stale-content accuses a healthy cache. + verdict="no-source-at-sha" + else + # The two sides are indexed into associative arrays rather than walked + # as parallel lists: a plugin that vendors `node_modules` carries + # thousands of cache files, and a nested-loop membership test over both + # directions is quadratic in exactly the population where it hurts. + local -a tree_paths=() tree_hashes=() + local -A tree_hash_by_path=() cache_hash_by_path=() + local rec meta path_rel blob + for rec in "${tree_recs[@]}"; do + [[ -n "$rec" ]] || continue + meta="${rec%%$'\t'*}" + path_rel="${rec#*$'\t'}" + blob="${meta##* }" + # Keep only blobs; a submodule (commit) entry has no file on disk to + # compare and is not a stale-content signal. Mode 120000 is a blob + # too — a symlink — and is kept, because the cache walk below hashes + # links the same way git stores them. + case "$meta" in + *" blob "*) ;; + *) continue ;; + esac + path_rel="${path_rel#"$source_dir"/}" + tree_paths+=("$path_rel") + tree_hashes+=("$blob") + tree_hash_by_path["$path_rel"]="$blob" + done + + # Cache side: one find, then one batched hash-object. `.in_use` is + # Claude Code's own refcount directory and `.git` would be a checkout + # artifact; both are excluded by name. `-print0` because a pathname may + # carry any byte but NUL, and the enumeration must not be the place a + # name gets mangled. Symlinks are enumerated alongside regular files; + # `-type f` alone would report every tracked link as missing forever. + # `sort -z` keeps the reported paths[] sample stable across runs on the + # same fixture, which a report read by a human is entitled to. + local -a cache_entries=() cache_paths=() batch_paths=() + mapfile -d '' -t cache_entries < <( + find "$install_path_native" \( -type f -o -type l \) \ + -not -path '*/.git/*' -not -path '*/.in_use/*' -print0 2>/dev/null | + sort -z + ) + + local cache_list="" f rel link_target="" one_hash="" + for f in ${cache_entries+"${cache_entries[@]}"}; do + [[ -n "$f" ]] || continue + rel="${f#"$install_path_native"/}" + cache_paths+=("$rel") + if [[ -L "$f" ]]; then + # git stores a symlink as a blob holding the TARGET TEXT, so that is + # what gets hashed. Feeding the link path to `hash-object` would + # hash the file it points at and call every link stale. + link_target=$(readlink "$f" 2>/dev/null) + if git_to one_hash -C "$install_loc_native" hash-object --stdin < <(printf '%s' "$link_target"); then + cache_hash_by_path["$rel"]="$one_hash" + fi + continue + fi + if [[ "$f" == *$'\n'* ]]; then + # `git hash-object --stdin-paths` has no `-z` switch, so its input + # is newline-terminated and a name containing a newline cannot ride + # it. That one character, and only that one, falls back to a process + # of its own; tabs, backslashes and non-ASCII all survive the batch. + if git_to one_hash -C "$install_loc_native" hash-object -- "$f"; then + cache_hash_by_path["$rel"]="$one_hash" + fi + continue + fi + batch_paths+=("$rel") + cache_list+="$f"$'\n' + done + + # The batch's output is positional: line N is the hash of input path N. + # If it ever comes back short, every entry after the gap is filed under + # the WRONG path and the run reports files as differing that are fine. + # `hash-object` is one line per input by contract, so this is a guard + # against a broken contract rather than an expected branch — and the + # answer to a broken contract is to say so, not to compare a table that + # may be shifted. + local hash_misaligned="" line + if [[ -n "$cache_list" ]]; then + local hash_out="" hi=0 + if git_to hash_out -C "$install_loc_native" hash-object --stdin-paths <<<"${cache_list%$'\n'}"; then + while IFS= read -r line; do + [[ -n "$line" ]] || continue + [[ $hi -lt ${#batch_paths[@]} ]] || break + cache_hash_by_path["${batch_paths[hi]}"]="$line" + hi=$((hi + 1)) + done <<<"$hash_out" + fi + [[ $hi -eq ${#batch_paths[@]} ]] || hash_misaligned="yes" + fi + + local i + if [[ -n "$hash_misaligned" ]]; then + verdict="hash-batch-misaligned" + tree_paths=() + fi + # Direction 1: every tree path must exist in the cache with the same + # blob id. + for ((i = 0; i < ${#tree_paths[@]}; i++)); do + if [[ -z "${cache_hash_by_path[${tree_paths[i]}]+set}" ]]; then + n_missing=$((n_missing + 1)) + paths+=("missing-from-cache: ${tree_paths[i]}") + continue + fi + if [[ "${cache_hash_by_path[${tree_paths[i]}]}" == "${tree_hashes[i]}" ]]; then + continue + fi + # Raw hashes disagree. Re-hash THIS file with the repo-relative path + # so `.gitattributes` (an `eol=crlf` pin, a clean filter) applies, and + # only report a difference the filtered hash also sees. This is the + # per-file process the batch above exists to avoid paying fleet-wide. + local filtered="" + if git_to filtered -C "$install_loc_native" hash-object \ + --path "$source_dir/${tree_paths[i]}" -- "$install_path_native/${tree_paths[i]}" 2>/dev/null && + [[ "$filtered" == "${tree_hashes[i]}" ]]; then + continue + fi + n_differ=$((n_differ + 1)) + paths+=("differs: ${tree_paths[i]}") + done + + # Direction 2: a file deleted at the sha but still sitting in the cache + # is the same defect seen from the other side, and is exactly one of + # the shapes the reported incident carried. + # + # Filtered through the marketplace's OWN .gitignore first, and that + # filter is not an optimization — without it the check is wrong. A + # cache directory is a live plugin root: Python writes `__pycache__` + # beside the scripts it runs and a plugin that vendors dependencies has + # a `node_modules` tree, none of which was ever in any commit. On the + # authoring machine those alone reported three installs as stale, one + # of them on 6,141 files, with zero genuinely differing bytes. A path + # the marketplace repo itself declines to track is generated state, not + # a stale build. One batched `git check-ignore` per plugin that has any + # extra at all, and none for a clean one. `--no-index` so the answer is + # the ignore rules' answer rather than "tracked, therefore not + # ignored"; the paths are rewritten under the plugin's source directory + # so a source-scoped rule matches the same way it would in the clone. + local -a extras=() + local cp + for cp in "${cache_paths[@]:-}"; do + [[ -z "$hash_misaligned" ]] || break + [[ -n "$cp" ]] || continue + [[ -z "${tree_hash_by_path[$cp]+set}" ]] || continue + extras+=("$cp") + done + if [[ ${#extras[@]} -gt 0 ]]; then + local -a ignored_recs=() + local -A ignored=() + # `-z` on both sides, and read as NUL records rather than through + # command substitution, for the same reason ls-tree is: a quoted + # pathname would not match the raw path it came from. + # check-ignore exits 1 when NOTHING matched, which is a valid answer, + # so its status is deliberately not read as a failure. + mapfile -d '' -t ignored_recs < <( + printf '%s\0' "${extras[@]/#/$source_dir/}" | + git -C "$install_loc_native" check-ignore -z --no-index --stdin 2>/dev/null + ) + for line in ${ignored_recs+"${ignored_recs[@]}"}; do + [[ -n "$line" ]] || continue + ignored["${line#"$source_dir"/}"]=1 + done + for cp in "${extras[@]}"; do + [[ -z "${ignored[$cp]+set}" ]] || continue + n_extra=$((n_extra + 1)) + paths+=("extra-in-cache: $cp") + done + fi + + if [[ -n "$hash_misaligned" ]]; then + : # verdict already set to hash-batch-misaligned; counts stay at zero + elif [[ $((n_differ + n_missing + n_extra)) -gt 0 ]]; then + verdict="stale-content" + else + verdict="match" + fi + fi + fi + + case "$verdict" in + match) n_match=$((n_match + 1)) ;; + stale-content) + n_stale=$((n_stale + 1)) + stale_ids+="$id"$'\n' + ;; + *) n_unverifiable=$((n_unverifiable + 1)) ;; + esac + + local j_id j_scope j_version j_sha j_path j_verdict + json_string_to j_id "$id" + json_string_to j_scope "$scope" + json_string_to j_version "$version" + json_string_to j_sha "$sha" + json_string_to j_path "$install_path" + json_string_to j_verdict "$verdict" + + local paths_json="" p n=0 pfirst="yes" jp + for p in "${paths[@]:-}"; do + [[ -n "$p" ]] || continue + n=$((n + 1)) + [[ $n -le $MAX_REPORTED_PATHS ]] || break + json_string_to jp "$p" + if [[ "$pfirst" == "yes" ]]; then + pfirst="no" + else + paths_json+="," + fi + paths_json+="$jp" + done + + if [[ "$first" == "yes" ]]; then + first="no" + else + records_json+="," + fi + records_json+="{\"id\":$j_id,\"scope\":$j_scope,\"version\":$j_version,\"gitCommitSha\":$j_sha" + records_json+=",\"installPath\":$j_path,\"verdict\":$j_verdict" + records_json+=",\"differing\":$n_differ,\"missing_from_cache\":$n_missing,\"extra_in_cache\":$n_extra" + records_json+=",\"paths\":[$paths_json]}" + done <<<"$records" + + if [[ -n "$IDS_MODE" ]]; then + printf '%s' "$stale_ids" + return 0 + fi + + local j_mp j_scope_out + json_string_to j_mp "$mp" + json_string_to j_scope_out "$SCOPE" + printf '{"marketplace":%s,"scope":%s,"checked":%d,"match":%d,"stale_content":%d,"unverifiable":%d,"skipped_absent_project_paths":%d,"installs":[%s]}' \ + "$j_mp" "$j_scope_out" "$checked" "$n_match" "$n_stale" "$n_unverifiable" "$n_skipped" "$records_json" +} + +# --- Dispatch ------------------------------------------------------------------ + +# `known` is a write-only sink: the membership answer this run acts on is +# jq's EXIT STATUS under -e, not the `true`/`false` it also prints. Declared +# so `jq_to`'s indirect `printf -v` has a target the linter can see. +# shellcheck disable=SC2034 +known="" +mp_names="" +envelope="" +j_name="" +j_err="" + +if [[ "$MODE" == "single" ]]; then + # shellcheck disable=SC2016 # a jq program: every $var is a jq variable + jq_to known -e --arg mp "$TARGET" 'has($mp)' "$MARKETPLACES_JSON" >/dev/null 2>&1 || { + echo "ERROR: unknown marketplace: $TARGET (not in $MARKETPLACES_JSON)" >&2 + exit 2 + } + out="" + out=$(check_marketplace "$TARGET") || { + echo "ERROR: could not read marketplace: $TARGET" >&2 + exit 2 + } + printf '%s' "$out" + [[ -n "$IDS_MODE" ]] || printf '\n' + exit 0 +fi + +jq_to mp_names -r 'keys[]' "$MARKETPLACES_JSON" || { + echo "ERROR: could not enumerate marketplaces in $MARKETPLACES_JSON" >&2 + exit 2 +} +envelope="" env_first="yes" +while IFS= read -r mp; do + [[ -n "$mp" ]] || continue + block="" + if ! block=$(check_marketplace "$mp"); then + json_string_to j_err "could not read marketplace" + block="{\"error\":$j_err}" + fi + json_string_to j_name "$mp" + if [[ "$env_first" == "yes" ]]; then + env_first="no" + else + envelope+="," + fi + envelope+="$j_name:$block" +done <<<"$mp_names" +printf '{"marketplaces":{%s}}\n' "$envelope" +exit 0 diff --git a/plugins/claude-ops/skills/plugins/scripts/cache-content-check.test.sh b/plugins/claude-ops/skills/plugins/scripts/cache-content-check.test.sh new file mode 100755 index 0000000000..8b1024a230 --- /dev/null +++ b/plugins/claude-ops/skills/plugins/scripts/cache-content-check.test.sh @@ -0,0 +1,806 @@ +#!/usr/bin/env bash +# Black-box contract tests for cache-content-check.sh (self-contained — ships +# with the plugin). Fixtures are built per-case into a temp dir: a throwaway git +# repo standing in for a marketplace's installLocation at two commits, a fake +# cache directory, and CACHE_CONTENT_* state files pointing at both. Mirrors +# fleet-state.test.sh's structure. +set -uo pipefail + +# Fixture git isolation: an inherited GIT_DIR/GIT_WORK_TREE/GIT_CONFIG would +# redirect `git init` / `git config` into the caller's repository. +unset GIT_DIR GIT_WORK_TREE GIT_CONFIG + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SCRIPT_DIR/cache-content-check.sh" +# Never /tmp: on Windows that is a drive-root path that accumulates silently. +# $TEMP there is a NATIVE Windows path spelled with drive letter and backslash +# separators, and those backslashes would be +# embedded raw into the fixture JSON, where a backslash is an escape character — +# the state files would be malformed and every case would read as the script +# correctly rejecting schema drift. Folded to forward slashes, which Git Bash +# accepts everywhere, before anything is built under it. +TMP_BASE="${TMPDIR:-${TEMP:-/var/tmp}}" +TMP_BASE="${TMP_BASE//\\//}" +TEST_TMPDIR="$(mktemp -d "$TMP_BASE/cache-content-check.XXXXXX")" +TEST_TMPDIR="${TEST_TMPDIR//\\//}" +trap 'rm -rf "$TEST_TMPDIR"' EXIT + +FAILED=0 +CASE_NUM=0 + +pass() { + printf 'PASS: %s\n' "$1" +} +fail() { + FAILED=$((FAILED + 1)) + printf 'FAIL: %s\n detail: %s\n' "$1" "$2" >&2 +} +assert_eq() { + if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "expected: $2, actual: $3"; fi +} +assert_exit() { + if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "expected exit $2, got $3"; fi +} +assert_contains() { + case "$2" in + *"$3"*) pass "$1" ;; + *) fail "$1" "expected to contain: $3 — got: $2" ;; + esac +} + +# Discriminating skips: each names the single tool whose absence it excuses, so +# a green run on a host missing it is not read as a green run of these cases. +if ! command -v jq >/dev/null 2>&1; then + echo "SKIP: jq not installed — every case here parses the script's JSON body" >&2 + exit 0 +fi +if ! command -v git >/dev/null 2>&1; then + echo "SKIP: git not installed — the script's whole compare is git plumbing" >&2 + exit 0 +fi + +# --- Fixture builders -------------------------------------------------------- + +# CASE_NUM must be incremented in the CALLER's shell before calling this, never +# inside it: `case_dir=$(new_case_dir)` runs in a subshell, so an assignment +# made in here is discarded and every case reuses case-1's directory. +new_case_dir() { + local case_dir="$TEST_TMPDIR/case-$CASE_NUM" + mkdir -p "$case_dir" + echo "$case_dir" +} + +write() { + local path="$1" content="$2" + mkdir -p "${path%/*}" + printf '%s' "$content" >"$path" +} + +# Builds the marketplace clone at TWO commits and echoes " ". +# commit 1: plugins/alpha/{hooks/run.sh, lib/util.sh, gone.txt} +# commit 2: run.sh changed, gone.txt deleted, lib/new.sh added +# Both commits carry the same marketplace.json, so a version number that never +# moves — the exact condition the reported cache-staleness mechanism turns on — +# is what the fixture models. +seed_market_repo() { + local case_dir="$1" repo="$1/market" + mkdir -p "$repo" + git -C "$repo" init -q -b main + git -C "$repo" config user.email fixture@example.invalid + git -C "$repo" config user.name fixture + git -C "$repo" config commit.gpgsign false + write "$repo/.claude-plugin/marketplace.json" \ + '{"plugins":[{"name":"alpha","source":"./plugins/alpha"}]}' + write "$repo/.gitignore" 'plugins/alpha/generated/' + write "$repo/plugins/alpha/hooks/run.sh" 'echo v1' + write "$repo/plugins/alpha/lib/util.sh" 'echo util' + write "$repo/plugins/alpha/gone.txt" 'removed at commit 2' + git -C "$repo" add -A + git -C "$repo" commit -q -m one + local sha1 + sha1=$(git -C "$repo" rev-parse HEAD) + + write "$repo/plugins/alpha/hooks/run.sh" 'echo v2' + rm -f "$repo/plugins/alpha/gone.txt" + write "$repo/plugins/alpha/lib/new.sh" 'echo new' + git -C "$repo" add -A + git -C "$repo" commit -q -m two + local sha2 + sha2=$(git -C "$repo" rev-parse HEAD) + echo "$sha1 $sha2" +} + +# Materializes the cache directory from a commit of the fixture repo, so "the +# cache holds commit N's build" is expressed as exactly that and never as a +# hand-copied approximation that could drift from what the commit contains. +seed_cache_from() { + local case_dir="$1" sha="$2" repo="$1/market" cache="$1/cache/alpha/1.0.0" + rm -rf "$cache" + mkdir -p "$cache" + # `-z`, and NUL-delimited reads, because a non-ASCII name comes back QUOTED + # from a plain ls-tree and `git show` would then be handed the quoted + # spelling — the fixture would fail to build rather than exercise the case. + local rel + while IFS= read -r -d '' rel; do + [[ -n "$rel" ]] || continue + mkdir -p "$(dirname "$cache/${rel#plugins/alpha/}")" + git -C "$repo" show "$sha:$rel" >"$cache/${rel#plugins/alpha/}" + done < <(git -C "$repo" ls-tree -r -z --name-only "$sha" -- plugins/alpha) + echo "$cache" +} + +# Args: case_dir, sha recorded on the install record, [scope], [projectPath] +seed_state() { + local case_dir="$1" sha="$2" scope="${3:-user}" project_path="${4:-}" + local extra="" + [[ -z "$project_path" ]] || extra=",\"projectPath\":\"$project_path\"" + write "$case_dir/installed_plugins.json" "{ + \"version\": 1, + \"plugins\": { + \"alpha@market1\": [ + {\"scope\":\"$scope\",\"version\":\"1.0.0\",\"gitCommitSha\":\"$sha\",\"installPath\":\"$case_dir/cache/alpha/1.0.0\"$extra} + ] + } + }" + write "$case_dir/known_marketplaces.json" \ + "{\"market1\": {\"installLocation\": \"$case_dir/market\", \"lastUpdated\": \"2026-01-01T00:00:00Z\"}}" +} + +run_check() { + local case_dir="$1" + shift + env \ + CACHE_CONTENT_INSTALLED_JSON="$case_dir/installed_plugins.json" \ + CACHE_CONTENT_MARKETPLACES_JSON="$case_dir/known_marketplaces.json" \ + bash "$SCRIPT" "$@" 2>&1 +} + +# Same run with stdout kept clean of stderr, for the cases that assert the +# "a rejection leaves stdout EMPTY" half of the --ids contract. +run_check_stdout() { + local case_dir="$1" + shift + env \ + CACHE_CONTENT_INSTALLED_JSON="$case_dir/installed_plugins.json" \ + CACHE_CONTENT_MARKETPLACES_JSON="$case_dir/known_marketplaces.json" \ + bash "$SCRIPT" "$@" +} + +# ============================================================================ +# Case: the cache holds exactly the recorded sha's build → match +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +read -r SHA1 SHA2 <<<"$(seed_market_repo "$case_dir")" +seed_cache_from "$case_dir" "$SHA2" >/dev/null +seed_state "$case_dir" "$SHA2" +out=$(run_check "$case_dir" --marketplace market1) +rc=$? +assert_exit "match: exit 0" 0 "$rc" +assert_eq "match: one install checked" "1" "$(jq -r '.checked' <<<"$out" 2>/dev/null)" +assert_eq "match: verdict is match" "match" "$(jq -r '.installs[0].verdict' <<<"$out" 2>/dev/null)" +assert_eq "match: the match tally counts it" "1" "$(jq -r '.match' <<<"$out" 2>/dev/null)" +assert_eq "match: nothing is reported stale" "0" "$(jq -r '.stale_content' <<<"$out" 2>/dev/null)" +assert_eq "match: the record carries its recorded sha back" "$SHA2" "$(jq -r '.installs[0].gitCommitSha' <<<"$out" 2>/dev/null)" + +# ============================================================================ +# Case: the cache holds the OLDER commit's build while the record's sha points +# at the newer one. This is the reported failure's exact shape, and the version +# number is identical across both commits, so a version-and-sha check sees +# nothing. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +read -r SHA1 SHA2 <<<"$(seed_market_repo "$case_dir")" +seed_cache_from "$case_dir" "$SHA1" >/dev/null +seed_state "$case_dir" "$SHA2" +out=$(run_check "$case_dir" --marketplace market1) +rc=$? +assert_exit "stale: exit 0 — a finding is not an error" 0 "$rc" +assert_eq "stale: verdict is stale-content" "stale-content" "$(jq -r '.installs[0].verdict' <<<"$out" 2>/dev/null)" +assert_eq "stale: the stale tally counts it" "1" "$(jq -r '.stale_content' <<<"$out" 2>/dev/null)" +assert_eq "stale: the changed file is counted as differing" "1" "$(jq -r '.installs[0].differing' <<<"$out" 2>/dev/null)" +assert_contains "stale: the changed file is named" "$(jq -r '.installs[0].paths | join(",")' <<<"$out" 2>/dev/null)" "differs: hooks/run.sh" +assert_eq "stale: the file added at the newer sha is missing from the cache" "1" "$(jq -r '.installs[0].missing_from_cache' <<<"$out" 2>/dev/null)" +assert_contains "stale: that missing file is named" "$(jq -r '.installs[0].paths | join(",")' <<<"$out" 2>/dev/null)" "missing-from-cache: lib/new.sh" +# The same fixture carries the deletion half: gone.txt exists at SHA1, which is +# what the cache was built from, and not at SHA2. +assert_eq "stale: a file deleted at the recorded sha but still in the cache is counted" "1" "$(jq -r '.installs[0].extra_in_cache' <<<"$out" 2>/dev/null)" +assert_contains "stale: that leftover file is named" "$(jq -r '.installs[0].paths | join(",")' <<<"$out" 2>/dev/null)" "extra-in-cache: gone.txt" +# An untouched file must NOT be swept into the finding — the report is read as +# a list of files to look at, so a false name in it costs the operator a read. +assert_eq "stale: an unchanged file is not reported" "" "$(jq -r '[.installs[].paths[] | select(test("util.sh"))] | join(",")' <<<"$out" 2>/dev/null)" + +# ============================================================================ +# Case: --ids emits only the stale-content ids, and emits them CR-free. Same +# fixture as above, so the id it emits is one the JSON body verdicts stale. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +ids=$(run_check_stdout "$case_dir" --marketplace market1 --ids 2>/dev/null) +rc=$? +assert_exit "--ids: exit 0" 0 "$rc" +assert_eq "--ids: emits the stale id, fully qualified" "alpha@market1" "$ids" +cr_free=$(printf '%s' "$ids" | tr -d '\r') +assert_eq "--ids: the id carries no carriage return" "$ids" "$cr_free" + +# ...and a matching fleet emits nothing at all, which is success, not an error. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +read -r SHA1 SHA2 <<<"$(seed_market_repo "$case_dir")" +seed_cache_from "$case_dir" "$SHA2" >/dev/null +seed_state "$case_dir" "$SHA2" +ids=$(run_check_stdout "$case_dir" --marketplace market1 --ids 2>/dev/null) +rc=$? +assert_exit "--ids: a clean fleet is exit 0" 0 "$rc" +assert_eq "--ids: a clean fleet emits nothing" "" "$ids" + +# ============================================================================ +# Case: the recorded sha is not an object in the installLocation clone. The +# check must say so and must NOT fetch — a fetch is a network mutation, and it +# would silently repair the very condition being reported. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +read -r SHA1 SHA2 <<<"$(seed_market_repo "$case_dir")" +seed_cache_from "$case_dir" "$SHA2" >/dev/null +seed_state "$case_dir" "0123456789abcdef0123456789abcdef01234567" +out=$(run_check "$case_dir" --marketplace market1) +rc=$? +assert_exit "sha-not-local: exit 0" 0 "$rc" +assert_eq "sha-not-local: verdict names the condition" "sha-not-local" "$(jq -r '.installs[0].verdict' <<<"$out" 2>/dev/null)" +assert_eq "sha-not-local: counted as unverifiable, not as a match" "1" "$(jq -r '.unverifiable' <<<"$out" 2>/dev/null)" +assert_eq "sha-not-local: and not as a match" "0" "$(jq -r '.match' <<<"$out" 2>/dev/null)" + +# ============================================================================ +# Case: a cache-only file the marketplace's own .gitignore covers is generated +# state, not a stale build. Without this filter a live plugin root reports +# stale on its own __pycache__ / node_modules. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +read -r SHA1 SHA2 <<<"$(seed_market_repo "$case_dir")" +cache=$(seed_cache_from "$case_dir" "$SHA2") +seed_state "$case_dir" "$SHA2" +write "$cache/generated/artifact.bin" 'generated at run time' +out=$(run_check "$case_dir" --marketplace market1) +assert_eq "gitignored extra: still a match" "match" "$(jq -r '.installs[0].verdict' <<<"$out" 2>/dev/null)" +assert_eq "gitignored extra: not counted as extra" "0" "$(jq -r '.installs[0].extra_in_cache' <<<"$out" 2>/dev/null)" +# A cache-only file the repo does NOT ignore is still a finding, so the filter +# above is a filter and not a blanket amnesty for every extra file. +write "$cache/leftover.txt" 'not ignored anywhere' +out=$(run_check "$case_dir" --marketplace market1) +assert_eq "unignored extra: is stale-content" "stale-content" "$(jq -r '.installs[0].verdict' <<<"$out" 2>/dev/null)" +assert_contains "unignored extra: is named" "$(jq -r '.installs[0].paths | join(",")' <<<"$out" 2>/dev/null)" "extra-in-cache: leftover.txt" + +# ============================================================================ +# Case: `.in_use` is Claude Code's own per-process refcount directory. It is in +# every cache version directory and in no commit; counting it would report the +# entire fleet stale on its existence alone. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +read -r SHA1 SHA2 <<<"$(seed_market_repo "$case_dir")" +cache=$(seed_cache_from "$case_dir" "$SHA2") +seed_state "$case_dir" "$SHA2" +write "$cache/.in_use/12345" '' +out=$(run_check "$case_dir" --marketplace market1) +assert_eq ".in_use: does not make the install stale" "match" "$(jq -r '.installs[0].verdict' <<<"$out" 2>/dev/null)" + +# ============================================================================ +# Case: a project-scope record whose projectPath is not present on this machine +# is skipped and COUNTED, never verdicted. Absent is not dead: an unmounted +# volume and a removed worktree are indistinguishable to a directory test. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +read -r SHA1 SHA2 <<<"$(seed_market_repo "$case_dir")" +seed_cache_from "$case_dir" "$SHA2" >/dev/null +seed_state "$case_dir" "$SHA2" project "$case_dir/no-such-repo" +out=$(run_check "$case_dir" --marketplace market1 --scope project) +rc=$? +assert_exit "absent projectPath: exit 0" 0 "$rc" +assert_eq "absent projectPath: counted as skipped" "1" "$(jq -r '.skipped_absent_project_paths' <<<"$out" 2>/dev/null)" +assert_eq "absent projectPath: not counted as checked" "0" "$(jq -r '.checked' <<<"$out" 2>/dev/null)" +assert_eq "absent projectPath: emits no install record" "0" "$(jq -r '.installs | length' <<<"$out" 2>/dev/null)" +# The same record with a present directory IS checked, so the skip is keyed on +# presence and not on the scope. +mkdir -p "$case_dir/present-repo" +seed_state "$case_dir" "$SHA2" project "$case_dir/present-repo" +out=$(run_check "$case_dir" --marketplace market1 --scope project) +assert_eq "present projectPath: checked" "1" "$(jq -r '.checked' <<<"$out" 2>/dev/null)" +assert_eq "present projectPath: nothing skipped" "0" "$(jq -r '.skipped_absent_project_paths' <<<"$out" 2>/dev/null)" +# ...and the default scope is `user`, so neither record appears without --scope. +out=$(run_check "$case_dir" --marketplace market1) +assert_eq "default scope is user: a project record is out of scope" "0" "$(jq -r '.checked' <<<"$out" 2>/dev/null)" +assert_eq "default scope is reported back" "user" "$(jq -r '.scope' <<<"$out" 2>/dev/null)" + +# ============================================================================ +# Case: the installLocation is a directory but not a git work tree. There is no +# tree to compare against, and that is a stated verdict, never a silent match. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +read -r SHA1 SHA2 <<<"$(seed_market_repo "$case_dir")" +seed_cache_from "$case_dir" "$SHA2" >/dev/null +seed_state "$case_dir" "$SHA2" +mkdir -p "$case_dir/plain" +write "$case_dir/known_marketplaces.json" \ + "{\"market1\": {\"installLocation\": \"$case_dir/plain\"}}" +out=$(run_check "$case_dir" --marketplace market1) +assert_eq "not-a-git-worktree: verdict names the condition" "not-a-git-worktree" "$(jq -r '.installs[0].verdict' <<<"$out" 2>/dev/null)" +assert_eq "not-a-git-worktree: counted as unverifiable" "1" "$(jq -r '.unverifiable' <<<"$out" 2>/dev/null)" + +# ...and an installLocation that is not on this machine at all is its own +# verdict, distinct from a directory that exists and is not a repo. +CASE_NUM=$((CASE_NUM + 1)) +write "$case_dir/known_marketplaces.json" \ + "{\"market1\": {\"installLocation\": \"$case_dir/does-not-exist\"}}" +out=$(run_check "$case_dir" --marketplace market1) +assert_eq "no-install-location: verdict names the condition" "no-install-location" "$(jq -r '.installs[0].verdict' <<<"$out" 2>/dev/null)" + +# ============================================================================ +# Case: the cache directory itself is gone. Reported, never treated as a match. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +read -r SHA1 SHA2 <<<"$(seed_market_repo "$case_dir")" +seed_state "$case_dir" "$SHA2" +out=$(run_check "$case_dir" --marketplace market1) +assert_eq "install-path-missing: verdict names the condition" "install-path-missing" "$(jq -r '.installs[0].verdict' <<<"$out" 2>/dev/null)" + +# ============================================================================ +# Case: malformed installed_plugins.json fails LOUD. Read leniently it would +# yield an empty install list, which is indistinguishable from a clean fleet — +# the one wrong answer this check must never give. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +read -r SHA1 SHA2 <<<"$(seed_market_repo "$case_dir")" +seed_state "$case_dir" "$SHA2" +write "$case_dir/installed_plugins.json" '{"version":1,"plugins":' +out=$(run_check "$case_dir" --marketplace market1) +rc=$? +assert_exit "malformed installed_plugins.json: exit 2" 2 "$rc" +assert_contains "malformed installed_plugins.json: the error names the file" "$out" "installed_plugins.json" +# Structurally-valid JSON of the WRONG SHAPE is refused too, not just a parse +# error: `.plugins` holding anything but a map of arrays is schema drift. +write "$case_dir/installed_plugins.json" '{"version":1,"plugins":{"alpha@market1":{"scope":"user"}}}' +out=$(run_check "$case_dir" --marketplace market1) +rc=$? +assert_exit "wrong-shape installed_plugins.json: exit 2" 2 "$rc" + +# ============================================================================ +# Case: an unknown marketplace is a usage error and names itself. Deliberately +# exit 2 rather than fleet-state.sh's exit 1 for an unresolvable marketplace: +# the name came from the caller's own argument. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +read -r SHA1 SHA2 <<<"$(seed_market_repo "$case_dir")" +seed_cache_from "$case_dir" "$SHA2" >/dev/null +seed_state "$case_dir" "$SHA2" +out=$(run_check "$case_dir" --marketplace nosuchmarket) +rc=$? +assert_exit "unknown marketplace: exit 2" 2 "$rc" +assert_contains "unknown marketplace: the error names it" "$out" "nosuchmarket" +stdout_only=$(run_check_stdout "$case_dir" --marketplace nosuchmarket 2>/dev/null) +assert_eq "unknown marketplace: stdout is left empty" "" "$stdout_only" + +# ============================================================================ +# Case: usage rejections. Each leaves stdout EMPTY, so a `< <(… --ids …)` +# consumer — which cannot see the process's exit status — can never read an +# error line as a plugin id. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +out=$(run_check "$case_dir" --all --ids) +rc=$? +assert_exit "--ids with --all: exit 2" 2 "$rc" +assert_contains "--ids with --all: the error says why" "$out" "--ids cannot be combined with --all" +assert_eq "--ids with --all: stdout is left empty" "" "$(run_check_stdout "$case_dir" --all --ids 2>/dev/null)" + +out=$(run_check "$case_dir") +rc=$? +assert_exit "no target: exit 2" 2 "$rc" +assert_contains "no target: the error names the required flags" "$out" "--marketplace" + +out=$(run_check "$case_dir" --marketplace market1 --scope sideways) +rc=$? +assert_exit "unknown scope: exit 2" 2 "$rc" +assert_contains "unknown scope: the error names the accepted values" "$out" "user, project, or all" + +out=$(run_check "$case_dir" --marketplace) +rc=$? +assert_exit "--marketplace with no name: exit 2, and does not spin" 2 "$rc" + +out=$(run_check "$case_dir" --nonsense) +rc=$? +assert_exit "unknown argument: exit 2" 2 "$rc" + +# ============================================================================ +# Case: --all sweeps every marketplace, and a per-marketplace failure is +# reported inline rather than aborting the sweep. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +read -r SHA1 SHA2 <<<"$(seed_market_repo "$case_dir")" +seed_cache_from "$case_dir" "$SHA2" >/dev/null +seed_state "$case_dir" "$SHA2" +write "$case_dir/known_marketplaces.json" \ + "{\"market1\": {\"installLocation\": \"$case_dir/market\"}, \"market2\": {\"installLocation\": \"$case_dir/absent\"}}" +out=$(run_check "$case_dir" --all) +rc=$? +assert_exit "--all: exit 0" 0 "$rc" +assert_eq "--all: both marketplaces appear in the envelope" "2" "$(jq -r '.marketplaces | length' <<<"$out" 2>/dev/null)" +assert_eq "--all: the healthy marketplace still reports its match" "match" "$(jq -r '.marketplaces.market1.installs[0].verdict' <<<"$out" 2>/dev/null)" +assert_eq "--all: a marketplace with no installs is still reported" "0" "$(jq -r '.marketplaces.market2.checked' <<<"$out" 2>/dev/null)" + +# ============================================================================ +# Case: the script writes NOTHING. That is its whole contract — it is the audit +# that must never repair the condition it reports — so both the cache directory +# and the marketplace clone are hash-compared across a full run. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +read -r SHA1 SHA2 <<<"$(seed_market_repo "$case_dir")" +seed_cache_from "$case_dir" "$SHA1" >/dev/null +seed_state "$case_dir" "$SHA2" +snapshot() { + find "$case_dir/cache" "$case_dir/market" -type f 2>/dev/null | sort | + while IFS= read -r f; do printf '%s %s\n' "$f" "$(git hash-object "$f" 2>/dev/null)"; done +} +before=$(snapshot) +run_check "$case_dir" --marketplace market1 >/dev/null 2>&1 +after=$(snapshot) +assert_eq "read-only: no file under the cache or the clone changed" "$before" "$after" +git_status=$(git -C "$case_dir/market" status --porcelain 2>&1) +assert_eq "read-only: the marketplace clone's work tree is still clean" "" "$git_status" +assert_eq "read-only: the clone's HEAD did not move" "$SHA2" "$(git -C "$case_dir/market" rev-parse HEAD)" + +# ============================================================================ +# Case: an install record with NO gitCommitSha field at all. Decoded with a +# whitespace separator the empty column would vanish and installPath would +# slide left into the sha, yielding install-path-missing with a fabricated sha. +# The honest answer is no-git-commit-sha, and the installPath must come back +# intact. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +read -r SHA1 SHA2 <<<"$(seed_market_repo "$case_dir")" +seed_cache_from "$case_dir" "$SHA2" >/dev/null +seed_state "$case_dir" "$SHA2" +write "$case_dir/installed_plugins.json" "{ + \"version\": 1, + \"plugins\": { + \"alpha@market1\": [ + {\"scope\":\"user\",\"version\":\"1.0.0\",\"installPath\":\"$case_dir/cache/alpha/1.0.0\"} + ] + } +}" +out=$(run_check "$case_dir" --marketplace market1) +rc=$? +assert_exit "absent gitCommitSha: exit 0" 0 "$rc" +assert_eq "absent gitCommitSha: verdict names the missing field" \ + "no-git-commit-sha" "$(jq -r '.installs[0].verdict' <<<"$out" 2>/dev/null)" +assert_eq "absent gitCommitSha: the sha comes back empty, never fabricated" \ + "" "$(jq -r '.installs[0].gitCommitSha' <<<"$out" 2>/dev/null)" +assert_eq "absent gitCommitSha: the installPath did not slide into another column" \ + "$case_dir/cache/alpha/1.0.0" "$(jq -r '.installs[0].installPath' <<<"$out" 2>/dev/null)" +assert_eq "absent gitCommitSha: counted as unverifiable" "1" "$(jq -r '.unverifiable' <<<"$out" 2>/dev/null)" + +# ============================================================================ +# Case: the plugin's source directory is RENAMED after the recorded commit. +# Resolving the source from the current checkout would look an older tree up +# under a path it does not contain, and `git ls-tree` reports a pathspec that +# matches nothing as success with EMPTY output — so every cache file becomes an +# extra and a perfectly matching cache reports stale-content. Reading the +# manifest at the recorded sha is what makes this a match. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +repo="$case_dir/market" +mkdir -p "$repo" +git -C "$repo" init -q -b main +git -C "$repo" config user.email fixture@example.invalid +git -C "$repo" config user.name fixture +git -C "$repo" config commit.gpgsign false +write "$repo/.claude-plugin/marketplace.json" \ + '{"plugins":[{"name":"alpha","source":"./plugins/alpha"}]}' +write "$repo/plugins/alpha/hooks/run.sh" 'echo v1' +write "$repo/plugins/alpha/lib/util.sh" 'echo util' +git -C "$repo" add -A +git -C "$repo" commit -q -m one +RENAME_SHA1=$(git -C "$repo" rev-parse HEAD) +git -C "$repo" mv plugins/alpha plugins/alpha-renamed +write "$repo/.claude-plugin/marketplace.json" \ + '{"plugins":[{"name":"alpha","source":"./plugins/alpha-renamed"}]}' +git -C "$repo" add -A +git -C "$repo" commit -q -m rename +# The cache holds the recorded commit's build, under the name that commit used. +cache="$case_dir/cache/alpha/1.0.0" +mkdir -p "$cache/hooks" "$cache/lib" +git -C "$repo" show "$RENAME_SHA1:plugins/alpha/hooks/run.sh" >"$cache/hooks/run.sh" +git -C "$repo" show "$RENAME_SHA1:plugins/alpha/lib/util.sh" >"$cache/lib/util.sh" +seed_state "$case_dir" "$RENAME_SHA1" +out=$(run_check "$case_dir" --marketplace market1) +rc=$? +assert_exit "renamed source: exit 0" 0 "$rc" +assert_eq "renamed source: the source path is read at the recorded sha, so it is a match" \ + "match" "$(jq -r '.installs[0].verdict' <<<"$out" 2>/dev/null)" +assert_eq "renamed source: nothing is called extra-in-cache" \ + "0" "$(jq -r '.installs[0].extra_in_cache' <<<"$out" 2>/dev/null)" +assert_eq "renamed source: nothing is called missing-from-cache" \ + "0" "$(jq -r '.installs[0].missing_from_cache' <<<"$out" 2>/dev/null)" + +# ============================================================================ +# Case: the manifest at the recorded sha names a source directory that commit +# does not contain. That is its own verdict — an empty expected tree would +# otherwise make every cache file an extra and report a healthy cache stale. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +repo="$case_dir/market" +mkdir -p "$repo" +git -C "$repo" init -q -b main +git -C "$repo" config user.email fixture@example.invalid +git -C "$repo" config user.name fixture +git -C "$repo" config commit.gpgsign false +write "$repo/.claude-plugin/marketplace.json" \ + '{"plugins":[{"name":"alpha","source":"./plugins/ghost"}]}' +write "$repo/plugins/other/keep.txt" 'unrelated' +git -C "$repo" add -A +git -C "$repo" commit -q -m ghost +GHOST_SHA=$(git -C "$repo" rev-parse HEAD) +cache="$case_dir/cache/alpha/1.0.0" +mkdir -p "$cache" +write "$cache/whatever.sh" 'echo hi' +seed_state "$case_dir" "$GHOST_SHA" +out=$(run_check "$case_dir" --marketplace market1) +rc=$? +assert_exit "no-source-at-sha: exit 0" 0 "$rc" +assert_eq "no-source-at-sha: verdict names the condition" \ + "no-source-at-sha" "$(jq -r '.installs[0].verdict' <<<"$out" 2>/dev/null)" +assert_eq "no-source-at-sha: counted as unverifiable" "1" "$(jq -r '.unverifiable' <<<"$out" 2>/dev/null)" +assert_eq "no-source-at-sha: not counted as stale" "0" "$(jq -r '.stale_content' <<<"$out" 2>/dev/null)" +assert_eq "no-source-at-sha: no cache file is called extra" "0" "$(jq -r '.installs[0].extra_in_cache' <<<"$out" 2>/dev/null)" + +# ============================================================================ +# Case: a tracked filename carrying non-ASCII bytes. Without `-z`, git QUOTES +# and escapes such a pathname in ls-tree output, and the line-oriented compare +# then reads the quoted spelling and the raw one as two different files — an +# unchanged file reported as BOTH missing-from-cache and extra-in-cache. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +repo="$case_dir/market" +mkdir -p "$repo" +git -C "$repo" init -q -b main +git -C "$repo" config user.email fixture@example.invalid +git -C "$repo" config user.name fixture +git -C "$repo" config commit.gpgsign false +write "$repo/.claude-plugin/marketplace.json" \ + '{"plugins":[{"name":"alpha","source":"./plugins/alpha"}]}' +write "$repo/plugins/alpha/plain.sh" 'echo plain' +write "$repo/plugins/alpha/café.txt" 'accented' +git -C "$repo" add -A +git -C "$repo" commit -q -m one +NONASCII_SHA=$(git -C "$repo" rev-parse HEAD) +seed_cache_from "$case_dir" "$NONASCII_SHA" >/dev/null +seed_state "$case_dir" "$NONASCII_SHA" +out=$(run_check "$case_dir" --marketplace market1) +rc=$? +assert_exit "non-ASCII path: exit 0" 0 "$rc" +assert_eq "non-ASCII path: an unchanged accented filename is a match, not a finding" \ + "match" "$(jq -r '.installs[0].verdict' <<<"$out" 2>/dev/null)" +assert_eq "non-ASCII path: it is not reported missing from the cache" \ + "0" "$(jq -r '.installs[0].missing_from_cache' <<<"$out" 2>/dev/null)" +assert_eq "non-ASCII path: nor extra in the cache" \ + "0" "$(jq -r '.installs[0].extra_in_cache' <<<"$out" 2>/dev/null)" +# ...and a genuine edit to that same file is still caught, so the fix is a fix +# and not a blanket exemption for awkward names. +printf '%s' 'edited' >"$case_dir/cache/alpha/1.0.0/café.txt" +out=$(run_check "$case_dir" --marketplace market1) +assert_eq "non-ASCII path: an edited accented file is still stale-content" \ + "stale-content" "$(jq -r '.installs[0].verdict' <<<"$out" 2>/dev/null)" +assert_eq "non-ASCII path: and is counted as differing, not as missing+extra" \ + "1" "$(jq -r '.installs[0].differing' <<<"$out" 2>/dev/null)" + +# ============================================================================ +# Case: a tracked SYMLINK. git stores it as a blob holding the target text, +# while `find -type f` excludes it entirely — so an unchanged link would be +# reported missing-from-cache forever. Guarded by a capability check: creating +# a symlink on Windows may need a privilege this host does not have, and a +# silent pass there would be a green run of a case that never executed. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +repo="$case_dir/market" +mkdir -p "$repo/plugins/alpha" +git -C "$repo" init -q -b main +git -C "$repo" config user.email fixture@example.invalid +git -C "$repo" config user.name fixture +git -C "$repo" config commit.gpgsign false +write "$repo/.claude-plugin/marketplace.json" \ + '{"plugins":[{"name":"alpha","source":"./plugins/alpha"}]}' +write "$repo/plugins/alpha/real.sh" 'echo real' +symlink_ok="" +if ln -s real.sh "$repo/plugins/alpha/link.sh" 2>/dev/null && [[ -L "$repo/plugins/alpha/link.sh" ]]; then + git -C "$repo" add -A + git -C "$repo" commit -q -m one + case "$(git -C "$repo" ls-tree -r HEAD -- plugins/alpha/link.sh)" in + 120000*) symlink_ok="yes" ;; + *) symlink_ok="" ;; + esac +fi +if [[ -z "$symlink_ok" ]]; then + printf 'SKIP: symlink case — this host cannot create a git-recorded symlink (Windows without the privilege, or core.symlinks=false)\n' >&2 +else + SYM_SHA=$(git -C "$repo" rev-parse HEAD) + cache="$case_dir/cache/alpha/1.0.0" + mkdir -p "$cache" + git -C "$repo" show "$SYM_SHA:plugins/alpha/real.sh" >"$cache/real.sh" + ln -s real.sh "$cache/link.sh" + seed_state "$case_dir" "$SYM_SHA" + out=$(run_check "$case_dir" --marketplace market1) + assert_eq "symlink: an unchanged tracked link is a match" \ + "match" "$(jq -r '.installs[0].verdict' <<<"$out" 2>/dev/null)" + assert_eq "symlink: it is not reported missing from the cache" \ + "0" "$(jq -r '.installs[0].missing_from_cache' <<<"$out" 2>/dev/null)" + # A link pointing somewhere else IS a difference, so the link is compared + # rather than excused. + rm -f "$cache/link.sh" + ln -s elsewhere.sh "$cache/link.sh" + out=$(run_check "$case_dir" --marketplace market1) + assert_eq "symlink: a link retargeted in the cache is stale-content" \ + "stale-content" "$(jq -r '.installs[0].verdict' <<<"$out" 2>/dev/null)" +fi + +# ============================================================================ +# Case: `git hash-object --stdin-paths` returns fewer hashes than it was given +# paths. Every entry after the gap would be filed under the WRONG path, so the +# run must refuse to compare rather than report healthy files as differing. A +# stub `git` earlier on PATH truncates that one subcommand's output and +# delegates everything else to the real binary. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +read -r SHA1 SHA2 <<<"$(seed_market_repo "$case_dir")" +seed_cache_from "$case_dir" "$SHA2" >/dev/null +seed_state "$case_dir" "$SHA2" +REAL_GIT="$(command -v git)" +mkdir -p "$case_dir/stub" +# The stub must call the real binary by ABSOLUTE path: a script named `git` +# that runs `git` would find itself first on PATH and recurse forever. +write "$case_dir/stub/git" "#!/usr/bin/env bash +case \"\$*\" in +*'hash-object --stdin-paths'*) + '$REAL_GIT' \"\$@\" | head -n -1 + exit 0 + ;; +esac +exec '$REAL_GIT' \"\$@\" +" +chmod +x "$case_dir/stub/git" +# PATH entries must be POSIX-spelled. The fixture root is derived from $TEMP, +# which on Windows is a drive-letter path, and a `C:/…` entry in PATH is not +# searched at all — the stub would be silently ignored and the case would report +# a plain match, which is exactly the vacuous pass it exists to prevent. +STUB_PATH_ENTRY="$case_dir/stub" +if command -v cygpath >/dev/null 2>&1; then + STUB_PATH_ENTRY="$(cygpath -u "$case_dir/stub")" +fi +out=$(env PATH="$STUB_PATH_ENTRY:$PATH" \ + CACHE_CONTENT_INSTALLED_JSON="$case_dir/installed_plugins.json" \ + CACHE_CONTENT_MARKETPLACES_JSON="$case_dir/known_marketplaces.json" \ + bash "$SCRIPT" --marketplace market1 2>&1) +rc=$? +assert_exit "hash-batch-misaligned: exit 0" 0 "$rc" +assert_eq "hash-batch-misaligned: verdict names the broken contract" \ + "hash-batch-misaligned" "$(jq -r '.installs[0].verdict' <<<"$out" 2>/dev/null)" +assert_eq "hash-batch-misaligned: counted as unverifiable" "1" "$(jq -r '.unverifiable' <<<"$out" 2>/dev/null)" +assert_eq "hash-batch-misaligned: not counted as stale" "0" "$(jq -r '.stale_content' <<<"$out" 2>/dev/null)" +assert_eq "hash-batch-misaligned: nothing is reported differing" \ + "0" "$(jq -r '.installs[0].differing' <<<"$out" 2>/dev/null)" +assert_eq "hash-batch-misaligned: nothing is reported missing from the cache" \ + "0" "$(jq -r '.installs[0].missing_from_cache' <<<"$out" 2>/dev/null)" +assert_eq "hash-batch-misaligned: nothing is reported extra in the cache" \ + "0" "$(jq -r '.installs[0].extra_in_cache' <<<"$out" 2>/dev/null)" +# The same fixture without the stub is a plain match, so the verdict above came +# from the truncation and not from the fixture being broken. +out=$(run_check "$case_dir" --marketplace market1) +assert_eq "hash-batch-misaligned: the same fixture with the real git is a match" \ + "match" "$(jq -r '.installs[0].verdict' <<<"$out" 2>/dev/null)" + +# ============================================================================ +# Process budget: a clean install costs a FIXED number of process creations, +# never one per file. Each creation is a fork() emulation plus a CreateProcess +# on Windows Git Bash (roughly 120 ms on a quiet host, seconds on a contended +# one), and the obvious implementation of this check — hash each cache file on +# its own — would pay one for every file in every plugin, which for a plugin +# that vendors dependencies is thousands. The batched `git hash-object +# --stdin-paths` is what keeps it flat, and the per-file `--path` re-hash is +# reached only for a file that already looks different. Counted the way +# fleet-state.test.sh counts it: `bash -x` with PS4 carrying $BASHPID, so every +# subshell shows up as a distinct pid, plus every external exec. Two fixtures +# differing only in FILE COUNT must cost the same. +# +# The probe is itself asserted, not just its result. `count_creations` returns +# `forks - 1 + execs`, so a trace it cannot parse at all yields -1 — which would +# satisfy any ceiling and turn this whole section into a test that cannot fail. +# The floor below makes a broken probe a FAILURE, and the measured number is +# printed either way so a silent drift in what the trace looks like is visible. +# ============================================================================ +count_creations() { + local trace="$1" forks execs + forks=$(grep -oE '^\++[0-9]+\+' "$trace" | sort -u | wc -l | tr -d ' ') + execs=$(grep -cE '^\++[0-9]+\+ (command )?(jq|git|find|tr|realpath|readlink|mktemp|head|sed|cat|grep|awk|sort|uniq|cut|wc|date|dirname|basename|ls) ' "$trace" || true) + echo $((forks - 1 + execs)) +} + +# Builds a one-commit fixture whose plugin holds $2 files, with the cache an +# exact copy of it, so every case here is a clean `match` and the only variable +# is how many files that match covers. +seed_budget_case() { + local case_dir="$1" n="$2" repo="$1/market" i + mkdir -p "$repo" + git -C "$repo" init -q -b main + git -C "$repo" config user.email fixture@example.invalid + git -C "$repo" config user.name fixture + git -C "$repo" config commit.gpgsign false + write "$repo/.claude-plugin/marketplace.json" \ + '{"plugins":[{"name":"alpha","source":"./plugins/alpha"}]}' + for ((i = 1; i <= n; i++)); do + write "$repo/plugins/alpha/file$i.sh" "echo $i" + done + git -C "$repo" add -A + git -C "$repo" commit -q -m one + seed_cache_from "$case_dir" "$(git -C "$repo" rev-parse HEAD)" >/dev/null + seed_state "$case_dir" "$(git -C "$repo" rev-parse HEAD)" +} + +run_traced() { + local case_dir="$1" trace="$2" + # shellcheck disable=SC2016 # PS4 must reach bash unexpanded: bash expands it per traced line + env \ + CACHE_CONTENT_INSTALLED_JSON="$case_dir/installed_plugins.json" \ + CACHE_CONTENT_MARKETPLACES_JSON="$case_dir/known_marketplaces.json" \ + PS4='+${BASHPID}+ ' \ + bash -x "$SCRIPT" --marketplace market1 2>"$trace" +} + +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +seed_budget_case "$case_dir" 2 +out=$(run_traced "$case_dir" "$case_dir/trace-small.log") +small=$(count_creations "$case_dir/trace-small.log") +assert_eq "process budget: the two-file run still reports a match" \ + "match" "$(jq -r '.installs[0].verdict' <<<"$out" 2>/dev/null)" + +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +seed_budget_case "$case_dir" 30 +out=$(run_traced "$case_dir" "$case_dir/trace-large.log") +large=$(count_creations "$case_dir/trace-large.log") +assert_eq "process budget: the thirty-file run still reports a match" \ + "match" "$(jq -r '.installs[0].verdict' <<<"$out" 2>/dev/null)" +assert_eq "process budget: the count does not grow with the file count (2 files vs 30)" \ + "$small" "$large" +# Floor first: a probe that parses nothing counts -1 and would pass every +# ceiling below it, so the ceiling assertion is only meaningful once the count +# is known to be real. +if [[ "$large" -ge 1 ]]; then + pass "process budget: the trace probe actually counted something (measured $large)" +else + fail "process budget: the trace probe actually counted something" \ + "measured $large — the pid-stamped PS4 did not reach the traced shell, so the ceiling below is vacuous (trace: $case_dir/trace-large.log)" +fi +if [[ "$large" -ge 1 && "$large" -le 26 ]]; then + pass "process budget: a one-install report costs at most 26 process creations (measured $large)" +else + fail "process budget: a one-install report costs at most 26 process creations" \ + "measured $large (trace: $case_dir/trace-large.log)" +fi + +# --- Summary ------------------------------------------------------------- +printf '\n%d cases, %d failed\n' "$CASE_NUM" "$FAILED" +[[ "$FAILED" -eq 0 ]] && exit 0 +exit 1