import { describe, it, beforeEach, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { resolve, join, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { mkdir, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { resetCounter } from '../../scanners/lib/output.mjs'; import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs'; import { scan } from '../../scanners/conflict-detector.mjs'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); const FIXTURES = resolve(__dirname, '../fixtures'); describe('CNF scanner — conflict project', () => { let result; beforeEach(async () => { resetCounter(); const discovery = await discoverConfigFiles(resolve(FIXTURES, 'conflict-project')); result = await scan(resolve(FIXTURES, 'conflict-project'), discovery); }); it('returns status ok', () => { assert.equal(result.status, 'ok'); }); it('reports scanner prefix CNF', () => { assert.equal(result.scanner, 'CNF'); }); it('finding IDs match CA-CNF-NNN pattern', () => { for (const f of result.findings) { assert.match(f.id, /^CA-CNF-\d{3}$/); } }); it('detects model key conflict', () => { assert.ok(result.findings.some(f => f.title.includes('model'))); }); it('settings conflict is medium severity', () => { const model = result.findings.find(f => f.title.includes('model')); assert.equal(model.severity, 'medium'); }); it('detects effortLevel key conflict', () => { assert.ok(result.findings.some(f => f.title.includes('effortLevel'))); }); it('detects permission allow/deny conflict', () => { assert.ok(result.findings.some(f => f.title.includes('Permission allow/deny'))); }); it('permission conflict is high severity', () => { const perm = result.findings.find(f => f.title.includes('Permission allow/deny')); assert.equal(perm.severity, 'high'); }); it('detects duplicate hook definition', () => { assert.ok(result.findings.some(f => f.title.includes('Duplicate hook'))); }); it('duplicate hook is low severity', () => { const hook = result.findings.find(f => f.title.includes('Duplicate hook')); assert.equal(hook.severity, 'low'); }); it('has exactly 4 findings', () => { assert.equal(result.findings.length, 4); }); it('includes evidence with scope info', () => { const perm = result.findings.find(f => f.title.includes('Permission')); assert.ok(perm.evidence); }); }); describe('CNF scanner — healthy project', () => { let result; beforeEach(async () => { resetCounter(); const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project')); result = await scan(resolve(FIXTURES, 'healthy-project'), discovery); }); it('returns ok with no conflicts', () => { assert.equal(result.status, 'ok'); }); it('has 0 findings', () => { assert.equal(result.findings.length, 0); }); }); describe('CNF scanner — empty project', () => { let result; beforeEach(async () => { resetCounter(); const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project')); result = await scan(resolve(FIXTURES, 'empty-project'), discovery); }); it('returns skipped when no config files', () => { assert.equal(result.status, 'skipped'); }); it('has 0 findings', () => { assert.equal(result.findings.length, 0); }); }); describe('CNF scanner — minimal project', () => { let result; beforeEach(async () => { resetCounter(); const discovery = await discoverConfigFiles(resolve(FIXTURES, 'minimal-project')); result = await scan(resolve(FIXTURES, 'minimal-project'), discovery); }); it('returns skipped with no settings files', () => { assert.equal(result.status, 'skipped'); }); it('has 0 findings', () => { assert.equal(result.findings.length, 0); }); }); describe('CNF scanner — param-qualified cross-scope conflicts', () => { // project allow: WebFetch(domain:good.com), Agent(model:sonnet) // local deny: WebFetch(domain:*), Agent(model:opus) // WebFetch: deny domain:* covers allow domain:good.com → genuine conflict. // Agent: deny model:opus vs allow model:sonnet → disjoint → NO conflict. let result; beforeEach(async () => { resetCounter(); const discovery = await discoverConfigFiles(resolve(FIXTURES, 'param-conflict-project')); result = await scan(resolve(FIXTURES, 'param-conflict-project'), discovery); }); it('detects the WebFetch wildcard-domain conflict (currently a false negative)', () => { const perm = result.findings.filter(f => f.title.includes('Permission allow/deny')); assert.ok( perm.some(f => /WebFetch/.test(`${f.description} ${f.evidence}`)), `expected a WebFetch allow/deny conflict; got: ${perm.map(f => f.evidence).join(' | ') || '(none)'}`, ); }); it('does NOT flag Agent(model:sonnet) vs Agent(model:opus) as a conflict', () => { const perm = result.findings.filter(f => f.title.includes('Permission allow/deny')); assert.ok( !perm.some(f => /Agent/.test(`${f.description} ${f.evidence}`)), 'distinct model params must not conflict', ); }); it('reports exactly one permission conflict', () => { const perm = result.findings.filter(f => f.title.includes('Permission allow/deny')); assert.equal(perm.length, 1); }); }); // B3 soft-spot proof: stale ~/.claude/plugins/cache versions ship hooks.json // that the SAME plugin also ships in its active version. CNF groups hooks by // event:matcher across sources, so multiple cached versions inflate the // "Duplicate hook" count. Excluding stale cache must measurably DROP CNF // findings. This verifies the mechanism rather than assuming it. describe('CNF scanner — cache exclusion drops duplicate-hook count (B3)', () => { let dir, includeCount, excludeCount; before(async () => { dir = join(tmpdir(), `config-audit-cnf-cache-${Date.now()}`); const pluginsDir = join(dir, 'plugins'); const versions = ['mkt/voyage/5.6.0', 'mkt/voyage/5.1.1', 'mkt/voyage/5.0.0']; for (const key of versions) { const verDir = join(pluginsDir, 'cache', ...key.split('/'), 'hooks'); await mkdir(verDir, { recursive: true }); await writeFile(join(verDir, 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ matcher: 'Edit' }] } })); } // Only 5.6.0 is active; 5.1.1 + 5.0.0 are stale. await writeFile(join(pluginsDir, 'installed_plugins.json'), JSON.stringify({ version: 2, plugins: { 'voyage@mkt': [{ scope: 'user', version: '5.6.0', installPath: join(pluginsDir, 'cache', 'mkt', 'voyage', '5.6.0') }], }, })); resetCounter(); const dIncl = await discoverConfigFiles(dir); includeCount = (await scan(dir, dIncl)).findings.filter(f => f.title.includes('Duplicate hook')).length; resetCounter(); const dExcl = await discoverConfigFiles(dir, { excludeCache: true }); excludeCount = (await scan(dir, dExcl)).findings.filter(f => f.title.includes('Duplicate hook')).length; }); after(async () => { await rm(dir, { recursive: true, force: true }); }); it('full walk surfaces ≥1 duplicate-hook finding from cached versions', () => { assert.ok(includeCount >= 1, `expected duplicate hooks with cache, got ${includeCount}`); }); it('excluding cache measurably drops the duplicate-hook count', () => { assert.ok(excludeCount < includeCount, `cache exclusion must drop CNF count: include=${includeCount} exclude=${excludeCount}`); }); }); // M-BUG-2: CNF must segregate plugin-bundled configs from the user's authored // cascade. Installed plugins ship their OWN settings.json / hooks.json — plus // bundled test fixtures and examples — under ~/.claude/plugins/. None of these // are user-authored config the user can edit, and a "conflict" between two // plugins' bundled files is not something the user can resolve. Yet CNF compared // every settings-json/hooks-json pairwise regardless of origin, inflating the // Conflicts grade to F with hundreds of bogus findings (339 on this machine, // ~315 of them high-severity permission "conflicts" between plugin test // fixtures). CNF must exclude any file whose path is under `.claude/plugins/`. // The exclusion belongs in CNF, NOT discovery: an active plugin's contributed // hooks.json/.mcp.json legitimately lives in plugins/cache and other scanners // need it — only conflict analysis must ignore it. describe('CNF scanner — excludes plugin-bundled configs (M-BUG-2)', () => { let dir, result, discovery; before(async () => { dir = join(tmpdir(), `config-audit-cnf-mbug2-${Date.now()}`); // Live, user-authored cascade (project-scope settings). const liveClaude = join(dir, '.claude'); await mkdir(liveClaude, { recursive: true }); await writeFile(join(liveClaude, 'settings.json'), JSON.stringify({ model: 'opus', permissions: { deny: ['Bash(curl:*)'] }, hooks: { PreToolUse: [{ matcher: 'Edit' }] }, })); // An installed plugin ships its OWN settings.json + hooks.json (active version). const p1 = join(liveClaude, 'plugins', 'cache', 'mkt', 'p1', '1.0.0'); await mkdir(join(p1, '.claude'), { recursive: true }); await writeFile(join(p1, '.claude', 'settings.json'), JSON.stringify({ model: 'sonnet', permissions: { allow: ['Bash(curl:*)'] }, hooks: { PreToolUse: [{ matcher: 'Edit' }] }, })); await mkdir(join(p1, 'hooks'), { recursive: true }); await writeFile(join(p1, 'hooks', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ matcher: 'Edit' }] }, })); // Another plugin ships a TEST FIXTURE settings.json — pure noise, never live. const p2fix = join(liveClaude, 'plugins', 'cache', 'mkt', 'p2', '2.0.0', 'tests', 'fixtures', 'proj', '.claude'); await mkdir(p2fix, { recursive: true }); await writeFile(join(p2fix, 'settings.json'), JSON.stringify({ model: 'haiku', permissions: { allow: ['Bash(rm:*)'] }, })); resetCounter(); discovery = await discoverConfigFiles(dir); result = await scan(dir, discovery); }); after(async () => { await rm(dir, { recursive: true, force: true }); }); it('discovery surfaces the plugin-bundled settings (exclusion must be CNF-side, not discovery-side)', () => { const pluginSettings = discovery.files.filter( f => f.type === 'settings-json' && f.absPath.includes(`.claude${sep}plugins${sep}`)); assert.ok(pluginSettings.length >= 2, `expected ≥2 plugin-bundled settings discovered; got ${pluginSettings.length}`); }); it('does not flag any conflict sourced from plugin-bundled configs', () => { assert.equal(result.findings.length, 0, `expected 0 CNF findings (only noise is plugin-bundled); got ${result.findings.length}: ${result.findings.map(f => f.title).join(', ')}`); }); }); // Guard against over-exclusion: the fix must NOT silence genuine conflicts // between the user's own authored files (user/project/local), which are not // under `.claude/plugins/`. describe('CNF scanner — genuine live conflict still flagged (M-BUG-2 guard)', () => { let dir, result; before(async () => { dir = join(tmpdir(), `config-audit-cnf-mbug2-guard-${Date.now()}`); const claude = join(dir, '.claude'); await mkdir(claude, { recursive: true }); await writeFile(join(claude, 'settings.json'), JSON.stringify({ model: 'opus' })); await writeFile(join(claude, 'settings.local.json'), JSON.stringify({ model: 'haiku' })); resetCounter(); const discovery = await discoverConfigFiles(dir); result = await scan(dir, discovery); }); after(async () => { await rm(dir, { recursive: true, force: true }); }); it('still flags the genuine cross-scope key conflict between user-authored files', () => { assert.ok(result.findings.some(f => f.title.includes('model')), `expected a genuine "model" conflict; got: ${result.findings.map(f => f.title).join(', ') || '(none)'}`); }); });