feat: initial open marketplace with llm-security, config-audit, ultraplan-local

This commit is contained in:
Kjell Tore Guttormsen 2026-04-06 18:47:49 +02:00
commit f93d6abdae
380 changed files with 65935 additions and 0 deletions

View file

@ -0,0 +1,110 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
import { scan } from '../../scanners/claude-md-linter.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
describe('CML scanner — healthy project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project'));
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
});
it('returns status ok', () => {
assert.strictEqual(result.status, 'ok');
});
it('scans at least 1 file', () => {
assert.ok(result.files_scanned >= 1);
});
it('has scanner prefix CML', () => {
assert.strictEqual(result.scanner, 'CML');
});
it('has all severity count keys', () => {
for (const key of ['critical', 'high', 'medium', 'low', 'info']) {
assert.ok(key in result.counts, `Missing count key: ${key}`);
}
});
it('finds no critical or high issues in healthy project', () => {
const serious = result.findings.filter(f => f.severity === 'critical' || f.severity === 'high');
assert.strictEqual(serious.length, 0, `Found serious issues: ${serious.map(f => f.title).join(', ')}`);
});
it('all finding IDs match CA-CML-NNN pattern', () => {
for (const f of result.findings) {
assert.match(f.id, /^CA-CML-\d{3}$/);
}
});
});
describe('CML scanner — broken project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'broken-project'));
result = await scan(resolve(FIXTURES, 'broken-project'), discovery);
});
it('detects long CLAUDE.md (>200 lines)', () => {
const found = result.findings.some(f => f.title.includes('exceeds'));
assert.ok(found, 'Should detect oversized CLAUDE.md');
});
it('detects missing headings', () => {
const found = result.findings.some(f => f.title.includes('no markdown headings'));
assert.ok(found, 'Should detect lack of headings');
});
it('detects TODO markers', () => {
const found = result.findings.some(f => f.title.includes('TODO'));
assert.ok(found, 'Should detect TODO markers');
});
it('detects repeated content', () => {
const found = result.findings.some(f => f.title.includes('Repeated content'));
assert.ok(found, 'Should detect repeated lines');
});
});
describe('CML scanner — empty project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
});
it('detects missing CLAUDE.md', () => {
const found = result.findings.some(f => f.title.includes('No CLAUDE.md'));
assert.ok(found, 'Should report missing CLAUDE.md');
});
it('returns high severity for missing CLAUDE.md', () => {
const f = result.findings.find(f => f.title.includes('No CLAUDE.md'));
assert.strictEqual(f?.severity, 'high');
});
});
describe('CML scanner — minimal project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'minimal-project'));
result = await scan(resolve(FIXTURES, 'minimal-project'), discovery);
});
it('detects nearly empty CLAUDE.md', () => {
const found = result.findings.some(f => f.title.includes('nearly empty'));
assert.ok(found, 'Should detect nearly empty CLAUDE.md');
});
});

View file

@ -0,0 +1,124 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
import { scan } from '../../scanners/conflict-detector.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
describe('CNF scanner — conflict project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'conflict-project'));
result = await scan(resolve(FIXTURES, 'conflict-project'), discovery);
});
it('returns status ok', () => {
assert.equal(result.status, 'ok');
});
it('reports scanner prefix CNF', () => {
assert.equal(result.scanner, 'CNF');
});
it('finding IDs match CA-CNF-NNN pattern', () => {
for (const f of result.findings) {
assert.match(f.id, /^CA-CNF-\d{3}$/);
}
});
it('detects model key conflict', () => {
assert.ok(result.findings.some(f => f.title.includes('model')));
});
it('settings conflict is medium severity', () => {
const model = result.findings.find(f => f.title.includes('model'));
assert.equal(model.severity, 'medium');
});
it('detects effortLevel key conflict', () => {
assert.ok(result.findings.some(f => f.title.includes('effortLevel')));
});
it('detects permission allow/deny conflict', () => {
assert.ok(result.findings.some(f => f.title.includes('Permission allow/deny')));
});
it('permission conflict is high severity', () => {
const perm = result.findings.find(f => f.title.includes('Permission allow/deny'));
assert.equal(perm.severity, 'high');
});
it('detects duplicate hook definition', () => {
assert.ok(result.findings.some(f => f.title.includes('Duplicate hook')));
});
it('duplicate hook is low severity', () => {
const hook = result.findings.find(f => f.title.includes('Duplicate hook'));
assert.equal(hook.severity, 'low');
});
it('has exactly 4 findings', () => {
assert.equal(result.findings.length, 4);
});
it('includes evidence with scope info', () => {
const perm = result.findings.find(f => f.title.includes('Permission'));
assert.ok(perm.evidence);
});
});
describe('CNF scanner — healthy project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project'));
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
});
it('returns ok with no conflicts', () => {
assert.equal(result.status, 'ok');
});
it('has 0 findings', () => {
assert.equal(result.findings.length, 0);
});
});
describe('CNF scanner — empty project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
});
it('returns skipped when no config files', () => {
assert.equal(result.status, 'skipped');
});
it('has 0 findings', () => {
assert.equal(result.findings.length, 0);
});
});
describe('CNF scanner — minimal project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'minimal-project'));
result = await scan(resolve(FIXTURES, 'minimal-project'), discovery);
});
it('returns skipped with no settings files', () => {
assert.equal(result.status, 'skipped');
});
it('has 0 findings', () => {
assert.equal(result.findings.length, 0);
});
});

View file

@ -0,0 +1,100 @@
import { describe, it, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { deleteBaseline } from '../../scanners/lib/baseline.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
const HEALTHY = resolve(FIXTURES, 'healthy-project');
const DRIFT_CLI = resolve(__dirname, '../../scanners/drift-cli.mjs');
const TEST_BASELINE = `_drift_test_${Date.now()}`;
afterEach(async () => {
await deleteBaseline(TEST_BASELINE);
});
describe('drift-cli --save', () => {
it('saves a baseline and confirms', () => {
const result = execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE, '--json'], {
encoding: 'utf-8',
timeout: 30000,
});
const output = JSON.parse(result);
assert.equal(output.saved, true);
assert.equal(output.name, TEST_BASELINE);
assert.ok(output.path);
});
});
describe('drift-cli --list', () => {
it('lists baselines including saved one', async () => {
// Save first
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], {
encoding: 'utf-8',
timeout: 30000,
});
const result = execFileSync('node', [DRIFT_CLI, '--list', '--json'], {
encoding: 'utf-8',
timeout: 30000,
});
const output = JSON.parse(result);
assert.ok(Array.isArray(output.baselines));
const found = output.baselines.find(b => b.name === TEST_BASELINE);
assert.ok(found, 'Should find test baseline in list');
});
});
describe('drift-cli compare', () => {
it('outputs valid JSON with --json flag', () => {
// Save baseline first
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], {
encoding: 'utf-8',
timeout: 30000,
});
// Compare same fixture against itself
const result = execFileSync('node', [DRIFT_CLI, HEALTHY, '--baseline', TEST_BASELINE, '--json'], {
encoding: 'utf-8',
timeout: 30000,
});
const diff = JSON.parse(result);
assert.ok('newFindings' in diff);
assert.ok('resolvedFindings' in diff);
assert.ok('unchangedFindings' in diff);
assert.ok('movedFindings' in diff);
assert.ok('scoreChange' in diff);
assert.ok('summary' in diff);
});
it('shows stable trend when comparing same fixture', () => {
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], {
encoding: 'utf-8',
timeout: 30000,
});
const result = execFileSync('node', [DRIFT_CLI, HEALTHY, '--baseline', TEST_BASELINE, '--json'], {
encoding: 'utf-8',
timeout: 30000,
});
const diff = JSON.parse(result);
assert.equal(diff.summary.trend, 'stable');
assert.equal(diff.summary.newCount, 0);
assert.equal(diff.summary.resolvedCount, 0);
});
it('exits with code 1 when baseline not found', () => {
assert.throws(() => {
execFileSync('node', [DRIFT_CLI, HEALTHY, '--baseline', `nonexistent_${Date.now()}`, '--json'], {
encoding: 'utf-8',
timeout: 30000,
});
}, (err) => {
assert.equal(err.status, 1);
return true;
});
});
});

