config-audit/tests/scanners/scan-orchestrator-humanizer.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

156 lines
6.5 KiB
JavaScript

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { readFile, unlink } from 'node:fs/promises';
import { hermeticEnv } from '../helpers/hermetic-home.mjs';
import { stripHotspotLoadPattern } from '../helpers/strip-hotspot-load-pattern.mjs';
import { stripAddedScanners } from '../helpers/strip-added-scanner.mjs';
import { stripRetiredGap } from '../helpers/strip-retired-gap.mjs';
const exec = promisify(execFile);
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(__dirname, '../..');
const CLI = resolve(REPO, 'scanners/scan-orchestrator.mjs');
const FIXTURE = resolve(REPO, 'tests/fixtures/marketplace-medium');
const SNAPSHOT_PATH = resolve(REPO, 'tests/snapshots/v5.0.0/scan-orchestrator.json');
/**
* Normalize a scan-orchestrator envelope for snapshot comparison by
* blanking out time-varying fields (timestamp, durations, target path)
* and ancestor-cascade-derived counts. `claudeMdEstimatedTokens` reflects
* walkClaudeMdCascade walking upward from the fixture; any docs edit to
* this plugin's own CLAUDE.md ripples into it even when scanner behavior
* is unchanged. Returns a NEW object — does not mutate input.
*/
function normalizeEnvelope(env) {
const out = JSON.parse(JSON.stringify(env));
if (out.meta) {
out.meta.target = '<TARGET>';
out.meta.timestamp = '<TIMESTAMP>';
}
if (Array.isArray(out.scanners)) {
for (const s of out.scanners) {
s.duration_ms = 0;
if (s.activeConfig && 'claudeMdEstimatedTokens' in s.activeConfig) {
s.activeConfig.claudeMdEstimatedTokens = '<ANCESTOR_DERIVED>';
}
}
}
return stripRetiredGap(stripAddedScanners(stripHotspotLoadPattern(out)));
}
async function runOrchestrator(flags) {
const out = `/tmp/scan-orch-humanizer-${process.pid}-${Date.now()}-${Math.random()}.json`;
try {
await exec('node', [CLI, FIXTURE, '--output-file', out, ...flags], {
timeout: 60000,
cwd: REPO,
env: hermeticEnv(),
});
const written = await readFile(out, 'utf-8');
return JSON.parse(written);
} finally {
await unlink(out).catch(() => {});
}
}
describe('scan-orchestrator humanizer wiring (Step 5)', () => {
describe('--json mode (SC-6: byte-equal v5.0.0)', () => {
it('produces envelope structurally equal to v5.0.0 snapshot', async () => {
const actual = await runOrchestrator(['--json']);
const expected = JSON.parse(await readFile(SNAPSHOT_PATH, 'utf-8'));
assert.deepStrictEqual(normalizeEnvelope(actual), normalizeEnvelope(expected));
});
it('does NOT add humanizer fields to findings', async () => {
const actual = await runOrchestrator(['--json']);
for (const s of actual.scanners) {
for (const f of s.findings) {
assert.equal(f.userImpactCategory, undefined,
`${f.id}: --json findings must not have userImpactCategory`);
assert.equal(f.userActionLanguage, undefined,
`${f.id}: --json findings must not have userActionLanguage`);
assert.equal(f.relevanceContext, undefined,
`${f.id}: --json findings must not have relevanceContext`);
}
}
});
});
describe('--raw mode (SC-7: byte-equal v5.0.0)', () => {
it('produces envelope structurally equal to v5.0.0 snapshot', async () => {
const actual = await runOrchestrator(['--raw']);
const expected = JSON.parse(await readFile(SNAPSHOT_PATH, 'utf-8'));
assert.deepStrictEqual(normalizeEnvelope(actual), normalizeEnvelope(expected));
});
it('does NOT add humanizer fields to findings', async () => {
const actual = await runOrchestrator(['--raw']);
for (const s of actual.scanners) {
for (const f of s.findings) {
assert.equal(f.userImpactCategory, undefined,
`${f.id}: --raw findings must not have userImpactCategory`);
}
}
});
});
describe('default mode (humanized)', () => {
it('preserves envelope-level shape', async () => {
const actual = await runOrchestrator([]);
assert.ok(actual.meta, 'meta present');
assert.ok(Array.isArray(actual.scanners), 'scanners array present');
assert.ok(actual.aggregate, 'aggregate present');
assert.equal(actual.scanners.length, 16, 'all 16 scanners present');
});
it('preserves scanner shape (scanner/status/findings/counts)', async () => {
const actual = await runOrchestrator([]);
for (const s of actual.scanners) {
assert.ok(typeof s.scanner === 'string', 'scanner name string');
assert.ok(typeof s.status === 'string', 'status string');
assert.ok(Array.isArray(s.findings), 'findings array');
assert.ok(s.counts, 'counts object');
}
});
it('adds humanizer fields to every finding', async () => {
const actual = await runOrchestrator([]);
let totalFindings = 0;
for (const s of actual.scanners) {
for (const f of s.findings) {
totalFindings++;
assert.equal(typeof f.userImpactCategory, 'string',
`${f.id}: userImpactCategory must be string`);
assert.equal(typeof f.userActionLanguage, 'string',
`${f.id}: userActionLanguage must be string`);
assert.equal(typeof f.relevanceContext, 'string',
`${f.id}: relevanceContext must be string`);
assert.ok(['test-fixture-no-impact', 'affects-this-machine-only', 'affects-everyone'].includes(f.relevanceContext),
`${f.id}: relevanceContext must be one of allowed values, got ${f.relevanceContext}`);
}
}
assert.ok(totalFindings > 0, 'expected at least one finding to assert against');
});
it('preserves stable identifiers (id, scanner, severity)', async () => {
const actualHumanized = await runOrchestrator([]);
const actualRaw = await runOrchestrator(['--raw']);
const flatHumanized = actualHumanized.scanners.flatMap(s => s.findings);
const flatRaw = actualRaw.scanners.flatMap(s => s.findings);
assert.equal(flatHumanized.length, flatRaw.length, 'finding count matches');
for (let i = 0; i < flatHumanized.length; i++) {
const h = flatHumanized[i];
const r = flatRaw[i];
assert.equal(h.id, r.id, `finding ${i} id matches`);
assert.equal(h.scanner, r.scanner, `finding ${i} scanner matches`);
assert.equal(h.severity, r.severity, `finding ${i} severity matches`);
}
});
});
});