import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { resolve, join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { mkdir, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { buildManifest, splitManifestByOwnership, classifyOwnership } from '../../scanners/manifest.mjs'; import { deriveLoadPattern } from '../../scanners/lib/active-config-reader.mjs'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); const PLUGIN_ROOT = resolve(__dirname, '../..'); const CLI = join(PLUGIN_ROOT, 'scanners', 'manifest.mjs'); function uniqueDir(suffix) { return join(tmpdir(), `config-audit-manifest-${suffix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); } function runCli(args, env = {}) { const proc = spawnSync('node', [CLI, ...args], { encoding: 'utf-8', env: { ...process.env, ...env }, }); return proc; } describe('manifest CLI — real-config path (plugin root)', () => { let output; before(() => { const proc = runCli([PLUGIN_ROOT, '--json']); assert.equal(proc.status, 0, `expected exit 0, got ${proc.status}; stderr: ${proc.stderr}`); output = JSON.parse(proc.stdout); }); it('emits non-empty sources array', () => { assert.ok(Array.isArray(output.sources)); assert.ok(output.sources.length > 0, `expected sources.length > 0, got ${output.sources.length}`); }); it('sources are sorted DESC by estimated_tokens', () => { for (let i = 1; i < output.sources.length; i++) { const prev = output.sources[i - 1].estimated_tokens; const curr = output.sources[i].estimated_tokens; assert.ok(prev >= curr, `sources[${i - 1}] (${prev}) should be >= sources[${i}] (${curr})`); } }); it('total ≈ sum(sources.estimated_tokens) (within rounding tolerance)', () => { const sum = output.sources.reduce((s, x) => s + (x.estimated_tokens || 0), 0); assert.ok(output.total >= sum - 1 && output.total <= sum + 1, `expected total ≈ ${sum}, got ${output.total}`); }); it('every source has kind/name/source/estimated_tokens', () => { for (const s of output.sources) { assert.ok(typeof s.kind === 'string' && s.kind.length > 0, 's.kind missing'); assert.ok(typeof s.name === 'string' && s.name.length > 0, 's.name missing'); assert.ok(typeof s.source === 'string', 's.source missing'); assert.equal(typeof s.estimated_tokens, 'number', 's.estimated_tokens not a number'); } }); it('meta.repoPath matches the requested path', () => { assert.equal(output.meta.repoPath, PLUGIN_ROOT); }); it('every source carries a load-pattern triple', () => { const LOAD_PATTERNS = new Set(['always', 'on-demand', 'external', 'unknown']); const SURVIVES = new Set(['yes', 'no', 'n/a']); for (const s of output.sources) { assert.ok(LOAD_PATTERNS.has(s.loadPattern), `${s.kind}:${s.name} has invalid loadPattern: ${s.loadPattern}`); assert.ok(SURVIVES.has(s.survivesCompaction), `${s.kind}:${s.name} has invalid survivesCompaction: ${s.survivesCompaction}`); assert.ok(typeof s.derivationConfidence === 'string' && s.derivationConfidence.length > 0, `${s.kind}:${s.name} missing derivationConfidence`); } }); it('emits a load-pattern summary with always/on-demand/external buckets', () => { assert.ok(output.summary, 'expected output.summary'); for (const bucket of ['always', 'onDemand', 'external']) { assert.ok(output.summary[bucket], `expected summary.${bucket}`); assert.equal(typeof output.summary[bucket].tokens, 'number'); assert.equal(typeof output.summary[bucket].count, 'number'); } // The bucket token sums must reconcile with the per-source loadPattern tags. const alwaysSum = output.sources .filter(s => s.loadPattern === 'always') .reduce((a, s) => a + s.estimated_tokens, 0); assert.equal(output.summary.always.tokens, alwaysSum, 'summary.always.tokens must equal sum of always-loaded sources'); }); }); describe('buildManifest — load-pattern accounting (unit)', () => { // Hand-built activeConfig covering every source kind, with a disabled MCP // server and a plugin roll-up entry that must both be excluded from the // ranked sources + totals. const activeConfig = { claudeMd: { files: [ { path: '/root/CLAUDE.md', scope: 'project', bytes: 100 }, { path: '/home/.claude/CLAUDE.md', scope: 'user', bytes: 100 }, ], totalBytes: 200, estimatedTokens: 50, // → 25 tokens each }, // Must be ignored entirely (roll-up double-counts components). plugins: [{ name: 'plug', estimatedTokens: 9999 }], skills: [ { name: 'sk1', source: 'user', pluginName: null, estimatedTokens: 40 }, { name: 'sk2', source: 'plugin', pluginName: 'plug', estimatedTokens: 30 }, ], rules: [ { name: 'unscoped.md', source: 'project', pluginName: null, scoped: false, estimatedTokens: 10, ...deriveLoadPattern('rule', { scoped: false }) }, { name: 'scoped.md', source: 'project', pluginName: null, scoped: true, estimatedTokens: 8, ...deriveLoadPattern('rule', { scoped: true }) }, ], agents: [ { name: 'a1', source: 'project', pluginName: null, estimatedTokens: 5, ...deriveLoadPattern('agent') }, ], outputStyles: [ { name: 's1', source: 'project', pluginName: null, estimatedTokens: 7, ...deriveLoadPattern('output-style') }, ], mcpServers: [ { name: 'm1', source: 'project', estimatedTokens: 500, enabled: true }, { name: 'm2', source: 'project', estimatedTokens: 999, enabled: false }, ], hooks: [ { event: 'PreToolUse', matcher: 'Edit', source: 'project', estimatedTokens: 0 }, ], }; const built = buildManifest(activeConfig); const byName = (kind, name) => built.sources.find(s => s.kind === kind && s.name === name); it('emits no kind:plugin source (roll-up dropped, component-level only)', () => { assert.ok(built.sources.every(s => s.kind !== 'plugin')); }); it('excludes the plugin roll-up and disabled MCP from the total', () => { // 25+25 (claude-md) + 40+30 (skills) + 10+8 (rules) + 5 (agent) + 7 (style) + 500 (mcp m1) assert.equal(built.total, 650); }); it('tags skills as on-demand (body paid on invoke, not every turn)', () => { assert.equal(byName('skill', 'sk1').loadPattern, 'on-demand'); assert.equal(byName('skill', 'sk1').survivesCompaction, 'n/a'); }); it('propagates rule load-pattern (unscoped=always, scoped=on-demand)', () => { assert.equal(byName('rule', 'unscoped.md').loadPattern, 'always'); assert.equal(byName('rule', 'scoped.md').loadPattern, 'on-demand'); }); it('tags agents and output styles as always-loaded', () => { assert.equal(byName('agent', 'a1').loadPattern, 'always'); assert.equal(byName('output-style', 's1').loadPattern, 'always'); }); it('tags MCP servers always and hooks external', () => { assert.equal(byName('mcp-server', 'm1').loadPattern, 'always'); assert.equal(byName('hook', 'PreToolUse:Edit').loadPattern, 'external'); }); it('excludes the disabled MCP server from sources', () => { assert.equal(byName('mcp-server', 'm2'), undefined); }); it('maps CLAUDE.md scope to the correct load pattern', () => { assert.equal(byName('claude-md', '/root/CLAUDE.md').loadPattern, 'always'); assert.equal(byName('claude-md', '/root/CLAUDE.md').survivesCompaction, 'yes'); assert.equal(byName('claude-md', '/home/.claude/CLAUDE.md').loadPattern, 'always'); }); it('summary buckets reconcile with per-source tags', () => { // always: 25+25+10+5+7+500 = 572 over 6 sources assert.equal(built.summary.always.tokens, 572); assert.equal(built.summary.always.count, 6); // on-demand: 40+30+8 = 78 over 3 sources assert.equal(built.summary.onDemand.tokens, 78); assert.equal(built.summary.onDemand.count, 3); // external: hook (0 tokens) over 1 source assert.equal(built.summary.external.tokens, 0); assert.equal(built.summary.external.count, 1); }); }); describe('splitManifestByOwnership — shared-global vs per-repo-delta (unit, v5.9 B2b)', () => { // Minimal source records — only the fields the splitter reads (source, loadPattern, // estimated_tokens), matching the shape buildManifest stamps onto every source. const src = (source, loadPattern, tokens) => ({ source, loadPattern, estimated_tokens: tokens }); it('classifyOwnership routes each source string to the right layer', () => { // Shared: paid once per machine, identical in every repo. assert.equal(classifyOwnership('user'), 'shared'); assert.equal(classifyOwnership('managed'), 'shared'); assert.equal(classifyOwnership('plugin:foo'), 'shared'); // Per-repo delta: the repo's own project/local contribution. assert.equal(classifyOwnership('project'), 'delta'); assert.equal(classifyOwnership('local'), 'delta'); assert.equal(classifyOwnership('.mcp.json'), 'delta'); // ~/.claude.json:projects is a per-repo MCP slice (keyed by repo path), NOT shared. assert.equal(classifyOwnership('~/.claude.json:projects'), 'delta'); // import + anything unrecognized → delta (never silently folded into the shared layer). assert.equal(classifyOwnership('import'), 'delta'); assert.equal(classifyOwnership('mystery'), 'delta'); assert.equal(classifyOwnership(undefined), 'delta'); }); const sources = [ src('user', 'always', 100), // global CLAUDE.md → shared src('managed', 'always', 50), // managed policy → shared src('plugin:foo', 'always', 30), // plugin agent/rule → shared src('~/.claude.json:projects', 'always', 20), // per-repo MCP slice → delta src('project', 'always', 10), // project CLAUDE.md/rule → delta src('local', 'always', 5), // local → delta src('.mcp.json', 'always', 7), // project MCP → delta src('import', 'always', 3), // conservative → delta ]; const { shared, delta } = splitManifestByOwnership(sources); it('folds global/user/managed/plugin into the shared layer', () => { assert.equal(shared.always.tokens, 100 + 50 + 30); // 180 assert.equal(shared.always.count, 3); }); it('attributes project/local/.mcp.json/claude.json-slice/import to the per-repo delta', () => { assert.equal(delta.always.tokens, 20 + 10 + 5 + 7 + 3); // 45 assert.equal(delta.always.count, 5); }); it('preserves load-pattern buckets within each layer', () => { const mixed = [ src('user', 'always', 100), src('plugin:foo', 'on-demand', 40), // plugin skill body → shared, onDemand src('plugin:foo', 'external', 0), // plugin hook → shared, external src('project', 'on-demand', 8), // delta, onDemand ]; const r = splitManifestByOwnership(mixed); assert.equal(r.shared.always.tokens, 100); assert.equal(r.shared.onDemand.tokens, 40); assert.equal(r.shared.external.count, 1); assert.equal(r.delta.onDemand.tokens, 8); }); it('is a total partition — every source lands in exactly one layer (no loss, no dup)', () => { const wholeTokens = sources.reduce((a, s) => a + s.estimated_tokens, 0); const buckets = (sum) => sum.always.tokens + sum.onDemand.tokens + sum.external.tokens + sum.unknown.tokens; const counts = (sum) => sum.always.count + sum.onDemand.count + sum.external.count + sum.unknown.count; assert.equal(buckets(shared) + buckets(delta), wholeTokens); assert.equal(counts(shared) + counts(delta), sources.length); }); it('emits the canonical {always,onDemand,external,unknown:{tokens,count}} shape for both layers', () => { for (const summary of [shared, delta]) { for (const bucket of ['always', 'onDemand', 'external', 'unknown']) { assert.equal(typeof summary[bucket].tokens, 'number', `${bucket}.tokens`); assert.equal(typeof summary[bucket].count, 'number', `${bucket}.count`); } } }); }); describe('manifest CLI — fixture path (rich-repo with patched HOME)', () => { let fixture; before(async () => { fixture = await buildRichManifestRepo(uniqueDir('rich')); }); after(async () => { if (fixture) await rm(fixture.root, { recursive: true, force: true }); }); it('discovers ≥5 sources (CLAUDE.md cascade + plugins + skills + MCP)', () => { const proc = runCli([fixture.root, '--json'], { HOME: fixture.fakeHome }); assert.equal(proc.status, 0, `stderr: ${proc.stderr}`); const out = JSON.parse(proc.stdout); assert.ok(out.sources.length >= 5, `expected sources.length >= 5, got ${out.sources.length}: ${out.sources.map(s => `${s.kind}:${s.name}`).join(', ')}`); }); it('drops the coarse plugin roll-up (component-level only, no kind:plugin)', () => { const proc = runCli([fixture.root, '--json'], { HOME: fixture.fakeHome }); const out = JSON.parse(proc.stdout); assert.ok(out.sources.every(s => s.kind !== 'plugin'), `expected no kind:plugin source (roll-up dropped); got: ${out.sources.map(s => s.kind).join(', ')}`); }); it('surfaces plugin-provided skills as component-level sources', () => { const proc = runCli([fixture.root, '--json'], { HOME: fixture.fakeHome }); const out = JSON.parse(proc.stdout); const fromA = out.sources.find(s => s.kind === 'skill' && s.name === 'alpha-skill'); assert.ok(fromA, 'expected alpha-skill as a component-level skill source'); assert.ok(/^plugin:/.test(fromA.source), `expected plugin-provided skill source to be namespaced plugin:*; got: ${fromA.source}`); }); it('includes 3 fixture skills (alpha-skill, beta-skill, gamma-skill)', () => { const proc = runCli([fixture.root, '--json'], { HOME: fixture.fakeHome }); const out = JSON.parse(proc.stdout); const skillNames = out.sources.filter(s => s.kind === 'skill').map(s => s.name); for (const expected of ['alpha-skill', 'beta-skill', 'gamma-skill']) { assert.ok(skillNames.includes(expected), `expected skill ${expected}; got skills: ${skillNames.join(', ')}`); } }); it('includes the project .mcp.json server (manifest-mcp)', () => { const proc = runCli([fixture.root, '--json'], { HOME: fixture.fakeHome }); const out = JSON.parse(proc.stdout); const mcpNames = out.sources.filter(s => s.kind === 'mcp-server').map(s => s.name); assert.ok(mcpNames.includes('manifest-mcp'), `expected manifest-mcp in mcp-servers; got: ${mcpNames.join(', ')}`); }); }); describe('manifest CLI — error handling', () => { it('exits 3 for nonexistent path', () => { const proc = runCli(['/nonexistent/path/should/not/exist', '--json']); assert.equal(proc.status, 3); }); it('--output-file writes JSON to the path', async () => { const outPath = join(tmpdir(), `manifest-output-${Date.now()}.json`); try { const proc = runCli([PLUGIN_ROOT, '--output-file', outPath]); assert.equal(proc.status, 0); const { readFile } = await import('node:fs/promises'); const content = await readFile(outPath, 'utf-8'); const parsed = JSON.parse(content); assert.ok(Array.isArray(parsed.sources)); } finally { await rm(outPath, { force: true }); } }); }); /** * Build a richer fixture for manifest tests: 2 plugins + 3 skills + project * .mcp.json. Mirrors buildRichRepo from active-config-reader.test.mjs but * gives every plugin/skill a unique, recognizable name so assertions can be * substring-based instead of count-based. */ async function buildRichManifestRepo(root) { const fakeHome = join(root, 'fake-home'); await mkdir(join(root, '.git'), { recursive: true }); await writeFile(join(root, '.git', 'HEAD'), 'ref: refs/heads/main\n'); await writeFile( join(root, 'CLAUDE.md'), '# Project\n\nManifest fixture.\n', ); await mkdir(join(fakeHome, '.claude'), { recursive: true }); await writeFile( join(fakeHome, '.claude', 'CLAUDE.md'), '# User\n\nFake home for manifest tests.\n', ); await writeFile( join(root, '.mcp.json'), JSON.stringify({ mcpServers: { 'manifest-mcp': { command: 'npx', args: ['fake-pkg'] }, }, }, null, 2), ); // Plugin A — has alpha-skill + beta-skill const pluginA = join(fakeHome, '.claude', 'plugins', 'marketplaces', 'mp', 'plugins', 'manifest-plugin-a'); await mkdir(join(pluginA, '.claude-plugin'), { recursive: true }); await writeFile( join(pluginA, '.claude-plugin', 'plugin.json'), JSON.stringify({ name: 'manifest-plugin-a', version: '1.0.0', description: 'plugin a' }, null, 2), ); await mkdir(join(pluginA, 'skills', 'alpha-skill'), { recursive: true }); await writeFile( join(pluginA, 'skills', 'alpha-skill', 'SKILL.md'), '---\nname: alpha-skill\ndescription: alpha skill from plugin a\n---\n\nAlpha body.\n', ); await mkdir(join(pluginA, 'skills', 'beta-skill'), { recursive: true }); await writeFile( join(pluginA, 'skills', 'beta-skill', 'SKILL.md'), '---\nname: beta-skill\ndescription: beta skill from plugin a\n---\n\nBeta body.\n', ); // Plugin B — has gamma-skill const pluginB = join(fakeHome, '.claude', 'plugins', 'marketplaces', 'mp', 'plugins', 'manifest-plugin-b'); await mkdir(join(pluginB, '.claude-plugin'), { recursive: true }); await writeFile( join(pluginB, '.claude-plugin', 'plugin.json'), JSON.stringify({ name: 'manifest-plugin-b', version: '1.0.0', description: 'plugin b' }, null, 2), ); await mkdir(join(pluginB, 'skills', 'gamma-skill'), { recursive: true }); await writeFile( join(pluginB, 'skills', 'gamma-skill', 'SKILL.md'), '---\nname: gamma-skill\ndescription: gamma skill from plugin b\n---\n\nGamma body.\n', ); return { root, fakeHome }; }