config-audit/tests/json-backcompat.test.mjs
Kjell Tore Guttormsen 7a794b47eb fix(scanners)!: a finding ID names the check, not the emission (M-BUG-28)
BREAKING CHANGE: the {NNN} in CA-{SCANNER}-{NNN} identifies the check that
produced the finding. It used to be the finding's position in that scanner's
output for that run, which made it unstable across CONFIGURATIONS, not just
across releases as STATE framed it. Measured on two fixtures: "No custom
subagents" was CA-GAP-007 on minimal-project and CA-GAP-004 on healthy-project.
A user who fixed an unrelated earlier gap silently renumbered every later one,
so a .config-audit-ignore pin retargeted to a neighbouring finding with no
version change at all.

Second measured arm: README already documented the opposite scheme. It and the
scanner headers describe ~20 numbers as check codes (CA-SKL-003 = oversized
body, CA-PLH-015 = folder shadowing, CA-TOK-006 = schema deferral), and the
counter could only produce those in the all-fire case -- source-order positions
are 4, 3 and 8. The documentation described the scheme; the implementation was
what was wrong. Every published number is preserved by construction and pinned
exhaustively in tests/lib/finding-codes.test.mjs.

scanners/lib/finding-codes.mjs is the single authority. Every finding() call
passes a `code`; an undeclared or missing one THROWS. No counter fallback --
that would reproduce D1's findGapId -> 'unknown' silent degradation and let a
half-converted scanner ship IDs that look valid. findingCounter/resetCounter
are deleted outright, not left as no-ops. Retirement is now a mechanism:
RETIRED_CODES tombstones a withdrawn key so its number is never reissued,
seeded with GAP t3_8 -- the D1 removal that opened this chunk.

IDs are consequently NOT unique per finding: one check failing in three files
emits three findings sharing an ID. That inverts which consumer is correct, so
every f.id/findingId site was classified before the change. diff-engine and
most of fix-engine already keyed on scanner+title+file (drift was never lying);
fix-engine's verification did not, and keyed on the ID alone -- fixing one of
two sibling instances marked both fixed, and the untouched one, still present
in the re-scan, was reported as a REGRESSION. Red test first, then keyed on
(findingId, file), which both planFixes and applyFixes already carry.
plugin-health's crossIds Set was measured and is a clean negative: cross
findings are allFindings.slice(crossPluginStart) and codes 18/19 are emitted
only in that tail, so the partition holds by construction.

unknownSuppressions() reports a pin that names no declared check, in the
--output-file payload (ux-rules rule 2 -- a stderr-only warning is invisible to
the commands) and only when one exists, so a clean config is byte-identical.
That is what makes the break safe: a stale pin goes loud instead of dying quiet.

Frozen tests/snapshots/v5.0.0/ untouched on disk. IDs are masked out of that
comparison (mask-finding-ids.mjs) rather than re-derived -- re-deriving
positional IDs would assert the retired scheme against itself, and #58's
isGapEntry off-by-one is the measured example of that misfiring. The dead
re-derivation is removed from strip-retired-gap.mjs. default-output snapshots
re-approved after confirming the diff is IDs and nothing else.

Guards, each seen red against its own defect: a missing code (scanner errors
out mid-sweep), an orphan declaration, a resurrected retired key, and a
documented ID naming no check. The sweep asserts the union across all 16
scanners, never per scanner -- a per-scanner assertion goes green on a partial
conversion.

Fasit written before implementation: docs/mbug28-id-semantics-fasit.local.md,
including one correction made before running (CML has 12 checks over 13 call
sites -- the anchored and calibrated char-budget arms are one check, which a
repeated-title sweep found and my call-site count had missed).

Suite 1535 -> 1573, 0 failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyqCQKK2ornJ1jFWwqx17E
2026-08-09 23:26:36 +02:00

321 lines
11 KiB
JavaScript

