Consume the v5.6 Foundation enumeration in buildManifest:
- Component-level sources: drop the coarse `kind:'plugin'` roll-up (it
double-counted skills/rules/agents already enumerated once). Kinds are now
claude-md/skill/rule/agent/output-style/mcp-server/hook.
- Every source carries loadPattern/survivesCompaction/derivationConfidence.
Rules/agents/output-styles propagate the foundation-derived values; CLAUDE.md
maps scope→kind (all cascade files always-loaded); skills are tagged on-demand
(skill-body) so the body cost does not inflate the always-loaded subtotal.
- New `summary` (always/onDemand/external/unknown {tokens,count}); the
always-loaded subtotal — "tokens that enter context every turn" — is the headline.
manifest is an environment-aware CLI → SC-6/SC-7 verify it by mode-equivalence,
not byte-equal, and it is not in SC-5. Adding fields in place keeps all snapshots
green with no regen (verified). `total` changes (de-duped) — intended correctness fix.
TOK's load-pattern column (byte-equal SC-6) is deferred to the next chunk (B2).
Tests 996→1008 (deterministic buildManifest unit test + CLI presence checks).
Self-audit A/A, scanner count unchanged at 13.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
334 lines
14 KiB
JavaScript
334 lines
14 KiB
JavaScript
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 } 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('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 };
|
|
}
|