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
186 lines
8.1 KiB
JavaScript
186 lines
8.1 KiB
JavaScript
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { resolve, dirname, join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
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, stripAddedScannerStderr } from '../helpers/strip-added-scanner.mjs';
|
|
import { stripRetiredGap, maskGapTallyStderr } 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/posture.mjs');
|
|
const FIXTURE = resolve(REPO, 'tests/fixtures/marketplace-medium');
|
|
const POSTURE_JSON_SNAPSHOT = resolve(REPO, 'tests/snapshots/v5.0.0/posture.json');
|
|
const POSTURE_STDERR_SNAPSHOT = resolve(REPO, 'tests/snapshots/v5.0.0-stderr/posture.txt');
|
|
|
|
/**
|
|
* Normalize a runPosture result for snapshot comparison by zeroing out
|
|
* time-varying fields, machine-specific paths, 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 though scanner behavior is unchanged.
|
|
*/
|
|
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;
|
|
if (s.activeConfig && 'claudeMdEstimatedTokens' in s.activeConfig) {
|
|
s.activeConfig.claudeMdEstimatedTokens = '<ANCESTOR_DERIVED>';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return stripRetiredGap(stripAddedScanners(stripHotspotLoadPattern(out)));
|
|
}
|
|
|
|
/**
|
|
* Strip time-varying durations (Xms) so progress lines compare verbatim across
|
|
* runs, and drop the additive [OST] progress line (v5.6 C) so a 14-scanner live
|
|
* run matches the frozen 13-scanner v5.0.0 stderr scorecard.
|
|
*/
|
|
function normalizeStderr(s) {
|
|
return maskGapTallyStderr(stripAddedScannerStderr(s)).replace(/\(\d+ms\)/g, '(0ms)');
|
|
}
|
|
|
|
async function runPosture(flags) {
|
|
const proc = await exec('node', [CLI, FIXTURE, ...flags], {
|
|
timeout: 60000,
|
|
cwd: REPO,
|
|
env: hermeticEnv(),
|
|
}).catch(err => err); // posture exits non-zero on findings — capture either way
|
|
return {
|
|
stdout: proc.stdout || '',
|
|
stderr: proc.stderr || '',
|
|
};
|
|
}
|
|
|
|
describe('posture humanizer wiring (Step 6)', () => {
|
|
describe('--json mode (SC-6: byte-equal stdout)', () => {
|
|
it('stdout JSON deepEquals v5.0.0 snapshot', async () => {
|
|
const { stdout } = await runPosture(['--json']);
|
|
const actual = JSON.parse(stdout);
|
|
const expected = JSON.parse(await readFile(POSTURE_JSON_SNAPSHOT, 'utf-8'));
|
|
assert.deepStrictEqual(normalizePosture(actual), normalizePosture(expected));
|
|
});
|
|
|
|
it('does NOT write a scorecard to stderr (suppressed)', async () => {
|
|
const { stderr } = await runPosture(['--json']);
|
|
assert.ok(!stderr.includes('Config-Audit Health Score'),
|
|
'stderr must NOT contain scorecard in --json mode');
|
|
assert.ok(!stderr.includes('Configuration health'),
|
|
'stderr must NOT contain humanized scorecard in --json mode');
|
|
});
|
|
|
|
it('preserves v5.0.0 finding shape (no humanizer fields in scannerEnvelope)', async () => {
|
|
const { stdout } = await runPosture(['--json']);
|
|
const actual = JSON.parse(stdout);
|
|
for (const s of actual.scannerEnvelope.scanners) {
|
|
for (const f of s.findings) {
|
|
assert.equal(f.userImpactCategory, undefined,
|
|
`${f.id}: --json findings must not have userImpactCategory`);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('--raw mode (SC-7: byte-equal stdout + verbatim stderr)', () => {
|
|
it('stdout JSON deepEquals v5.0.0 snapshot', async () => {
|
|
const { stdout } = await runPosture(['--raw']);
|
|
const actual = JSON.parse(stdout);
|
|
const expected = JSON.parse(await readFile(POSTURE_JSON_SNAPSHOT, 'utf-8'));
|
|
assert.deepStrictEqual(normalizePosture(actual), normalizePosture(expected));
|
|
});
|
|
|
|
it('stderr scorecard verbatim matches v5.0.0 stderr snapshot', async () => {
|
|
const { stderr } = await runPosture(['--raw']);
|
|
const expected = await readFile(POSTURE_STDERR_SNAPSHOT, 'utf-8');
|
|
// Compare the scorecard portion verbatim (modulo timing in scanner progress lines)
|
|
assert.equal(normalizeStderr(stderr).trim(), normalizeStderr(expected).trim());
|
|
});
|
|
|
|
it('preserves v5.0.0 finding shape in stdout', async () => {
|
|
const { stdout } = await runPosture(['--raw']);
|
|
const actual = JSON.parse(stdout);
|
|
for (const s of actual.scannerEnvelope.scanners) {
|
|
for (const f of s.findings) {
|
|
assert.equal(f.userImpactCategory, undefined,
|
|
`${f.id}: --raw findings must not have userImpactCategory`);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('default mode (humanized scorecard)', () => {
|
|
it('writes humanized scorecard to stderr', async () => {
|
|
const { stderr } = await runPosture([]);
|
|
// Humanized scorecard must contain at least one user-friendly cue not in raw v5.0.0
|
|
const hasGradeContext = /healthy|good shape|attention|polish|setup/i.test(stderr);
|
|
assert.ok(hasGradeContext,
|
|
`humanized stderr scorecard must contain user-friendly phrasing, got:\n${stderr}`);
|
|
});
|
|
|
|
it('does NOT write JSON to stdout in default mode', async () => {
|
|
const { stdout } = await runPosture([]);
|
|
assert.equal(stdout.trim(), '', 'default mode must not write JSON to stdout');
|
|
});
|
|
|
|
it('humanized scorecard differs byte-wise from v5.0.0 stderr', async () => {
|
|
const { stderr } = await runPosture([]);
|
|
const expected = await readFile(POSTURE_STDERR_SNAPSHOT, 'utf-8');
|
|
assert.notEqual(normalizeStderr(stderr).trim(), normalizeStderr(expected).trim(),
|
|
'humanized stderr must differ from v5.0.0 verbatim stderr');
|
|
});
|
|
});
|
|
|
|
// M-BUG-12: feature-gap.md/posture.md read findings from posture.mjs --output-file
|
|
// and group on humanizer fields (userActionLanguage etc.). Default-mode output-file
|
|
// must therefore humanize findings inside the nested scannerEnvelope; --raw stays raw.
|
|
describe('default mode --output-file (M-BUG-12: humanized findings)', () => {
|
|
it('writes humanized GAP findings (userActionLanguage defined) to the output file', async () => {
|
|
const tmp = join(tmpdir(), `ca-posture-outfile-${process.pid}.json`);
|
|
try {
|
|
await runPosture(['--output-file', tmp]);
|
|
const env = JSON.parse(await readFile(tmp, 'utf-8'));
|
|
const gap = env.scannerEnvelope.scanners.find(s => s.scanner === 'GAP');
|
|
assert.ok(gap, 'GAP scanner must be present in the output file');
|
|
assert.ok(gap.findings.length > 0, 'fixture must yield at least one GAP finding');
|
|
for (const f of gap.findings) {
|
|
assert.notEqual(f.userActionLanguage, undefined,
|
|
`${f.id}: default-mode output-file findings must carry userActionLanguage`);
|
|
assert.notEqual(f.userImpactCategory, undefined,
|
|
`${f.id}: default-mode output-file findings must carry userImpactCategory`);
|
|
}
|
|
} finally {
|
|
await unlink(tmp).catch(() => {});
|
|
}
|
|
});
|
|
|
|
it('--raw --output-file keeps v5.0.0 raw finding shape (no humanizer fields)', async () => {
|
|
const tmp = join(tmpdir(), `ca-posture-outfile-raw-${process.pid}.json`);
|
|
try {
|
|
await runPosture(['--raw', '--output-file', tmp]);
|
|
const env = JSON.parse(await readFile(tmp, 'utf-8'));
|
|
for (const s of env.scannerEnvelope.scanners) {
|
|
for (const f of s.findings) {
|
|
assert.equal(f.userActionLanguage, undefined,
|
|
`${f.id}: --raw output-file must not carry userActionLanguage`);
|
|
}
|
|
}
|
|
} finally {
|
|
await unlink(tmp).catch(() => {});
|
|
}
|
|
});
|
|
});
|
|
});
|