config-audit/tests/lib/scoring.test.mjs
Kjell Tore Guttormsen 4027cdcf54 fix(scanners): retire the autoMode GAP dimension, a /doctor duplicate (D1)
CC 2.1.226's /doctor Check 8 covers auto mode with usage-weighted judgement.
The binding positioning forbids carrying a feature whose whole value is
duplicating a /doctor check, so the "adopt this feature" nudge goes. The
deterministic side stays: SET still validates autoMode structure and still
flags it as dead config in shared project settings. GAP dimensions 25 -> 24.

The title lived in FOUR tables, not the two the removal was scoped against:
the dimension list, scoring TITLE_TO_ID, the humanizer's static translations,
and the scoring denominators (TIER_COUNTS t3 8->7, TOTAL_DIMENSIONS 25->24,
MAX_WEIGHTED 42->41) -- the one that moves a user-visible number. findGapId
falls back to 'unknown' silently, so a partial removal would have degraded
without failing. A blanket sync invariant now asserts all four against
GAP_CHECKS instead of comparing occurrences pairwise; each arm was verified
red against its own defect (denominator drift, orphaned humanizer entry,
resurrected dimension).

Frozen tests/snapshots/v5.0.0/ stays untouched. strip-retired-gap.mjs is the
removal twin of strip-added-scanner.mjs: it strips the retired dimension from
whichever side still carries it and re-derives GAP IDs, since retiring a
dimension from mid-list shifts every later ID by one. Derived utilization
figures are dropped from comparison rather than recomputed -- recomputing them
in a test helper would assert the new arithmetic against itself, and
scoring.test.mjs already pins them exactly. Re-seeding was rejected: it would
silently bake in any other drift across every scanner those four files cover.

risk_score, risk_band, verdict, overallGrade, maturity and segment are
byte-identical across the change (severity info carries zero risk weight; GAP
is excluded from the overall grade). Utilization shifts 43 -> 44 on the fixture.

D2 (CA-SKL-002) is NOT removed. Verified against the primary source first: the
CC changelog carries exactly one budget-fraction statement (L3786, 2.1.32) and
nothing supersedes it, so our 2% is current and 002 is not a duplicate with a
stale figure. /doctor's ~1% could not be reconciled from the changelog and it
discloses its own numbers as disk estimates, so it is recorded, not adopted.
Left explicitly unverified in a code note: L3786 says "character budget" while
we express tokens -- a 4x difference nobody can settle from the wording.

Suite 1531 -> 1535, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RsfPGxgwbR3MY54wDC6hat
2026-08-09 22:43:04 +02:00

614 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 },
};
}
function makeScannerResultWithSeverities(scanner, severities) {
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
const findings = severities.map((sev, i) => {
if (counts[sev] !== undefined) counts[sev]++;
return {
id: `CA-${scanner}-${String(i + 1).padStart(3, '0')}`,
scanner,
severity: sev,
title: `Finding ${i + 1}`,
category: scanner === 'GAP' ? 't2' : null,
recommendation: 'Fix',
};
});
return {
scanner,
status: 'ok',
files_scanned: 5,
duration_ms: 10,
findings,
counts,
};
}
// ========================================
// 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 24 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 41. Present: 26/41 = 63%
assert.equal(result.score, 63);
});
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 41. Present: 36/41 = 88%
assert.equal(result.score, 88);
});
it('T1+T2 present but no T3+T4 scores ~71%', () => {
// T3: 7 dims × 1 = 7, T4: 5 dims × 1 = 5. Lost = 12 out of 41. Present = 29/41 = 71%
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, 71);
});
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 9 scanners', () => {
const scanners = ['CML', 'SET', 'HKV', 'RUL', 'MCP', 'IMP', 'CNF', 'GAP', 'TOK']
.map(s => makeScannerResult(s, 0));
const result = scoreByArea(scanners);
assert.equal(result.areas.length, 9);
});
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 high-severity findings → lower grade (v5 severity-weighted)', () => {
const scanners = [makeScannerResultWithSeverities('CML', ['high', 'high', 'high'])];
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);
});
it('exposes scoringVersion: v5', () => {
const result = scoreByArea([makeScannerResult('CML', 0)]);
assert.equal(result.scoringVersion, 'v5');
});
});
// ========================================
// scoreByArea — severity weighting (v5 F3)
// ========================================
describe('scoreByArea — severity weighting (v5 F3)', () => {
it('clean scanner → 100/A', () => {
const result = scoreByArea([makeScannerResultWithSeverities('CML', [])]);
assert.equal(result.areas[0].score, 100);
assert.equal(result.areas[0].grade, 'A');
});
it('5 lows scores higher than 1 critical', () => {
const fiveLows = scoreByArea([makeScannerResultWithSeverities('CML', ['low', 'low', 'low', 'low', 'low'])]);
const oneCritical = scoreByArea([makeScannerResultWithSeverities('CML', ['critical'])]);
assert.ok(fiveLows.areas[0].score > oneCritical.areas[0].score,
`5 lows (${fiveLows.areas[0].score}) should score higher than 1 critical (${oneCritical.areas[0].score})`);
});
it('1 critical → grade is D or F (penalty exceeds budget)', () => {
const result = scoreByArea([makeScannerResultWithSeverities('CML', ['critical'])]);
assert.ok(['D', 'F'].includes(result.areas[0].grade),
`1 critical produced grade ${result.areas[0].grade}, expected D or F`);
});
it('a few lows still score A (low impact respected)', () => {
const result = scoreByArea([makeScannerResultWithSeverities('CML', ['low', 'low', 'low'])]);
assert.ok(result.areas[0].score >= 75,
`3 lows scored ${result.areas[0].score}, expected >= 75 (B+ range)`);
});
it('info-only findings are not penalized', () => {
const result = scoreByArea([makeScannerResultWithSeverities('CML', ['info', 'info', 'info'])]);
assert.equal(result.areas[0].score, 100);
});
it('1 high → grade is C or worse', () => {
const result = scoreByArea([makeScannerResultWithSeverities('CML', ['high'])]);
assert.ok(['C', 'D', 'F'].includes(result.areas[0].grade),
`1 high produced grade ${result.areas[0].grade}, expected C/D/F`);
});
});
// ========================================
// 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 24 entries', () => {
assert.equal(Object.keys(TITLE_TO_ID).length, 24);
});
it('TIER_COUNTS sum to 24', () => {
const sum = Object.values(TIER_COUNTS).reduce((a, b) => a + b, 0);
assert.equal(sum, 24);
});
it('MAX_WEIGHTED is 41', () => {
assert.equal(MAX_WEIGHTED, 41);
});
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);
});
});