Foundation chunk of v5.6 "steering-model II" (internal plumbing for B/C;
no command-output change, so --json/--raw/SC-5/6/7 stay byte-stable, count
stays 13).
active-config-reader.mjs:
- deriveLoadPattern(kind,{scoped}) — pure helper mapping each source kind to
loadPattern {always,on-demand,external} + survivesCompaction {yes,no,n/a}
+ derivationConfidence {confirmed,inferred}, traced to the published
loading model (V-rows in docs/v5.5-steering-model-plan.md).
- enumerateRules / enumerateAgents / enumerateOutputStyles — the three
source kinds previously unenumerated (mirror enumerateSkills). Output-style
discovery is direct (not a new file-discovery type) to keep the discovery
surface stable.
- readActiveConfig now exposes rules/agents/outputStyles arrays + totals
counts/subtotals (folded into grandTotal).
yaml-parser.mjs:
- parseSimpleYaml now reads YAML block sequences (paths:\n - a), not just
inline paths:. An empty-valued key with no `- ` items stays null
(backcompat). Resolves a pre-existing RUL false-positive (a block-seq-scoped
rule was misread as unscoped) — fix flows through unchanged RUL code.
Tests +35 (961 -> 996): block-seq parser cases, RUL block-seq regression
(no-misflag + durability-fires), deriveLoadPattern table, three enumerators
(positive+negative). Amended two existing ACR asserts (top-level key shape +
grandTotal sum). self-audit A/A, readmeCheck passed, mismatches []. tests
badge 961+->996+; README testing prose de-staled (635/36 -> 996/56);
CLAUDE.md Foundation note.
B (manifest/tokens render + snapshot regen) and C (CA-OST, count->14)
deferred to their own sessions/GO.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ter3E2JSi1Khgmuf2kady8
876 lines
36 KiB
JavaScript
876 lines
36 KiB
JavaScript
import { describe, it, before, after, beforeEach, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { join, dirname, resolve } from 'node:path';
|
|
import { mkdir, writeFile, rm, readFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import {
|
|
estimateTokens,
|
|
detectGitRoot,
|
|
walkClaudeMdCascade,
|
|
readClaudeJsonProjectSlice,
|
|
enumeratePlugins,
|
|
enumerateSkills,
|
|
readActiveHooks,
|
|
readActiveMcpServers,
|
|
readActiveConfig,
|
|
deriveLoadPattern,
|
|
enumerateRules,
|
|
enumerateAgents,
|
|
enumerateOutputStyles,
|
|
} from '../../scanners/lib/active-config-reader.mjs';
|
|
|
|
function uniqueDir(suffix) {
|
|
return join(tmpdir(), `config-audit-acr-${suffix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
}
|
|
|
|
/**
|
|
* Build a rich-repo fixture under `root`.
|
|
* Layout mirrors feature plan §8 — git-repo, CLAUDE.md cascade, settings layers,
|
|
* .mcp.json, fake-home with plugins + .claude.json.
|
|
*/
|
|
async function buildRichRepo(root) {
|
|
const fakeHome = join(root, 'fake-home');
|
|
// Repo marker
|
|
await mkdir(join(root, '.git'), { recursive: true });
|
|
await writeFile(join(root, '.git', 'HEAD'), 'ref: refs/heads/main\n');
|
|
|
|
// Project CLAUDE.md with @import
|
|
await mkdir(join(root, 'docs'), { recursive: true });
|
|
await writeFile(
|
|
join(root, 'CLAUDE.md'),
|
|
'# Project Instructions\n\n@docs/conv.md\n\nBuild with care.\n',
|
|
);
|
|
await writeFile(join(root, 'docs', 'conv.md'), '# Conventions\n\nUse conventional commits.\n');
|
|
|
|
// Settings cascade
|
|
await mkdir(join(root, '.claude', 'rules'), { recursive: true });
|
|
await writeFile(
|
|
join(root, '.claude', 'settings.json'),
|
|
JSON.stringify({
|
|
permissions: { allow: ['Read', 'Write'] },
|
|
hooks: {
|
|
PreToolUse: [
|
|
{ matcher: 'Bash', hooks: [{ type: 'command', command: 'check.sh' }] },
|
|
],
|
|
},
|
|
}, null, 2),
|
|
);
|
|
await writeFile(
|
|
join(root, '.claude', 'settings.local.json'),
|
|
JSON.stringify({ env: { DEBUG: 'true' } }, null, 2),
|
|
);
|
|
await writeFile(join(root, '.claude', 'rules', 'team.md'), '# Team Rule\n');
|
|
|
|
// Project .mcp.json
|
|
await writeFile(
|
|
join(root, '.mcp.json'),
|
|
JSON.stringify({
|
|
mcpServers: {
|
|
alpha: { command: 'npx', args: ['alpha-server'] },
|
|
beta: { command: 'npx', args: ['beta-server'] },
|
|
},
|
|
}, null, 2),
|
|
);
|
|
|
|
// Fake HOME — user CLAUDE.md, settings, plugins, .claude.json
|
|
await mkdir(join(fakeHome, '.claude'), { recursive: true });
|
|
await writeFile(
|
|
join(fakeHome, '.claude', 'CLAUDE.md'),
|
|
'# User Instructions\n\nBe terse.\n',
|
|
);
|
|
await writeFile(
|
|
join(fakeHome, '.claude', 'settings.json'),
|
|
JSON.stringify({
|
|
hooks: {
|
|
Stop: [{ hooks: [{ type: 'command', command: 'reminder.sh' }] }],
|
|
},
|
|
}, null, 2),
|
|
);
|
|
|
|
// Plugin: demo plugin with 1 command, 1 skill, 1 hook
|
|
const pluginRoot = join(
|
|
fakeHome, '.claude', 'plugins', 'marketplaces', 'mp', 'plugins', 'demo',
|
|
);
|
|
await mkdir(join(pluginRoot, '.claude-plugin'), { recursive: true });
|
|
await writeFile(
|
|
join(pluginRoot, '.claude-plugin', 'plugin.json'),
|
|
JSON.stringify({ name: 'demo', description: 'test plugin', version: '0.1.0' }, null, 2),
|
|
);
|
|
await mkdir(join(pluginRoot, 'commands'), { recursive: true });
|
|
await writeFile(
|
|
join(pluginRoot, 'commands', 'foo.md'),
|
|
'---\nname: demo:foo\ndescription: foo\nmodel: sonnet\n---\n\nFoo command.\n',
|
|
);
|
|
await mkdir(join(pluginRoot, 'skills', 'bar'), { recursive: true });
|
|
await writeFile(
|
|
join(pluginRoot, 'skills', 'bar', 'SKILL.md'),
|
|
'---\nname: bar\ndescription: bar skill\n---\n\nBar skill body.\n',
|
|
);
|
|
await mkdir(join(pluginRoot, 'hooks'), { recursive: true });
|
|
await writeFile(
|
|
join(pluginRoot, 'hooks', 'hooks.json'),
|
|
JSON.stringify({
|
|
hooks: {
|
|
PostToolUse: [{ hooks: [{ type: 'command', command: 'demo-hook.sh' }] }],
|
|
},
|
|
}, null, 2),
|
|
);
|
|
|
|
// ~/.claude.json with projects slice matching the repo root
|
|
await writeFile(
|
|
join(fakeHome, '.claude.json'),
|
|
JSON.stringify({
|
|
projects: {
|
|
[root]: {
|
|
mcpServers: {
|
|
gamma: { command: 'gamma-server' },
|
|
},
|
|
disabledMcpjsonServers: ['beta'],
|
|
},
|
|
},
|
|
}, null, 2),
|
|
);
|
|
|
|
return { root, fakeHome, pluginRoot };
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// estimateTokens
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('estimateTokens', () => {
|
|
it('markdown: 4 chars per token, rounded up', () => {
|
|
assert.equal(estimateTokens(400, 'markdown'), 100);
|
|
assert.equal(estimateTokens(401, 'markdown'), 101);
|
|
assert.equal(estimateTokens(0, 'markdown'), 0);
|
|
});
|
|
|
|
it('json: 3.5 chars per token, rounded up', () => {
|
|
assert.equal(estimateTokens(350, 'json'), 100);
|
|
assert.equal(estimateTokens(100, 'json'), 29);
|
|
});
|
|
|
|
it('frontmatter: caps at 600 bytes / 150 tokens', () => {
|
|
assert.equal(estimateTokens(100, 'frontmatter'), 25);
|
|
assert.equal(estimateTokens(600, 'frontmatter'), 150);
|
|
assert.equal(estimateTokens(10_000, 'frontmatter'), 150);
|
|
});
|
|
|
|
it('item: flat 15 regardless of bytes', () => {
|
|
assert.equal(estimateTokens(0, 'item'), 15);
|
|
assert.equal(estimateTokens(9999, 'item'), 15);
|
|
});
|
|
|
|
it('defaults to markdown when kind omitted', () => {
|
|
assert.equal(estimateTokens(400), 100);
|
|
});
|
|
|
|
it('handles invalid bytes gracefully', () => {
|
|
assert.equal(estimateTokens(-1, 'markdown'), 0);
|
|
assert.equal(estimateTokens(NaN, 'markdown'), 0);
|
|
});
|
|
|
|
// v5 F2: differentiated MCP estimate
|
|
it('mcp: 0 bytes → at least 500 (base overhead floor)', () => {
|
|
assert.ok(estimateTokens(0, 'mcp') >= 500,
|
|
`expected >= 500, got ${estimateTokens(0, 'mcp')}`);
|
|
});
|
|
|
|
it('mcp: with toolCount: 10 → at least 2000', () => {
|
|
assert.ok(estimateTokens(0, 'mcp', { toolCount: 10 }) >= 2000,
|
|
`expected >= 2000, got ${estimateTokens(0, 'mcp', { toolCount: 10 })}`);
|
|
});
|
|
|
|
it('mcp: ratio mcp/item ≥ 30 for 10-tool server', () => {
|
|
const mcp = estimateTokens(0, 'mcp', { toolCount: 10 });
|
|
const item = estimateTokens(0, 'item');
|
|
assert.ok(mcp / item >= 30,
|
|
`expected ratio >= 30, got mcp=${mcp} item=${item} ratio=${mcp / item}`);
|
|
});
|
|
|
|
it('mcp: with bytes uses json-rate floor', () => {
|
|
// 700 bytes JSON ≈ 200 tokens, but mcp keeps 500 floor
|
|
assert.equal(estimateTokens(700, 'mcp'), 500);
|
|
// 3500 bytes JSON = 1000 tokens, exceeds floor
|
|
assert.equal(estimateTokens(3500, 'mcp'), 1000);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// detectGitRoot
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('detectGitRoot', () => {
|
|
let root;
|
|
before(async () => {
|
|
root = uniqueDir('git');
|
|
await mkdir(join(root, '.git'), { recursive: true });
|
|
await mkdir(join(root, 'src', 'deep'), { recursive: true });
|
|
await writeFile(join(root, '.git', 'HEAD'), '\n');
|
|
});
|
|
after(async () => { await rm(root, { recursive: true, force: true }); });
|
|
|
|
it('finds .git in start dir', async () => {
|
|
const result = await detectGitRoot(root);
|
|
assert.equal(result, resolve(root));
|
|
});
|
|
|
|
it('walks up to find .git', async () => {
|
|
const result = await detectGitRoot(join(root, 'src', 'deep'));
|
|
assert.equal(result, resolve(root));
|
|
});
|
|
|
|
it('returns null when no .git in chain', async () => {
|
|
const noGit = uniqueDir('nogit');
|
|
await mkdir(noGit, { recursive: true });
|
|
try {
|
|
const result = await detectGitRoot(noGit);
|
|
// Could resolve to outer repo (the plugin repo) if tmpdir happens to be nested.
|
|
// Accept null OR a path that is NOT noGit itself.
|
|
if (result !== null) {
|
|
assert.notEqual(result, resolve(noGit));
|
|
}
|
|
} finally {
|
|
await rm(noGit, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// walkClaudeMdCascade
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('walkClaudeMdCascade', () => {
|
|
let fixture;
|
|
let originalHome;
|
|
|
|
beforeEach(async () => {
|
|
fixture = await buildRichRepo(uniqueDir('cascade'));
|
|
originalHome = process.env.HOME;
|
|
process.env.HOME = fixture.fakeHome;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
process.env.HOME = originalHome;
|
|
await rm(fixture.root, { recursive: true, force: true });
|
|
});
|
|
|
|
it('returns files in load order (user first, then project, then imports)', async () => {
|
|
const result = await walkClaudeMdCascade(fixture.root);
|
|
const scopes = result.files.map(f => f.scope);
|
|
assert.ok(scopes.includes('user'), 'expected user scope');
|
|
assert.ok(scopes.includes('project'), 'expected project scope');
|
|
assert.ok(scopes.includes('import'), 'expected import scope');
|
|
|
|
// user CLAUDE.md should come before project CLAUDE.md
|
|
const userIdx = result.files.findIndex(f => f.scope === 'user');
|
|
const projIdx = result.files.findIndex(f => f.scope === 'project');
|
|
assert.ok(userIdx < projIdx, 'user scope must come before project');
|
|
});
|
|
|
|
it('resolves @imports and marks them with parent', async () => {
|
|
const result = await walkClaudeMdCascade(fixture.root);
|
|
const imp = result.files.find(f => f.path.endsWith('docs/conv.md'));
|
|
assert.ok(imp, 'import should be discovered');
|
|
assert.equal(imp.scope, 'import');
|
|
assert.ok(imp.parent && imp.parent.endsWith('CLAUDE.md'));
|
|
});
|
|
|
|
it('counts bytes and lines', async () => {
|
|
const result = await walkClaudeMdCascade(fixture.root);
|
|
assert.ok(result.totalBytes > 0);
|
|
assert.ok(result.totalLines > 0);
|
|
for (const f of result.files) {
|
|
assert.ok(f.bytes > 0);
|
|
assert.ok(f.lines > 0);
|
|
}
|
|
});
|
|
|
|
it('computes estimatedTokens via markdown heuristic', async () => {
|
|
const result = await walkClaudeMdCascade(fixture.root);
|
|
assert.equal(result.estimatedTokens, Math.ceil(result.totalBytes / 4));
|
|
});
|
|
|
|
it('handles missing user CLAUDE.md gracefully', async () => {
|
|
// Remove user CLAUDE.md
|
|
await rm(join(fixture.fakeHome, '.claude', 'CLAUDE.md'));
|
|
const result = await walkClaudeMdCascade(fixture.root);
|
|
const userFiles = result.files.filter(f => f.scope === 'user');
|
|
assert.equal(userFiles.length, 0);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// readClaudeJsonProjectSlice
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('readClaudeJsonProjectSlice', () => {
|
|
let fixture;
|
|
let originalHome;
|
|
|
|
beforeEach(async () => {
|
|
fixture = await buildRichRepo(uniqueDir('slice'));
|
|
originalHome = process.env.HOME;
|
|
process.env.HOME = fixture.fakeHome;
|
|
});
|
|
afterEach(async () => {
|
|
process.env.HOME = originalHome;
|
|
await rm(fixture.root, { recursive: true, force: true });
|
|
});
|
|
|
|
it('finds exact-match project key', async () => {
|
|
const slice = await readClaudeJsonProjectSlice(fixture.root);
|
|
assert.equal(slice.projectKey, fixture.root);
|
|
assert.deepEqual(slice.disabledMcpjsonServers, ['beta']);
|
|
assert.ok('gamma' in slice.mcpServers);
|
|
});
|
|
|
|
it('returns empty slice when no .claude.json exists', async () => {
|
|
await rm(join(fixture.fakeHome, '.claude.json'));
|
|
const slice = await readClaudeJsonProjectSlice(fixture.root);
|
|
assert.equal(slice.projectKey, null);
|
|
assert.deepEqual(slice.mcpServers, {});
|
|
});
|
|
|
|
it('longest-prefix match: deeper key wins over shallower', async () => {
|
|
// Rewrite .claude.json with two keys — ancestor and the repo
|
|
const parent = dirname(fixture.root);
|
|
const content = JSON.stringify({
|
|
projects: {
|
|
[parent]: { mcpServers: { shallow: { command: 'shallow' } } },
|
|
[fixture.root]: { mcpServers: { deep: { command: 'deep' } } },
|
|
},
|
|
}, null, 2);
|
|
await writeFile(join(fixture.fakeHome, '.claude.json'), content);
|
|
|
|
const slice = await readClaudeJsonProjectSlice(fixture.root);
|
|
assert.equal(slice.projectKey, fixture.root);
|
|
assert.ok('deep' in slice.mcpServers);
|
|
assert.ok(!('shallow' in slice.mcpServers));
|
|
});
|
|
|
|
it('ancestor prefix matches when target is a subdir of a key', async () => {
|
|
const parent = dirname(fixture.root);
|
|
await writeFile(
|
|
join(fixture.fakeHome, '.claude.json'),
|
|
JSON.stringify({ projects: { [parent]: { mcpServers: { anc: {} } } } }, null, 2),
|
|
);
|
|
const slice = await readClaudeJsonProjectSlice(fixture.root);
|
|
assert.equal(slice.projectKey, parent);
|
|
});
|
|
|
|
it('returns null projectKey when no key matches', async () => {
|
|
await writeFile(
|
|
join(fixture.fakeHome, '.claude.json'),
|
|
JSON.stringify({ projects: { '/some/other/path': {} } }, null, 2),
|
|
);
|
|
const slice = await readClaudeJsonProjectSlice(fixture.root);
|
|
assert.equal(slice.projectKey, null);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// enumeratePlugins
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('enumeratePlugins', () => {
|
|
let fixture;
|
|
let originalHome;
|
|
|
|
beforeEach(async () => {
|
|
fixture = await buildRichRepo(uniqueDir('plugins'));
|
|
originalHome = process.env.HOME;
|
|
process.env.HOME = fixture.fakeHome;
|
|
});
|
|
afterEach(async () => {
|
|
process.env.HOME = originalHome;
|
|
await rm(fixture.root, { recursive: true, force: true });
|
|
});
|
|
|
|
it('discovers plugin and reads plugin.json version', async () => {
|
|
const plugins = await enumeratePlugins();
|
|
assert.ok(plugins.length >= 1);
|
|
const demo = plugins.find(p => p.name === 'demo');
|
|
assert.ok(demo, 'demo plugin should be discovered');
|
|
assert.equal(demo.version, '0.1.0');
|
|
});
|
|
|
|
it('counts commands, skills, hooks', async () => {
|
|
const plugins = await enumeratePlugins();
|
|
const demo = plugins.find(p => p.name === 'demo');
|
|
assert.equal(demo.commands, 1);
|
|
assert.equal(demo.skills, 1);
|
|
assert.equal(demo.hooks, 1);
|
|
});
|
|
|
|
it('returns empty array when HOME has no plugins', async () => {
|
|
process.env.HOME = uniqueDir('empty');
|
|
await mkdir(process.env.HOME, { recursive: true });
|
|
try {
|
|
const plugins = await enumeratePlugins();
|
|
assert.deepEqual(plugins, []);
|
|
} finally {
|
|
await rm(process.env.HOME, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// enumerateSkills
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('enumerateSkills', () => {
|
|
let fixture;
|
|
let originalHome;
|
|
|
|
beforeEach(async () => {
|
|
fixture = await buildRichRepo(uniqueDir('skills'));
|
|
originalHome = process.env.HOME;
|
|
process.env.HOME = fixture.fakeHome;
|
|
});
|
|
afterEach(async () => {
|
|
process.env.HOME = originalHome;
|
|
await rm(fixture.root, { recursive: true, force: true });
|
|
});
|
|
|
|
it('finds plugin skills', async () => {
|
|
const plugins = await enumeratePlugins();
|
|
const skills = await enumerateSkills(plugins);
|
|
const bar = skills.find(s => s.name === 'bar');
|
|
assert.ok(bar, 'plugin skill should be discovered');
|
|
assert.equal(bar.source, 'plugin');
|
|
assert.equal(bar.pluginName, 'demo');
|
|
});
|
|
|
|
it('finds user skills', async () => {
|
|
// Add a user skill
|
|
await mkdir(join(fixture.fakeHome, '.claude', 'skills', 'userskill'), { recursive: true });
|
|
await writeFile(
|
|
join(fixture.fakeHome, '.claude', 'skills', 'userskill', 'SKILL.md'),
|
|
'# user skill\n',
|
|
);
|
|
const skills = await enumerateSkills([]);
|
|
const userSkill = skills.find(s => s.name === 'userskill');
|
|
assert.ok(userSkill, 'user skill should be discovered');
|
|
assert.equal(userSkill.source, 'user');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// readActiveHooks
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('readActiveHooks', () => {
|
|
let fixture;
|
|
let originalHome;
|
|
|
|
beforeEach(async () => {
|
|
fixture = await buildRichRepo(uniqueDir('hooks'));
|
|
originalHome = process.env.HOME;
|
|
process.env.HOME = fixture.fakeHome;
|
|
});
|
|
afterEach(async () => {
|
|
process.env.HOME = originalHome;
|
|
await rm(fixture.root, { recursive: true, force: true });
|
|
});
|
|
|
|
it('merges hooks from user + project + plugin', async () => {
|
|
const plugins = await enumeratePlugins();
|
|
const hooks = await readActiveHooks(fixture.root, plugins);
|
|
const sources = new Set(hooks.map(h => h.source));
|
|
assert.ok(sources.has('user'), 'user hook present');
|
|
assert.ok(sources.has('project'), 'project hook present');
|
|
assert.ok([...sources].some(s => s.startsWith('plugin:')), 'plugin hook present');
|
|
});
|
|
|
|
it('does not dedupe across scopes', async () => {
|
|
// Add duplicate hook in user and project settings
|
|
const dupeHook = {
|
|
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'same.sh' }] }] },
|
|
};
|
|
await writeFile(join(fixture.fakeHome, '.claude', 'settings.json'), JSON.stringify(dupeHook));
|
|
await writeFile(join(fixture.root, '.claude', 'settings.json'), JSON.stringify(dupeHook));
|
|
const hooks = await readActiveHooks(fixture.root, []);
|
|
const sameCmd = hooks.filter(h => h.command === 'same.sh');
|
|
assert.equal(sameCmd.length, 2, 'should report both occurrences');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// readActiveMcpServers
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('readActiveMcpServers', () => {
|
|
let fixture;
|
|
let originalHome;
|
|
|
|
beforeEach(async () => {
|
|
fixture = await buildRichRepo(uniqueDir('mcp'));
|
|
originalHome = process.env.HOME;
|
|
process.env.HOME = fixture.fakeHome;
|
|
});
|
|
afterEach(async () => {
|
|
process.env.HOME = originalHome;
|
|
await rm(fixture.root, { recursive: true, force: true });
|
|
});
|
|
|
|
it('merges project .mcp.json + .claude.json slice', async () => {
|
|
const servers = await readActiveMcpServers(fixture.root);
|
|
const names = servers.map(s => s.name);
|
|
assert.ok(names.includes('alpha'), 'alpha from project');
|
|
assert.ok(names.includes('beta'), 'beta from project');
|
|
assert.ok(names.includes('gamma'), 'gamma from .claude.json');
|
|
});
|
|
|
|
it('honors disabledMcpjsonServers', async () => {
|
|
const servers = await readActiveMcpServers(fixture.root);
|
|
const beta = servers.find(s => s.name === 'beta');
|
|
assert.equal(beta.enabled, false);
|
|
assert.equal(beta.disabledBy, 'disabledMcpjsonServers');
|
|
|
|
const alpha = servers.find(s => s.name === 'alpha');
|
|
assert.equal(alpha.enabled, true);
|
|
assert.equal(alpha.disabledBy, null);
|
|
});
|
|
|
|
it('estimatedTokens >= 500 for every MCP server (v5 F2)', async () => {
|
|
const servers = await readActiveMcpServers(fixture.root);
|
|
assert.ok(servers.length > 0, 'fixture should produce MCP servers');
|
|
for (const s of servers) {
|
|
assert.ok(s.estimatedTokens >= 500,
|
|
`${s.name} from ${s.source} has estimatedTokens=${s.estimatedTokens}, expected >= 500`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// readActiveMcpServers — tool-count detection (v5 M1)
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('readActiveMcpServers — tool-count detection (v5 M1)', () => {
|
|
it('detects toolCount from project node_modules/<pkg>/package.json', async () => {
|
|
const fixturePath = resolve(import.meta.dirname || dirname(new URL(import.meta.url).pathname),
|
|
'..', 'fixtures', 'mcp-tool-heavy');
|
|
const servers = await readActiveMcpServers(fixturePath);
|
|
const heavy = servers.find(s => s.name === 'heavy');
|
|
assert.ok(heavy, 'expected heavy server from fixture');
|
|
assert.equal(heavy.toolCount, 20, `expected toolCount=20, got ${heavy.toolCount}`);
|
|
assert.equal(heavy.toolCountUnknown, false);
|
|
});
|
|
|
|
it('falls back to toolCount: null + toolCountUnknown: true when manifest missing', async () => {
|
|
const fixturePath = resolve(import.meta.dirname || dirname(new URL(import.meta.url).pathname),
|
|
'..', 'fixtures', 'mcp-tool-heavy');
|
|
const servers = await readActiveMcpServers(fixturePath);
|
|
const light = servers.find(s => s.name === 'light');
|
|
assert.ok(light, 'expected light server from fixture');
|
|
assert.equal(light.toolCount, null);
|
|
assert.equal(light.toolCountUnknown, true);
|
|
});
|
|
|
|
it('detects toolCount from cache file in $HOME/.claude/config-audit/mcp-cache/', async () => {
|
|
const fakeHome = uniqueDir('mcp-cache');
|
|
const repoRoot = uniqueDir('mcp-cache-repo');
|
|
await mkdir(repoRoot, { recursive: true });
|
|
await writeFile(
|
|
join(repoRoot, '.mcp.json'),
|
|
JSON.stringify({ mcpServers: { cached: { command: 'npx', args: ['unknown-pkg'] } } }, null, 2),
|
|
);
|
|
await mkdir(join(fakeHome, '.claude', 'config-audit', 'mcp-cache'), { recursive: true });
|
|
await writeFile(
|
|
join(fakeHome, '.claude', 'config-audit', 'mcp-cache', 'cached.json'),
|
|
JSON.stringify({ tools: Array.from({ length: 12 }, (_, i) => ({ name: `t${i}` })) }, null, 2),
|
|
);
|
|
const originalHome = process.env.HOME;
|
|
process.env.HOME = fakeHome;
|
|
try {
|
|
const servers = await readActiveMcpServers(repoRoot);
|
|
const cached = servers.find(s => s.name === 'cached');
|
|
assert.ok(cached, 'expected cached server');
|
|
assert.equal(cached.toolCount, 12, `expected toolCount=12 from cache, got ${cached.toolCount}`);
|
|
assert.equal(cached.toolCountUnknown, false);
|
|
} finally {
|
|
process.env.HOME = originalHome;
|
|
await rm(fakeHome, { recursive: true, force: true });
|
|
await rm(repoRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('toolCount drives estimateTokens (heavy > light)', async () => {
|
|
const fixturePath = resolve(import.meta.dirname || dirname(new URL(import.meta.url).pathname),
|
|
'..', 'fixtures', 'mcp-tool-heavy');
|
|
const servers = await readActiveMcpServers(fixturePath);
|
|
const heavy = servers.find(s => s.name === 'heavy');
|
|
const light = servers.find(s => s.name === 'light');
|
|
assert.ok(heavy.estimatedTokens > light.estimatedTokens,
|
|
`expected heavy (${heavy.estimatedTokens}) > light (${light.estimatedTokens})`);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// readActiveConfig (integration)
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('readActiveConfig (integration)', () => {
|
|
let fixture;
|
|
let originalHome;
|
|
|
|
beforeEach(async () => {
|
|
fixture = await buildRichRepo(uniqueDir('full'));
|
|
originalHome = process.env.HOME;
|
|
process.env.HOME = fixture.fakeHome;
|
|
});
|
|
afterEach(async () => {
|
|
process.env.HOME = originalHome;
|
|
await rm(fixture.root, { recursive: true, force: true });
|
|
});
|
|
|
|
it('produces expected top-level shape', async () => {
|
|
const result = await readActiveConfig(fixture.root);
|
|
const keys = Object.keys(result).sort();
|
|
assert.deepEqual(keys, [
|
|
'agents', 'claudeMd', 'hooks', 'mcpServers', 'meta', 'outputStyles',
|
|
'plugins', 'rules', 'settings', 'skills', 'suggestDisables', 'totals', 'warnings',
|
|
]);
|
|
});
|
|
|
|
it('meta contains required fields', async () => {
|
|
const result = await readActiveConfig(fixture.root);
|
|
assert.equal(result.meta.tool, 'config-audit:whats-active');
|
|
assert.equal(result.meta.version, '1.0.0');
|
|
assert.ok(typeof result.meta.generatedAt === 'string');
|
|
assert.equal(result.meta.repoPath, resolve(fixture.root));
|
|
assert.equal(result.meta.gitRoot, resolve(fixture.root));
|
|
assert.equal(result.meta.projectKey, fixture.root);
|
|
assert.ok(typeof result.meta.durationMs === 'number');
|
|
});
|
|
|
|
it('settings cascade reflects all three layers', async () => {
|
|
const result = await readActiveConfig(fixture.root);
|
|
const scopes = result.settings.cascade.map(c => c.scope);
|
|
assert.deepEqual(scopes, ['user', 'project', 'local']);
|
|
const user = result.settings.cascade.find(c => c.scope === 'user');
|
|
const project = result.settings.cascade.find(c => c.scope === 'project');
|
|
assert.equal(user.exists, true);
|
|
assert.equal(project.exists, true);
|
|
});
|
|
|
|
it('totals.grandTotal equals sum of category subtotals', async () => {
|
|
const result = await readActiveConfig(fixture.root);
|
|
const t = result.totals.estimatedTokens;
|
|
assert.equal(t.grandTotal,
|
|
t.claudeMd + t.plugins + t.skills + t.mcpServers + t.hooks + t.rules + t.agents + t.outputStyles);
|
|
});
|
|
|
|
it('performance budget: durationMs < 2000', async () => {
|
|
const result = await readActiveConfig(fixture.root);
|
|
assert.ok(result.meta.durationMs < 2000,
|
|
`expected < 2000ms, got ${result.meta.durationMs}ms`);
|
|
});
|
|
|
|
it('token estimate within ±20% of hand-computed value', async () => {
|
|
const result = await readActiveConfig(fixture.root);
|
|
const expectedClaudeMd = Math.ceil(result.claudeMd.totalBytes / 4);
|
|
const low = Math.floor(expectedClaudeMd * 0.8);
|
|
const high = Math.ceil(expectedClaudeMd * 1.2);
|
|
assert.ok(
|
|
result.totals.estimatedTokens.claudeMd >= low &&
|
|
result.totals.estimatedTokens.claudeMd <= high,
|
|
`claudeMd tokens ${result.totals.estimatedTokens.claudeMd} outside [${low}, ${high}]`,
|
|
);
|
|
});
|
|
|
|
it('suggestDisables is null by default, object when flag set', async () => {
|
|
const noFlag = await readActiveConfig(fixture.root);
|
|
assert.equal(noFlag.suggestDisables, null);
|
|
|
|
const withFlag = await readActiveConfig(fixture.root, { suggestDisables: true });
|
|
assert.ok(withFlag.suggestDisables && Array.isArray(withFlag.suggestDisables.candidates));
|
|
});
|
|
|
|
it('suggestDisables flags disabled MCP servers', async () => {
|
|
const result = await readActiveConfig(fixture.root, { suggestDisables: true });
|
|
const betaCandidate = result.suggestDisables.candidates.find(
|
|
c => c.kind === 'mcp' && c.name === 'beta',
|
|
);
|
|
assert.ok(betaCandidate, 'beta should be flagged as already disabled');
|
|
assert.equal(betaCandidate.confidence, 'high');
|
|
});
|
|
|
|
it('enumerates rules/agents/outputStyles into the result and totals', async () => {
|
|
const result = await readActiveConfig(fixture.root);
|
|
// buildRichRepo ships one unscoped project rule (.claude/rules/team.md)
|
|
assert.ok(Array.isArray(result.rules) && result.rules.length >= 1);
|
|
const team = result.rules.find(r => r.name === 'team.md');
|
|
assert.ok(team, 'team.md rule should be enumerated');
|
|
assert.equal(team.loadPattern, 'always');
|
|
assert.ok(Array.isArray(result.agents));
|
|
assert.ok(Array.isArray(result.outputStyles));
|
|
assert.equal(result.totals.rules, result.rules.length);
|
|
assert.equal(result.totals.agents, result.agents.length);
|
|
assert.equal(result.totals.outputStyles, result.outputStyles.length);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// v5.6 Foundation — deriveLoadPattern + rule/agent/output-style enumeration
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('deriveLoadPattern (v5.6)', () => {
|
|
const cases = [
|
|
['claude-md-root', {}, 'always', 'yes', 'confirmed'],
|
|
['claude-md-nested', {}, 'on-demand', 'no', 'confirmed'],
|
|
['claude-md-user', {}, 'always', 'yes', 'inferred'],
|
|
['claude-md-managed', {}, 'always', 'yes', 'inferred'],
|
|
['claude-md-import', {}, 'always', 'yes', 'inferred'],
|
|
['rule', { scoped: true }, 'on-demand', 'no', 'confirmed'],
|
|
['rule', { scoped: false }, 'always', 'yes', 'confirmed'],
|
|
['skill-listing', {}, 'always', 'n/a', 'confirmed'],
|
|
['skill-body', {}, 'on-demand', 'n/a', 'confirmed'],
|
|
['agent', {}, 'always', 'n/a', 'inferred'],
|
|
['output-style', {}, 'always', 'yes', 'confirmed'],
|
|
['hook', {}, 'external', 'n/a', 'confirmed'],
|
|
['mcp', {}, 'always', 'yes', 'inferred'],
|
|
];
|
|
for (const [kind, opts, loadPattern, survivesCompaction, derivationConfidence] of cases) {
|
|
const label = `${kind}${opts.scoped !== undefined ? ` (scoped=${opts.scoped})` : ''}`;
|
|
it(`${label} → ${loadPattern}/${survivesCompaction}/${derivationConfidence}`, () => {
|
|
assert.deepEqual(deriveLoadPattern(kind, opts), {
|
|
loadPattern, survivesCompaction, derivationConfidence,
|
|
});
|
|
});
|
|
}
|
|
it('falls back safely for an unknown kind', () => {
|
|
assert.deepEqual(deriveLoadPattern('nope'), {
|
|
loadPattern: 'unknown', survivesCompaction: 'n/a', derivationConfidence: 'inferred',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('enumerateRules (v5.6)', () => {
|
|
let root, emptyHome, originalHome;
|
|
beforeEach(async () => {
|
|
root = uniqueDir('rules');
|
|
emptyHome = uniqueDir('rules-home');
|
|
await mkdir(emptyHome, { recursive: true });
|
|
originalHome = process.env.HOME;
|
|
process.env.HOME = emptyHome;
|
|
});
|
|
afterEach(async () => {
|
|
process.env.HOME = originalHome;
|
|
await rm(root, { recursive: true, force: true });
|
|
await rm(emptyHome, { recursive: true, force: true });
|
|
});
|
|
|
|
it('tags scoped vs unscoped project rules (block-sequence paths detected)', async () => {
|
|
await mkdir(join(root, '.claude', 'rules'), { recursive: true });
|
|
await writeFile(join(root, '.claude', 'rules', 'always.md'), '# Always-on rule\n');
|
|
await writeFile(
|
|
join(root, '.claude', 'rules', 'scoped.md'),
|
|
'---\npaths:\n - src/**/*.ts\n---\n# Scoped rule\n',
|
|
);
|
|
const rules = await enumerateRules(root, []);
|
|
const always = rules.find(r => r.name === 'always.md');
|
|
const scoped = rules.find(r => r.name === 'scoped.md');
|
|
assert.equal(always.scoped, false);
|
|
assert.equal(always.loadPattern, 'always');
|
|
assert.equal(always.survivesCompaction, 'yes');
|
|
assert.equal(always.source, 'project');
|
|
assert.equal(scoped.scoped, true);
|
|
assert.equal(scoped.loadPattern, 'on-demand');
|
|
assert.equal(scoped.survivesCompaction, 'no');
|
|
});
|
|
|
|
it('discovers user-level rules', async () => {
|
|
await mkdir(join(emptyHome, '.claude', 'rules'), { recursive: true });
|
|
await writeFile(join(emptyHome, '.claude', 'rules', 'user.md'), '# User rule\n');
|
|
const rules = await enumerateRules(root, []);
|
|
const user = rules.find(r => r.name === 'user.md');
|
|
assert.ok(user);
|
|
assert.equal(user.source, 'user');
|
|
});
|
|
|
|
it('returns [] when there are no rules', async () => {
|
|
await mkdir(root, { recursive: true });
|
|
assert.deepEqual(await enumerateRules(root, []), []);
|
|
});
|
|
});
|
|
|
|
describe('enumerateAgents (v5.6)', () => {
|
|
let root, emptyHome, originalHome;
|
|
beforeEach(async () => {
|
|
root = uniqueDir('agents');
|
|
emptyHome = uniqueDir('agents-home');
|
|
await mkdir(emptyHome, { recursive: true });
|
|
originalHome = process.env.HOME;
|
|
process.env.HOME = emptyHome;
|
|
});
|
|
afterEach(async () => {
|
|
process.env.HOME = originalHome;
|
|
await rm(root, { recursive: true, force: true });
|
|
await rm(emptyHome, { recursive: true, force: true });
|
|
});
|
|
|
|
it('enumerates project agents as always-loaded (inferred)', async () => {
|
|
await mkdir(join(root, '.claude', 'agents'), { recursive: true });
|
|
await writeFile(
|
|
join(root, '.claude', 'agents', 'reviewer.md'),
|
|
'---\nname: reviewer\ndescription: reviews code\n---\nbody\n',
|
|
);
|
|
const agents = await enumerateAgents(root, []);
|
|
const a = agents.find(x => x.name === 'reviewer');
|
|
assert.ok(a);
|
|
assert.equal(a.source, 'project');
|
|
assert.equal(a.loadPattern, 'always');
|
|
assert.equal(a.survivesCompaction, 'n/a');
|
|
assert.equal(a.derivationConfidence, 'inferred');
|
|
});
|
|
|
|
it('returns [] when there are no agents', async () => {
|
|
await mkdir(root, { recursive: true });
|
|
assert.deepEqual(await enumerateAgents(root, []), []);
|
|
});
|
|
});
|
|
|
|
describe('enumerateOutputStyles (v5.6)', () => {
|
|
let root, emptyHome, originalHome;
|
|
beforeEach(async () => {
|
|
root = uniqueDir('ost');
|
|
emptyHome = uniqueDir('ost-home');
|
|
await mkdir(emptyHome, { recursive: true });
|
|
originalHome = process.env.HOME;
|
|
process.env.HOME = emptyHome;
|
|
});
|
|
afterEach(async () => {
|
|
process.env.HOME = originalHome;
|
|
await rm(root, { recursive: true, force: true });
|
|
await rm(emptyHome, { recursive: true, force: true });
|
|
});
|
|
|
|
it('enumerates project + user output styles as always/yes', async () => {
|
|
await mkdir(join(root, '.claude', 'output-styles'), { recursive: true });
|
|
await writeFile(
|
|
join(root, '.claude', 'output-styles', 'custom.md'),
|
|
'---\nname: Custom\ndescription: x\n---\nStyle instructions.\n',
|
|
);
|
|
await mkdir(join(emptyHome, '.claude', 'output-styles'), { recursive: true });
|
|
await writeFile(
|
|
join(emptyHome, '.claude', 'output-styles', 'user-style.md'),
|
|
'---\nname: UserStyle\n---\nMore.\n',
|
|
);
|
|
const styles = await enumerateOutputStyles(root, []);
|
|
const proj = styles.find(s => s.name === 'custom');
|
|
const user = styles.find(s => s.name === 'user-style');
|
|
assert.ok(proj && user);
|
|
assert.equal(proj.source, 'project');
|
|
assert.equal(proj.loadPattern, 'always');
|
|
assert.equal(proj.survivesCompaction, 'yes');
|
|
assert.equal(proj.derivationConfidence, 'confirmed');
|
|
assert.equal(user.source, 'user');
|
|
});
|
|
|
|
it('returns [] when there are no output styles', async () => {
|
|
await mkdir(root, { recursive: true });
|
|
assert.deepEqual(await enumerateOutputStyles(root, []), []);
|
|
});
|
|
});
|