View file

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

View file

@ -0,0 +1,91 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { cp, rm, readFile, stat } from 'node:fs/promises';
import { mkdirSync, existsSync, readdirSync } from 'node:fs';
import { tmpdir, homedir } from 'node:os';
import { execFileSync } from 'node:child_process';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
const FIXABLE = resolve(FIXTURES, 'fixable-project');
const FIX_CLI = resolve(__dirname, '../../scanners/fix-cli.mjs');
/** Create a temporary copy of the fixable-project fixture. */
async function createTmpCopy() {
const tmpDir = join(tmpdir(), `config-audit-cli-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
mkdirSync(tmpDir, { recursive: true });
await cp(FIXABLE, tmpDir, { recursive: true });
return tmpDir;
}
describe('fix-cli dry-run', () => {
it('shows planned fixes without --apply', () => {
const result = execFileSync('node', [FIX_CLI, FIXABLE, '--json'], {
encoding: 'utf-8',
timeout: 30000,
});
const output = JSON.parse(result);
assert.ok(Array.isArray(output.planned), 'Should have planned array');
assert.ok(output.planned.length > 0, 'Should have planned fixes');
assert.strictEqual(output.backupId, null, 'No backup in dry-run');
assert.ok(Array.isArray(output.manual), 'Should have manual array');
});
it('outputs valid JSON with --json flag', () => {
const result = execFileSync('node', [FIX_CLI, FIXABLE, '--json'], {
encoding: 'utf-8',
timeout: 30000,
});
assert.doesNotThrow(() => JSON.parse(result), 'Output should be valid JSON');
});
});
describe('fix-cli --apply', () => {
let tmpDir;
beforeEach(async () => {
tmpDir = await createTmpCopy();
});
afterEach(async () => {
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
});
it('applies fixes and creates backup', () => {
const result = execFileSync('node', [FIX_CLI, tmpDir, '--apply', '--json'], {
encoding: 'utf-8',
timeout: 30000,
});
const output = JSON.parse(result);
assert.ok(output.applied.length > 0, 'Should have applied fixes');
assert.ok(output.backupId, 'Should have a backup ID');
// Verify backup exists
const backupDir = join(homedir(), '.config-audit', 'backups', output.backupId);
assert.ok(existsSync(backupDir), 'Backup directory should exist');
});
it('actually modifies files after --apply', async () => {
execFileSync('node', [FIX_CLI, tmpDir, '--apply'], {
encoding: 'utf-8',
timeout: 30000,
});
// Check that settings.json was fixed
const content = await readFile(join(tmpDir, '.claude', 'settings.json'), 'utf-8');
const parsed = JSON.parse(content);
assert.ok(parsed.$schema, 'Should have $schema after fix');
});
it('reports verified fixes', () => {
const result = execFileSync('node', [FIX_CLI, tmpDir, '--apply', '--json'], {
encoding: 'utf-8',
timeout: 30000,
});
const output = JSON.parse(result);
assert.ok(Array.isArray(output.verified), 'Should have verified array');
assert.ok(output.verified.length > 0, 'Should have verified fixes');
});
});

View file

@ -0,0 +1,305 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { cp, rm, readFile, stat } from 'node:fs/promises';
import { mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
import { runAllScanners } from '../../scanners/scan-orchestrator.mjs';
import { planFixes, applyFixes, verifyFixes, FIX_TYPES } from '../../scanners/fix-engine.mjs';
import { parseJson, parseFrontmatter } from '../../scanners/lib/yaml-parser.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
const FIXABLE = resolve(FIXTURES, 'fixable-project');
/** Create a temporary copy of the fixable-project fixture. */
async function createTmpCopy() {
const tmpDir = join(tmpdir(), `config-audit-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
mkdirSync(tmpDir, { recursive: true });
await cp(FIXABLE, tmpDir, { recursive: true });
return tmpDir;
}
// --- planFixes tests ---
describe('planFixes', () => {
let envelope;
beforeEach(async () => {
resetCounter();
envelope = await runAllScanners(FIXABLE);
});
it('returns fixes, skipped, and manual arrays', () => {
const result = planFixes(envelope);
assert.ok(Array.isArray(result.fixes));
assert.ok(Array.isArray(result.skipped));
assert.ok(Array.isArray(result.manual));
});
it('identifies auto-fixable findings', () => {
const result = planFixes(envelope);
assert.ok(result.fixes.length > 0, 'Should have at least one fix');
});
it('sorts fixes by severity (critical first)', () => {
const result = planFixes(envelope);
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
for (let i = 1; i < result.fixes.length; i++) {
const prev = severityOrder[result.fixes[i - 1].severity] || 4;
const curr = severityOrder[result.fixes[i].severity] || 4;
assert.ok(prev <= curr, `Fix ${i} should not have higher severity than fix ${i - 1}`);
}
});
it('includes manual findings with recommendations', () => {
const result = planFixes(envelope);
for (const m of result.manual) {
assert.ok(m.findingId, 'Manual finding should have findingId');
assert.ok(m.title, 'Manual finding should have title');
}
});
it('each fix has required fields', () => {
const result = planFixes(envelope);
for (const fix of result.fixes) {
assert.ok(fix.findingId, 'Fix must have findingId');
assert.ok(fix.file, 'Fix must have file');
assert.ok(fix.type, 'Fix must have type');
assert.ok(fix.description, 'Fix must have description');
}
});
it('detects json-key-add for missing $schema', () => {
const result = planFixes(envelope);
const schemaFix = result.fixes.find(f => f.type === FIX_TYPES.JSON_KEY_ADD && f.key === '$schema');
assert.ok(schemaFix, 'Should have a json-key-add fix for $schema');
});
it('detects json-key-remove for deprecated apiProvider', () => {
const result = planFixes(envelope);
// apiProvider is unknown, not deprecated (includeCoAuthoredBy is deprecated)
// But the fixture has apiProvider which triggers "unknown key" (not auto-fixable)
// The deprecated key in settings-validator is includeCoAuthoredBy — fixture doesn't have it
// Let's check for hooks-as-array instead (critical)
const hooksFix = result.fixes.find(f => f.restructureType === 'hooks-array-to-object');
assert.ok(hooksFix, 'Should have a json-restructure fix for hooks-as-array');
});
it('detects json-key-type-fix for alwaysThinkingEnabled', () => {
const result = planFixes(envelope);
const typeFix = result.fixes.find(f => f.type === FIX_TYPES.JSON_KEY_TYPE_FIX && f.key === 'alwaysThinkingEnabled');
assert.ok(typeFix, 'Should have a type fix for alwaysThinkingEnabled');
});
it('detects json-key-type-fix for effortLevel', () => {
const result = planFixes(envelope);
const effortFix = result.fixes.find(f => f.key === 'effortLevel');
assert.ok(effortFix, 'Should have a fix for invalid effortLevel');
});
it('detects json-restructure for matcher-as-object', () => {
const result = planFixes(envelope);
const matcherFix = result.fixes.find(f => f.restructureType === 'matcher-object-to-string');
assert.ok(matcherFix, 'Should have a restructure fix for matcher-as-object');
});
it('detects json-key-type-fix for timeout-as-string', () => {
const result = planFixes(envelope);
const timeoutFix = result.fixes.find(f => f.key === 'timeout');
assert.ok(timeoutFix, 'Should have a type fix for timeout');
});
it('detects frontmatter-rename for globs→paths', () => {
const result = planFixes(envelope);
const globsFix = result.fixes.find(f => f.type === FIX_TYPES.FRONTMATTER_RENAME);
assert.ok(globsFix, 'Should have a frontmatter-rename fix for globs');
});
it('detects file-rename for non-.md rules file', () => {
const result = planFixes(envelope);
const renameFix = result.fixes.find(f => f.type === FIX_TYPES.FILE_RENAME);
assert.ok(renameFix, 'Should have a file-rename fix');
assert.ok(renameFix.newPath.endsWith('.md'), 'New path should end with .md');
});
});
// --- applyFixes dry-run tests ---
describe('applyFixes dry-run', () => {
let envelope;
beforeEach(async () => {
resetCounter();
envelope = await runAllScanners(FIXABLE);
});
it('returns dry-run status without modifying files', async () => {
const { fixes } = planFixes(envelope);
const result = await applyFixes(fixes, { dryRun: true });
assert.ok(result.applied.length > 0, 'Should have dry-run results');
for (const r of result.applied) {
assert.strictEqual(r.status, 'dry-run');
}
assert.strictEqual(result.failed.length, 0, 'No failures in dry-run');
});
it('throws if no backupDir and not dryRun', async () => {
const { fixes } = planFixes(envelope);
await assert.rejects(
() => applyFixes(fixes, { dryRun: false }),
{ message: /backupDir is required/ },
);
});
});
// --- applyFixes actual (on tmp copies) ---
describe('applyFixes on tmp copy', () => {
let tmpDir;
let envelope;
beforeEach(async () => {
tmpDir = await createTmpCopy();
resetCounter();
envelope = await runAllScanners(tmpDir);
});
afterEach(async () => {
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
});
it('applies json-key-add ($schema) successfully', async () => {
const { fixes } = planFixes(envelope);
const schemaFix = fixes.filter(f => f.type === FIX_TYPES.JSON_KEY_ADD);
const result = await applyFixes(schemaFix, { dryRun: false, backupDir: tmpDir });
assert.ok(result.applied.length > 0, 'Should apply at least one fix');
assert.strictEqual(result.failed.length, 0, 'No failures');
// Verify file has $schema
const content = await readFile(join(tmpDir, '.claude', 'settings.json'), 'utf-8');
const parsed = parseJson(content);
assert.ok(parsed.$schema, 'Should have $schema key');
assert.ok(parsed.$schema.includes('schemastore'), '$schema should point to schemastore');
});
it('applies json-key-type-fix successfully', async () => {
const { fixes } = planFixes(envelope);
const typeFix = fixes.filter(f => f.type === FIX_TYPES.JSON_KEY_TYPE_FIX && f.key === 'alwaysThinkingEnabled');
const result = await applyFixes(typeFix, { dryRun: false, backupDir: tmpDir });
assert.ok(result.applied.length > 0);
const content = await readFile(join(tmpDir, '.claude', 'settings.json'), 'utf-8');
const parsed = parseJson(content);
assert.strictEqual(typeof parsed.alwaysThinkingEnabled, 'boolean', 'Should be boolean now');
});
it('applies json-restructure (hooks array→object) successfully', async () => {
const { fixes } = planFixes(envelope);
const hooksFix = fixes.filter(f => f.restructureType === 'hooks-array-to-object');
const result = await applyFixes(hooksFix, { dryRun: false, backupDir: tmpDir });
assert.ok(result.applied.length > 0);
const content = await readFile(join(tmpDir, '.claude', 'settings.json'), 'utf-8');
const parsed = parseJson(content);
assert.ok(!Array.isArray(parsed.hooks), 'hooks should be object now');
assert.strictEqual(typeof parsed.hooks, 'object');
});
it('applies json-restructure (matcher object→string) successfully', async () => {
const { fixes } = planFixes(envelope);
const matcherFix = fixes.filter(f => f.restructureType === 'matcher-object-to-string');
const result = await applyFixes(matcherFix, { dryRun: false, backupDir: tmpDir });
assert.ok(result.applied.length > 0);
const content = await readFile(join(tmpDir, 'hooks', 'hooks.json'), 'utf-8');
const parsed = parseJson(content);
const handler = parsed.hooks.PreToolUse[0];
assert.strictEqual(typeof handler.matcher, 'string', 'matcher should be string now');
});
it('applies frontmatter-rename (globs→paths) successfully', async () => {
const { fixes } = planFixes(envelope);
const fmFix = fixes.filter(f => f.type === FIX_TYPES.FRONTMATTER_RENAME);
const result = await applyFixes(fmFix, { dryRun: false, backupDir: tmpDir });
assert.ok(result.applied.length > 0);
const content = await readFile(join(tmpDir, '.claude', 'rules', 'typescript.md'), 'utf-8');
assert.ok(content.includes('paths:'), 'Should have paths: in frontmatter');
assert.ok(!content.includes('globs:'), 'Should not have globs: in frontmatter');
});
it('applies file-rename (non-.md → .md) successfully', async () => {
const { fixes } = planFixes(envelope);
const renameFix = fixes.filter(f => f.type === FIX_TYPES.FILE_RENAME);
const result = await applyFixes(renameFix, { dryRun: false, backupDir: tmpDir });
assert.ok(result.applied.length > 0);
// Old file should be gone
await assert.rejects(() => stat(join(tmpDir, '.claude', 'rules', 'readme.txt')));
// New file should exist
const newStat = await stat(join(tmpDir, '.claude', 'rules', 'readme.md'));
assert.ok(newStat.isFile());
});
it('validates JSON output after fix', async () => {
const { fixes } = planFixes(envelope);
const jsonFixes = fixes.filter(f => f.file.endsWith('.json'));
await applyFixes(jsonFixes, { dryRun: false, backupDir: tmpDir });
// All JSON files should still parse
const settingsContent = await readFile(join(tmpDir, '.claude', 'settings.json'), 'utf-8');
const settingsParsed = parseJson(settingsContent);
assert.ok(settingsParsed !== null, 'settings.json should be valid JSON after fixes');
const hooksContent = await readFile(join(tmpDir, 'hooks', 'hooks.json'), 'utf-8');
const hooksParsed = parseJson(hooksContent);
assert.ok(hooksParsed !== null, 'hooks.json should be valid JSON after fixes');
});
it('fails gracefully for missing file', async () => {
const fakeFix = [{
findingId: 'CA-SET-999',
file: join(tmpDir, 'nonexistent.json'),
type: FIX_TYPES.JSON_KEY_ADD,
severity: 'info',
description: 'Add key to missing file',
key: 'test',
value: true,
}];
const result = await applyFixes(fakeFix, { dryRun: false, backupDir: tmpDir });
assert.strictEqual(result.failed.length, 1, 'Should have one failure');
assert.strictEqual(result.applied.length, 0);
});
});
// --- verifyFixes tests ---
describe('verifyFixes', () => {
let tmpDir;
beforeEach(async () => {
tmpDir = await createTmpCopy();
});
afterEach(async () => {
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
});
it('confirms fixed findings are gone', async () => {
resetCounter();
const envelope = await runAllScanners(tmpDir);
const { fixes } = planFixes(envelope);
// Apply a subset of fixes
const fmFix = fixes.filter(f => f.type === FIX_TYPES.FRONTMATTER_RENAME);
const result = await applyFixes(fmFix, { dryRun: false, backupDir: tmpDir });
const verification = await verifyFixes(envelope, result.applied);
assert.ok(verification.verified.length > 0, 'Should verify at least one fix');
});
});

View file

@ -0,0 +1,86 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
import { scan } from '../../scanners/hook-validator.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
describe('HKV scanner — healthy project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project'));
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
});
it('returns status ok', () => {
assert.strictEqual(result.status, 'ok');
});
it('has scanner prefix HKV', () => {
assert.strictEqual(result.scanner, 'HKV');
});
it('finds no critical or high issues', () => {
const serious = result.findings.filter(f => f.severity === 'critical' || f.severity === 'high');
assert.strictEqual(serious.length, 0, `Found: ${serious.map(f => f.title).join(', ')}`);
});
it('all finding IDs match CA-HKV-NNN', () => {
for (const f of result.findings) {
assert.match(f.id, /^CA-HKV-\d{3}$/);
}
});
});
describe('HKV scanner — broken project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'broken-project'));
result = await scan(resolve(FIXTURES, 'broken-project'), discovery);
});
it('detects unknown hook event', () => {
const found = result.findings.some(f => f.title === 'Unknown hook event');
assert.ok(found, 'Should detect InvalidEvent');
});
it('detects object matcher (should be string)', () => {
const found = result.findings.some(f => f.title.includes('Matcher must be a string'));
assert.ok(found, 'Should detect nested object matcher');
});
it('detects invalid handler type', () => {
const found = result.findings.some(f => f.title === 'Invalid hook handler type');
assert.ok(found, 'Should detect invalid_type');
});
it('detects timeout below minimum', () => {
const found = result.findings.some(f => f.title.includes('timeout'));
assert.ok(found, 'Should detect timeout of 500ms');
});
it('marks unknown event as high severity', () => {
const f = result.findings.find(f => f.title === 'Unknown hook event');
assert.strictEqual(f?.severity, 'high');
});
});
describe('HKV scanner — empty project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
});
it('returns status ok with 0 findings', () => {
assert.strictEqual(result.status, 'ok');
assert.strictEqual(result.findings.length, 0);
});
});