/**
* SC-6 — JSON backwards-compatibility test (Wave 4 Step 10).
*
* For each CLI that has a frozen v5.0.0 JSON snapshot, run the CLI with
* --json against the marketplace-medium fixture and compare the output
* to the snapshot. Time-varying fields are normalized.
*
* 5 fixture-deterministic CLIs are checked byte-equal against the v5.0.0
* snapshot:
* - scan-orchestrator
* - posture
* - token-hotspots-cli
* - drift-cli (requires saved baseline; falls back to mode-equivalence
* if the baseline cannot be created)
* - fix-cli
*
* 3 environment-aware CLIs (plugin-health, manifest, whats-active) read
* the active config cascade, so frozen snapshots drift as the
* marketplace evolves. They are verified by mode-equivalence
* (--json == --raw) instead — the same strategy Wave 3
* cli-humanizer.test.mjs already uses.
*/
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, writeFile, access, mkdir } from 'node:fs/promises';
import { homedir } from 'node:os';
import { hermeticEnv, HERMETIC_HOME } from './helpers/hermetic-home.mjs';
import { stripHotspotLoadPattern } from './helpers/strip-hotspot-load-pattern.mjs';
import { stripAddedScanners, stripAddedScannerStderr } from './helpers/strip-added-scanner.mjs';
import { stripRetiredGap } from './helpers/strip-retired-gap.mjs';
import { maskFindingIds } from './helpers/mask-finding-ids.mjs';
const exec = promisify(execFile);
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(__dirname, '..');
const FIXTURE = resolve(REPO, 'tests/fixtures/marketplace-medium');
const SNAPSHOT_DIR = resolve(REPO, 'tests/snapshots/v5.0.0');
// Baseline lives under the hermetic HOME so drift output is isolated from
// the developer's ~/.config-audit state, matching the scan env below.
const BASELINE_DIR = resolve(HERMETIC_HOME, '.config-audit/baselines');
const DEFAULT_BASELINE = resolve(BASELINE_DIR, 'default.json');
const SEED = process.env.SEED_SNAPSHOT === '1';
async function runCli(scriptPath, args) {
try {
const { stdout, stderr } = await exec('node', [scriptPath, ...args], {
timeout: 60000,
cwd: REPO,
maxBuffer: 10 * 1024 * 1024,
env: hermeticEnv(),
});
return { stdout: stdout || '', stderr: stderr || '' };
} catch (err) {
return { stdout: err.stdout || '', stderr: err.stderr || '' };
}
}
async function ensureDriftBaseline() {
try {
await access(DEFAULT_BASELINE);
return true;
} catch {
try {
await mkdir(BASELINE_DIR, { recursive: true });
await runCli(resolve(REPO, 'scanners/drift-cli.mjs'), [FIXTURE, '--save']);
await access(DEFAULT_BASELINE);
return true;
} catch {
return false;
}
}
}
// ---------------------------------------------------------------------------
// Normalizers — strip time / path / ancestor-derived fields that vary
// independently of scanner internals. `claudeMdEstimatedTokens` is computed
// by walking the FS cascade upward from the fixture; any edit to this
// plugin's own CLAUDE.md ripples into it, even though scanner behavior is
// unchanged. The byte-stability contract covers scanner output shape, not
// the size of ancestor input docs.
// ---------------------------------------------------------------------------
function stripAncestorDerived(envOrEnvelope) {
if (Array.isArray(envOrEnvelope?.scanners)) {
for (const s of envOrEnvelope.scanners) {
if (s?.activeConfig && 'claudeMdEstimatedTokens' in s.activeConfig) {
s.activeConfig.claudeMdEstimatedTokens = '<ANCESTOR_DERIVED>';
}
}
}
}
function normalizeScanOrchestrator(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;
}
}
stripAncestorDerived(out);
return maskFindingIds(stripRetiredGap(stripAddedScanners(stripHotspotLoadPattern(out))));
}
function normalizePosture(p) {
const out = JSON.parse(JSON.stringify(p));
if (out.scannerEnvelope) {
if (out.scannerEnvelope.meta) {
out.scannerEnvelope.meta.target = '<TARGET>';
out.scannerEnvelope.meta.timestamp = '<TIMESTAMP>';
}
if (Array.isArray(out.scannerEnvelope.scanners)) {
for (const s of out.scannerEnvelope.scanners) {
s.duration_ms = 0;
}
}
stripAncestorDerived(out.scannerEnvelope);
}
return maskFindingIds(stripRetiredGap(stripAddedScanners(stripHotspotLoadPattern(out))));
}
function normalizeTokenHotspots(p) {
const out = JSON.parse(JSON.stringify(p));
out.duration_ms = 0;
return maskFindingIds(stripHotspotLoadPattern(out));
}
function normalizeDrift(p) {
// Drift result has no time fields — round-trip through JSON for safety.
return maskFindingIds(stripRetiredGap(JSON.parse(JSON.stringify(p))));
}
function normalizeFix(p) {
// Fix-cli stdout is the planFixes result with no time fields.
return maskFindingIds(stripRetiredGap(JSON.parse(JSON.stringify(p))));
}
function normalizePluginHealth(p) {
const out = JSON.parse(JSON.stringify(p));
out.duration_ms = 0;
return maskFindingIds(out);
}
function normalizeManifest(o) {
const out = JSON.parse(JSON.stringify(o));
if (out.meta) {
out.meta.repoPath = '<TARGET>';
out.meta.generatedAt = '<TIMESTAMP>';
out.meta.durationMs = 0;
}
return out;
}
function normalizeWhatsActive(o) {
const out = JSON.parse(JSON.stringify(o));
if (out.meta) {
out.meta.repoPath = '<TARGET>';
out.meta.generatedAt = '<TIMESTAMP>';
out.meta.durationMs = 0;
if (out.meta.gitRoot) out.meta.gitRoot = '<GITROOT>';
if (out.meta.projectKey) out.meta.projectKey = '<PROJECTKEY>';
}
return out;
}
// ---------------------------------------------------------------------------
// Fixture-deterministic CLIs — strict byte-equal against v5.0.0 snapshot.
// ---------------------------------------------------------------------------
const DETERMINISTIC_CLIS = [
{
name: 'scan-orchestrator',
script: 'scanners/scan-orchestrator.mjs',
snapshot: 'scan-orchestrator.json',
normalize: normalizeScanOrchestrator,
},
{
name: 'posture',
script: 'scanners/posture.mjs',
snapshot: 'posture.json',
normalize: normalizePosture,
},
{
name: 'token-hotspots-cli',
script: 'scanners/token-hotspots-cli.mjs',
snapshot: 'token-hotspots.json',
normalize: normalizeTokenHotspots,
},
{
name: 'fix-cli',
script: 'scanners/fix-cli.mjs',
snapshot: 'fix-cli.json',
normalize: normalizeFix,
},
];
describe('SC-6 JSON backwards-compatibility — fixture-deterministic CLIs', () => {
for (const cli of DETERMINISTIC_CLIS) {
it(`${cli.name} --json byte-equals v5.0.0 snapshot`, async () => {
const script = resolve(REPO, cli.script);
const { stdout } = await runCli(script, [FIXTURE, '--json']);
const actual = JSON.parse(stdout);
const snapshotPath = resolve(SNAPSHOT_DIR, cli.snapshot);
if (SEED) {
await writeFile(snapshotPath, JSON.stringify(actual, null, 2) + '\n', 'utf8');
return;
}
const expected = JSON.parse(await readFile(snapshotPath, 'utf8'));
assert.deepStrictEqual(cli.normalize(actual), cli.normalize(expected));
});
}
});
// ---------------------------------------------------------------------------
// Drift-cli: separate suite because it requires a baseline precondition.
// ---------------------------------------------------------------------------
describe('SC-6 JSON backwards-compatibility — drift-cli', () => {
it('drift-cli --json byte-equals v5.0.0 snapshot (when baseline available)', async () => {
const ok = await ensureDriftBaseline();
if (!ok) {
// Skip silently — environment cannot create a baseline. Wave 0 + Wave 3
// tests already exercise drift extensively; this is a defensive fallback.
return;
}
const script = resolve(REPO, 'scanners/drift-cli.mjs');
const { stdout } = await runCli(script, [FIXTURE, '--json']);
const actual = JSON.parse(stdout);
const snapshotPath = resolve(SNAPSHOT_DIR, 'drift.json');
if (SEED) {
await writeFile(snapshotPath, JSON.stringify(actual, null, 2) + '\n', 'utf8');
return;
}
const expected = JSON.parse(await readFile(snapshotPath, 'utf8'));
assert.deepStrictEqual(normalizeDrift(actual), normalizeDrift(expected));
});
});
// ---------------------------------------------------------------------------
// Environment-aware CLIs — mode-equivalence (--json == --raw). Frozen v5.0.0
// snapshots drift as marketplace state evolves, so byte-equal would be flaky.
// ---------------------------------------------------------------------------
const ENV_AWARE_CLIS = [
{
name: 'plugin-health-scanner',
script: 'scanners/plugin-health-scanner.mjs',
normalize: normalizePluginHealth,
},
{
name: 'manifest',
script: 'scanners/manifest.mjs',
normalize: normalizeManifest,
},
{
name: 'whats-active',
script: 'scanners/whats-active.mjs',
normalize: normalizeWhatsActive,
},
];
describe('SC-6 JSON backwards-compatibility — environment-aware CLIs (mode-equivalence)', () => {
for (const cli of ENV_AWARE_CLIS) {
it(`${cli.name} --json equals --raw (machine modes are byte-identical)`, async () => {
const script = resolve(REPO, cli.script);
const { stdout: jsonOut } = await runCli(script, [FIXTURE, '--json']);
const { stdout: rawOut } = await runCli(script, [FIXTURE, '--raw']);
assert.deepStrictEqual(
cli.normalize(JSON.parse(jsonOut)),
cli.normalize(JSON.parse(rawOut)),
);
});
}
});
// ---------------------------------------------------------------------------
// Cross-cutting: --json must NOT add humanizer fields to any CLI's findings.
// ---------------------------------------------------------------------------
describe('SC-6 JSON output never carries humanizer fields', () => {
const EXPECTED_HUMANIZER_FIELDS = ['userImpactCategory', 'userActionLanguage', 'relevanceContext'];
function* walkFindings(payload) {
if (!payload || typeof payload !== 'object') return;
if (Array.isArray(payload.findings)) {
for (const f of payload.findings) yield f;
}
if (Array.isArray(payload.scanners)) {
for (const s of payload.scanners) {
if (Array.isArray(s.findings)) {
for (const f of s.findings) yield f;
}
}
}
if (payload.scannerEnvelope) yield* walkFindings(payload.scannerEnvelope);
}
for (const cli of DETERMINISTIC_CLIS) {
it(`${cli.name} --json findings carry no humanizer fields`, async () => {
const script = resolve(REPO, cli.script);
const { stdout } = await runCli(script, [FIXTURE, '--json']);
const actual = JSON.parse(stdout);
for (const f of walkFindings(actual)) {
for (const field of EXPECTED_HUMANIZER_FIELDS) {
assert.equal(
f[field],
undefined,
`${cli.name} ${f.id ?? '<no-id>'}: --json must not add ${field}`,
);
}
}
});
}
});