Wave 2 / Step 4 of v5.1.0 plain-language UX humanizer rollout. Re-anchors 34 title-string assertions across 4 test files so they survive Wave 3's title/description/recommendation rewriting at the CLI layer. Anchoring strategy per scanner: - GAP findings: scanner + category + recommendation substring (humanizer preserves stable identifiers like CLAUDE.md, .mcp.json, hook in rec). Hardcoded CA-GAP-NNN IDs for positive checks. - HKV findings: scanner + evidence regex (evidence preserved verbatim). - SET findings: scanner + evidence regex (evidence preserved verbatim). - PLH findings: scanner + hardcoded CA-PLH-NNN IDs (no evidence on most PLH findings, so ID is the only stable anchor for specific cases; negative checks use scanner + title-substring spanning raw + humanized). Per docs/v5.1.0-test-audit.md classification: only (b) WILL BREAK assertions modified. (a) shape-only assertions (error-message formatting, pure existence checks) untouched. tests/lib/output.test.mjs and tests/lib/diff-engine.test.mjs and tests/scanners/fix-engine.test.mjs unchanged (synthetic test inputs, not scanner output). Test count unchanged: 689/689 pass. IDs harvested via deterministic runtime dump per fixture (resetCounter + scan).
208 lines
6.9 KiB
JavaScript
208 lines
6.9 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.scanner === 'GAP' && f.category === 't1' && /CLAUDE\.md/.test(f.recommendation || '')
|
|
));
|
|
});
|
|
|
|
it('does NOT report missing MCP', () => {
|
|
assert.ok(!result.findings.some(f =>
|
|
f.scanner === 'GAP' && f.category === 't1' && /\.mcp\.json/.test(f.recommendation || '')
|
|
));
|
|
});
|
|
|
|
it('does NOT report missing hooks', () => {
|
|
assert.ok(!result.findings.some(f =>
|
|
f.scanner === 'GAP' && f.category === 't1' && /hook/i.test(f.recommendation || '')
|
|
));
|
|
});
|
|
|
|
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', () => {
|
|
// CA-GAP-002 in minimal-project = t1_3 (No hooks configured); see docs/v5.1.0-test-audit.md.
|
|
assert.ok(result.findings.some(f => f.scanner === 'GAP' && f.id === 'CA-GAP-002'));
|
|
});
|
|
|
|
it('reports missing MCP', () => {
|
|
// CA-GAP-004 in minimal-project = t1_5 (No MCP servers configured).
|
|
assert.ok(result.findings.some(f => f.scanner === 'GAP' && f.id === 'CA-GAP-004'));
|
|
});
|
|
|
|
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', () => {
|
|
// CA-GAP-001 in empty-project = t1_1 (No CLAUDE.md file).
|
|
assert.ok(result.findings.some(f => f.scanner === 'GAP' && f.id === 'CA-GAP-001'));
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|