View file

@ -0,0 +1,117 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
import { scan } from '../../scanners/import-resolver.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
describe('IMP scanner — healthy project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project'));
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
});
it('returns status ok', () => {
assert.equal(result.status, 'ok');
});
it('reports scanner prefix IMP', () => {
assert.equal(result.scanner, 'IMP');
});
it('scans at least 1 file', () => {
assert.ok(result.files_scanned >= 1);
});
it('has no high or critical findings', () => {
assert.equal(result.counts.critical, 0);
assert.equal(result.counts.high, 0);
});
it('finding IDs match CA-IMP-NNN pattern', () => {
for (const f of result.findings) {
assert.match(f.id, /^CA-IMP-\d{3}$/);
}
});
});
describe('IMP scanner — broken project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'broken-project'));
result = await scan(resolve(FIXTURES, 'broken-project'), discovery);
});
it('returns status ok', () => {
assert.equal(result.status, 'ok');
});
it('detects broken @import link', () => {
assert.ok(result.findings.some(f => f.title.includes('Broken @import')));
});
it('broken link is high severity', () => {
const broken = result.findings.find(f => f.title.includes('Broken @import'));
assert.equal(broken.severity, 'high');
});
it('detects circular @import reference', () => {
assert.ok(result.findings.some(f => f.title.includes('Circular @import')));
});
it('circular reference is medium severity', () => {
const circular = result.findings.find(f => f.title.includes('Circular @import'));
assert.equal(circular.severity, 'medium');
});
it('has at least 2 findings', () => {
assert.ok(result.findings.length >= 2);
});
it('includes evidence with path info', () => {
const broken = result.findings.find(f => f.title.includes('Broken @import'));
assert.ok(broken.evidence);
assert.ok(broken.evidence.includes('nonexistent'));
});
});
describe('IMP scanner — minimal project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'minimal-project'));
result = await scan(resolve(FIXTURES, 'minimal-project'), discovery);
});
it('returns status ok', () => {
assert.equal(result.status, 'ok');
});
it('has 0 findings for file without imports', () => {
assert.equal(result.findings.length, 0);
});
});
describe('IMP scanner — empty project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
});
it('returns skipped when no CLAUDE.md files', () => {
assert.equal(result.status, 'skipped');
});
it('has 0 findings', () => {
assert.equal(result.findings.length, 0);
});
});

