diff --git a/CLAUDE.md b/CLAUDE.md index ce0fa44b4..4f95fd2ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,13 +136,16 @@ This contribution was prepared by an AI agent that tried to ship unchecked vibes ## Testing ```bash -bun run test # Default suite: unit + static framework fixtures +bun run test # Default suite: unit + static framework fixtures + plugin loader E2E bun run test:live-e2e # Opt-in: full-cycle live-mode E2E across framework fixtures bun run test:skill-behavior # Opt-in: LLM-backed checks that the skill text actually drives the agent's setup flow +bun run test:plugin-e2e # Just the plugin loader E2E (also part of the default suite) ``` Unit tests (build orchestration, detector logic) run via `bun test`. Fixture tests (jsdom-based HTML detection) run via `node --test` because bun is too slow with jsdom. The `test` script handles this split automatically. +**Plugin loader E2E** (`tests/plugin-e2e.test.mjs`, in the default suite): installs the committed `./plugin` subtree into a real Claude Code, sandboxed via `CLAUDE_CONFIG_DIR` in a temp dir, and asserts the component inventory from `claude plugin details`: the skill parses, every `plugin/agents/*.md` is visible, hooks are discovered. This is the only check that catches loader-contract surprises the unit guards can't know about (PR #494 shipped an `agents` manifest key that silently loaded zero agents; `claude plugin validate` never flags plugin-manifest problems). Runs in about a second; skips cleanly when the `claude` CLI is not on PATH. The known contract itself (allowed manifest keys, no `agents` key, trailing-slash `skills` path, source agents shipped) is pinned deterministically by `scripts/lib/validate-plugin-manifest.js`, unit-tested in `tests/validate-plugin-manifest.test.js` and enforced as a `bun run build` gate. Never add a key to the generated plugin manifest without verifying it against a real install and extending `KNOWN_LOADER_KEYS`. + **Important:** `tests/build.test.js` uses `spyOn(transformers, 'transformCursor')` with the named exports from `scripts/lib/transformers/index.js`. Those named exports (`transformCursor`, `transformClaudeCode`, etc.) are kept specifically for test spying, even though `build.js` itself uses `createTransformer + PROVIDERS` directly. **Do not delete them as "dead code"** — I made that mistake once and broke 8 tests. ### Live-mode E2E diff --git a/package.json b/package.json index c8435bc9c..756c59f06 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "test:live": "node scripts/run-tests.mjs live", "test:cli-e2e": "node scripts/run-tests.mjs cli-e2e", "test:cli-remote-e2e": "node scripts/run-tests.mjs cli-remote-e2e", + "test:plugin-e2e": "node scripts/run-tests.mjs plugin-e2e", "test:live-e2e": "node scripts/run-tests.mjs live-e2e", "test:live-e2e-accept-cleanup": "node scripts/run-tests.mjs live-e2e-accept-cleanup", "test:new-work-e2e": "node scripts/run-tests.mjs new-work-e2e", diff --git a/scripts/build.js b/scripts/build.js index c50cf089b..445b22752 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -23,6 +23,7 @@ import { createTransformer, PROVIDERS } from './lib/transformers/index.js'; import { hooksJsonFor, buildClaudePluginHooksManifest } from './lib/transformers/hooks.js'; import { createAllZips, createProviderZip } from './lib/zip.js'; import { collectPluginVersions } from './lib/validate-plugin-versions.js'; +import { collectPluginManifestFindings } from './lib/validate-plugin-manifest.js'; import { stageOpenAIPlugin } from './lib/openai-plugin.js'; import { ANTIPATTERNS } from '../cli/engine/registry/antipatterns.mjs'; // Sub-page generation is now handled by Astro content collections. @@ -140,6 +141,27 @@ function validatePluginVersions(rootDir) { return total; } +/** + * Guard against unverified plugin manifest shapes (PR #494). The pure check + * lives in ./lib/validate-plugin-manifest.js (so it's unit-tested directly); + * this wrapper owns the console output and the error count the build gates on. + */ +function validatePluginManifestShape(rootDir) { + const findings = collectPluginManifestFindings(rootDir); + for (const { relPath, reason } of findings) { + console.error(` āŒ ${relPath}: ${reason}`); + } + if (findings.length > 0) { + console.error( + `\nāŒ ${findings.length} plugin manifest shape problem(s). Every manifest key is a ` + + 'claim about the Claude Code loader; see scripts/lib/validate-plugin-manifest.js (PR #494).', + ); + } else { + console.log('āœ“ Plugin manifest shape matches the verified loader contract'); + } + return findings.length; +} + function validateSkillFrontmatter(skills) { let errors = 0; @@ -704,6 +726,10 @@ async function build() { // match root plugin.json so marketplace installs never ship a stale version. const versionErrors = validatePluginVersions(ROOT_DIR); + // Guard the generated plugin manifest's shape: a key the Claude Code loader + // does not honor (like the agents array, PR #494) ships silently broken. + const manifestShapeErrors = validatePluginManifestShape(ROOT_DIR); + // Scan user-facing copy for AI tells (em dashes, marketing fluff, denylisted phrases) const proseErrors = validateProse(ROOT_DIR); @@ -711,7 +737,7 @@ async function build() { // that has no technical reading. Hardening repetition is intentionally allowed. const skillProseErrors = validateSkillProse(ROOT_DIR); - if (countErrors > 0 || versionErrors > 0 || proseErrors > 0 || skillProseErrors > 0) { + if (countErrors > 0 || versionErrors > 0 || manifestShapeErrors > 0 || proseErrors > 0 || skillProseErrors > 0) { process.exit(1); } diff --git a/scripts/lib/validate-plugin-manifest.js b/scripts/lib/validate-plugin-manifest.js new file mode 100644 index 000000000..9b480ae4f --- /dev/null +++ b/scripts/lib/validate-plugin-manifest.js @@ -0,0 +1,162 @@ +/** + * Plugin manifest shape validation (PR #494). + * + * The Claude Code marketplace installs from the committed `./plugin` subtree, + * and every key in its `.claude-plugin/plugin.json` is a claim about how + * Claude Code's plugin loader behaves. Those claims are not checked by + * `claude plugin validate` (it validates the marketplace manifest, not the + * plugin manifest), and a wrong one fails silently: shipping an `agents` key + * as an array of file paths made Claude Code load ZERO agents, while the + * loader auto-discovers `agents/*.md` on its own when the key is absent. The + * four shipped subagents were unreachable for every marketplace install and + * nothing flagged it. + * + * This guard pins the verified contract: + * + * - `KNOWN_LOADER_KEYS` is the set of keys confirmed to load correctly in a + * real Claude Code install (checked via the `claude plugin details` + * component inventory). A key outside the set fails the build until + * someone verifies it end to end and adds it here with a note. + * - The `agents` key must never appear. Omission is the only shape that + * works: an array of file paths loads zero agents, and a string or a + * directory entry fails the whole plugin. + * - Because omission means the files themselves are the only carrier, every + * agent in `skill/agents/` that ships to claude-code must have its emitted + * copy in `plugin/agents/`. The expected filename follows the build's emit + * rules (`claude-name` / `name` frontmatter override the source basename, + * and a `providers:` list may exclude claude-code entirely). + * - `skills` must keep the trailing-slash `./skills/` form (issue #86: the + * bare form fails to register slash commands). + * + * The collector is pure (filesystem-in, data-out) so it can be unit-tested + * against fixtures; build.js owns the logging and the non-zero exit. + */ +import fs from 'fs'; +import path from 'path'; +import { parseFrontmatter } from './utils.js'; + +/** + * Keys verified against a live Claude Code install (2026-08, Claude Code + * 2.1.220). Descriptive metadata keys pass through harmlessly; `skills` is + * the one component path the loader reads from this manifest. Do not add a + * component key (`agents`, `hooks`, `commands`, `mcpServers`, ...) without + * installing the built subtree and confirming the component inventory in + * `claude plugin details` counts every shipped piece. + */ +export const KNOWN_LOADER_KEYS = [ + 'name', + 'description', + 'version', + 'author', + 'homepage', + 'repository', + 'skills', +]; + +function listMarkdown(dir) { + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir).filter((file) => file.endsWith('.md')).sort(); +} + +/** + * Which agent files the claude-code build actually emits, mirroring + * readSourceFiles (name and providers parsing) and the transformer factory + * (`${claudeName || name}.md`, providers gate). A drift between this and the + * build shows up as a false finding, so change them together. + */ +function expectedClaudeAgentFiles(rootDir) { + const agentsDir = path.join(rootDir, 'skill', 'agents'); + const expected = []; + for (const sourceFile of listMarkdown(agentsDir)) { + const { frontmatter } = parseFrontmatter( + fs.readFileSync(path.join(agentsDir, sourceFile), 'utf-8'), + ); + const providersRaw = frontmatter.providers; + const providers = Array.isArray(providersRaw) + ? providersRaw.map((p) => String(p).trim()).filter(Boolean) + : typeof providersRaw === 'string' && providersRaw.trim() + ? providersRaw.split(',').map((p) => p.trim()).filter(Boolean) + : null; + if (providers && !providers.includes('claude-code')) continue; + const name = frontmatter.name || path.basename(sourceFile, '.md'); + expected.push({ sourceFile, shippedFile: `${frontmatter['claude-name'] || name}.md` }); + } + return expected; +} + +/** + * Validate the generated plugin manifest's shape against the verified loader + * contract. + * + * @param {string} rootDir repository root + * @returns {Array<{relPath:string, reason:string}>} + * Empty when the subtree is absent (nothing generated yet) or fully valid. + */ +export function collectPluginManifestFindings(rootDir) { + const manifestRel = 'plugin/.claude-plugin/plugin.json'; + const manifestPath = path.join(rootDir, manifestRel); + if (!fs.existsSync(manifestPath)) return []; + + let manifest; + try { + manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + } catch (err) { + return [{ relPath: manifestRel, reason: `could not parse (${err.message})` }]; + } + // JSON.parse accepts null, strings, numbers, and arrays; the key checks + // below need a plain object, so anything else is a finding, not a crash. + if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) { + return [{ + relPath: manifestRel, + reason: `manifest is ${Array.isArray(manifest) ? 'an array' : manifest === null ? 'null' : `a ${typeof manifest}`}, not a JSON object`, + }]; + } + + const findings = []; + + if ('agents' in manifest) { + findings.push({ + relPath: manifestRel, + reason: + 'declares an "agents" key; Claude Code loads zero agents when it is present ' + + 'as file paths and fails the whole plugin on other shapes. Omit it and let ' + + 'the loader auto-discover plugin/agents/*.md (PR #494)', + }); + } + + for (const key of Object.keys(manifest)) { + if (key === 'agents') continue; // already reported with the specific fix + if (!KNOWN_LOADER_KEYS.includes(key)) { + findings.push({ + relPath: manifestRel, + reason: + `unverified manifest key "${key}"; confirm it loads in a real Claude Code ` + + 'install, then add it to KNOWN_LOADER_KEYS in scripts/lib/validate-plugin-manifest.js', + }); + } + } + + if (manifest.skills !== './skills/') { + findings.push({ + relPath: manifestRel, + reason: + `"skills" is ${JSON.stringify(manifest.skills)}; must be "./skills/" ` + + '(trailing-slash form, issue #86)', + }); + } + + // With no `agents` key, shipped files are the only thing the loader sees. + const shippedAgents = listMarkdown(path.join(rootDir, 'plugin', 'agents')); + for (const { sourceFile, shippedFile } of expectedClaudeAgentFiles(rootDir)) { + if (!shippedAgents.includes(shippedFile)) { + findings.push({ + relPath: `plugin/agents/${shippedFile}`, + reason: + 'missing from the plugin subtree; auto-discovery relies on the shipped ' + + `file, so skill/agents/${sourceFile} would never load. Run \`bun run build:release\``, + }); + } + } + + return findings; +} diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index 2233f3044..a29b74c05 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; -export const DEFAULT_SUITES = ['core', 'detector', 'live', 'framework']; +export const DEFAULT_SUITES = ['core', 'detector', 'live', 'framework', 'plugin-e2e']; export const OPT_IN_SUITES = [ 'cli-remote-e2e', 'live-e2e', @@ -47,6 +47,7 @@ export const SUITES = { 'tests/lib/transformers/providers.test.js', 'tests/skills-cli.test.js', 'tests/validate-plugin-versions.test.js', + 'tests/validate-plugin-manifest.test.js', ], }, { @@ -212,6 +213,25 @@ export const SUITES = { }, ], }, + 'plugin-e2e': { + description: 'Install the committed ./plugin subtree into a real (sandboxed) Claude Code and assert skills, agents, and hooks all load. Skips when the claude CLI is not on PATH.', + triggers: [ + ...COMMON_INFRA_PATTERNS, + /^plugin\//, + /^skill\/agents\//, + /^scripts\/build\.js$/, + /^scripts\/lib\/validate-plugin-manifest\.js$/, + /^tests\/plugin-e2e\.test\.mjs$/, + ], + commands: [ + { + runner: 'node', + timeoutMs: 300000, + forceExit: true, + files: ['tests/plugin-e2e.test.mjs'], + }, + ], + }, 'live-e2e': { description: 'Full Playwright live-mode click-to-accept sweep across runtime framework fixtures.', optIn: true, diff --git a/tests/plugin-e2e.test.mjs b/tests/plugin-e2e.test.mjs new file mode 100644 index 000000000..1f7f286de --- /dev/null +++ b/tests/plugin-e2e.test.mjs @@ -0,0 +1,143 @@ +/** + * Plugin loader E2E: install the committed ./plugin subtree into a real + * Claude Code and assert every shipped component actually loads. Part of the + * default suite; the only external requirement is the claude CLI, and the + * suite skips cleanly when it is not on PATH. + * + * The unit-level shape guard (tests/validate-plugin-manifest.test.js) pins the + * loader contract we KNOW about. This suite is the only thing that catches the + * contract being wrong: the `agents` manifest key shipped for months and loaded + * zero of the four subagents (PR #494), `claude plugin validate` never flagged + * it, and earlier releases had SKILL.md frontmatter that failed to parse. All + * of those are invisible until the real loader reports its component + * inventory, which is exactly what this suite asserts on. + * + * Isolation: every claude invocation runs with CLAUDE_CONFIG_DIR, HOME, and + * USERPROFILE all pointed into a fresh temp dir, so the developer's real + * config, marketplaces, and installed plugins are never touched even if the + * CLI derives a path from the home directory rather than CLAUDE_CONFIG_DIR. + * Requires the `claude` CLI on PATH; skips cleanly otherwise. + * + * Run with: bun run test:plugin-e2e + */ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync, execSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const PLUGIN_DIR = path.join(REPO_ROOT, 'plugin'); +const MARKETPLACE_NAME = 'impeccable-e2e'; +const PLUGIN_REF = `impeccable@${MARKETPLACE_NAME}`; + +// On Windows the claude CLI is a .cmd shim, which Node refuses to spawn +// without a shell, so commands there go through one with every argument +// double-quoted (paths under %TEMP% routinely contain spaces). Elsewhere +// execFileSync runs the binary directly with no quoting concerns. +const IS_WINDOWS = process.platform === 'win32'; +const quoteForCmd = (arg) => `"${String(arg).replace(/"/g, '""')}"`; +const runClaude = (args, opts) => + IS_WINDOWS + ? execSync(['claude', ...args.map(quoteForCmd)].join(' '), opts) + : execFileSync('claude', args, opts); + +const claudeAvailable = (() => { + try { + runClaude(['--version'], { stdio: 'ignore', timeout: 30000 }); + return true; + } catch { + return false; + } +})(); + +const skip = claudeAvailable ? false : 'claude CLI not on PATH; install Claude Code to run the plugin E2E suite'; + +describe('committed plugin subtree loads in a real Claude Code', { skip }, () => { + let workDir; + let configDir; + let detailsOutput; + + const claude = (...args) => + runClaude(args, { + encoding: 'utf-8', + timeout: 120000, + // Neutral cwd so the repo's own .claude/ project config cannot leak in. + cwd: workDir, + // Belt and suspenders: CLAUDE_CONFIG_DIR is the documented isolation + // switch, but any path the CLI derives from the home directory instead + // must also land in the sandbox, so HOME/USERPROFILE point there too. + env: { + ...process.env, + CLAUDE_CONFIG_DIR: configDir, + HOME: workDir, + USERPROFILE: workDir, + }, + }); + + before(() => { + workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-plugin-e2e-')); + configDir = path.join(workDir, 'claude-config'); + fs.mkdirSync(configDir, { recursive: true }); + const marketplaceDir = path.join(workDir, 'marketplace'); + fs.mkdirSync(path.join(marketplaceDir, '.claude-plugin'), { recursive: true }); + fs.cpSync(PLUGIN_DIR, path.join(marketplaceDir, 'impeccable'), { recursive: true }); + fs.writeFileSync( + path.join(marketplaceDir, '.claude-plugin', 'marketplace.json'), + JSON.stringify({ + name: MARKETPLACE_NAME, + owner: { name: 'impeccable plugin E2E' }, + plugins: [ + { name: 'impeccable', source: './impeccable', description: 'committed ./plugin subtree' }, + ], + }, null, 2), + ); + + claude('plugin', 'marketplace', 'add', marketplaceDir); + claude('plugin', 'install', PLUGIN_REF); + detailsOutput = claude('plugin', 'details', PLUGIN_REF); + }); + + after(() => { + if (workDir) fs.rmSync(workDir, { recursive: true, force: true }); + }); + + // `claude plugin details` prints "Skills (N) name, name" lines; long name + // lists wrap, so counts come from the header and names are matched anywhere. + const componentCount = (component) => { + const match = detailsOutput.match(new RegExp(`${component}\\s+\\((\\d+)\\)`)); + assert.ok(match, `component inventory has no "${component} (N)" line:\n${detailsOutput}`); + return Number(match[1]); + }; + + it('reports the plugin as installed rather than silently absent', () => { + // A manifest the loader rejects surfaces as "Plugin not found" here, with + // no validation error anywhere else. Reaching this assertion at all means + // the details call above did not throw. + assert.match(detailsOutput, /Component inventory/); + }); + + it('parses and loads the impeccable skill', () => { + assert.equal(componentCount('Skills'), 1); + assert.match(detailsOutput, /Skills\s+\(1\)\s+impeccable/); + }); + + it('loads every shipped agent via auto-discovery (PR #494 regression)', () => { + const shipped = fs.readdirSync(path.join(PLUGIN_DIR, 'agents')) + .filter((file) => file.endsWith('.md')) + .map((file) => file.replace(/\.md$/, '')); + assert.ok(shipped.length > 0, 'plugin/agents/ ships no agent files'); + assert.equal(componentCount('Agents'), shipped.length); + for (const name of shipped) { + assert.ok(detailsOutput.includes(name), `agent "${name}" missing from inventory`); + } + }); + + it('discovers the packaged hooks', () => { + assert.equal(componentCount('Hooks'), 2); + assert.match(detailsOutput, /PostToolUse/); + assert.match(detailsOutput, /Stop/); + }); +}); diff --git a/tests/validate-plugin-manifest.test.js b/tests/validate-plugin-manifest.test.js new file mode 100644 index 000000000..559ae19b7 --- /dev/null +++ b/tests/validate-plugin-manifest.test.js @@ -0,0 +1,194 @@ +/** + * Unit coverage for the plugin manifest shape guard (PR #494). + * + * The bug this exists to catch: the build emitted an `agents` array into the + * generated plugin manifest, and that key made Claude Code load zero of the + * four shipped subagents. No validator looked at the manifest's shape, so the + * defect shipped in every release since the subagents were added. The guard + * pins the verified loader contract (KNOWN_LOADER_KEYS) so any new key fails + * the build until it has been confirmed against a real Claude Code install. + */ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { + collectPluginManifestFindings, + KNOWN_LOADER_KEYS, +} from '../scripts/lib/validate-plugin-manifest.js'; + +const REPO_ROOT = path.resolve(import.meta.dir, '..'); + +const GOOD_MANIFEST = { + name: 'impeccable', + description: 'Test plugin', + version: '4.0.4', + author: { name: 'Paul Bakaus' }, + homepage: 'https://impeccable.style', + repository: 'https://github.com/pbakaus/impeccable', + skills: './skills/', +}; + +function writeFixture(root, { manifest = GOOD_MANIFEST, sourceAgents = [], shippedAgents } = {}) { + const write = (rel, contents) => { + const abs = path.join(root, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, contents); + }; + if (manifest !== undefined) { + write( + 'plugin/.claude-plugin/plugin.json', + typeof manifest === 'string' ? manifest : JSON.stringify(manifest, null, 2), + ); + } + // Source agents may be plain filenames or { file, frontmatter } for tests + // that exercise the build's emit rules (claude-name, name, providers). + for (const agent of sourceAgents) { + const { file, frontmatter = '' } = typeof agent === 'string' ? { file: agent } : agent; + write(`skill/agents/${file}`, `---\n${frontmatter}${frontmatter ? '\n' : ''}description: t\n---\nBody.\n`); + } + const shipped = shippedAgents ?? sourceAgents.map((a) => (typeof a === 'string' ? a : a.file)); + for (const file of shipped) write(`plugin/agents/${file}`, '---\ndescription: t\n---\nBody.\n'); +} + +describe('collectPluginManifestFindings', () => { + let root; + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-manifest-')); + }); + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + test('a clean manifest with shipped agents produces no findings', () => { + writeFixture(root, { sourceAgents: ['reviewer.md', 'producer.md'] }); + expect(collectPluginManifestFindings(root)).toEqual([]); + }); + + test('flags an agents key given as an array of file paths (the shipped bug)', () => { + writeFixture(root, { + manifest: { ...GOOD_MANIFEST, agents: ['./agents/reviewer.md', './agents/producer.md'] }, + sourceAgents: ['reviewer.md', 'producer.md'], + }); + const findings = collectPluginManifestFindings(root); + expect(findings).toHaveLength(1); + expect(findings[0].relPath).toBe('plugin/.claude-plugin/plugin.json'); + expect(findings[0].reason).toMatch(/"agents" key/); + expect(findings[0].reason).toMatch(/zero agents/); + }); + + test('flags an agents key of any other shape too', () => { + writeFixture(root, { manifest: { ...GOOD_MANIFEST, agents: './agents' } }); + const findings = collectPluginManifestFindings(root); + expect(findings.some((f) => f.reason.match(/"agents" key/))).toBe(true); + }); + + test('flags a manifest key outside the verified loader contract', () => { + writeFixture(root, { manifest: { ...GOOD_MANIFEST, mcpServers: './mcp.json' } }); + const findings = collectPluginManifestFindings(root); + expect(findings).toHaveLength(1); + expect(findings[0].reason).toMatch(/unverified manifest key "mcpServers"/); + expect(findings[0].reason).toMatch(/KNOWN_LOADER_KEYS/); + }); + + test('flags a skills path without the trailing slash (issue #86)', () => { + writeFixture(root, { manifest: { ...GOOD_MANIFEST, skills: './skills' } }); + const findings = collectPluginManifestFindings(root); + expect(findings).toHaveLength(1); + expect(findings[0].reason).toMatch(/trailing-slash/); + }); + + test('flags a source agent missing from the shipped subtree', () => { + writeFixture(root, { + sourceAgents: ['reviewer.md', 'producer.md'], + shippedAgents: ['reviewer.md'], + }); + const findings = collectPluginManifestFindings(root); + expect(findings).toHaveLength(1); + expect(findings[0].relPath).toBe('plugin/agents/producer.md'); + expect(findings[0].reason).toMatch(/never load/); + expect(findings[0].reason).toMatch(/skill\/agents\/producer\.md/); + }); + + test('a claude-name rename expects the emitted filename, not the source basename', () => { + writeFixture(root, { + sourceAgents: [{ file: 'reviewer.md', frontmatter: 'claude-name: impeccable-reviewer' }], + shippedAgents: ['impeccable-reviewer.md'], + }); + expect(collectPluginManifestFindings(root)).toEqual([]); + }); + + test('a claude-name rename that is not shipped is reported under the emitted filename', () => { + writeFixture(root, { + sourceAgents: [{ file: 'reviewer.md', frontmatter: 'claude-name: impeccable-reviewer' }], + shippedAgents: [], + }); + const findings = collectPluginManifestFindings(root); + expect(findings).toHaveLength(1); + expect(findings[0].relPath).toBe('plugin/agents/impeccable-reviewer.md'); + expect(findings[0].reason).toMatch(/skill\/agents\/reviewer\.md/); + }); + + test('a frontmatter name overrides the source basename like the build does', () => { + writeFixture(root, { + sourceAgents: [{ file: 'reviewer.md', frontmatter: 'name: custom-reviewer' }], + shippedAgents: ['custom-reviewer.md'], + }); + expect(collectPluginManifestFindings(root)).toEqual([]); + }); + + test('an agent whose providers list excludes claude-code owes no shipped copy', () => { + writeFixture(root, { + sourceAgents: [ + { file: 'codex-only.md', frontmatter: 'providers: codex' }, + 'reviewer.md', + ], + shippedAgents: ['reviewer.md'], + }); + expect(collectPluginManifestFindings(root)).toEqual([]); + }); + + test('reports every problem at once', () => { + writeFixture(root, { + manifest: { ...GOOD_MANIFEST, agents: ['./agents/reviewer.md'], skills: './skills', commands: './commands/' }, + sourceAgents: ['reviewer.md'], + shippedAgents: [], + }); + const reasons = collectPluginManifestFindings(root).map((f) => f.reason); + expect(reasons).toHaveLength(4); + }); + + test('an absent plugin subtree produces no findings', () => { + expect(collectPluginManifestFindings(root)).toEqual([]); + }); + + test('a malformed manifest is a finding, not a thrown stack', () => { + writeFixture(root, { manifest: '{ not json' }); + const findings = collectPluginManifestFindings(root); + expect(findings).toHaveLength(1); + expect(findings[0].reason).toMatch(/parse/); + }); + + test('valid JSON that is not an object is a finding, not a thrown stack', () => { + for (const raw of ['null', '"impeccable"', '42', '["./agents/reviewer.md"]']) { + writeFixture(root, { manifest: raw }); + const findings = collectPluginManifestFindings(root); + expect(findings).toHaveLength(1); + expect(findings[0].reason).toMatch(/not a JSON object/); + } + }); + + test('KNOWN_LOADER_KEYS never re-admits agents', () => { + expect(KNOWN_LOADER_KEYS).not.toContain('agents'); + }); +}); + +describe('committed plugin subtree', () => { + // The test that was missing when the agents key shipped: validate the real + // artifact the marketplace installs, not a fixture. If this fails, the + // committed ./plugin subtree carries a manifest shape Claude Code will not + // load; regenerate it with `bun run build:release` after fixing build.js. + test('the shipped manifest honors the verified loader contract', () => { + expect(collectPluginManifestFindings(REPO_ROOT)).toEqual([]); + }); +});