import { describe, it, beforeEach, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { resolve, join } 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}`); }); });