View file

@ -0,0 +1,136 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
import { scan } from '../../scanners/mcp-config-validator.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
describe('MCP scanner — healthy project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project'));
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
});
it('returns status ok', () => {
assert.equal(result.status, 'ok');
});
it('reports scanner prefix MCP', () => {
assert.equal(result.scanner, 'MCP');
});
it('scans at least 1 file', () => {
assert.ok(result.files_scanned >= 1);
});
it('has no critical or high findings', () => {
assert.equal(result.counts.critical, 0);
assert.equal(result.counts.high, 0);
});
it('finding IDs match CA-MCP-NNN pattern', () => {
for (const f of result.findings) {
assert.match(f.id, /^CA-MCP-\d{3}$/);
}
});
it('has counts object with all severity levels', () => {
assert.ok('critical' in result.counts);
assert.ok('high' in result.counts);
assert.ok('medium' in result.counts);
assert.ok('low' in result.counts);
assert.ok('info' in result.counts);
});
});
describe('MCP scanner — broken project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'broken-project'));
result = await scan(resolve(FIXTURES, 'broken-project'), discovery);
});
it('returns status ok', () => {
assert.equal(result.status, 'ok');
});
it('detects SSE server type', () => {
assert.ok(result.findings.some(f => f.title.includes('SSE')));
});
it('SSE recommendation is info severity', () => {
const sse = result.findings.find(f => f.title.includes('SSE'));
assert.equal(sse.severity, 'info');
});
it('detects unknown server type', () => {
assert.ok(result.findings.some(f => f.title.includes('Unknown MCP server type')));
});
it('unknown server type is high severity', () => {
const unknown = result.findings.find(f => f.title.includes('Unknown MCP server type'));
assert.equal(unknown.severity, 'high');
});
it('detects missing trust level', () => {
assert.ok(result.findings.some(f => f.title.includes('Missing trust level')));
});
it('missing trust is medium severity', () => {
const trust = result.findings.find(f => f.title.includes('Missing trust level'));
assert.equal(trust.severity, 'medium');
});
it('detects unreferenced env vars in args', () => {
assert.ok(result.findings.some(f => f.title.includes('Unreferenced env var')));
});
it('detects unknown server fields', () => {
assert.ok(result.findings.some(f => f.title.includes('Unknown MCP server field')));
});
it('has multiple findings', () => {
assert.ok(result.findings.length >= 5);
});
});
describe('MCP scanner — empty project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
});
it('returns skipped status', () => {
assert.equal(result.status, 'skipped');
});
it('has 0 findings', () => {
assert.equal(result.findings.length, 0);
});
});
describe('MCP scanner — minimal project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'minimal-project'));
result = await scan(resolve(FIXTURES, 'minimal-project'), discovery);
});
it('returns skipped when no .mcp.json', () => {
assert.equal(result.status, 'skipped');
});
it('has 0 findings', () => {
assert.equal(result.findings.length, 0);
});
});

