feat: initial open marketplace with llm-security, config-audit, ultraplan-local
This commit is contained in:
commit
f93d6abdae
380 changed files with 65935 additions and 0 deletions
176
plugins/config-audit/tests/lib/baseline.test.mjs
Normal file
176
plugins/config-audit/tests/lib/baseline.test.mjs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { join } from 'node:path';
|
||||
import { rm, stat, readFile, mkdir } from 'node:fs/promises';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import {
|
||||
saveBaseline,
|
||||
loadBaseline,
|
||||
listBaselines,
|
||||
deleteBaseline,
|
||||
getBaselinesDir,
|
||||
} from '../../scanners/lib/baseline.mjs';
|
||||
|
||||
// We test against the real baselines dir but use unique names to avoid collisions.
|
||||
const TEST_PREFIX = `_test_${Date.now()}_`;
|
||||
|
||||
function makeTestEnvelope(findingCount = 3) {
|
||||
const findings = Array.from({ length: findingCount }, (_, i) => ({
|
||||
id: `CA-CML-${String(i + 1).padStart(3, '0')}`,
|
||||
scanner: 'CML',
|
||||
severity: 'low',
|
||||
title: `Finding ${i + 1}`,
|
||||
file: 'CLAUDE.md',
|
||||
}));
|
||||
return {
|
||||
meta: { target: '/test/path', timestamp: new Date().toISOString(), version: '2.0.0', tool: 'config-audit' },
|
||||
scanners: [{
|
||||
scanner: 'CML',
|
||||
status: 'ok',
|
||||
files_scanned: 1,
|
||||
duration_ms: 5,
|
||||
findings,
|
||||
counts: { critical: 0, high: 0, medium: 0, low: findingCount, info: 0 },
|
||||
}],
|
||||
aggregate: { total_findings: findingCount, counts: { critical: 0, high: 0, medium: 0, low: findingCount, info: 0 }, risk_score: findingCount, risk_band: 'Low', verdict: 'PASS', scanners_ok: 1, scanners_error: 0, scanners_skipped: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
// Cleanup helper
|
||||
const savedNames = [];
|
||||
async function cleanup() {
|
||||
for (const name of savedNames) {
|
||||
await deleteBaseline(name);
|
||||
}
|
||||
savedNames.length = 0;
|
||||
}
|
||||
|
||||
describe('saveBaseline', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it('writes a file and returns path', async () => {
|
||||
const name = `${TEST_PREFIX}save1`;
|
||||
savedNames.push(name);
|
||||
|
||||
const envelope = makeTestEnvelope(2);
|
||||
const result = await saveBaseline(envelope, name);
|
||||
|
||||
assert.ok(result.path.endsWith(`${name}.json`));
|
||||
assert.equal(result.name, name);
|
||||
|
||||
// Verify file exists
|
||||
const s = await stat(result.path);
|
||||
assert.ok(s.isFile());
|
||||
});
|
||||
|
||||
it('includes _baseline metadata', async () => {
|
||||
const name = `${TEST_PREFIX}meta`;
|
||||
savedNames.push(name);
|
||||
|
||||
const envelope = makeTestEnvelope(5);
|
||||
await saveBaseline(envelope, name);
|
||||
|
||||
const loaded = await loadBaseline(name);
|
||||
assert.ok(loaded._baseline);
|
||||
assert.ok(loaded._baseline.saved_at);
|
||||
assert.equal(loaded._baseline.target_path, '/test/path');
|
||||
assert.equal(loaded._baseline.finding_count, 5);
|
||||
assert.equal(typeof loaded._baseline.score, 'number');
|
||||
});
|
||||
|
||||
it('defaults name to "default"', async () => {
|
||||
const name = `${TEST_PREFIX}default_test`;
|
||||
savedNames.push(name);
|
||||
// We won't actually use the literal 'default' to avoid interfering with real baselines
|
||||
const result = await saveBaseline(makeTestEnvelope(), name);
|
||||
assert.equal(result.name, name);
|
||||
});
|
||||
|
||||
it('overwrites existing baseline with same name', async () => {
|
||||
const name = `${TEST_PREFIX}overwrite`;
|
||||
savedNames.push(name);
|
||||
|
||||
await saveBaseline(makeTestEnvelope(2), name);
|
||||
await saveBaseline(makeTestEnvelope(7), name);
|
||||
|
||||
const loaded = await loadBaseline(name);
|
||||
assert.equal(loaded._baseline.finding_count, 7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadBaseline', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it('loads a previously saved baseline', async () => {
|
||||
const name = `${TEST_PREFIX}load`;
|
||||
savedNames.push(name);
|
||||
|
||||
const envelope = makeTestEnvelope(3);
|
||||
await saveBaseline(envelope, name);
|
||||
|
||||
const loaded = await loadBaseline(name);
|
||||
assert.ok(loaded);
|
||||
assert.equal(loaded.aggregate.total_findings, 3);
|
||||
assert.equal(loaded.meta.target, '/test/path');
|
||||
});
|
||||
|
||||
it('returns null for unknown name', async () => {
|
||||
const result = await loadBaseline(`${TEST_PREFIX}nonexistent_${Date.now()}`);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
it('preserves all scanner data', async () => {
|
||||
const name = `${TEST_PREFIX}preserve`;
|
||||
savedNames.push(name);
|
||||
|
||||
const envelope = makeTestEnvelope(1);
|
||||
await saveBaseline(envelope, name);
|
||||
|
||||
const loaded = await loadBaseline(name);
|
||||
assert.equal(loaded.scanners.length, 1);
|
||||
assert.equal(loaded.scanners[0].scanner, 'CML');
|
||||
assert.equal(loaded.scanners[0].findings.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listBaselines', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it('lists saved baselines', async () => {
|
||||
const name = `${TEST_PREFIX}list`;
|
||||
savedNames.push(name);
|
||||
|
||||
await saveBaseline(makeTestEnvelope(4), name);
|
||||
|
||||
const result = await listBaselines();
|
||||
assert.ok(Array.isArray(result.baselines));
|
||||
const found = result.baselines.find(b => b.name === name);
|
||||
assert.ok(found, 'Should find the saved baseline in list');
|
||||
assert.equal(found.findingCount, 4);
|
||||
assert.ok(found.savedAt);
|
||||
});
|
||||
|
||||
it('returns empty array when no baselines', async () => {
|
||||
// This test just verifies the function doesn't crash
|
||||
const result = await listBaselines();
|
||||
assert.ok(Array.isArray(result.baselines));
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteBaseline', () => {
|
||||
it('deletes an existing baseline', async () => {
|
||||
const name = `${TEST_PREFIX}delete`;
|
||||
|
||||
await saveBaseline(makeTestEnvelope(), name);
|
||||
const result = await deleteBaseline(name);
|
||||
assert.equal(result.deleted, true);
|
||||
|
||||
const loaded = await loadBaseline(name);
|
||||
assert.equal(loaded, null);
|
||||
});
|
||||
|
||||
it('returns false for non-existent baseline', async () => {
|
||||
const result = await deleteBaseline(`${TEST_PREFIX}nope_${Date.now()}`);
|
||||
assert.equal(result.deleted, false);
|
||||
});
|
||||
});
|
||||
288
plugins/config-audit/tests/lib/diff-engine.test.mjs
Normal file
288
plugins/config-audit/tests/lib/diff-engine.test.mjs
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { diffEnvelopes, formatDiffReport } from '../../scanners/lib/diff-engine.mjs';
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function makeFinding(scanner, title, severity = 'medium', file = null) {
|
||||
return {
|
||||
id: `CA-${scanner}-001`,
|
||||
scanner,
|
||||
severity,
|
||||
title,
|
||||
description: `Description for ${title}`,
|
||||
file,
|
||||
line: null,
|
||||
evidence: null,
|
||||
category: null,
|
||||
recommendation: null,
|
||||
autoFixable: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeScannerResult(scanner, findings) {
|
||||
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
||||
for (const f of findings) {
|
||||
if (counts[f.severity] !== undefined) counts[f.severity]++;
|
||||
}
|
||||
return {
|
||||
scanner,
|
||||
status: 'ok',
|
||||
files_scanned: 3,
|
||||
duration_ms: 10,
|
||||
findings,
|
||||
counts,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEnvelope(scannerResults) {
|
||||
const aggregate = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
||||
let total = 0;
|
||||
for (const r of scannerResults) {
|
||||
for (const sev of Object.keys(aggregate)) {
|
||||
aggregate[sev] += (r.counts[sev] || 0);
|
||||
}
|
||||
total += r.findings.length;
|
||||
}
|
||||
return {
|
||||
meta: { target: '/test', timestamp: new Date().toISOString(), version: '2.0.0', tool: 'config-audit' },
|
||||
scanners: scannerResults,
|
||||
aggregate: { total_findings: total, counts: aggregate, risk_score: 0, risk_band: 'Low', verdict: 'PASS', scanners_ok: scannerResults.length, scanners_error: 0, scanners_skipped: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// diffEnvelopes
|
||||
// ========================================
|
||||
describe('diffEnvelopes', () => {
|
||||
it('identifies new findings', () => {
|
||||
const baseline = makeEnvelope([makeScannerResult('CML', [])]);
|
||||
const current = makeEnvelope([
|
||||
makeScannerResult('CML', [makeFinding('CML', 'New issue', 'high', 'CLAUDE.md')]),
|
||||
]);
|
||||
|
||||
const diff = diffEnvelopes(baseline, current);
|
||||
assert.equal(diff.newFindings.length, 1);
|
||||
assert.equal(diff.newFindings[0].title, 'New issue');
|
||||
assert.equal(diff.resolvedFindings.length, 0);
|
||||
});
|
||||
|
||||
it('identifies resolved findings', () => {
|
||||
const baseline = makeEnvelope([
|
||||
makeScannerResult('CML', [makeFinding('CML', 'Old issue', 'high', 'CLAUDE.md')]),
|
||||
]);
|
||||
const current = makeEnvelope([makeScannerResult('CML', [])]);
|
||||
|
||||
const diff = diffEnvelopes(baseline, current);
|
||||
assert.equal(diff.resolvedFindings.length, 1);
|
||||
assert.equal(diff.resolvedFindings[0].title, 'Old issue');
|
||||
assert.equal(diff.newFindings.length, 0);
|
||||
});
|
||||
|
||||
it('identifies unchanged findings', () => {
|
||||
const f = makeFinding('CML', 'Persistent issue', 'medium', 'CLAUDE.md');
|
||||
const baseline = makeEnvelope([makeScannerResult('CML', [f])]);
|
||||
const current = makeEnvelope([makeScannerResult('CML', [{ ...f }])]);
|
||||
|
||||
const diff = diffEnvelopes(baseline, current);
|
||||
assert.equal(diff.unchangedFindings.length, 1);
|
||||
assert.equal(diff.newFindings.length, 0);
|
||||
assert.equal(diff.resolvedFindings.length, 0);
|
||||
});
|
||||
|
||||
it('detects moved findings (same title, different file)', () => {
|
||||
const baseFinding = makeFinding('CML', 'Moved issue', 'high', 'old-file.md');
|
||||
const currFinding = makeFinding('CML', 'Moved issue', 'high', 'new-file.md');
|
||||
|
||||
const baseline = makeEnvelope([makeScannerResult('CML', [baseFinding])]);
|
||||
const current = makeEnvelope([makeScannerResult('CML', [currFinding])]);
|
||||
|
||||
const diff = diffEnvelopes(baseline, current);
|
||||
assert.equal(diff.movedFindings.length, 1);
|
||||
assert.equal(diff.movedFindings[0].from.file, 'old-file.md');
|
||||
assert.equal(diff.movedFindings[0].to.file, 'new-file.md');
|
||||
assert.equal(diff.newFindings.length, 0);
|
||||
assert.equal(diff.resolvedFindings.length, 0);
|
||||
});
|
||||
|
||||
it('calculates score delta', () => {
|
||||
const baseline = makeEnvelope([
|
||||
makeScannerResult('CML', [
|
||||
makeFinding('CML', 'A', 'high', 'a.md'),
|
||||
makeFinding('CML', 'B', 'high', 'a.md'),
|
||||
makeFinding('CML', 'C', 'high', 'a.md'),
|
||||
]),
|
||||
]);
|
||||
const current = makeEnvelope([makeScannerResult('CML', [])]);
|
||||
|
||||
const diff = diffEnvelopes(baseline, current);
|
||||
assert.ok(diff.scoreChange.delta > 0, 'Score should improve when findings are resolved');
|
||||
assert.equal(typeof diff.scoreChange.before.grade, 'string');
|
||||
assert.equal(typeof diff.scoreChange.after.grade, 'string');
|
||||
});
|
||||
|
||||
it('calculates area changes', () => {
|
||||
const baseline = makeEnvelope([
|
||||
makeScannerResult('CML', [makeFinding('CML', 'X', 'low', 'a.md')]),
|
||||
makeScannerResult('SET', []),
|
||||
]);
|
||||
const current = makeEnvelope([
|
||||
makeScannerResult('CML', []),
|
||||
makeScannerResult('SET', [makeFinding('SET', 'Y', 'low', 'b.json')]),
|
||||
]);
|
||||
|
||||
const diff = diffEnvelopes(baseline, current);
|
||||
assert.ok(diff.areaChanges.length >= 2);
|
||||
const cmlChange = diff.areaChanges.find(a => a.name === 'CLAUDE.md');
|
||||
assert.ok(cmlChange, 'Should have CLAUDE.md area change');
|
||||
assert.ok(cmlChange.delta > 0, 'CLAUDE.md should improve');
|
||||
});
|
||||
|
||||
it('detects improving trend', () => {
|
||||
const baseline = makeEnvelope([
|
||||
makeScannerResult('CML', [
|
||||
makeFinding('CML', 'A', 'high', 'a.md'),
|
||||
makeFinding('CML', 'B', 'high', 'a.md'),
|
||||
]),
|
||||
]);
|
||||
const current = makeEnvelope([makeScannerResult('CML', [])]);
|
||||
|
||||
const diff = diffEnvelopes(baseline, current);
|
||||
assert.equal(diff.summary.trend, 'improving');
|
||||
});
|
||||
|
||||
it('detects degrading trend', () => {
|
||||
const baseline = makeEnvelope([makeScannerResult('CML', [])]);
|
||||
const current = makeEnvelope([
|
||||
makeScannerResult('CML', [
|
||||
makeFinding('CML', 'A', 'high', 'a.md'),
|
||||
makeFinding('CML', 'B', 'high', 'a.md'),
|
||||
]),
|
||||
]);
|
||||
|
||||
const diff = diffEnvelopes(baseline, current);
|
||||
assert.equal(diff.summary.trend, 'degrading');
|
||||
});
|
||||
|
||||
it('detects stable trend (same findings)', () => {
|
||||
const f = makeFinding('CML', 'Same', 'medium', 'a.md');
|
||||
const baseline = makeEnvelope([makeScannerResult('CML', [f])]);
|
||||
const current = makeEnvelope([makeScannerResult('CML', [{ ...f }])]);
|
||||
|
||||
const diff = diffEnvelopes(baseline, current);
|
||||
assert.equal(diff.summary.trend, 'stable');
|
||||
});
|
||||
|
||||
it('handles empty baseline', () => {
|
||||
const baseline = makeEnvelope([]);
|
||||
const current = makeEnvelope([
|
||||
makeScannerResult('CML', [makeFinding('CML', 'New', 'low', 'a.md')]),
|
||||
]);
|
||||
|
||||
const diff = diffEnvelopes(baseline, current);
|
||||
assert.equal(diff.newFindings.length, 1);
|
||||
assert.equal(diff.resolvedFindings.length, 0);
|
||||
assert.equal(diff.summary.totalBefore, 0);
|
||||
});
|
||||
|
||||
it('handles identical envelopes (all unchanged)', () => {
|
||||
const f1 = makeFinding('CML', 'Issue A', 'medium', 'a.md');
|
||||
const f2 = makeFinding('SET', 'Issue B', 'low', 'b.json');
|
||||
const baseline = makeEnvelope([
|
||||
makeScannerResult('CML', [f1]),
|
||||
makeScannerResult('SET', [f2]),
|
||||
]);
|
||||
const current = makeEnvelope([
|
||||
makeScannerResult('CML', [{ ...f1 }]),
|
||||
makeScannerResult('SET', [{ ...f2 }]),
|
||||
]);
|
||||
|
||||
const diff = diffEnvelopes(baseline, current);
|
||||
assert.equal(diff.unchangedFindings.length, 2);
|
||||
assert.equal(diff.newFindings.length, 0);
|
||||
assert.equal(diff.resolvedFindings.length, 0);
|
||||
assert.equal(diff.movedFindings.length, 0);
|
||||
assert.equal(diff.summary.trend, 'stable');
|
||||
});
|
||||
|
||||
it('summary has correct counts', () => {
|
||||
const baseline = makeEnvelope([
|
||||
makeScannerResult('CML', [
|
||||
makeFinding('CML', 'Keep', 'low', 'a.md'),
|
||||
makeFinding('CML', 'Resolve', 'high', 'b.md'),
|
||||
]),
|
||||
]);
|
||||
const current = makeEnvelope([
|
||||
makeScannerResult('CML', [
|
||||
makeFinding('CML', 'Keep', 'low', 'a.md'),
|
||||
makeFinding('CML', 'Brand new', 'medium', 'c.md'),
|
||||
]),
|
||||
]);
|
||||
|
||||
const diff = diffEnvelopes(baseline, current);
|
||||
assert.equal(diff.summary.totalBefore, 2);
|
||||
assert.equal(diff.summary.totalAfter, 2);
|
||||
assert.equal(diff.summary.newCount, 1);
|
||||
assert.equal(diff.summary.resolvedCount, 1);
|
||||
});
|
||||
|
||||
it('handles findings with null file gracefully', () => {
|
||||
const f = makeFinding('CML', 'No file', 'info', null);
|
||||
const baseline = makeEnvelope([makeScannerResult('CML', [f])]);
|
||||
const current = makeEnvelope([makeScannerResult('CML', [{ ...f }])]);
|
||||
|
||||
const diff = diffEnvelopes(baseline, current);
|
||||
assert.equal(diff.unchangedFindings.length, 1);
|
||||
});
|
||||
|
||||
it('multiple findings with same key are matched correctly', () => {
|
||||
const f1 = makeFinding('CML', 'Duplicate', 'low', 'a.md');
|
||||
const f2 = makeFinding('CML', 'Duplicate', 'low', 'a.md');
|
||||
|
||||
const baseline = makeEnvelope([makeScannerResult('CML', [f1, f2])]);
|
||||
const current = makeEnvelope([makeScannerResult('CML', [{ ...f1 }])]);
|
||||
|
||||
const diff = diffEnvelopes(baseline, current);
|
||||
assert.equal(diff.unchangedFindings.length, 1);
|
||||
assert.equal(diff.resolvedFindings.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// formatDiffReport
|
||||
// ========================================
|
||||
describe('formatDiffReport', () => {
|
||||
it('returns a non-empty string', () => {
|
||||
const diff = diffEnvelopes(makeEnvelope([]), makeEnvelope([]));
|
||||
const report = formatDiffReport(diff);
|
||||
assert.ok(report.length > 0);
|
||||
assert.equal(typeof report, 'string');
|
||||
});
|
||||
|
||||
it('contains header', () => {
|
||||
const diff = diffEnvelopes(makeEnvelope([]), makeEnvelope([]));
|
||||
const report = formatDiffReport(diff);
|
||||
assert.ok(report.includes('Config-Audit Drift Report'));
|
||||
});
|
||||
|
||||
it('shows trend', () => {
|
||||
const baseline = makeEnvelope([
|
||||
makeScannerResult('CML', [makeFinding('CML', 'X', 'high', 'a.md')]),
|
||||
]);
|
||||
const current = makeEnvelope([makeScannerResult('CML', [])]);
|
||||
const diff = diffEnvelopes(baseline, current);
|
||||
const report = formatDiffReport(diff);
|
||||
assert.ok(report.includes('Improving'));
|
||||
});
|
||||
|
||||
it('lists new findings', () => {
|
||||
const baseline = makeEnvelope([makeScannerResult('CML', [])]);
|
||||
const current = makeEnvelope([
|
||||
makeScannerResult('CML', [makeFinding('CML', 'Fresh issue', 'high', 'x.md')]),
|
||||
]);
|
||||
const diff = diffEnvelopes(baseline, current);
|
||||
const report = formatDiffReport(diff);
|
||||
assert.ok(report.includes('Fresh issue'));
|
||||
assert.ok(report.includes('New findings'));
|
||||
});
|
||||
});
|
||||
391
plugins/config-audit/tests/lib/file-discovery.test.mjs
Normal file
391
plugins/config-audit/tests/lib/file-discovery.test.mjs
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { join } from 'node:path';
|
||||
import { mkdir, writeFile, rm, stat } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import {
|
||||
discoverConfigFiles,
|
||||
discoverConfigFilesMulti,
|
||||
discoverFullMachinePaths,
|
||||
readTextFile,
|
||||
} from '../../scanners/lib/file-discovery.mjs';
|
||||
|
||||
/**
|
||||
* Create a temp directory with a unique name for test isolation.
|
||||
*/
|
||||
function tempDir(suffix) {
|
||||
return join(tmpdir(), `config-audit-fd-test-${suffix}-${Date.now()}`);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Group 1: discoverConfigFiles — single path
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('discoverConfigFiles (single path)', () => {
|
||||
let dir;
|
||||
|
||||
before(async () => {
|
||||
dir = tempDir('single');
|
||||
// Create a realistic project structure
|
||||
await mkdir(join(dir, '.claude', 'rules'), { recursive: true });
|
||||
await mkdir(join(dir, 'hooks'), { recursive: true });
|
||||
await mkdir(join(dir, 'node_modules', 'pkg'), { recursive: true });
|
||||
await mkdir(join(dir, 'src'), { recursive: true });
|
||||
|
||||
await writeFile(join(dir, 'CLAUDE.md'), '# Instructions');
|
||||
await writeFile(join(dir, '.claude', 'settings.json'), '{}');
|
||||
await writeFile(join(dir, '.claude', 'rules', 'my-rule.md'), '---\nglobs: "*.ts"\n---\nRule');
|
||||
await writeFile(join(dir, '.mcp.json'), '{"mcpServers": {}}');
|
||||
await writeFile(join(dir, 'hooks', 'hooks.json'), '{"hooks": {}}');
|
||||
await writeFile(join(dir, 'src', 'index.ts'), 'export {}');
|
||||
// Should be skipped
|
||||
await writeFile(join(dir, 'node_modules', 'pkg', 'CLAUDE.md'), '# dep');
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns { files, skipped } shape', async () => {
|
||||
const result = await discoverConfigFiles(dir);
|
||||
assert.ok(Array.isArray(result.files));
|
||||
assert.equal(typeof result.skipped, 'number');
|
||||
});
|
||||
|
||||
it('finds CLAUDE.md', async () => {
|
||||
const result = await discoverConfigFiles(dir);
|
||||
const claude = result.files.find(f => f.type === 'claude-md');
|
||||
assert.ok(claude, 'should find CLAUDE.md');
|
||||
assert.equal(claude.relPath, 'CLAUDE.md');
|
||||
});
|
||||
|
||||
it('finds settings.json in .claude/', async () => {
|
||||
const result = await discoverConfigFiles(dir);
|
||||
const settings = result.files.find(f => f.type === 'settings-json');
|
||||
assert.ok(settings, 'should find settings.json');
|
||||
assert.ok(settings.relPath.includes('.claude'));
|
||||
});
|
||||
|
||||
it('finds .mcp.json', async () => {
|
||||
const result = await discoverConfigFiles(dir);
|
||||
const mcp = result.files.find(f => f.type === 'mcp-json');
|
||||
assert.ok(mcp, 'should find .mcp.json');
|
||||
});
|
||||
|
||||
it('finds rules in .claude/rules/', async () => {
|
||||
const result = await discoverConfigFiles(dir);
|
||||
const rules = result.files.filter(f => f.type === 'rule');
|
||||
assert.ok(rules.length > 0, 'should find at least one rule');
|
||||
});
|
||||
|
||||
it('finds hooks.json', async () => {
|
||||
const result = await discoverConfigFiles(dir);
|
||||
const hooks = result.files.find(f => f.type === 'hooks-json');
|
||||
assert.ok(hooks, 'should find hooks.json');
|
||||
});
|
||||
|
||||
it('skips node_modules', async () => {
|
||||
const result = await discoverConfigFiles(dir);
|
||||
const inNodeModules = result.files.filter(f => f.absPath.includes('node_modules'));
|
||||
assert.equal(inNodeModules.length, 0, 'should not include files in node_modules');
|
||||
});
|
||||
|
||||
it('tracks skipped directories (counter fix)', async () => {
|
||||
const result = await discoverConfigFiles(dir);
|
||||
assert.ok(result.skipped >= 1, 'should count at least node_modules as skipped');
|
||||
});
|
||||
|
||||
it('does not include non-config files', async () => {
|
||||
const result = await discoverConfigFiles(dir);
|
||||
const ts = result.files.find(f => f.absPath.endsWith('.ts'));
|
||||
assert.equal(ts, undefined, 'should not include .ts files');
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Group 2: File classification
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('file classification via discoverConfigFiles', () => {
|
||||
let dir;
|
||||
|
||||
before(async () => {
|
||||
dir = tempDir('classify');
|
||||
await mkdir(join(dir, '.claude-plugin'), { recursive: true });
|
||||
await mkdir(join(dir, 'agents'), { recursive: true });
|
||||
await mkdir(join(dir, 'commands'), { recursive: true });
|
||||
await mkdir(join(dir, 'skills', 'my-skill'), { recursive: true });
|
||||
await mkdir(join(dir, 'hooks'), { recursive: true });
|
||||
|
||||
await writeFile(join(dir, '.claude-plugin', 'plugin.json'), '{}');
|
||||
await writeFile(join(dir, 'agents', 'test-agent.md'), '---\nname: test\n---');
|
||||
await writeFile(join(dir, 'commands', 'test-cmd.md'), '---\nname: test\n---');
|
||||
await writeFile(join(dir, 'skills', 'my-skill', 'SKILL.md'), '# Skill');
|
||||
await writeFile(join(dir, 'hooks', 'hooks.json'), '{}');
|
||||
await writeFile(join(dir, 'CLAUDE.local.md'), '# Local');
|
||||
// Not a config file
|
||||
await writeFile(join(dir, 'random.txt'), 'hello');
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('classifies plugin.json in .claude-plugin/', async () => {
|
||||
const { files } = await discoverConfigFiles(dir);
|
||||
const plugin = files.find(f => f.type === 'plugin-json');
|
||||
assert.ok(plugin, 'should find plugin.json');
|
||||
});
|
||||
|
||||
it('classifies agent .md in agents/', async () => {
|
||||
const { files } = await discoverConfigFiles(dir);
|
||||
const agent = files.find(f => f.type === 'agent-md');
|
||||
assert.ok(agent, 'should find agent markdown');
|
||||
});
|
||||
|
||||
it('classifies command .md in commands/', async () => {
|
||||
const { files } = await discoverConfigFiles(dir);
|
||||
const cmd = files.find(f => f.type === 'command-md');
|
||||
assert.ok(cmd, 'should find command markdown');
|
||||
});
|
||||
|
||||
it('classifies SKILL.md', async () => {
|
||||
const { files } = await discoverConfigFiles(dir);
|
||||
const skill = files.find(f => f.type === 'skill-md');
|
||||
assert.ok(skill, 'should find SKILL.md');
|
||||
});
|
||||
|
||||
it('classifies hooks.json in hooks/', async () => {
|
||||
const { files } = await discoverConfigFiles(dir);
|
||||
const hooks = files.find(f => f.type === 'hooks-json');
|
||||
assert.ok(hooks, 'should find hooks.json');
|
||||
});
|
||||
|
||||
it('classifies CLAUDE.local.md', async () => {
|
||||
const { files } = await discoverConfigFiles(dir);
|
||||
const local = files.find(f => f.absPath.endsWith('CLAUDE.local.md'));
|
||||
assert.ok(local, 'should find CLAUDE.local.md');
|
||||
assert.equal(local.type, 'claude-md');
|
||||
});
|
||||
|
||||
it('does not discover random.txt', async () => {
|
||||
const { files } = await discoverConfigFiles(dir);
|
||||
const txt = files.find(f => f.absPath.endsWith('random.txt'));
|
||||
assert.equal(txt, undefined, 'should not discover .txt files');
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Group 3: Depth limit
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('depth limit', () => {
|
||||
let dir;
|
||||
|
||||
before(async () => {
|
||||
dir = tempDir('depth');
|
||||
// Create structure: a/b/c/d/e/f/g/CLAUDE.md (depth 7 from root)
|
||||
const shallow = join(dir, 'a', 'b');
|
||||
const deep = join(dir, 'a', 'b', 'c', 'd', 'e', 'f', 'g');
|
||||
await mkdir(shallow, { recursive: true });
|
||||
await mkdir(deep, { recursive: true });
|
||||
await writeFile(join(shallow, 'CLAUDE.md'), '# Shallow (depth 2)');
|
||||
await writeFile(join(deep, 'CLAUDE.md'), '# Deep (depth 7)');
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('finds deep files with default maxDepth (10)', async () => {
|
||||
const { files } = await discoverConfigFiles(dir);
|
||||
const deep = files.find(f => f.absPath.includes('f/g/CLAUDE.md'));
|
||||
assert.ok(deep, 'should find CLAUDE.md at depth 7 with default maxDepth');
|
||||
});
|
||||
|
||||
it('respects custom maxDepth: 3', async () => {
|
||||
const { files } = await discoverConfigFiles(dir, { maxDepth: 3 });
|
||||
const shallow = files.find(f => f.absPath.includes('a/b/CLAUDE.md'));
|
||||
const deep = files.find(f => f.absPath.includes('f/g/CLAUDE.md'));
|
||||
assert.ok(shallow, 'should find shallow CLAUDE.md');
|
||||
assert.equal(deep, undefined, 'should NOT find deep CLAUDE.md with maxDepth: 3');
|
||||
});
|
||||
|
||||
it('old depth limit of 5 would have missed depth-7 files', async () => {
|
||||
const { files } = await discoverConfigFiles(dir, { maxDepth: 5 });
|
||||
const deep = files.find(f => f.absPath.includes('f/g/CLAUDE.md'));
|
||||
assert.equal(deep, undefined, 'maxDepth: 5 should miss depth-7 files');
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Group 4: discoverConfigFilesMulti
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('discoverConfigFilesMulti', () => {
|
||||
let rootA, rootB;
|
||||
|
||||
before(async () => {
|
||||
rootA = tempDir('multiA');
|
||||
rootB = tempDir('multiB');
|
||||
await mkdir(rootA, { recursive: true });
|
||||
await mkdir(rootB, { recursive: true });
|
||||
await mkdir(join(rootB, 'a', 'b', 'c', 'd'), { recursive: true });
|
||||
await mkdir(join(rootA, 'node_modules'), { recursive: true });
|
||||
|
||||
await writeFile(join(rootA, 'CLAUDE.md'), '# Project A');
|
||||
await writeFile(join(rootA, '.mcp.json'), '{}');
|
||||
await writeFile(join(rootB, 'CLAUDE.md'), '# Project B');
|
||||
await writeFile(join(rootB, 'a', 'b', 'c', 'd', 'CLAUDE.md'), '# Deep B');
|
||||
// Skippable
|
||||
await writeFile(join(rootA, 'node_modules', 'CLAUDE.md'), '# Skip');
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await rm(rootA, { recursive: true, force: true });
|
||||
await rm(rootB, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('discovers files from both roots', async () => {
|
||||
const roots = [
|
||||
{ path: rootA, maxDepth: 10 },
|
||||
{ path: rootB, maxDepth: 10 },
|
||||
];
|
||||
const { files } = await discoverConfigFilesMulti(roots);
|
||||
const fromA = files.find(f => f.absPath.includes(rootA) && f.type === 'claude-md');
|
||||
const fromB = files.find(f => f.absPath === join(rootB, 'CLAUDE.md'));
|
||||
assert.ok(fromA, 'should find CLAUDE.md from root A');
|
||||
assert.ok(fromB, 'should find CLAUDE.md from root B');
|
||||
});
|
||||
|
||||
it('deduplicates when same root listed twice', async () => {
|
||||
const roots = [
|
||||
{ path: rootA, maxDepth: 10 },
|
||||
{ path: rootA, maxDepth: 10 },
|
||||
];
|
||||
const { files } = await discoverConfigFilesMulti(roots);
|
||||
const claudeFiles = files.filter(f => f.absPath === join(rootA, 'CLAUDE.md'));
|
||||
assert.equal(claudeFiles.length, 1, 'should deduplicate same file');
|
||||
});
|
||||
|
||||
it('accumulates skipped count across roots', async () => {
|
||||
const roots = [
|
||||
{ path: rootA, maxDepth: 10 },
|
||||
{ path: rootB, maxDepth: 10 },
|
||||
];
|
||||
const { skipped } = await discoverConfigFilesMulti(roots);
|
||||
assert.ok(skipped >= 1, 'should accumulate skipped dirs from rootA');
|
||||
});
|
||||
|
||||
it('respects per-root maxDepth', async () => {
|
||||
const roots = [
|
||||
{ path: rootA, maxDepth: 10 },
|
||||
{ path: rootB, maxDepth: 2 }, // blocks depth-4 file in rootB
|
||||
];
|
||||
const { files } = await discoverConfigFilesMulti(roots);
|
||||
const deepB = files.find(f => f.absPath.includes('c/d/CLAUDE.md'));
|
||||
assert.equal(deepB, undefined, 'should not find deep file when maxDepth is 2');
|
||||
});
|
||||
|
||||
it('respects global maxFiles cap', async () => {
|
||||
const roots = [
|
||||
{ path: rootA, maxDepth: 10 },
|
||||
{ path: rootB, maxDepth: 10 },
|
||||
];
|
||||
const { files } = await discoverConfigFilesMulti(roots, { maxFiles: 1 });
|
||||
assert.equal(files.length, 1, 'should stop after maxFiles');
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Group 5: discoverFullMachinePaths
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('discoverFullMachinePaths', () => {
|
||||
it('returns an array', async () => {
|
||||
const paths = await discoverFullMachinePaths();
|
||||
assert.ok(Array.isArray(paths));
|
||||
});
|
||||
|
||||
it('each entry has path (string) and maxDepth (number)', async () => {
|
||||
const paths = await discoverFullMachinePaths();
|
||||
for (const entry of paths) {
|
||||
assert.equal(typeof entry.path, 'string', 'path should be string');
|
||||
assert.equal(typeof entry.maxDepth, 'number', 'maxDepth should be number');
|
||||
}
|
||||
});
|
||||
|
||||
it('only returns existing directories', async () => {
|
||||
const paths = await discoverFullMachinePaths();
|
||||
for (const entry of paths) {
|
||||
const s = await stat(entry.path);
|
||||
assert.ok(s.isDirectory(), `${entry.path} should be a directory`);
|
||||
}
|
||||
});
|
||||
|
||||
it('includes ~/.claude if it exists', async () => {
|
||||
const home = process.env.HOME || '';
|
||||
const paths = await discoverFullMachinePaths();
|
||||
const hasClaude = paths.some(p => p.path === join(home, '.claude'));
|
||||
// Only assert if ~/.claude exists on this machine
|
||||
try {
|
||||
await stat(join(home, '.claude'));
|
||||
assert.ok(hasClaude, 'should include ~/.claude');
|
||||
} catch {
|
||||
// ~/.claude doesn't exist, skip
|
||||
}
|
||||
});
|
||||
|
||||
it('has no duplicate paths', async () => {
|
||||
const paths = await discoverFullMachinePaths();
|
||||
const seen = new Set();
|
||||
for (const entry of paths) {
|
||||
assert.ok(!seen.has(entry.path), `duplicate path: ${entry.path}`);
|
||||
seen.add(entry.path);
|
||||
}
|
||||
});
|
||||
|
||||
it('~/.claude gets maxDepth >= 6', async () => {
|
||||
const home = process.env.HOME || '';
|
||||
const paths = await discoverFullMachinePaths();
|
||||
const claude = paths.find(p => p.path === join(home, '.claude'));
|
||||
if (claude) {
|
||||
assert.ok(claude.maxDepth >= 6, 'maxDepth for ~/.claude should be >= 6');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Group 6: readTextFile
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('readTextFile', () => {
|
||||
let dir;
|
||||
|
||||
before(async () => {
|
||||
dir = tempDir('readtext');
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, 'good.md'), '# Hello\nWorld');
|
||||
await writeFile(join(dir, 'binary.bin'), Buffer.from([0x48, 0x65, 0x00, 0x6c, 0x6f]));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns string for valid UTF-8 file', async () => {
|
||||
const content = await readTextFile(join(dir, 'good.md'));
|
||||
assert.equal(typeof content, 'string');
|
||||
assert.ok(content.includes('Hello'));
|
||||
});
|
||||
|
||||
it('returns null for binary file (null bytes)', async () => {
|
||||
const content = await readTextFile(join(dir, 'binary.bin'));
|
||||
assert.equal(content, null);
|
||||
});
|
||||
|
||||
it('returns null for nonexistent file', async () => {
|
||||
const content = await readTextFile(join(dir, 'nope.txt'));
|
||||
assert.equal(content, null);
|
||||
});
|
||||
});
|
||||
149
plugins/config-audit/tests/lib/output.test.mjs
Normal file
149
plugins/config-audit/tests/lib/output.test.mjs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { describe, it, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { finding, scannerResult, envelope, resetCounter } from '../../scanners/lib/output.mjs';
|
||||
|
||||
describe('resetCounter', () => {
|
||||
it('resets finding ID counter', () => {
|
||||
resetCounter();
|
||||
const f1 = finding({ scanner: 'TST', severity: 'info', title: 'a', description: 'b' });
|
||||
assert.strictEqual(f1.id, 'CA-TST-001');
|
||||
resetCounter();
|
||||
const f2 = finding({ scanner: 'TST', severity: 'info', title: 'c', description: 'd' });
|
||||
assert.strictEqual(f2.id, 'CA-TST-001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('finding', () => {
|
||||
beforeEach(() => { resetCounter(); });
|
||||
|
||||
it('generates correct ID format', () => {
|
||||
const f = finding({ scanner: 'CML', severity: 'high', title: 't', description: 'd' });
|
||||
assert.match(f.id, /^CA-CML-\d{3}$/);
|
||||
});
|
||||
|
||||
it('auto-increments IDs', () => {
|
||||
const f1 = finding({ scanner: 'CML', severity: 'info', title: 'a', description: 'b' });
|
||||
const f2 = finding({ scanner: 'CML', severity: 'info', title: 'c', description: 'd' });
|
||||
assert.strictEqual(f1.id, 'CA-CML-001');
|
||||
assert.strictEqual(f2.id, 'CA-CML-002');
|
||||
});
|
||||
|
||||
it('includes all required fields', () => {
|
||||
const f = finding({
|
||||
scanner: 'SET',
|
||||
severity: 'critical',
|
||||
title: 'Test',
|
||||
description: 'Desc',
|
||||
file: '/foo.json',
|
||||
line: 10,
|
||||
evidence: 'x=1',
|
||||
category: 'Structure',
|
||||
recommendation: 'Fix it',
|
||||
autoFixable: true,
|
||||
});
|
||||
assert.strictEqual(f.scanner, 'SET');
|
||||
assert.strictEqual(f.severity, 'critical');
|
||||
assert.strictEqual(f.title, 'Test');
|
||||
assert.strictEqual(f.description, 'Desc');
|
||||
assert.strictEqual(f.file, '/foo.json');
|
||||
assert.strictEqual(f.line, 10);
|
||||
assert.strictEqual(f.evidence, 'x=1');
|
||||
assert.strictEqual(f.category, 'Structure');
|
||||
assert.strictEqual(f.recommendation, 'Fix it');
|
||||
assert.strictEqual(f.autoFixable, true);
|
||||
});
|
||||
|
||||
it('defaults nullable fields to null', () => {
|
||||
const f = finding({ scanner: 'TST', severity: 'info', title: 't', description: 'd' });
|
||||
assert.strictEqual(f.file, null);
|
||||
assert.strictEqual(f.line, null);
|
||||
assert.strictEqual(f.evidence, null);
|
||||
assert.strictEqual(f.category, null);
|
||||
assert.strictEqual(f.recommendation, null);
|
||||
assert.strictEqual(f.autoFixable, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scannerResult', () => {
|
||||
beforeEach(() => { resetCounter(); });
|
||||
|
||||
it('counts severity correctly', () => {
|
||||
const findings = [
|
||||
finding({ scanner: 'TST', severity: 'critical', title: 'a', description: 'b' }),
|
||||
finding({ scanner: 'TST', severity: 'high', title: 'c', description: 'd' }),
|
||||
finding({ scanner: 'TST', severity: 'high', title: 'e', description: 'f' }),
|
||||
finding({ scanner: 'TST', severity: 'info', title: 'g', description: 'h' }),
|
||||
];
|
||||
const r = scannerResult('TST', 'ok', findings, 5, 100);
|
||||
assert.strictEqual(r.counts.critical, 1);
|
||||
assert.strictEqual(r.counts.high, 2);
|
||||
assert.strictEqual(r.counts.medium, 0);
|
||||
assert.strictEqual(r.counts.low, 0);
|
||||
assert.strictEqual(r.counts.info, 1);
|
||||
});
|
||||
|
||||
it('includes error message when provided', () => {
|
||||
const r = scannerResult('TST', 'error', [], 0, 50, 'boom');
|
||||
assert.strictEqual(r.error, 'boom');
|
||||
});
|
||||
|
||||
it('omits error when not provided', () => {
|
||||
const r = scannerResult('TST', 'ok', [], 3, 100);
|
||||
assert.strictEqual(r.error, undefined);
|
||||
});
|
||||
|
||||
it('returns correct structure', () => {
|
||||
const r = scannerResult('CML', 'ok', [], 2, 42);
|
||||
assert.strictEqual(r.scanner, 'CML');
|
||||
assert.strictEqual(r.status, 'ok');
|
||||
assert.strictEqual(r.files_scanned, 2);
|
||||
assert.strictEqual(r.duration_ms, 42);
|
||||
assert.deepStrictEqual(r.findings, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('envelope', () => {
|
||||
beforeEach(() => { resetCounter(); });
|
||||
|
||||
it('aggregates across scanners', () => {
|
||||
const r1 = scannerResult('A', 'ok', [
|
||||
finding({ scanner: 'A', severity: 'high', title: 'x', description: 'y' }),
|
||||
], 1, 10);
|
||||
resetCounter();
|
||||
const r2 = scannerResult('B', 'ok', [
|
||||
finding({ scanner: 'B', severity: 'critical', title: 'a', description: 'b' }),
|
||||
finding({ scanner: 'B', severity: 'low', title: 'c', description: 'd' }),
|
||||
], 2, 20);
|
||||
|
||||
const env = envelope('/target', [r1, r2], 50);
|
||||
assert.strictEqual(env.aggregate.total_findings, 3);
|
||||
assert.strictEqual(env.aggregate.counts.critical, 1);
|
||||
assert.strictEqual(env.aggregate.counts.high, 1);
|
||||
assert.strictEqual(env.aggregate.counts.low, 1);
|
||||
assert.strictEqual(env.aggregate.scanners_ok, 2);
|
||||
});
|
||||
|
||||
it('counts scanner statuses', () => {
|
||||
const r1 = scannerResult('A', 'ok', [], 1, 10);
|
||||
const r2 = scannerResult('B', 'skipped', [], 0, 5);
|
||||
const r3 = scannerResult('C', 'error', [], 0, 3, 'fail');
|
||||
|
||||
const env = envelope('/t', [r1, r2, r3], 30);
|
||||
assert.strictEqual(env.aggregate.scanners_ok, 1);
|
||||
assert.strictEqual(env.aggregate.scanners_skipped, 1);
|
||||
assert.strictEqual(env.aggregate.scanners_error, 1);
|
||||
});
|
||||
|
||||
it('includes meta with version and tool', () => {
|
||||
const env = envelope('/t', [], 0);
|
||||
assert.strictEqual(env.meta.version, '2.2.0');
|
||||
assert.strictEqual(env.meta.tool, 'config-audit');
|
||||
assert.strictEqual(env.meta.target, '/t');
|
||||
assert.ok(env.meta.timestamp);
|
||||
});
|
||||
|
||||
it('calculates verdict correctly', () => {
|
||||
const env = envelope('/t', [], 0);
|
||||
assert.strictEqual(env.aggregate.verdict, 'PASS');
|
||||
});
|
||||
});
|
||||
252
plugins/config-audit/tests/lib/report-generator.test.mjs
Normal file
252
plugins/config-audit/tests/lib/report-generator.test.mjs
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
generatePostureReport,
|
||||
generateDriftReport,
|
||||
generatePluginHealthReport,
|
||||
generateFullReport,
|
||||
} from '../../scanners/lib/report-generator.mjs';
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function makePostureResult(overrides = {}) {
|
||||
return {
|
||||
utilization: { score: 65, overhang: 35 },
|
||||
maturity: { level: 2, name: 'Structured', description: 'Rules, skills, hooks' },
|
||||
segment: { segment: 'Strong', description: 'Well-configured' },
|
||||
areas: [
|
||||
{ name: 'CLAUDE.md', grade: 'A', score: 95, findingCount: 0 },
|
||||
{ name: 'Settings', grade: 'B', score: 80, findingCount: 2 },
|
||||
{ name: 'Hooks', grade: 'C', score: 60, findingCount: 4 },
|
||||
],
|
||||
overallGrade: 'B',
|
||||
topActions: ['Add MCP server', 'Configure hooks diversity', 'Add custom skills'],
|
||||
scannerEnvelope: {
|
||||
meta: { target: '/test/project', timestamp: '2026-04-03T12:00:00.000Z', version: '2.0.0', tool: 'config-audit' },
|
||||
scanners: [
|
||||
{ scanner: 'CML', findings: [], counts: { critical: 0, high: 0, medium: 0, low: 0, info: 0 } },
|
||||
{ scanner: 'SET', findings: [
|
||||
{ severity: 'medium', title: 'Unknown key', file: 'settings.json' },
|
||||
{ severity: 'low', title: 'Missing schema', file: 'settings.json' },
|
||||
], counts: { critical: 0, high: 0, medium: 1, low: 1, info: 0 } },
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDiffResult(overrides = {}) {
|
||||
return {
|
||||
summary: { totalBefore: 10, totalAfter: 8, newCount: 1, resolvedCount: 3, trend: 'improving' },
|
||||
scoreChange: {
|
||||
before: { score: 60, grade: 'C' },
|
||||
after: { score: 75, grade: 'B' },
|
||||
delta: 15,
|
||||
},
|
||||
newFindings: [
|
||||
{ severity: 'medium', title: 'New finding', file: 'test.json' },
|
||||
],
|
||||
resolvedFindings: [
|
||||
{ severity: 'high', title: 'Fixed issue' },
|
||||
{ severity: 'medium', title: 'Another fixed' },
|
||||
{ severity: 'low', title: 'Minor fix' },
|
||||
],
|
||||
areaChanges: [
|
||||
{ name: 'Settings', before: { score: 60, grade: 'C' }, after: { score: 80, grade: 'B' }, delta: 20 },
|
||||
{ name: 'Hooks', before: { score: 70, grade: 'B' }, after: { score: 70, grade: 'B' }, delta: 0 },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makePluginResults() {
|
||||
return [
|
||||
{ name: 'plugin-a', findings: [], commandCount: 5, agentCount: 2 },
|
||||
{ name: 'plugin-b', findings: [
|
||||
{ severity: 'medium', title: 'Missing frontmatter' },
|
||||
{ severity: 'low', title: 'No README' },
|
||||
], commandCount: 3, agentCount: 1 },
|
||||
];
|
||||
}
|
||||
|
||||
function makeScanResult(crossPluginFindings = []) {
|
||||
return {
|
||||
scanner: 'PLH',
|
||||
status: 'ok',
|
||||
findings: [...crossPluginFindings],
|
||||
counts: { critical: 0, high: 0, medium: 0, low: 0, info: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// generatePostureReport
|
||||
// ========================================
|
||||
describe('generatePostureReport', () => {
|
||||
it('returns markdown with health header and grade', () => {
|
||||
const report = generatePostureReport(makePostureResult());
|
||||
assert.ok(report.includes('## Health Assessment'));
|
||||
assert.ok(report.includes('Health Grade'));
|
||||
assert.ok(report.includes('**B**'));
|
||||
});
|
||||
|
||||
it('includes area breakdown table (quality areas only)', () => {
|
||||
const report = generatePostureReport(makePostureResult());
|
||||
assert.ok(report.includes('| CLAUDE.md |'));
|
||||
assert.ok(report.includes('| Settings |'));
|
||||
assert.ok(report.includes('| Hooks |'));
|
||||
});
|
||||
|
||||
it('excludes Feature Coverage from area breakdown', () => {
|
||||
const result = makePostureResult({
|
||||
areas: [
|
||||
{ name: 'CLAUDE.md', grade: 'A', score: 95, findingCount: 0 },
|
||||
{ name: 'Feature Coverage', grade: 'F', score: 20, findingCount: 15 },
|
||||
],
|
||||
});
|
||||
const report = generatePostureReport(result);
|
||||
assert.ok(!report.includes('| Feature Coverage |'));
|
||||
assert.ok(report.includes('| CLAUDE.md |'));
|
||||
});
|
||||
|
||||
it('shows opportunity count when present', () => {
|
||||
const report = generatePostureReport(makePostureResult({ opportunityCount: 5 }));
|
||||
assert.ok(report.includes('5 features available'));
|
||||
});
|
||||
|
||||
it('does not show legacy metrics (utilization, maturity, segment)', () => {
|
||||
const report = generatePostureReport(makePostureResult());
|
||||
assert.ok(!report.includes('Utilization'));
|
||||
assert.ok(!report.includes('Maturity'));
|
||||
assert.ok(!report.includes('Segment'));
|
||||
});
|
||||
|
||||
it('includes scanner findings in collapsed details', () => {
|
||||
const report = generatePostureReport(makePostureResult());
|
||||
assert.ok(report.includes('<details>'));
|
||||
assert.ok(report.includes('SET'));
|
||||
assert.ok(report.includes('Unknown key'));
|
||||
});
|
||||
|
||||
it('skips scanners with no findings', () => {
|
||||
const report = generatePostureReport(makePostureResult());
|
||||
// CML has 0 findings, should not appear in details
|
||||
assert.ok(!report.includes('<summary>CML'));
|
||||
});
|
||||
|
||||
it('handles empty areas', () => {
|
||||
const report = generatePostureReport(makePostureResult({ areas: [], topActions: [] }));
|
||||
assert.ok(report.includes('## Health Assessment'));
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// generateDriftReport
|
||||
// ========================================
|
||||
describe('generateDriftReport', () => {
|
||||
it('returns markdown with baseline info', () => {
|
||||
const report = generateDriftReport(makeDiffResult(), 'my-baseline');
|
||||
assert.ok(report.includes('## Drift Report'));
|
||||
assert.ok(report.includes('my-baseline'));
|
||||
});
|
||||
|
||||
it('shows trend indicator', () => {
|
||||
const report = generateDriftReport(makeDiffResult(), 'default');
|
||||
assert.ok(report.includes('Improving'));
|
||||
});
|
||||
|
||||
it('shows score delta', () => {
|
||||
const report = generateDriftReport(makeDiffResult(), 'default');
|
||||
assert.ok(report.includes('+15'));
|
||||
});
|
||||
|
||||
it('shows new findings table', () => {
|
||||
const report = generateDriftReport(makeDiffResult(), 'default');
|
||||
assert.ok(report.includes('New Findings'));
|
||||
assert.ok(report.includes('New finding'));
|
||||
});
|
||||
|
||||
it('shows resolved findings table', () => {
|
||||
const report = generateDriftReport(makeDiffResult(), 'default');
|
||||
assert.ok(report.includes('Resolved Findings'));
|
||||
assert.ok(report.includes('Fixed issue'));
|
||||
});
|
||||
|
||||
it('shows area changes (only non-zero delta)', () => {
|
||||
const report = generateDriftReport(makeDiffResult(), 'default');
|
||||
assert.ok(report.includes('Settings'));
|
||||
// Hooks has delta 0, should not appear
|
||||
assert.ok(!report.includes('| Hooks |'));
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// generatePluginHealthReport
|
||||
// ========================================
|
||||
describe('generatePluginHealthReport', () => {
|
||||
it('returns markdown with plugin table', () => {
|
||||
const report = generatePluginHealthReport(makeScanResult(), makePluginResults());
|
||||
assert.ok(report.includes('## Plugin Health'));
|
||||
assert.ok(report.includes('plugin-a'));
|
||||
assert.ok(report.includes('plugin-b'));
|
||||
});
|
||||
|
||||
it('shows grades and counts', () => {
|
||||
const report = generatePluginHealthReport(makeScanResult(), makePluginResults());
|
||||
assert.ok(report.includes('| 5 |'));
|
||||
assert.ok(report.includes('| 3 |'));
|
||||
});
|
||||
|
||||
it('includes per-plugin findings', () => {
|
||||
const report = generatePluginHealthReport(makeScanResult(), makePluginResults());
|
||||
assert.ok(report.includes('Missing frontmatter'));
|
||||
});
|
||||
|
||||
it('handles no plugins', () => {
|
||||
const report = generatePluginHealthReport(makeScanResult(), []);
|
||||
assert.ok(report.includes('No plugins found'));
|
||||
});
|
||||
|
||||
it('shows cross-plugin issues', () => {
|
||||
const crossFindings = [
|
||||
{ severity: 'high', title: 'Cross-plugin conflict', description: 'Command name clash' },
|
||||
];
|
||||
const report = generatePluginHealthReport(makeScanResult(crossFindings), makePluginResults());
|
||||
assert.ok(report.includes('Cross-Plugin Issues'));
|
||||
assert.ok(report.includes('Command name clash'));
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// generateFullReport
|
||||
// ========================================
|
||||
describe('generateFullReport', () => {
|
||||
it('combines all sections', () => {
|
||||
const report = generateFullReport(
|
||||
makePostureResult(),
|
||||
{ diff: makeDiffResult(), baselineName: 'default' },
|
||||
{ scanResult: makeScanResult(), pluginResults: makePluginResults() },
|
||||
);
|
||||
assert.ok(report.includes('# Config-Audit Report'));
|
||||
assert.ok(report.includes('## Health Assessment'));
|
||||
assert.ok(report.includes('## Drift Report'));
|
||||
assert.ok(report.includes('## Plugin Health'));
|
||||
});
|
||||
|
||||
it('skips null sections', () => {
|
||||
const report = generateFullReport(makePostureResult(), null, null);
|
||||
assert.ok(report.includes('## Health Assessment'));
|
||||
assert.ok(!report.includes('## Drift Report'));
|
||||
assert.ok(!report.includes('## Plugin Health'));
|
||||
});
|
||||
|
||||
it('handles all null inputs', () => {
|
||||
const report = generateFullReport(null, null, null);
|
||||
assert.ok(report.includes('No data provided'));
|
||||
});
|
||||
|
||||
it('stays under 500 lines', () => {
|
||||
const report = generateFullReport(makePostureResult(), null, null);
|
||||
const lineCount = report.split('\n').length;
|
||||
assert.ok(lineCount <= 502); // 500 + truncation notice
|
||||
});
|
||||
});
|
||||
545
plugins/config-audit/tests/lib/scoring.test.mjs
Normal file
545
plugins/config-audit/tests/lib/scoring.test.mjs
Normal file
|
|
@ -0,0 +1,545 @@
|
|||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
calculateUtilization,
|
||||
determineMaturityLevel,
|
||||
determineSegment,
|
||||
scoreByArea,
|
||||
topActions,
|
||||
generateScorecard,
|
||||
generateHealthScorecard,
|
||||
TITLE_TO_ID,
|
||||
TIER_WEIGHTS,
|
||||
TIER_COUNTS,
|
||||
MAX_WEIGHTED,
|
||||
MATURITY_LEVELS,
|
||||
SEGMENTS,
|
||||
} from '../../scanners/lib/scoring.mjs';
|
||||
|
||||
// --- Helpers ---
|
||||
function makeGapFinding(title, tier) {
|
||||
return { id: 'CA-GAP-999', scanner: 'GAP', severity: 'info', title, category: tier, recommendation: 'Fix it' };
|
||||
}
|
||||
|
||||
function allGapFindings() {
|
||||
return Object.entries(TITLE_TO_ID).map(([title, id]) => {
|
||||
const tier = id.split('_')[0];
|
||||
return makeGapFinding(title, tier);
|
||||
});
|
||||
}
|
||||
|
||||
function t1GapFindings() {
|
||||
return Object.entries(TITLE_TO_ID)
|
||||
.filter(([, id]) => id.startsWith('t1'))
|
||||
.map(([title]) => makeGapFinding(title, 't1'));
|
||||
}
|
||||
|
||||
function t4GapFindings() {
|
||||
return Object.entries(TITLE_TO_ID)
|
||||
.filter(([, id]) => id.startsWith('t4'))
|
||||
.map(([title]) => makeGapFinding(title, 't4'));
|
||||
}
|
||||
|
||||
function makeScannerResult(scanner, findingCount) {
|
||||
const findings = Array.from({ length: findingCount }, (_, i) => ({
|
||||
id: `CA-${scanner}-${String(i + 1).padStart(3, '0')}`,
|
||||
scanner,
|
||||
severity: 'low',
|
||||
title: `Finding ${i + 1}`,
|
||||
category: scanner === 'GAP' ? 't2' : null,
|
||||
recommendation: 'Fix',
|
||||
}));
|
||||
return {
|
||||
scanner,
|
||||
status: 'ok',
|
||||
files_scanned: 5,
|
||||
duration_ms: 10,
|
||||
findings,
|
||||
counts: { critical: 0, high: 0, medium: 0, low: findingCount, info: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// calculateUtilization
|
||||
// ========================================
|
||||
describe('calculateUtilization', () => {
|
||||
it('returns 100% with no gap findings', () => {
|
||||
const result = calculateUtilization([]);
|
||||
assert.equal(result.score, 100);
|
||||
assert.equal(result.overhang, 0);
|
||||
});
|
||||
|
||||
it('returns 0% when all 25 dimensions are gaps', () => {
|
||||
const result = calculateUtilization(allGapFindings());
|
||||
assert.equal(result.score, 0);
|
||||
assert.equal(result.overhang, 100);
|
||||
});
|
||||
|
||||
it('weighs T1 gaps heavier (3x)', () => {
|
||||
const onlyT1 = t1GapFindings(); // 5 T1 gaps = 15 weight lost
|
||||
const result = calculateUtilization(onlyT1);
|
||||
// Lost: 5 × 3 = 15 out of 42. Present: 27/42 = 64%
|
||||
assert.equal(result.score, 64);
|
||||
});
|
||||
|
||||
it('weighs T4 gaps lighter (1x)', () => {
|
||||
const onlyT4 = t4GapFindings(); // 5 T4 gaps = 5 weight lost
|
||||
const result = calculateUtilization(onlyT4);
|
||||
// Lost: 5 × 1 = 5 out of 42. Present: 37/42 = 88%
|
||||
assert.equal(result.score, 88);
|
||||
});
|
||||
|
||||
it('T1+T2 present but no T3+T4 scores ~69%', () => {
|
||||
// T3: 8 dims × 1 = 8, T4: 5 dims × 1 = 5. Lost = 13 out of 42. Present = 29/42 = 69%
|
||||
const t3t4Gaps = Object.entries(TITLE_TO_ID)
|
||||
.filter(([, id]) => id.startsWith('t3') || id.startsWith('t4'))
|
||||
.map(([title, id]) => makeGapFinding(title, id.split('_')[0]));
|
||||
const result = calculateUtilization(t3t4Gaps);
|
||||
assert.equal(result.score, 69);
|
||||
});
|
||||
|
||||
it('score + overhang = 100', () => {
|
||||
const result = calculateUtilization(t1GapFindings());
|
||||
assert.equal(result.score + result.overhang, 100);
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
const result = calculateUtilization([]);
|
||||
assert.equal(typeof result.score, 'number');
|
||||
assert.equal(typeof result.overhang, 'number');
|
||||
});
|
||||
|
||||
it('ignores findings with unknown category', () => {
|
||||
const weird = [{ category: 'tx' }, { category: undefined }];
|
||||
const result = calculateUtilization(weird);
|
||||
assert.equal(result.score, 100); // unknown tiers don't count
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// determineMaturityLevel
|
||||
// ========================================
|
||||
describe('determineMaturityLevel', () => {
|
||||
const discovery = { files: [] };
|
||||
|
||||
it('returns Level 0 when CLAUDE.md is missing', () => {
|
||||
const gaps = [makeGapFinding('No CLAUDE.md file', 't1')];
|
||||
const result = determineMaturityLevel(gaps, discovery);
|
||||
assert.equal(result.level, 0);
|
||||
assert.equal(result.name, 'Bare');
|
||||
});
|
||||
|
||||
it('returns Level 1 when CLAUDE.md present but no permissions', () => {
|
||||
const gaps = [
|
||||
makeGapFinding('No permissions configured', 't1'),
|
||||
makeGapFinding('No hooks configured', 't1'),
|
||||
];
|
||||
const result = determineMaturityLevel(gaps, discovery);
|
||||
assert.equal(result.level, 1);
|
||||
});
|
||||
|
||||
it('returns Level 2 when permissions + hooks + modular present but no MCP', () => {
|
||||
const gaps = [
|
||||
makeGapFinding('No MCP servers configured', 't1'),
|
||||
makeGapFinding('Low hook diversity', 't2'),
|
||||
makeGapFinding('No custom subagents', 't2'),
|
||||
];
|
||||
const result = determineMaturityLevel(gaps, discovery);
|
||||
assert.equal(result.level, 2);
|
||||
assert.equal(result.name, 'Structured');
|
||||
});
|
||||
|
||||
it('returns Level 3 when MCP + hook diversity + subagents present but no plugin', () => {
|
||||
const gaps = [
|
||||
makeGapFinding('No custom plugin', 't4'),
|
||||
];
|
||||
const result = determineMaturityLevel(gaps, discovery);
|
||||
assert.equal(result.level, 3);
|
||||
assert.equal(result.name, 'Automated');
|
||||
});
|
||||
|
||||
it('returns Level 4 when all requirements met', () => {
|
||||
const result = determineMaturityLevel([], discovery);
|
||||
assert.equal(result.level, 4);
|
||||
assert.equal(result.name, 'Governed');
|
||||
});
|
||||
|
||||
it('Level 2 requires modular OR path-rules (modular)', () => {
|
||||
// Has permissions, hooks, modular — but no path-rules. Should still be level 2.
|
||||
const gaps = [
|
||||
makeGapFinding('No path-scoped rules', 't2'),
|
||||
makeGapFinding('No MCP servers configured', 't1'),
|
||||
makeGapFinding('Low hook diversity', 't2'),
|
||||
makeGapFinding('No custom subagents', 't2'),
|
||||
];
|
||||
const result = determineMaturityLevel(gaps, discovery);
|
||||
assert.equal(result.level, 2);
|
||||
});
|
||||
|
||||
it('Level 2 requires modular OR path-rules (path-rules)', () => {
|
||||
// Has permissions, hooks, path-rules — but not modular. Should still be level 2.
|
||||
const gaps = [
|
||||
makeGapFinding('CLAUDE.md not modular', 't2'),
|
||||
makeGapFinding('No MCP servers configured', 't1'),
|
||||
makeGapFinding('Low hook diversity', 't2'),
|
||||
makeGapFinding('No custom subagents', 't2'),
|
||||
];
|
||||
const result = determineMaturityLevel(gaps, discovery);
|
||||
assert.equal(result.level, 2);
|
||||
});
|
||||
|
||||
it('stays Level 1 when neither modular nor path-rules', () => {
|
||||
const gaps = [
|
||||
makeGapFinding('CLAUDE.md not modular', 't2'),
|
||||
makeGapFinding('No path-scoped rules', 't2'),
|
||||
];
|
||||
const result = determineMaturityLevel(gaps, discovery);
|
||||
assert.equal(result.level, 1);
|
||||
});
|
||||
|
||||
it('Level 3 blocked by missing hook diversity', () => {
|
||||
const gaps = [
|
||||
makeGapFinding('Low hook diversity', 't2'),
|
||||
];
|
||||
const result = determineMaturityLevel(gaps, discovery);
|
||||
assert.equal(result.level, 2);
|
||||
});
|
||||
|
||||
it('Level 4 blocked by missing project MCP in git', () => {
|
||||
const gaps = [
|
||||
makeGapFinding('No project .mcp.json in git', 't4'),
|
||||
];
|
||||
const result = determineMaturityLevel(gaps, discovery);
|
||||
assert.equal(result.level, 3);
|
||||
});
|
||||
|
||||
it('all MATURITY_LEVELS have required fields', () => {
|
||||
for (const ml of MATURITY_LEVELS) {
|
||||
assert.ok(typeof ml.level === 'number');
|
||||
assert.ok(typeof ml.name === 'string');
|
||||
assert.ok(typeof ml.description === 'string');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// determineSegment
|
||||
// ========================================
|
||||
describe('determineSegment', () => {
|
||||
it('Top Performer at score > 80', () => {
|
||||
assert.equal(determineSegment(81).segment, 'Top Performer');
|
||||
assert.equal(determineSegment(100).segment, 'Top Performer');
|
||||
});
|
||||
|
||||
it('Strong at 65-80', () => {
|
||||
assert.equal(determineSegment(65).segment, 'Strong');
|
||||
assert.equal(determineSegment(80).segment, 'Strong');
|
||||
});
|
||||
|
||||
it('Competent at 45-64', () => {
|
||||
assert.equal(determineSegment(45).segment, 'Competent');
|
||||
assert.equal(determineSegment(64).segment, 'Competent');
|
||||
});
|
||||
|
||||
it('Developing at 25-44', () => {
|
||||
assert.equal(determineSegment(25).segment, 'Developing');
|
||||
assert.equal(determineSegment(44).segment, 'Developing');
|
||||
});
|
||||
|
||||
it('Beginner at < 25', () => {
|
||||
assert.equal(determineSegment(0).segment, 'Beginner');
|
||||
assert.equal(determineSegment(24).segment, 'Beginner');
|
||||
});
|
||||
|
||||
it('returns description string', () => {
|
||||
const result = determineSegment(50);
|
||||
assert.ok(result.description.length > 0);
|
||||
});
|
||||
|
||||
it('edge case: exactly 80 is Strong', () => {
|
||||
assert.equal(determineSegment(80).segment, 'Strong');
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// scoreByArea
|
||||
// ========================================
|
||||
describe('scoreByArea', () => {
|
||||
it('returns areas for all 8 scanners', () => {
|
||||
const scanners = ['CML', 'SET', 'HKV', 'RUL', 'MCP', 'IMP', 'CNF', 'GAP']
|
||||
.map(s => makeScannerResult(s, 0));
|
||||
const result = scoreByArea(scanners);
|
||||
assert.equal(result.areas.length, 8);
|
||||
});
|
||||
|
||||
it('zero findings → A grade', () => {
|
||||
const scanners = [makeScannerResult('CML', 0)];
|
||||
const result = scoreByArea(scanners);
|
||||
assert.equal(result.areas[0].grade, 'A');
|
||||
assert.equal(result.areas[0].score, 100);
|
||||
});
|
||||
|
||||
it('many findings → lower grade', () => {
|
||||
const scanners = [makeScannerResult('CML', 8)];
|
||||
const result = scoreByArea(scanners);
|
||||
assert.ok(result.areas[0].score < 50);
|
||||
});
|
||||
|
||||
it('GAP scanner uses utilization-based scoring', () => {
|
||||
const gapResult = makeScannerResult('GAP', 0);
|
||||
const result = scoreByArea([gapResult]);
|
||||
assert.equal(result.areas[0].name, 'Feature Coverage');
|
||||
assert.equal(result.areas[0].score, 100); // 0 gaps = 100%
|
||||
});
|
||||
|
||||
it('overall grade is average of quality areas (excludes GAP)', () => {
|
||||
const scanners = [
|
||||
makeScannerResult('CML', 0), // 100
|
||||
makeScannerResult('SET', 0), // 100
|
||||
makeScannerResult('GAP', 20), // low utilization — should NOT drag down grade
|
||||
];
|
||||
const result = scoreByArea(scanners);
|
||||
assert.equal(result.overallGrade, 'A'); // only CML+SET averaged
|
||||
assert.equal(result.areas.length, 3); // GAP still in areas for display
|
||||
});
|
||||
|
||||
it('mixed grades produce mixed overall', () => {
|
||||
const scanners = [
|
||||
makeScannerResult('CML', 0), // 100 → A
|
||||
makeScannerResult('SET', 10), // low → F range
|
||||
];
|
||||
const result = scoreByArea(scanners);
|
||||
assert.ok(['A', 'B', 'C', 'D', 'F'].includes(result.overallGrade));
|
||||
});
|
||||
|
||||
it('empty scanner array → overallGrade F', () => {
|
||||
const result = scoreByArea([]);
|
||||
assert.equal(result.areas.length, 0);
|
||||
assert.equal(result.overallGrade, 'F');
|
||||
});
|
||||
|
||||
it('area objects have required fields', () => {
|
||||
const scanners = [makeScannerResult('CML', 2)];
|
||||
const result = scoreByArea(scanners);
|
||||
const area = result.areas[0];
|
||||
assert.ok('name' in area);
|
||||
assert.ok('grade' in area);
|
||||
assert.ok('score' in area);
|
||||
assert.ok('findingCount' in area);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// topActions
|
||||
// ========================================
|
||||
describe('topActions', () => {
|
||||
it('returns max 3 actions', () => {
|
||||
const gaps = allGapFindings();
|
||||
const result = topActions(gaps);
|
||||
assert.equal(result.length, 3);
|
||||
});
|
||||
|
||||
it('prioritizes T1 over T2', () => {
|
||||
const gaps = [
|
||||
{ ...makeGapFinding('Low hook diversity', 't2'), recommendation: 'Add hooks' },
|
||||
{ ...makeGapFinding('No CLAUDE.md file', 't1'), recommendation: 'Create CLAUDE.md' },
|
||||
];
|
||||
const result = topActions(gaps);
|
||||
assert.equal(result[0], 'Create CLAUDE.md');
|
||||
});
|
||||
|
||||
it('returns empty array for no gaps', () => {
|
||||
assert.deepEqual(topActions([]), []);
|
||||
});
|
||||
|
||||
it('returns all items if fewer than 3', () => {
|
||||
const gaps = [makeGapFinding('No CLAUDE.md file', 't1')];
|
||||
assert.equal(topActions(gaps).length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// generateScorecard
|
||||
// ========================================
|
||||
describe('generateScorecard', () => {
|
||||
const sampleAreas = {
|
||||
areas: [
|
||||
{ name: 'CLAUDE.md', grade: 'A', score: 92 },
|
||||
{ name: 'Settings', grade: 'B', score: 78 },
|
||||
{ name: 'Hooks', grade: 'C', score: 55 },
|
||||
{ name: 'Rules', grade: 'B', score: 71 },
|
||||
],
|
||||
overallGrade: 'B',
|
||||
};
|
||||
const sampleUtil = { score: 68, overhang: 32 };
|
||||
const sampleMaturity = { level: 2, name: 'Structured' };
|
||||
const sampleSegment = { segment: 'Strong' };
|
||||
const sampleActions = ['Configure MCP', 'Add hooks', 'Create agents'];
|
||||
|
||||
it('returns a string', () => {
|
||||
const result = generateScorecard(sampleAreas, sampleUtil, sampleMaturity, sampleSegment, sampleActions);
|
||||
assert.equal(typeof result, 'string');
|
||||
});
|
||||
|
||||
it('contains header line', () => {
|
||||
const result = generateScorecard(sampleAreas, sampleUtil, sampleMaturity, sampleSegment, sampleActions);
|
||||
assert.ok(result.includes('Config-Audit Posture Score'));
|
||||
});
|
||||
|
||||
it('contains overall grade', () => {
|
||||
const result = generateScorecard(sampleAreas, sampleUtil, sampleMaturity, sampleSegment, sampleActions);
|
||||
assert.ok(result.includes('Overall: B'));
|
||||
});
|
||||
|
||||
it('contains maturity level', () => {
|
||||
const result = generateScorecard(sampleAreas, sampleUtil, sampleMaturity, sampleSegment, sampleActions);
|
||||
assert.ok(result.includes('Level 2 (Structured)'));
|
||||
});
|
||||
|
||||
it('contains utilization', () => {
|
||||
const result = generateScorecard(sampleAreas, sampleUtil, sampleMaturity, sampleSegment, sampleActions);
|
||||
assert.ok(result.includes('Utilization: 68%'));
|
||||
});
|
||||
|
||||
it('contains segment', () => {
|
||||
const result = generateScorecard(sampleAreas, sampleUtil, sampleMaturity, sampleSegment, sampleActions);
|
||||
assert.ok(result.includes('Segment: Strong'));
|
||||
});
|
||||
|
||||
it('contains all area names', () => {
|
||||
const result = generateScorecard(sampleAreas, sampleUtil, sampleMaturity, sampleSegment, sampleActions);
|
||||
assert.ok(result.includes('CLAUDE.md'));
|
||||
assert.ok(result.includes('Settings'));
|
||||
assert.ok(result.includes('Hooks'));
|
||||
assert.ok(result.includes('Rules'));
|
||||
});
|
||||
|
||||
it('contains top actions', () => {
|
||||
const result = generateScorecard(sampleAreas, sampleUtil, sampleMaturity, sampleSegment, sampleActions);
|
||||
assert.ok(result.includes('1. Configure MCP'));
|
||||
assert.ok(result.includes('2. Add hooks'));
|
||||
assert.ok(result.includes('3. Create agents'));
|
||||
});
|
||||
|
||||
it('works with empty areas', () => {
|
||||
const result = generateScorecard({ areas: [], overallGrade: 'F' }, sampleUtil, sampleMaturity, sampleSegment, []);
|
||||
assert.equal(typeof result, 'string');
|
||||
assert.ok(result.includes('Config-Audit Posture Score'));
|
||||
});
|
||||
|
||||
it('works with odd number of areas', () => {
|
||||
const odd = { areas: [{ name: 'Test', grade: 'A', score: 95 }], overallGrade: 'A' };
|
||||
const result = generateScorecard(odd, sampleUtil, sampleMaturity, sampleSegment, []);
|
||||
assert.ok(result.includes('Test'));
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// generateHealthScorecard (v3)
|
||||
// ========================================
|
||||
describe('generateHealthScorecard', () => {
|
||||
const sampleAreas = {
|
||||
areas: [
|
||||
{ name: 'CLAUDE.md', grade: 'A', score: 92 },
|
||||
{ name: 'Settings', grade: 'B', score: 78 },
|
||||
{ name: 'Hooks', grade: 'C', score: 55 },
|
||||
{ name: 'Feature Coverage', grade: 'F', score: 20 },
|
||||
],
|
||||
overallGrade: 'B',
|
||||
};
|
||||
|
||||
it('returns a string', () => {
|
||||
const result = generateHealthScorecard(sampleAreas, 12);
|
||||
assert.equal(typeof result, 'string');
|
||||
});
|
||||
|
||||
it('contains Health header (not Overall)', () => {
|
||||
const result = generateHealthScorecard(sampleAreas, 5);
|
||||
assert.ok(result.includes('Config-Audit Health Score'));
|
||||
assert.ok(result.includes('Health: B'));
|
||||
assert.ok(!result.includes('Overall:'));
|
||||
});
|
||||
|
||||
it('does NOT contain Maturity, Utilization, or Segment', () => {
|
||||
const result = generateHealthScorecard(sampleAreas, 5);
|
||||
assert.ok(!result.includes('Maturity:'));
|
||||
assert.ok(!result.includes('Utilization:'));
|
||||
assert.ok(!result.includes('Segment:'));
|
||||
});
|
||||
|
||||
it('excludes Feature Coverage from area display', () => {
|
||||
const result = generateHealthScorecard(sampleAreas, 5);
|
||||
assert.ok(!result.includes('Feature Coverage'));
|
||||
assert.ok(result.includes('CLAUDE.md'));
|
||||
assert.ok(result.includes('Settings'));
|
||||
assert.ok(result.includes('Hooks'));
|
||||
});
|
||||
|
||||
it('shows opportunity count', () => {
|
||||
const result = generateHealthScorecard(sampleAreas, 12);
|
||||
assert.ok(result.includes('12 opportunities available'));
|
||||
});
|
||||
|
||||
it('uses singular for 1 opportunity', () => {
|
||||
const result = generateHealthScorecard(sampleAreas, 1);
|
||||
assert.ok(result.includes('1 opportunity available'));
|
||||
});
|
||||
|
||||
it('hides opportunity line when count is 0', () => {
|
||||
const result = generateHealthScorecard(sampleAreas, 0);
|
||||
assert.ok(!result.includes('opportunit'));
|
||||
});
|
||||
|
||||
it('shows areas scanned count', () => {
|
||||
const result = generateHealthScorecard(sampleAreas, 5);
|
||||
assert.ok(result.includes('3 areas scanned')); // 3 quality areas (excl Feature Coverage)
|
||||
});
|
||||
|
||||
it('computes avgScore from quality areas only', () => {
|
||||
// Quality areas: A(92), B(78), C(55) → avg = 75
|
||||
const result = generateHealthScorecard(sampleAreas, 5);
|
||||
assert.ok(result.includes('(75/100)'));
|
||||
});
|
||||
|
||||
it('works with empty areas', () => {
|
||||
const result = generateHealthScorecard({ areas: [], overallGrade: 'F' }, 0);
|
||||
assert.equal(typeof result, 'string');
|
||||
assert.ok(result.includes('Config-Audit Health Score'));
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Constants and exports
|
||||
// ========================================
|
||||
describe('scoring constants', () => {
|
||||
it('TITLE_TO_ID has 25 entries', () => {
|
||||
assert.equal(Object.keys(TITLE_TO_ID).length, 25);
|
||||
});
|
||||
|
||||
it('TIER_COUNTS sum to 25', () => {
|
||||
const sum = Object.values(TIER_COUNTS).reduce((a, b) => a + b, 0);
|
||||
assert.equal(sum, 25);
|
||||
});
|
||||
|
||||
it('MAX_WEIGHTED is 42', () => {
|
||||
assert.equal(MAX_WEIGHTED, 42);
|
||||
});
|
||||
|
||||
it('TIER_WEIGHTS match spec', () => {
|
||||
assert.equal(TIER_WEIGHTS.t1, 3);
|
||||
assert.equal(TIER_WEIGHTS.t2, 2);
|
||||
assert.equal(TIER_WEIGHTS.t3, 1);
|
||||
assert.equal(TIER_WEIGHTS.t4, 1);
|
||||
});
|
||||
|
||||
it('SEGMENTS covers full 0-100 range', () => {
|
||||
assert.equal(SEGMENTS[SEGMENTS.length - 1].min, 0);
|
||||
assert.ok(SEGMENTS[0].min >= 80);
|
||||
});
|
||||
|
||||
it('MATURITY_LEVELS has 5 levels (0-4)', () => {
|
||||
assert.equal(MATURITY_LEVELS.length, 5);
|
||||
assert.equal(MATURITY_LEVELS[0].level, 0);
|
||||
assert.equal(MATURITY_LEVELS[4].level, 4);
|
||||
});
|
||||
});
|
||||
133
plugins/config-audit/tests/lib/severity.test.mjs
Normal file
133
plugins/config-audit/tests/lib/severity.test.mjs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { SEVERITY, riskScore, verdict, riskBand, gradeFromPassRate, QUALITY_CATEGORIES } from '../../scanners/lib/severity.mjs';
|
||||
|
||||
describe('SEVERITY constants', () => {
|
||||
it('has all 5 levels', () => {
|
||||
assert.deepStrictEqual(Object.keys(SEVERITY).sort(), ['critical', 'high', 'info', 'low', 'medium']);
|
||||
});
|
||||
|
||||
it('is frozen', () => {
|
||||
assert.throws(() => { SEVERITY.critical = 'x'; }, TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('riskScore', () => {
|
||||
it('returns 0 for empty counts', () => {
|
||||
assert.strictEqual(riskScore({}), 0);
|
||||
});
|
||||
|
||||
it('returns 0 for info-only findings', () => {
|
||||
assert.strictEqual(riskScore({ info: 10 }), 0);
|
||||
});
|
||||
|
||||
it('scores low findings at 1 point each', () => {
|
||||
assert.strictEqual(riskScore({ low: 5 }), 5);
|
||||
});
|
||||
|
||||
it('scores medium findings at 4 points each', () => {
|
||||
assert.strictEqual(riskScore({ medium: 3 }), 12);
|
||||
});
|
||||
|
||||
it('scores high findings at 10 points each', () => {
|
||||
assert.strictEqual(riskScore({ high: 2 }), 20);
|
||||
});
|
||||
|
||||
it('scores critical findings at 25 points each', () => {
|
||||
assert.strictEqual(riskScore({ critical: 1 }), 25);
|
||||
});
|
||||
|
||||
it('caps at 100', () => {
|
||||
assert.strictEqual(riskScore({ critical: 10 }), 100);
|
||||
});
|
||||
|
||||
it('combines all severities', () => {
|
||||
assert.strictEqual(riskScore({ critical: 1, high: 1, medium: 1, low: 1, info: 1 }), 40);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verdict', () => {
|
||||
it('returns PASS for no findings', () => {
|
||||
assert.strictEqual(verdict({}), 'PASS');
|
||||
});
|
||||
|
||||
it('returns PASS for low findings only', () => {
|
||||
assert.strictEqual(verdict({ low: 5, info: 10 }), 'PASS');
|
||||
});
|
||||
|
||||
it('returns WARNING for any high finding', () => {
|
||||
assert.strictEqual(verdict({ high: 1 }), 'WARNING');
|
||||
});
|
||||
|
||||
it('returns FAIL for any critical finding', () => {
|
||||
assert.strictEqual(verdict({ critical: 1 }), 'FAIL');
|
||||
});
|
||||
|
||||
it('returns FAIL for score >= 61', () => {
|
||||
assert.strictEqual(verdict({ high: 6, medium: 1 }), 'FAIL');
|
||||
});
|
||||
|
||||
it('returns WARNING for score >= 21', () => {
|
||||
assert.strictEqual(verdict({ medium: 6 }), 'WARNING');
|
||||
});
|
||||
});
|
||||
|
||||
describe('riskBand', () => {
|
||||
it('returns Low for score 0', () => {
|
||||
assert.strictEqual(riskBand(0), 'Low');
|
||||
});
|
||||
|
||||
it('returns Low for score 10', () => {
|
||||
assert.strictEqual(riskBand(10), 'Low');
|
||||
});
|
||||
|
||||
it('returns Medium for score 11-30', () => {
|
||||
assert.strictEqual(riskBand(20), 'Medium');
|
||||
});
|
||||
|
||||
it('returns High for score 31-60', () => {
|
||||
assert.strictEqual(riskBand(50), 'High');
|
||||
});
|
||||
|
||||
it('returns Critical for score 61-80', () => {
|
||||
assert.strictEqual(riskBand(70), 'Critical');
|
||||
});
|
||||
|
||||
it('returns Extreme for score > 80', () => {
|
||||
assert.strictEqual(riskBand(90), 'Extreme');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gradeFromPassRate', () => {
|
||||
it('returns A for 90+', () => {
|
||||
assert.strictEqual(gradeFromPassRate(95), 'A');
|
||||
});
|
||||
|
||||
it('returns B for 75-89', () => {
|
||||
assert.strictEqual(gradeFromPassRate(80), 'B');
|
||||
});
|
||||
|
||||
it('returns C for 60-74', () => {
|
||||
assert.strictEqual(gradeFromPassRate(65), 'C');
|
||||
});
|
||||
|
||||
it('returns D for 40-59', () => {
|
||||
assert.strictEqual(gradeFromPassRate(50), 'D');
|
||||
});
|
||||
|
||||
it('returns F for below 40', () => {
|
||||
assert.strictEqual(gradeFromPassRate(20), 'F');
|
||||
});
|
||||
});
|
||||
|
||||
describe('QUALITY_CATEGORIES', () => {
|
||||
it('has expected categories', () => {
|
||||
assert.ok(QUALITY_CATEGORIES.STRUCTURE);
|
||||
assert.ok(QUALITY_CATEGORIES.FEATURES);
|
||||
assert.ok(QUALITY_CATEGORIES.SECURITY);
|
||||
});
|
||||
|
||||
it('is frozen', () => {
|
||||
assert.throws(() => { QUALITY_CATEGORIES.NEW = 'x'; }, TypeError);
|
||||
});
|
||||
});
|
||||
116
plugins/config-audit/tests/lib/string-utils.test.mjs
Normal file
116
plugins/config-audit/tests/lib/string-utils.test.mjs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { lineCount, truncate, isSimilar, extractKeys, normalizePath } from '../../scanners/lib/string-utils.mjs';
|
||||
|
||||
describe('lineCount', () => {
|
||||
it('counts lines correctly', () => {
|
||||
assert.strictEqual(lineCount('a\nb\nc'), 3);
|
||||
});
|
||||
|
||||
it('returns 0 for empty/null input', () => {
|
||||
assert.strictEqual(lineCount(''), 0);
|
||||
assert.strictEqual(lineCount(null), 0);
|
||||
assert.strictEqual(lineCount(undefined), 0);
|
||||
});
|
||||
|
||||
it('counts single line', () => {
|
||||
assert.strictEqual(lineCount('hello'), 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncate', () => {
|
||||
it('returns short strings unchanged', () => {
|
||||
assert.strictEqual(truncate('hello', 10), 'hello');
|
||||
});
|
||||
|
||||
it('truncates long strings with ellipsis', () => {
|
||||
const result = truncate('a very long string that needs truncating', 20);
|
||||
assert.strictEqual(result.length, 20);
|
||||
assert.ok(result.endsWith('...'));
|
||||
});
|
||||
|
||||
it('handles empty/null input', () => {
|
||||
assert.strictEqual(truncate(''), '');
|
||||
assert.strictEqual(truncate(null), '');
|
||||
assert.strictEqual(truncate(undefined), '');
|
||||
});
|
||||
|
||||
it('uses default maxLen of 100', () => {
|
||||
const long = 'x'.repeat(200);
|
||||
assert.strictEqual(truncate(long).length, 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSimilar', () => {
|
||||
it('returns true for identical strings', () => {
|
||||
assert.ok(isSimilar('hello world foo bar', 'hello world foo bar'));
|
||||
});
|
||||
|
||||
it('returns true for highly similar strings', () => {
|
||||
assert.ok(isSimilar(
|
||||
'use typescript for all code in this project',
|
||||
'use typescript for all code in this repository'
|
||||
));
|
||||
});
|
||||
|
||||
it('returns false for dissimilar strings', () => {
|
||||
assert.ok(!isSimilar('hello world', 'goodbye universe'));
|
||||
});
|
||||
|
||||
it('returns false for empty strings', () => {
|
||||
assert.ok(!isSimilar('', ''));
|
||||
});
|
||||
|
||||
it('ignores short words', () => {
|
||||
assert.ok(!isSimilar('a b c d', 'a b c d'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractKeys', () => {
|
||||
it('extracts top-level keys', () => {
|
||||
const keys = extractKeys({ a: 1, b: 2 });
|
||||
assert.deepStrictEqual(keys, ['a', 'b']);
|
||||
});
|
||||
|
||||
it('extracts nested keys with dot notation', () => {
|
||||
const keys = extractKeys({ a: { b: { c: 1 } } });
|
||||
assert.ok(keys.includes('a'));
|
||||
assert.ok(keys.includes('a.b'));
|
||||
assert.ok(keys.includes('a.b.c'));
|
||||
});
|
||||
|
||||
it('handles arrays as leaf values', () => {
|
||||
const keys = extractKeys({ list: [1, 2, 3] });
|
||||
assert.deepStrictEqual(keys, ['list']);
|
||||
});
|
||||
|
||||
it('uses prefix', () => {
|
||||
const keys = extractKeys({ a: 1 }, 'root');
|
||||
assert.deepStrictEqual(keys, ['root.a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePath', () => {
|
||||
it('expands ~ to HOME', () => {
|
||||
const home = process.env.HOME;
|
||||
assert.strictEqual(normalizePath('~/foo'), `${home}/foo`);
|
||||
});
|
||||
|
||||
it('strips trailing slashes', () => {
|
||||
assert.ok(!normalizePath('/foo/bar/').endsWith('/'));
|
||||
});
|
||||
|
||||
it('strips trailing backslashes (Windows paths)', () => {
|
||||
const result = normalizePath('C:\\Users\\foo\\');
|
||||
assert.ok(!result.endsWith('\\'), 'trailing backslash should be stripped');
|
||||
});
|
||||
|
||||
it('strips multiple trailing backslashes', () => {
|
||||
const result = normalizePath('C:\\foo\\\\');
|
||||
assert.ok(!result.endsWith('\\'));
|
||||
});
|
||||
|
||||
it('handles absolute paths', () => {
|
||||
assert.strictEqual(normalizePath('/usr/bin'), '/usr/bin');
|
||||
});
|
||||
});
|
||||
199
plugins/config-audit/tests/lib/suppression.test.mjs
Normal file
199
plugins/config-audit/tests/lib/suppression.test.mjs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { join } from 'node:path';
|
||||
import { writeFile, mkdir, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import {
|
||||
loadSuppressions,
|
||||
parseIgnoreFile,
|
||||
applySuppressions,
|
||||
formatSuppressionSummary,
|
||||
} from '../../scanners/lib/suppression.mjs';
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function makeFinding(id, scanner, severity = 'medium') {
|
||||
return {
|
||||
id,
|
||||
scanner,
|
||||
severity,
|
||||
title: `Finding ${id}`,
|
||||
description: `Description for ${id}`,
|
||||
file: null,
|
||||
line: null,
|
||||
evidence: null,
|
||||
category: null,
|
||||
recommendation: null,
|
||||
autoFixable: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// parseIgnoreFile
|
||||
// ========================================
|
||||
describe('parseIgnoreFile', () => {
|
||||
it('parses exact finding IDs', () => {
|
||||
const result = parseIgnoreFile('CA-CML-001\nCA-SET-003');
|
||||
assert.equal(result.length, 2);
|
||||
assert.equal(result[0].pattern, 'CA-CML-001');
|
||||
assert.equal(result[1].pattern, 'CA-SET-003');
|
||||
});
|
||||
|
||||
it('parses glob patterns', () => {
|
||||
const result = parseIgnoreFile('CA-GAP-*');
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].pattern, 'CA-GAP-*');
|
||||
});
|
||||
|
||||
it('skips comments and empty lines', () => {
|
||||
const content = `# This is a comment
|
||||
CA-CML-001
|
||||
|
||||
# Another comment
|
||||
|
||||
CA-SET-002`;
|
||||
const result = parseIgnoreFile(content);
|
||||
assert.equal(result.length, 2);
|
||||
});
|
||||
|
||||
it('extracts inline comments', () => {
|
||||
const result = parseIgnoreFile('CA-HKV-003 # Known timeout in CI');
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].pattern, 'CA-HKV-003');
|
||||
assert.equal(result[0].comment, 'Known timeout in CI');
|
||||
});
|
||||
|
||||
it('returns empty array for empty content', () => {
|
||||
const result = parseIgnoreFile('');
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
it('returns empty array for comment-only content', () => {
|
||||
const result = parseIgnoreFile('# Just comments\n# Nothing else');
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// applySuppressions
|
||||
// ========================================
|
||||
describe('applySuppressions', () => {
|
||||
it('filters exact match', () => {
|
||||
const findings = [
|
||||
makeFinding('CA-CML-001', 'CML'),
|
||||
makeFinding('CA-CML-002', 'CML'),
|
||||
];
|
||||
const suppressions = [{ pattern: 'CA-CML-001', comment: '' }];
|
||||
const { active, suppressed } = applySuppressions(findings, suppressions);
|
||||
|
||||
assert.equal(active.length, 1);
|
||||
assert.equal(active[0].id, 'CA-CML-002');
|
||||
assert.equal(suppressed.length, 1);
|
||||
assert.equal(suppressed[0].id, 'CA-CML-001');
|
||||
});
|
||||
|
||||
it('filters glob pattern CA-SET-*', () => {
|
||||
const findings = [
|
||||
makeFinding('CA-SET-001', 'SET'),
|
||||
makeFinding('CA-SET-002', 'SET'),
|
||||
makeFinding('CA-CML-001', 'CML'),
|
||||
];
|
||||
const suppressions = [{ pattern: 'CA-SET-*', comment: '' }];
|
||||
const { active, suppressed } = applySuppressions(findings, suppressions);
|
||||
|
||||
assert.equal(active.length, 1);
|
||||
assert.equal(active[0].id, 'CA-CML-001');
|
||||
assert.equal(suppressed.length, 2);
|
||||
});
|
||||
|
||||
it('returns all active when no suppressions', () => {
|
||||
const findings = [makeFinding('CA-CML-001', 'CML')];
|
||||
const { active, suppressed } = applySuppressions(findings, []);
|
||||
|
||||
assert.equal(active.length, 1);
|
||||
assert.equal(suppressed.length, 0);
|
||||
});
|
||||
|
||||
it('returns all active when suppressions is null', () => {
|
||||
const findings = [makeFinding('CA-CML-001', 'CML')];
|
||||
const { active, suppressed } = applySuppressions(findings, null);
|
||||
|
||||
assert.equal(active.length, 1);
|
||||
assert.equal(suppressed.length, 0);
|
||||
});
|
||||
|
||||
it('handles empty findings list', () => {
|
||||
const suppressions = [{ pattern: 'CA-CML-*', comment: '' }];
|
||||
const { active, suppressed } = applySuppressions([], suppressions);
|
||||
|
||||
assert.equal(active.length, 0);
|
||||
assert.equal(suppressed.length, 0);
|
||||
});
|
||||
|
||||
it('applies multiple suppression patterns', () => {
|
||||
const findings = [
|
||||
makeFinding('CA-CML-001', 'CML'),
|
||||
makeFinding('CA-SET-001', 'SET'),
|
||||
makeFinding('CA-GAP-001', 'GAP'),
|
||||
];
|
||||
const suppressions = [
|
||||
{ pattern: 'CA-CML-001', comment: '' },
|
||||
{ pattern: 'CA-GAP-*', comment: '' },
|
||||
];
|
||||
const { active, suppressed } = applySuppressions(findings, suppressions);
|
||||
|
||||
assert.equal(active.length, 1);
|
||||
assert.equal(active[0].id, 'CA-SET-001');
|
||||
assert.equal(suppressed.length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// formatSuppressionSummary
|
||||
// ========================================
|
||||
describe('formatSuppressionSummary', () => {
|
||||
it('formats correct count and groups', () => {
|
||||
const suppressed = [
|
||||
makeFinding('CA-GAP-001', 'GAP'),
|
||||
makeFinding('CA-GAP-002', 'GAP'),
|
||||
makeFinding('CA-HKV-003', 'HKV'),
|
||||
];
|
||||
const summary = formatSuppressionSummary(suppressed);
|
||||
|
||||
assert.ok(summary.includes('3 finding(s) suppressed'));
|
||||
assert.ok(summary.includes('CA-GAP-*'));
|
||||
assert.ok(summary.includes('CA-HKV-*'));
|
||||
});
|
||||
|
||||
it('returns zero message for empty array', () => {
|
||||
assert.equal(formatSuppressionSummary([]), '0 findings suppressed');
|
||||
});
|
||||
|
||||
it('returns zero message for null', () => {
|
||||
assert.equal(formatSuppressionSummary(null), '0 findings suppressed');
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// loadSuppressions
|
||||
// ========================================
|
||||
describe('loadSuppressions', () => {
|
||||
const tmpDir = join(tmpdir(), `config-audit-suppress-test-${Date.now()}`);
|
||||
|
||||
it('returns empty when no .config-audit-ignore exists', async () => {
|
||||
const result = await loadSuppressions('/nonexistent/path');
|
||||
assert.deepEqual(result.suppressions, []);
|
||||
assert.equal(result.source, 'none');
|
||||
});
|
||||
|
||||
it('loads from project directory', async () => {
|
||||
await mkdir(tmpDir, { recursive: true });
|
||||
await writeFile(join(tmpDir, '.config-audit-ignore'), 'CA-GAP-*\nCA-CML-001\n');
|
||||
|
||||
const result = await loadSuppressions(tmpDir);
|
||||
assert.equal(result.suppressions.length, 2);
|
||||
assert.equal(result.source, 'project');
|
||||
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
147
plugins/config-audit/tests/lib/yaml-parser.test.mjs
Normal file
147
plugins/config-audit/tests/lib/yaml-parser.test.mjs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseFrontmatter, parseSimpleYaml, parseJson, findImports, extractSections } from '../../scanners/lib/yaml-parser.mjs';
|
||||
|
||||
describe('parseFrontmatter', () => {
|
||||
it('parses standard frontmatter', () => {
|
||||
const content = '---\nname: test\nmodel: opus\n---\n\nBody here';
|
||||
const { frontmatter, body } = parseFrontmatter(content);
|
||||
assert.deepStrictEqual(frontmatter, { name: 'test', model: 'opus' });
|
||||
assert.ok(body.includes('Body here'));
|
||||
});
|
||||
|
||||
it('returns null frontmatter when none exists', () => {
|
||||
const { frontmatter, body } = parseFrontmatter('Just body text');
|
||||
assert.strictEqual(frontmatter, null);
|
||||
assert.strictEqual(body, 'Just body text');
|
||||
});
|
||||
|
||||
it('handles empty frontmatter', () => {
|
||||
const { frontmatter } = parseFrontmatter('---\n---\nBody');
|
||||
assert.deepStrictEqual(frontmatter, {});
|
||||
});
|
||||
|
||||
it('calculates bodyStartLine correctly', () => {
|
||||
const content = '---\na: 1\nb: 2\n---\nBody';
|
||||
const { bodyStartLine } = parseFrontmatter(content);
|
||||
assert.strictEqual(bodyStartLine, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSimpleYaml', () => {
|
||||
it('parses key-value pairs', () => {
|
||||
const result = parseSimpleYaml('name: test\nmodel: opus');
|
||||
assert.strictEqual(result.name, 'test');
|
||||
assert.strictEqual(result.model, 'opus');
|
||||
});
|
||||
|
||||
it('parses boolean values', () => {
|
||||
const result = parseSimpleYaml('enabled: true\ndisabled: false');
|
||||
assert.strictEqual(result.enabled, true);
|
||||
assert.strictEqual(result.disabled, false);
|
||||
});
|
||||
|
||||
it('parses numeric values', () => {
|
||||
const result = parseSimpleYaml('count: 42\nrate: 3.14');
|
||||
assert.strictEqual(result.count, 42);
|
||||
assert.strictEqual(result.rate, 3.14);
|
||||
});
|
||||
|
||||
it('parses inline arrays', () => {
|
||||
const result = parseSimpleYaml('tools: [Read, Write, Bash]');
|
||||
assert.deepStrictEqual(result.tools, ['Read', 'Write', 'Bash']);
|
||||
});
|
||||
|
||||
it('strips quotes from values', () => {
|
||||
const result = parseSimpleYaml('name: "quoted value"');
|
||||
assert.strictEqual(result.name, 'quoted value');
|
||||
});
|
||||
|
||||
it('normalizes hyphens to underscores in keys', () => {
|
||||
const result = parseSimpleYaml('allowed-tools: Read');
|
||||
assert.ok('allowed_tools' in result);
|
||||
});
|
||||
|
||||
it('normalizes comma-separated strings in list fields', () => {
|
||||
const result = parseSimpleYaml('allowed-tools: Read, Write, Bash');
|
||||
assert.deepStrictEqual(result.allowed_tools, ['Read', 'Write', 'Bash']);
|
||||
});
|
||||
|
||||
it('handles null values', () => {
|
||||
const result = parseSimpleYaml('value: null\ntilde: ~\nempty:');
|
||||
assert.strictEqual(result.value, null);
|
||||
assert.strictEqual(result.tilde, null);
|
||||
assert.strictEqual(result.empty, null);
|
||||
});
|
||||
|
||||
it('skips comments', () => {
|
||||
const result = parseSimpleYaml('# comment\nname: test\n# another');
|
||||
assert.strictEqual(result.name, 'test');
|
||||
assert.strictEqual(Object.keys(result).length, 1);
|
||||
});
|
||||
|
||||
it('handles multi-line pipe values', () => {
|
||||
const result = parseSimpleYaml('description: |\n Line 1\n Line 2\nname: test');
|
||||
assert.ok(result.description.includes('Line 1'));
|
||||
assert.ok(result.description.includes('Line 2'));
|
||||
assert.strictEqual(result.name, 'test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseJson', () => {
|
||||
it('parses valid JSON', () => {
|
||||
const result = parseJson('{"key": "value"}');
|
||||
assert.deepStrictEqual(result, { key: 'value' });
|
||||
});
|
||||
|
||||
it('returns null for invalid JSON', () => {
|
||||
assert.strictEqual(parseJson('{invalid}'), null);
|
||||
});
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
assert.strictEqual(parseJson(''), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findImports', () => {
|
||||
it('finds @import lines', () => {
|
||||
const content = '# Title\n@path/to/file.md\nSome text\n@another/file.md';
|
||||
const imports = findImports(content);
|
||||
assert.strictEqual(imports.length, 2);
|
||||
assert.strictEqual(imports[0].path, 'path/to/file.md');
|
||||
assert.strictEqual(imports[0].line, 2);
|
||||
assert.strictEqual(imports[1].path, 'another/file.md');
|
||||
assert.strictEqual(imports[1].line, 4);
|
||||
});
|
||||
|
||||
it('returns empty array when no imports', () => {
|
||||
assert.deepStrictEqual(findImports('Just text'), []);
|
||||
});
|
||||
|
||||
it('ignores @ in the middle of lines', () => {
|
||||
const imports = findImports('Email me at user@example.com');
|
||||
assert.strictEqual(imports.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractSections', () => {
|
||||
it('extracts markdown headings', () => {
|
||||
const content = '# Title\n## Section 1\nText\n### Sub-section\n## Section 2';
|
||||
const sections = extractSections(content);
|
||||
assert.strictEqual(sections.length, 4);
|
||||
assert.strictEqual(sections[0].heading, 'Title');
|
||||
assert.strictEqual(sections[0].level, 1);
|
||||
assert.strictEqual(sections[1].heading, 'Section 1');
|
||||
assert.strictEqual(sections[1].level, 2);
|
||||
});
|
||||
|
||||
it('returns empty for no headings', () => {
|
||||
assert.deepStrictEqual(extractSections('Just plain text'), []);
|
||||
});
|
||||
|
||||
it('includes line numbers', () => {
|
||||
const content = 'line1\n## Heading\nline3';
|
||||
const sections = extractSections(content);
|
||||
assert.strictEqual(sections[0].line, 2);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue