config-audit/tests/scanners/posture-humanizer.test.mjs
Kjell Tore Guttormsen b58393099a fix(acr): posture --output-file humanizes findings in default mode (M-BUG-12)
feature-gap.md (Step 3-4) and posture.md (Step 3-4) read findings from
`posture.mjs --output-file` and group on the humanizer fields
(userActionLanguage / userImpactCategory / relevanceContext). But posture.mjs
only humanized the stderr scorecard — its --output-file JSON wrote the raw
v5.0.0-shape `result`, so every finding's humanizer fields were `undefined`.
Both commands silently degraded to the raw tier-fallback: v5.1.0 plain-language
output was dead for feature-gap and for posture's finding-level grouping.

Re-derived on tests/fixtures/marketplace-medium: 17 GAP findings, all three
humanizer fields undefined in the default --output-file JSON.

Fix (posture-CLI-local, surgical): humanize the output-file payload in default
mode, mirroring scan-orchestrator.mjs:277 — but posture nests the scanner
envelope under `result.scannerEnvelope` (its `result` has no top-level
`scanners` array), so humanizeEnvelope is applied to `result.scannerEnvelope`,
not `result` (the latter would no-op). --json / --raw stay raw, so the
explicit-v5.0.0-shape contract and snapshot byte-compat are preserved.

TDD: red-first test in posture-humanizer.test.mjs default-mode block asserts
GAP findings in the output file carry userActionLanguage/userImpactCategory;
a --raw --output-file guard asserts the raw shape is unchanged.

Suite 1350/0 (+2). Frozen v5.0.0 + SC-5/6/7 + default-output snapshots
byte-stable: --json/--raw bypass the humanizer (their snapshot tests use those
flags), and the default --output-file JSON is not snapshot-pinned. Committed,
not released — batches with M-BUG-11 in a later hardening release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01683eAqVecv9VZfQzL8CQ9h
2026-06-30 09:10:07 +02:00

185 lines
8 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';
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 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 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(() => {});
}
});
});
});