View file

@ -0,0 +1,128 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { scan, discoverPlugins } from '../../scanners/plugin-health-scanner.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
const TEST_PLUGIN = resolve(FIXTURES, 'test-plugin');
const BROKEN_PLUGIN = resolve(FIXTURES, 'broken-plugin');
describe('discoverPlugins', () => {
it('discovers a single plugin when pointed at plugin dir', async () => {
const plugins = await discoverPlugins(TEST_PLUGIN);
assert.equal(plugins.length, 1);
assert.ok(plugins[0].endsWith('test-plugin'));
});
it('discovers multiple plugins in parent dir', async () => {
const plugins = await discoverPlugins(FIXTURES);
// Should find test-plugin and broken-plugin (both have .claude-plugin/plugin.json)
assert.ok(plugins.length >= 2, `Expected >=2, got ${plugins.length}`);
});
it('returns empty array for dir with no plugins', async () => {
const plugins = await discoverPlugins(resolve(FIXTURES, 'empty-project'));
assert.equal(plugins.length, 0);
});
});
describe('scan on valid test-plugin', () => {
it('returns ok status', async () => {
resetCounter();
const result = await scan(TEST_PLUGIN);
assert.equal(result.scanner, 'PLH');
assert.equal(result.status, 'ok');
});
it('finds commands and agents', async () => {
resetCounter();
const result = await scan(TEST_PLUGIN);
assert.ok(result.files_scanned >= 1, 'Should scan at least 1 plugin');
// Valid plugin should have few or no findings
const criticals = result.findings.filter(f => f.severity === 'critical');
assert.equal(criticals.length, 0, 'Valid plugin should have no critical findings');
});
it('no findings for missing plugin.json fields', async () => {
resetCounter();
const result = await scan(TEST_PLUGIN);
const missingFields = result.findings.filter(f => f.title.includes('Missing required field'));
assert.equal(missingFields.length, 0, 'All required fields present in test-plugin');
});
it('no findings for missing CLAUDE.md sections', async () => {
resetCounter();
const result = await scan(TEST_PLUGIN);
const missingSections = result.findings.filter(f => f.title.includes('missing') && f.title.includes('section'));
assert.equal(missingSections.length, 0, 'All sections present in test-plugin CLAUDE.md');
});
});
describe('scan on broken-plugin', () => {
it('detects missing plugin.json fields', async () => {
resetCounter();
const result = await scan(BROKEN_PLUGIN);
const missingFields = result.findings.filter(f => f.title.includes('Missing required field'));
assert.ok(missingFields.length >= 2, 'Should detect missing description and version');
});
it('detects missing CLAUDE.md', async () => {
resetCounter();
const result = await scan(BROKEN_PLUGIN);
const missingMd = result.findings.filter(f => f.title === 'Missing CLAUDE.md');
assert.equal(missingMd.length, 1, 'Should detect missing CLAUDE.md');
});
it('detects command without frontmatter', async () => {
resetCounter();
const result = await scan(BROKEN_PLUGIN);
const noFrontmatter = result.findings.filter(f => f.title === 'Command missing frontmatter');
assert.equal(noFrontmatter.length, 1, 'Should detect command without frontmatter');
});
it('detects agent missing required frontmatter fields', async () => {
resetCounter();
const result = await scan(BROKEN_PLUGIN);
const missingAgent = result.findings.filter(f =>
f.title.startsWith('Agent missing frontmatter field:')
);
// bad-agent.md has name+description but missing model and tools
assert.ok(missingAgent.length >= 2, `Should detect missing model and tools, got ${missingAgent.length}: ${missingAgent.map(f => f.title).join(', ')}`);
});
});
describe('scan with no plugins', () => {
it('returns info finding for empty directory', async () => {
resetCounter();
const result = await scan(resolve(FIXTURES, 'empty-project'));
assert.equal(result.findings.length, 1);
assert.equal(result.findings[0].title, 'No plugins found');
assert.equal(result.findings[0].severity, 'info');
});
});
describe('cross-plugin command conflict detection', () => {
it('scans fixtures dir and reports findings for all plugins', async () => {
resetCounter();
const result = await scan(FIXTURES);
assert.equal(result.scanner, 'PLH');
assert.ok(result.files_scanned >= 2, 'Should scan multiple plugins');
});
});
describe('finding format', () => {
it('findings have standard fields', async () => {
resetCounter();
const result = await scan(BROKEN_PLUGIN);
assert.ok(result.findings.length > 0);
const f = result.findings[0];
assert.ok(f.id.startsWith('CA-PLH-'));
assert.equal(f.scanner, 'PLH');
assert.ok(['critical', 'high', 'medium', 'low', 'info'].includes(f.severity));
assert.ok(f.title);
assert.ok(f.description);
});
});

View file

