ktg-plugin-marketplace/plugins/config-audit/tests/scanners/feature-gap-scanner.test.mjs

199 lines
6.4 KiB
JavaScript

import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { scan, opportunitySummary } from '../../scanners/feature-gap-scanner.mjs';
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
// Pre-discover fixture files WITHOUT includeGlobal so tests are environment-independent.
// The GAP scanner uses shared discovery when it has files, avoiding its own includeGlobal scan.
async function fixtureDiscovery(name) {
return discoverConfigFiles(resolve(FIXTURES, name));
}
describe('GAP scanner — healthy project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await fixtureDiscovery('healthy-project');
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
});
it('returns status ok', () => {
assert.equal(result.status, 'ok');
});
it('reports scanner prefix GAP', () => {
assert.equal(result.scanner, 'GAP');
});
it('scans multiple files', () => {
assert.ok(result.files_scanned >= 1);
});
it('finding IDs match CA-GAP-NNN pattern', () => {
for (const f of result.findings) {
assert.match(f.id, /^CA-GAP-\d{3}$/);
}
});
it('does NOT report missing CLAUDE.md', () => {
assert.ok(!result.findings.some(f => f.title === 'No CLAUDE.md file'));
});
it('does NOT report missing MCP', () => {
assert.ok(!result.findings.some(f => f.title === 'No MCP servers configured'));
});
it('does NOT report missing hooks', () => {
assert.ok(!result.findings.some(f => f.title === 'No hooks configured'));
});
it('has counts object with all severity levels', () => {
assert.ok('critical' in result.counts);
assert.ok('high' in result.counts);
assert.ok('medium' in result.counts);
assert.ok('low' in result.counts);
assert.ok('info' in result.counts);
});
it('has no critical or high findings', () => {
assert.equal(result.counts.critical, 0);
assert.equal(result.counts.high, 0);
});
it('all findings have recommendations', () => {
for (const f of result.findings) {
assert.ok(f.recommendation, `Finding ${f.id} missing recommendation`);
}
});
it('T3/T4 findings are info severity', () => {
const infoFindings = result.findings.filter(f => f.category === 't3' || f.category === 't4');
for (const f of infoFindings) {
assert.equal(f.severity, 'info', `${f.id} (${f.category}) should be info, got ${f.severity}`);
}
});
});
describe('GAP scanner — minimal project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await fixtureDiscovery('minimal-project');
result = await scan(resolve(FIXTURES, 'minimal-project'), discovery);
});
it('returns status ok', () => {
assert.equal(result.status, 'ok');
});
it('reports missing hooks', () => {
assert.ok(result.findings.some(f => f.title === 'No hooks configured'));
});
it('reports missing MCP', () => {
assert.ok(result.findings.some(f => f.title === 'No MCP servers configured'));
});
it('T1 gaps are medium severity', () => {
const t1 = result.findings.filter(f => f.category === 't1');
for (const f of t1) {
assert.equal(f.severity, 'medium', `${f.id} should be medium, got ${f.severity}`);
}
});
it('T2 gaps are low severity', () => {
const t2 = result.findings.filter(f => f.category === 't2');
for (const f of t2) {
assert.equal(f.severity, 'low', `${f.id} should be low, got ${f.severity}`);
}
});
it('has more findings than healthy project', async () => {
resetCounter();
const discovery = await fixtureDiscovery('healthy-project');
const healthyResult = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
assert.ok(result.findings.length > healthyResult.findings.length);
});
});
describe('GAP scanner — empty project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await fixtureDiscovery('empty-project');
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
});
it('returns status ok (never skips)', () => {
assert.equal(result.status, 'ok');
});
it('has multiple medium findings (T1 gaps)', () => {
const mediums = result.findings.filter(f => f.severity === 'medium');
assert.ok(mediums.length >= 1);
});
it('all findings have category field', () => {
for (const f of result.findings) {
assert.ok(f.category, `Finding ${f.id} missing category`);
assert.match(f.category, /^t[1-4]$/);
}
});
it('reports T1 gaps including missing CLAUDE.md', () => {
assert.ok(result.findings.some(f => f.title === 'No CLAUDE.md file'));
});
});
describe('opportunitySummary', () => {
it('returns empty arrays for no findings', () => {
const result = opportunitySummary([]);
assert.deepEqual(result.highImpact, []);
assert.deepEqual(result.mediumImpact, []);
assert.deepEqual(result.explore, []);
});
it('routes T1 to highImpact', () => {
const findings = [{ category: 't1', title: 'No CLAUDE.md' }];
const result = opportunitySummary(findings);
assert.equal(result.highImpact.length, 1);
assert.equal(result.mediumImpact.length, 0);
assert.equal(result.explore.length, 0);
});
it('routes T2 to mediumImpact', () => {
const findings = [{ category: 't2', title: 'Low hook diversity' }];
const result = opportunitySummary(findings);
assert.equal(result.highImpact.length, 0);
assert.equal(result.mediumImpact.length, 1);
});
it('routes T3 and T4 to explore', () => {
const findings = [
{ category: 't3', title: 'No status line' },
{ category: 't4', title: 'No custom plugin' },
];
const result = opportunitySummary(findings);
assert.equal(result.explore.length, 2);
});
it('handles mixed tiers', () => {
const findings = [
{ category: 't1', title: 'A' },
{ category: 't2', title: 'B' },
{ category: 't2', title: 'C' },
{ category: 't3', title: 'D' },
{ category: 't4', title: 'E' },
];
const result = opportunitySummary(findings);
assert.equal(result.highImpact.length, 1);
assert.equal(result.mediumImpact.length, 2);
assert.equal(result.explore.length, 2);
});
});