config-audit/tests/scanners/output-file-robustness.test.mjs
Kjell Tore Guttormsen caea8aca23 fix(commands): stop answering questions the caller did not ask
Dogfooding `campaign` + `knowledge-refresh` against a throwaway ledger. Seven
defects, all found by running the commands as written and measuring, not by
reading them.

The headline pair only existed together. `knowledge-refresh` built
`STALE_AFTER="--stale-after 30"` and expanded it unquoted, trusting the shell to
split it in two. bash does; zsh — the macOS default, and what the Bash tool runs
here — does not. The CLI got one argv entry, matched no flag, and because it had
no unknown-flag branch, silently kept the 90-day default and reported "✓ All 14
register entries were re-verified within the last 90 days": a true-sounding
sentence about a threshold the user had just overridden. Fixing either half alone
leaves a silent wrong answer or a loud one; both are fixed, and a guard now
rejects any template that packs a flag and its value into one variable.

`knowledge-refresh` also read one register and wrote another: step 6 named an
unanchored `knowledge/best-practices.json` while the CLI reads
`${CLAUDE_PLUGIN_ROOT}/…`, which for an installed plugin is the cache. The
validation gate then ran the cached test against the cached register — green no
matter what was written. The two copies were byte-identical that day, which is
exactly why it was invisible.

`campaign` vouched for repos it could not read. `add /finnes/ikke` returned
`added` + exit 0; `refresh-tokens` then put the phantom in `swept[]` with a
0-token delta and left `skipped[]` empty, so the machine-wide bill claimed
coverage of three repos on a machine with two. Paths stay tracked — an unmounted
volume is a legitimate absence — but are reported as `addedUnverified`, and the
command names them.

Two class sweeps, both measured rather than assumed. `posture` was the single
scanner (1 of 14) whose fatal catch exited 1, which ux-rules defines as a normal
WARNING grade — a crash indistinguishable from a result. And all 13 payload
writers failed on a `--output-file` whose parent did not exist, which on a fresh
machine turned `campaign`'s first run into "the ledger may be corrupt"; they now
share `scanners/lib/write-output.mjs`.

Predicted breadth was too wide for the first time in five sessions: 6 of 8 CLIs
predicted to lack unknown-flag rejection, 4 measured. `drift` and `fix` already
reject them, via a construct the grep did not recognise — a grep matches an
implementation, the invariant is a behaviour. The sweep was rewritten to run each
CLI with a bogus flag and read the exit code.

Suite 1453 → 1469/0. Frozen snapshots untouched. `optimize-lens-cli` and
`token-hotspots-cli` share the unknown-flag defect and are deferred to the v5.14
argument-handling chunk with their positional-swallow arm; the count is recorded
in the guard rather than rounded down to zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012NHWjN8EnoxSqRvMTLK2NE
2026-08-01 21:26:39 +02:00

127 lines
5.8 KiB
JavaScript