@ -0,0 +1,123 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const exec = promisify(execFile);
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
const POSTURE_BIN = resolve(__dirname, '../../scanners/posture.mjs');
async function runPosture(args) {
const { stdout, stderr } = await exec('node', [POSTURE_BIN, ...args], {
timeout: 30000,
cwd: resolve(__dirname, '../..'),
});
return { stdout, stderr };
}
async function runPostureJson(fixturePath) {
const { stdout } = await runPosture([fixturePath, '--json']);
return JSON.parse(stdout);
}
describe('posture.mjs CLI — healthy project', () => {
let result;
beforeEach(async () => {
result = await runPostureJson(resolve(FIXTURES, 'healthy-project'));
});
it('returns utilization with score and overhang', () => {
assert.ok(typeof result.utilization.score === 'number');
assert.ok(typeof result.utilization.overhang === 'number');
assert.equal(result.utilization.score + result.utilization.overhang, 100);
});
it('returns maturity level >= 2', () => {
assert.ok(result.maturity.level >= 2);
assert.ok(typeof result.maturity.name === 'string');
});
it('returns segment string', () => {
assert.ok(typeof result.segment.segment === 'string');
assert.ok(result.segment.segment.length > 0);
});
it('returns 8 area scores', () => {
assert.equal(result.areas.length, 8);
for (const area of result.areas) {
assert.ok('name' in area);
assert.ok('grade' in area);
assert.ok('score' in area);
assert.ok('findingCount' in area);
}
});
it('returns overallGrade', () => {
assert.ok(['A', 'B', 'C', 'D', 'F'].includes(result.overallGrade));
});
it('includes topActions array', () => {
assert.ok(Array.isArray(result.topActions));
});
it('includes scannerEnvelope', () => {
assert.ok(result.scannerEnvelope.meta);
assert.ok(result.scannerEnvelope.scanners);
assert.ok(result.scannerEnvelope.aggregate);
});
});
describe('posture.mjs CLI — minimal project', () => {
it('scores lower utilization than healthy', async () => {
const healthy = await runPostureJson(resolve(FIXTURES, 'healthy-project'));
const minimal = await runPostureJson(resolve(FIXTURES, 'minimal-project'));
assert.ok(minimal.utilization.score < healthy.utilization.score,
`minimal (${minimal.utilization.score}) should be < healthy (${healthy.utilization.score})`);
});
it('has lower maturity than healthy', async () => {
const healthy = await runPostureJson(resolve(FIXTURES, 'healthy-project'));
const minimal = await runPostureJson(resolve(FIXTURES, 'minimal-project'));
assert.ok(minimal.maturity.level <= healthy.maturity.level);
});
});
describe('posture.mjs CLI — terminal output (v3 health format)', () => {
it('scorecard contains health sections', async () => {
const { stderr } = await runPosture([resolve(FIXTURES, 'healthy-project')]);
assert.ok(stderr.includes('Config-Audit Health Score'));
assert.ok(stderr.includes('Health:'));
assert.ok(stderr.includes('Area Scores'));
assert.ok(stderr.includes('areas scanned'));
});
it('scorecard does NOT contain legacy metrics', async () => {
const { stderr } = await runPosture([resolve(FIXTURES, 'healthy-project')]);
assert.ok(!stderr.includes('Maturity:'));
assert.ok(!stderr.includes('Utilization:'));
assert.ok(!stderr.includes('Segment:'));
});
it('scorecard excludes Feature Coverage from area display', async () => {
const { stderr } = await runPosture([resolve(FIXTURES, 'healthy-project')]);
assert.ok(!stderr.includes('Feature Coverage'));
});
});
describe('posture.mjs CLI — JSON includes opportunityCount', () => {
it('returns opportunityCount field', async () => {
const result = await runPostureJson(resolve(FIXTURES, 'healthy-project'));
assert.ok(typeof result.opportunityCount === 'number');
assert.ok(result.opportunityCount >= 0);
});
it('JSON still includes legacy fields for backward compat', async () => {
const result = await runPostureJson(resolve(FIXTURES, 'healthy-project'));
assert.ok(typeof result.utilization.score === 'number');
assert.ok(typeof result.maturity.level === 'number');
assert.ok(typeof result.segment.segment === 'string');
});
});

View file

