import { describe, it, beforeEach, afterEach } 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 { scan } from '../../scanners/collision-scanner.mjs'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); const FIXTURES = resolve(__dirname, '../fixtures'); const COLLISION_FIXTURE_HOME = resolve(FIXTURES, 'collision-plugins', 'fake-home'); function uniqueDir(suffix) { return join(tmpdir(), `config-audit-col-${suffix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); } /** * The COL scanner uses process.env.HOME via enumeratePlugins/enumerateSkills. * Tests must override HOME, run, and restore — never rely on user-state. */ async function runScannerWithHome(home) { resetCounter(); const original = process.env.HOME; process.env.HOME = home; try { return await scan('/unused', { files: [] }); } finally { process.env.HOME = original; } } describe('COL scanner — basic structure', () => { it('reports scanner prefix COL', async () => { const result = await runScannerWithHome(COLLISION_FIXTURE_HOME); assert.equal(result.scanner, 'COL'); }); it('finding IDs match CA-COL-NNN pattern', async () => { const result = await runScannerWithHome(COLLISION_FIXTURE_HOME); for (const f of result.findings) { assert.match(f.id, /^CA-COL-\d{3}$/); } }); }); describe('COL scanner — user-vs-plugin collision (medium severity)', () => { it('flags review skill collision between user-level and plugin-bundled', async () => { const result = await runScannerWithHome(COLLISION_FIXTURE_HOME); const f = result.findings.find(x => /user-level and plugin sources/i.test(x.title || '')); assert.ok(f, `expected user-vs-plugin finding; got: ${result.findings.map(x => x.title).join(' | ')}`); assert.equal(f.severity, 'medium', `expected medium, got ${f.severity}`); assert.match(String(f.title), /review/); }); it('user-vs-plugin finding includes details.namespaces', async () => { const result = await runScannerWithHome(COLLISION_FIXTURE_HOME); const f = result.findings.find(x => /user-level and plugin sources/i.test(x.title || '')); assert.ok(f); assert.ok(Array.isArray(f.details?.namespaces), `expected details.namespaces array; got: ${JSON.stringify(f.details)}`); assert.ok(f.details.namespaces.length >= 2); const sources = f.details.namespaces.map(n => n.source); assert.ok(sources.includes('user'), `expected user in sources; got: ${sources.join(', ')}`); }); }); describe('COL scanner — negative cases', () => { it('plugin-c summarize (unique name) generates no finding', async () => { const result = await runScannerWithHome(COLLISION_FIXTURE_HOME); const f = result.findings.find(x => /summarize/i.test(x.title || '')); assert.equal(f, undefined, `expected no finding for unique plugin-c summarize skill; got: ${f?.title}`); }); it('clean fake-home with no plugins yields zero findings', async () => { const cleanHome = uniqueDir('clean'); try { await mkdir(join(cleanHome, '.claude', 'plugins'), { recursive: true }); const result = await runScannerWithHome(cleanHome); assert.equal(result.findings.length, 0, `expected 0 findings; got: ${result.findings.map(f => f.title).join(' | ')}`); } finally { await rm(cleanHome, { recursive: true, force: true }); } }); }); describe('COL scanner — plugin-vs-plugin (low severity, no user-level competitor)', () => { let altHome; beforeEach(async () => { altHome = uniqueDir('plugin-only'); const root = join(altHome, '.claude', 'plugins', 'marketplaces', 'mp', 'plugins'); await mkdir(join(root, 'plugin-x', '.claude-plugin'), { recursive: true }); await writeFile( join(root, 'plugin-x', '.claude-plugin', 'plugin.json'), JSON.stringify({ name: 'plugin-x', version: '1.0.0', description: 'x' }), ); await mkdir(join(root, 'plugin-x', 'skills', 'analyze'), { recursive: true }); await writeFile( join(root, 'plugin-x', 'skills', 'analyze', 'SKILL.md'), '---\nname: x:analyze\ndescription: analyze from x\n---\nBody.\n', ); await mkdir(join(root, 'plugin-y', '.claude-plugin'), { recursive: true }); await writeFile( join(root, 'plugin-y', '.claude-plugin', 'plugin.json'), JSON.stringify({ name: 'plugin-y', version: '1.0.0', description: 'y' }), ); await mkdir(join(root, 'plugin-y', 'skills', 'analyze'), { recursive: true }); await writeFile( join(root, 'plugin-y', 'skills', 'analyze', 'SKILL.md'), '---\nname: y:analyze\ndescription: analyze from y\n---\nBody.\n', ); }); afterEach(async () => { if (altHome) await rm(altHome, { recursive: true, force: true }); }); it('plugin-x and plugin-y both define analyze → finding (low severity)', async () => { const result = await runScannerWithHome(altHome); const f = result.findings.find(x => /multiple plugins/i.test(x.title || '')); assert.ok(f, `expected plugin-vs-plugin finding; got: ${result.findings.map(x => x.title).join(' | ')}`); assert.equal(f.severity, 'low', `expected low, got ${f.severity}`); assert.match(String(f.title), /analyze/); assert.ok(Array.isArray(f.details?.namespaces)); assert.equal(f.details.namespaces.length, 2); }); }); describe('COL scanner — suppression compatibility', () => { it('CA-COL-001 is NOT matched by CA-TOK-* glob suppression', async () => { const { applySuppressions } = await import('../../scanners/lib/suppression.mjs'); const result = await runScannerWithHome(COLLISION_FIXTURE_HOME); assert.ok(result.findings.length > 0, 'precondition: at least one COL finding to test against'); // Apply CA-TOK-* glob suppression — should leave COL findings untouched. const { active, suppressed } = applySuppressions(result.findings, [ { pattern: 'CA-TOK-*', source: 'test', sourceLine: 1 }, ]); assert.equal(active.length, result.findings.length, 'CA-TOK-* glob should not match CA-COL-* findings'); assert.equal(suppressed.length, 0); }); });