/**
* Session #51 — `--output-file` must be usable, and a crash must never look normal.
*
* Two defects found while sweeping the campaign/knowledge-refresh chunk, both about the
* seam every command depends on: the command runs a scanner with `--output-file <path>
* 2>/dev/null`, checks the exit code, and Reads the file (ux-rules 2-4).
*
* 1. NO SCANNER CREATES THE PARENT DIRECTORY. `saveLedger` does; the payload write does
* not. `commands/campaign.md` step 2 writes to
* `~/.claude/config-audit/sessions/campaign-report.json` — on a machine where that
* directory does not exist yet (the FIRST run, exactly the case campaign-cli otherwise
* handles gracefully with `initialized:false`) the write throws ENOENT, and the command's
* own exit-code table then tells the user "the campaign ledger couldn't be read — it may
* be corrupt. Stop; do not attempt a write over a corrupt ledger." The ledger is not
* corrupt; it does not exist. The user is steered away from the one action that helps.
*
* 2. `posture.mjs` REPORTED A CRASH AS A PASSING GRADE. Its top-level catch set
* `process.exitCode = 1`, and every command in this plugin is instructed that "codes 0,
* 1, 2 are normal (PASS/WARNING/FAIL). Only 3 is a real error." So a fatal error was
* indistinguishable from a WARNING grade — measured live: posture exited 1, wrote no
* file, and the command would have gone on to Read a file that was never created.
* Every other scanner used 3; posture was the single outlier (1 of 14, measured).
*/
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawn } from 'node:child_process';
import { readFile, mkdtemp, mkdir, writeFile, readdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { resolve, dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..', '..');
const SCANNERS_DIR = resolve(ROOT, 'scanners');
/**
* Every scanner that writes a `--output-file` payload, with argv that reaches the write.
* Determined empirically (each one was confirmed to produce the file when the parent
* directory already exists); `self-audit.mjs` is absent because it has no such flag.
*/
const WRITERS = [
{ cli: 'campaign-cli.mjs', argv: () => ['--ledger-file', LEDGER] },
{ cli: 'campaign-write-cli.mjs', argv: (d) => ['init', '--ledger-file', join(d, 'l.json')] },
{ cli: 'campaign-export-cli.mjs', argv: () => ['--repo', REPO, '--ledger-file', LEDGER] },
{ cli: 'knowledge-refresh-cli.mjs', argv: () => [] },
{ cli: 'optimize-lens-cli.mjs', argv: () => [ROOT] },
{ cli: 'token-hotspots-cli.mjs', argv: () => [ROOT] },
{ cli: 'drift-cli.mjs', argv: () => [ROOT] },
{ cli: 'fix-cli.mjs', argv: () => [ROOT] },
{ cli: 'manifest.mjs', argv: () => [ROOT] },
{ cli: 'posture.mjs', argv: () => [ROOT] },
{ cli: 'whats-active.mjs', argv: () => [ROOT] },
{ cli: 'plugin-health-scanner.mjs', argv: () => [ROOT] },
{ cli: 'scan-orchestrator.mjs', argv: () => [ROOT] },
];
let LEDGER;
let REPO;
function run(cli, argv) {
return new Promise((res) => {
const child = spawn(process.execPath, [resolve(SCANNERS_DIR, cli), ...argv], { cwd: ROOT });
child.stdout.on('data', () => {});
child.stderr.on('data', () => {});
child.on('close', (code) => res(code));
});
}
test('every --output-file writer creates its parent directory', async (t) => {
const base = await mkdtemp(join(tmpdir(), 'ca-outfile-'));
// A tracked repo + ledger so the campaign CLIs get past their own gates and reach the write.
REPO = join(base, 'repo');
await mkdir(REPO, { recursive: true });
LEDGER = join(base, 'ledger.json');
await writeFile(
LEDGER,
JSON.stringify({
schemaVersion: 1, createdDate: '2026-06-22', updatedDate: '2026-06-22',
repos: [{ path: REPO, name: 'repo', status: 'pending', sessionId: null, findingsBySeverity: null, tokens: null, updatedDate: '2026-06-22' }],
}, null, 2),
'utf-8',
);
const failures = [];
for (const { cli, argv } of WRITERS) {
const dir = join(base, `work-${cli}`);
await mkdir(dir, { recursive: true });
// The parent of the output file deliberately does not exist.
const out = join(dir, 'not', 'created', 'yet', 'payload.json');
const code = await run(cli, [...argv(dir), '--output-file', out]);
if (!existsSync(out)) failures.push(`${cli} (exit ${code})`);
}
assert.deepEqual(
failures,
[],
'These scanners crash instead of creating the output path. A command that writes its\n' +
'payload under ~/.claude/config-audit/sessions/ on a fresh machine gets ENOENT and\n' +
'reports it as a corrupt/unreadable input. Add mkdir(dirname(outputFile),\n' +
'{recursive:true}) before the write:\n ' + failures.join('\n '),
);
});
test('a fatal error exits 3 — never a code the commands treat as a normal grade', async () => {
const entries = (await readdir(SCANNERS_DIR)).filter((f) => f.endsWith('.mjs')).sort();
const offenders = [];
for (const file of entries) {
const src = await readFile(resolve(SCANNERS_DIR, file), 'utf-8');
const idx = src.indexOf('main().catch');
if (idx === -1) continue;
const block = src.slice(idx, idx + 400);
const m = block.match(/process\.exitCode\s*=\s*(\d+)/);
if (m && m[1] !== '3') offenders.push(`${file}: exitCode = ${m[1]}`);
}
assert.deepEqual(
offenders,
[],
'ux-rules tells every command that exit 0/1/2 are normal results (PASS/WARNING/FAIL)\n' +
'and only 3 is a real error. A fatal catch that sets anything else makes a crash\n' +
'indistinguishable from a grade, and the command goes on to Read a file that was\n' +
'never written:\n ' + offenders.join('\n '),
);
});