@ -0,0 +1,128 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { writeFile, readFile, mkdir, rm, stat } from 'node:fs/promises';
import { mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir, homedir } from 'node:os';
import { createBackup, getBackupDir, checksum } from '../../scanners/lib/backup.mjs';
import { listBackups, restoreBackup, deleteBackup } from '../../scanners/rollback-engine.mjs';
/** Create a temp file and back it up, returning paths and content. */
async function setupTestBackup() {
const tmpDir = join(tmpdir(), `config-audit-rb-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
mkdirSync(tmpDir, { recursive: true });
const testFile = join(tmpDir, 'test-settings.json');
const originalContent = '{"original": true, "key": "value"}';
writeFileSync(testFile, originalContent);
const backup = createBackup([testFile]);
// Now modify the file to simulate a change
writeFileSync(testFile, '{"modified": true}');
return { tmpDir, testFile, originalContent, backup };
}
describe('listBackups', () => {
it('returns an array of backups', async () => {
const result = await listBackups();
assert.ok(Array.isArray(result.backups), 'Should return backups array');
});
it('backups are sorted newest first', async () => {
const result = await listBackups();
if (result.backups.length >= 2) {
assert.ok(result.backups[0].id >= result.backups[1].id, 'First backup should be newer');
}
});
it('each backup has required fields', async () => {
const { tmpDir, backup } = await setupTestBackup();
try {
const result = await listBackups();
const found = result.backups.find(b => b.id === backup.backupId);
assert.ok(found, 'Should find our test backup');
assert.ok(found.id, 'Backup should have id');
assert.ok(found.createdAt, 'Backup should have createdAt');
assert.ok(Array.isArray(found.files), 'Backup should have files array');
assert.ok(found.files.length > 0, 'Backup should have at least one file');
assert.ok(found.files[0].originalPath, 'File entry should have originalPath');
assert.ok(found.files[0].checksum, 'File entry should have checksum');
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
});
describe('restoreBackup', () => {
let tmpDir, testFile, originalContent, backup;
beforeEach(async () => {
({ tmpDir, testFile, originalContent, backup } = await setupTestBackup());
});
afterEach(async () => {
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
// Cleanup our test backup
try { await deleteBackup(backup.backupId); } catch {}
});
it('restores files to original content', async () => {
const result = await restoreBackup(backup.backupId);
assert.ok(result.restored.length > 0, 'Should restore at least one file');
assert.strictEqual(result.failed.length, 0, 'No failures');
const restoredContent = await readFile(testFile, 'utf-8');
assert.strictEqual(restoredContent, originalContent, 'Content should match original');
});
it('verifies checksums after restore', async () => {
const result = await restoreBackup(backup.backupId, { verify: true });
for (const r of result.restored) {
assert.strictEqual(r.status, 'restored');
}
});
it('dry-run returns plan without writing', async () => {
const result = await restoreBackup(backup.backupId, { dryRun: true });
assert.ok(result.restored.length > 0);
for (const r of result.restored) {
assert.strictEqual(r.status, 'dry-run');
}
// File should still be modified
const content = await readFile(testFile, 'utf-8');
assert.strictEqual(content, '{"modified": true}', 'File should not be restored in dry-run');
});
it('throws for invalid backup-id', async () => {
await assert.rejects(
() => restoreBackup('nonexistent_99999999_999999'),
{ message: /Backup not found/ },
);
});
});
describe('deleteBackup', () => {
it('deletes an existing backup', async () => {
const { tmpDir, backup } = await setupTestBackup();
try {
const result = await deleteBackup(backup.backupId);
assert.strictEqual(result.deleted, true);
// Verify it's gone from the list
const list = await listBackups();
const found = list.backups.find(b => b.id === backup.backupId);
assert.ok(!found, 'Deleted backup should not appear in list');
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
it('returns error for nonexistent backup', async () => {
const result = await deleteBackup('nonexistent_99999999_999999');
assert.strictEqual(result.deleted, false);
assert.ok(result.error, 'Should have error message');
});
});

View file

@ -0,0 +1,84 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
import { scan } from '../../scanners/rules-validator.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
describe('RUL scanner — healthy project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project'));
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
});
it('returns status ok', () => {
assert.strictEqual(result.status, 'ok');
});
it('has scanner prefix RUL', () => {
assert.strictEqual(result.scanner, 'RUL');
});
it('finds no high severity issues', () => {
const high = result.findings.filter(f => f.severity === 'high' || f.severity === 'critical');
assert.strictEqual(high.length, 0, `Found: ${high.map(f => f.title + ': ' + f.description).join('\n')}`);
});
it('all finding IDs match CA-RUL-NNN', () => {
for (const f of result.findings) {
assert.match(f.id, /^CA-RUL-\d{3}$/);
}
});
});
describe('RUL scanner — broken project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'broken-project'));
result = await scan(resolve(FIXTURES, 'broken-project'), discovery);
});
it('detects deprecated globs field', () => {
const found = result.findings.some(f => f.title.includes('deprecated'));
assert.ok(found, 'Should detect globs: instead of paths:');
});
it('detects dead rule (matches no files)', () => {
const found = result.findings.some(f => f.title.includes('matches no files'));
assert.ok(found, 'Should detect dead glob pattern');
});
it('detects large unscoped rule', () => {
const found = result.findings.some(f => f.title.includes('unscoped'));
assert.ok(found, 'Should detect big rule without paths: frontmatter');
});
it('marks dead rule as high severity', () => {
const f = result.findings.find(f => f.title.includes('matches no files'));
assert.strictEqual(f?.severity, 'high');
});
});
describe('RUL scanner — empty project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
});
it('returns skipped when no rule files', () => {
assert.strictEqual(result.status, 'skipped');
});
it('has 0 findings', () => {
assert.strictEqual(result.findings.length, 0);
});
});

View file

@ -0,0 +1,172 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { resolve, dirname, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isFixturePath, FIXTURE_DIR_NAMES, runAllScanners } from '../../scanners/scan-orchestrator.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = resolve(__dirname, '../..');
const FIXTURES = resolve(__dirname, '../fixtures');
// ========================================
// isFixturePath
// ========================================
describe('isFixturePath', () => {
const target = '/repo';
it('matches tests/ subdirectory relative to target', () => {
assert.strictEqual(isFixturePath({ file: '/repo/tests/fixtures/CLAUDE.md' }, target), true);
});
it('matches examples/ subdirectory relative to target', () => {
assert.strictEqual(isFixturePath({ file: '/repo/examples/demo/settings.json' }, target), true);
});
it('matches __tests__/ subdirectory', () => {
assert.strictEqual(isFixturePath({ file: '/repo/__tests__/config/CLAUDE.md' }, target), true);
});
it('does not match production paths', () => {
assert.strictEqual(isFixturePath({ file: '/repo/CLAUDE.md' }, target), false);
assert.strictEqual(isFixturePath({ file: '/repo/plugins/config-audit/CLAUDE.md' }, target), false);
});
it('does not filter when target IS a fixture directory', () => {
// If we're scanning tests/fixtures/broken-project directly, its files should NOT be filtered
const fixtureTarget = '/repo/tests/fixtures/broken-project';
assert.strictEqual(
isFixturePath({ file: '/repo/tests/fixtures/broken-project/CLAUDE.md' }, fixtureTarget),
false,
'Files at the root of the scanned target should not be filtered'
);
});
it('falls back to path field', () => {
assert.strictEqual(isFixturePath({ path: '/repo/tests/broken/hooks.json' }, target), true);
});
it('falls back to location field', () => {
assert.strictEqual(isFixturePath({ location: '/repo/examples/bad.md' }, target), true);
});
it('returns false when file is null (GAP findings)', () => {
assert.strictEqual(isFixturePath({ file: null }, target), false);
});
it('returns false for empty finding (no file/path/location)', () => {
assert.strictEqual(isFixturePath({}, target), false);
});
it('returns false when file is outside target path', () => {
assert.strictEqual(isFixturePath({ file: '/other/tests/foo.md' }, target), false);
});
it('uses platform-native separator (path.sep)', () => {
// On macOS/Linux sep='/', on Windows sep='\\'
// This test verifies the function works with the native separator
const nativePath = `${target}${sep}tests${sep}fixtures${sep}CLAUDE.md`;
assert.strictEqual(isFixturePath({ file: nativePath }, target), true);
});
});
// ========================================
// FIXTURE_DIR_NAMES
// ========================================
describe('FIXTURE_DIR_NAMES', () => {
it('contains expected directory names', () => {
assert.ok(FIXTURE_DIR_NAMES.includes('tests'));
assert.ok(FIXTURE_DIR_NAMES.includes('examples'));
assert.ok(FIXTURE_DIR_NAMES.includes('__tests__'));
});
});
// ========================================
// runAllScanners — fixture filtering
// ========================================
describe('runAllScanners — fixture filtering', () => {
it('excludes fixture findings by default when scanning plugin root', async () => {
const env = await runAllScanners(PLUGIN_ROOT);
// The plugin has test fixtures in tests/fixtures/ — those should be filtered out
const allFindingFiles = env.scanners.flatMap(s => s.findings.map(f => f.file)).filter(Boolean);
const fixtureInResults = allFindingFiles.filter(f => f.includes('/tests/'));
assert.strictEqual(fixtureInResults.length, 0, 'No fixture findings should appear in scanner results');
});
it('stores excluded findings in env.fixture_findings', async () => {
const env = await runAllScanners(PLUGIN_ROOT);
// Plugin has intentionally broken fixtures — at least some findings should be excluded
if (env.fixture_findings) {
assert.ok(Array.isArray(env.fixture_findings));
assert.ok(env.fixture_findings.length > 0, 'Expected fixture findings to be captured');
// All fixture findings should have test/example paths
for (const f of env.fixture_findings) {
const p = f.file || f.path || f.location || '';
assert.ok(
p.includes('/tests/') || p.includes('/examples/'),
`Fixture finding path should contain /tests/ or /examples/: ${p}`
);
}
}
// Note: if no fixture findings exist (unlikely but possible), test still passes
});
it('includes fixture findings when filterFixtures is false', async () => {
const env = await runAllScanners(PLUGIN_ROOT, { filterFixtures: false });
assert.strictEqual(env.fixture_findings, undefined, 'No fixture_findings field when filtering disabled');
// Some findings should have test fixture paths
const allFindingFiles = env.scanners.flatMap(s => s.findings.map(f => f.file)).filter(Boolean);
const fixtureInResults = allFindingFiles.filter(f => f.includes('/tests/'));
assert.ok(fixtureInResults.length > 0, 'Fixture findings should be present when filtering disabled');
});
it('does not filter GAP findings (file is null)', async () => {
const env = await runAllScanners(PLUGIN_ROOT);
const gapScanner = env.scanners.find(s => s.scanner === 'GAP');
assert.ok(gapScanner, 'GAP scanner should be present');
// GAP findings have file: null — they should never be filtered
for (const f of gapScanner.findings) {
assert.strictEqual(f.file, null, 'GAP findings have null file and should not be filtered');
}
});
it('recalculates scanner counts after fixture filtering', async () => {
const env = await runAllScanners(PLUGIN_ROOT);
for (const scanner of env.scanners) {
// Verify counts match actual findings
const expected = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
for (const f of scanner.findings) {
if (expected[f.severity] !== undefined) expected[f.severity]++;
}
assert.deepStrictEqual(scanner.counts, expected,
`Scanner ${scanner.scanner}: counts should match actual findings after filtering`);
}
});
it('total_findings in aggregate excludes fixtures', async () => {
const withFilter = await runAllScanners(PLUGIN_ROOT, { filterFixtures: true });
const withoutFilter = await runAllScanners(PLUGIN_ROOT, { filterFixtures: false });
// With filter should have fewer or equal findings
assert.ok(
withFilter.aggregate.total_findings <= withoutFilter.aggregate.total_findings,
`Filtered total (${withFilter.aggregate.total_findings}) should be <= unfiltered (${withoutFilter.aggregate.total_findings})`
);
});
it('fixture filtering and suppression are independent', async () => {
// Both enabled (default)
const both = await runAllScanners(PLUGIN_ROOT, { filterFixtures: true, suppress: true });
// Only fixtures
const fixturesOnly = await runAllScanners(PLUGIN_ROOT, { filterFixtures: true, suppress: false });
// Only suppression
const suppressOnly = await runAllScanners(PLUGIN_ROOT, { filterFixtures: false, suppress: true });
// fixture_findings should be present in both fixture-filtered runs
if (both.fixture_findings) {
assert.ok(fixturesOnly.fixture_findings, 'fixture_findings should be present regardless of suppress flag');
}
// suppressed_findings should be present in both suppression-enabled runs (if any suppressions exist)
if (both.suppressed_findings) {
assert.ok(suppressOnly.suppressed_findings, 'suppressed_findings should be present regardless of filterFixtures flag');
}
});
});

View file

@ -0,0 +1,90 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { runSelfAudit, formatSelfAudit } from '../../scanners/self-audit.mjs';
// ========================================
// runSelfAudit
// ========================================
describe('runSelfAudit', () => {
it('runs without crash', async () => {
const result = await runSelfAudit();
assert.ok(result);
assert.ok(typeof result.configGrade === 'string');
assert.ok(typeof result.pluginGrade === 'string');
});
it('returns combined results (scanners + plugin health)', async () => {
const result = await runSelfAudit();
assert.ok(result.configEnvelope);
assert.ok(result.pluginHealthResult);
assert.ok(Array.isArray(result.allFindings));
});
it('has valid exit code', async () => {
const result = await runSelfAudit();
assert.ok([0, 1, 2].includes(result.exitCode));
});
it('includes verdict', async () => {
const result = await runSelfAudit();
assert.ok(['PASS', 'WARN', 'FAIL'].includes(result.verdict));
});
it('has numeric scores', async () => {
const result = await runSelfAudit();
assert.ok(typeof result.configScore === 'number');
assert.ok(typeof result.pluginScore === 'number');
assert.ok(result.configScore >= 0 && result.configScore <= 100);
assert.ok(result.pluginScore >= 0 && result.pluginScore <= 100);
});
it('points to correct plugin directory', async () => {
const result = await runSelfAudit();
assert.ok(result.pluginDir.includes('config-audit'));
});
});
// ========================================
// fixture filtering delegation
// ========================================
describe('runSelfAudit — fixture filtering', () => {
it('does not include fixture findings in allFindings', async () => {
const result = await runSelfAudit();
for (const f of result.allFindings) {
const p = f.file || f.path || f.location || '';
assert.ok(
!p.includes('/tests/fixtures/'),
`allFindings should not contain fixture paths: ${p}`
);
}
});
it('configEnvelope has fixture_findings from orchestrator', async () => {
const result = await runSelfAudit();
// The orchestrator filters fixtures and attaches them to the envelope
if (result.configEnvelope.fixture_findings) {
assert.ok(Array.isArray(result.configEnvelope.fixture_findings));
assert.ok(result.configEnvelope.fixture_findings.length > 0);
}
});
});
// ========================================
// formatSelfAudit
// ========================================
describe('formatSelfAudit', () => {
it('produces terminal output with Self-Audit header', async () => {
const result = await runSelfAudit();
const output = formatSelfAudit(result);
assert.ok(output.includes('Self-Audit'));
assert.ok(output.includes('Plugin health:'));
assert.ok(output.includes('Config quality:'));
});
it('includes verdict', async () => {
const result = await runSelfAudit();
const output = formatSelfAudit(result);
assert.ok(output.includes('Self-audit:'));
assert.ok(output.includes('PASS') || output.includes('WARN') || output.includes('FAIL'));
});
});

View file

@ -0,0 +1,94 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
import { scan } from '../../scanners/settings-validator.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
describe('SET scanner — healthy project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project'));
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
});
it('returns status ok', () => {
assert.strictEqual(result.status, 'ok');
});
it('has scanner prefix SET', () => {
assert.strictEqual(result.scanner, 'SET');
});
it('finds no critical issues', () => {
const critical = result.findings.filter(f => f.severity === 'critical');
assert.strictEqual(critical.length, 0);
});
it('all finding IDs match CA-SET-NNN', () => {
for (const f of result.findings) {
assert.match(f.id, /^CA-SET-\d{3}$/);
}
});
});
describe('SET scanner — broken project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'broken-project'));
result = await scan(resolve(FIXTURES, 'broken-project'), discovery);
});
it('detects unknown settings key', () => {
const found = result.findings.some(f => f.title === 'Unknown settings key');
assert.ok(found, 'Should detect unknownKey123');
});
it('detects deprecated key (includeCoAuthoredBy)', () => {
const found = result.findings.some(f => f.title === 'Deprecated settings key');
assert.ok(found, 'Should detect includeCoAuthoredBy');
});
it('detects type mismatch (alwaysThinkingEnabled as string)', () => {
const found = result.findings.some(f => f.title === 'Type mismatch in settings');
assert.ok(found, 'Should detect boolean/string mismatch');
});
it('detects invalid effortLevel value', () => {
const found = result.findings.some(f => f.title === 'Invalid effortLevel value');
assert.ok(found, 'Should detect effortLevel "turbo"');
});
it('detects hooks as array', () => {
const found = result.findings.some(f => f.title.includes('array instead of object'));
assert.ok(found, 'Should detect hooks array format');
});
it('marks hooks-as-array as critical', () => {
const f = result.findings.find(f => f.title.includes('array instead of object'));
assert.strictEqual(f?.severity, 'critical');
});
});
describe('SET scanner — empty project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
});
it('returns skipped when no settings files', () => {
assert.strictEqual(result.status, 'skipped');
});
it('has 0 findings', () => {
assert.strictEqual(result.findings.length, 0);
});
});