/** * CLI pipe integrity (session #48) — reported from org-ops, then MEASURED here. * * `process.exit()` terminates the process immediately. When stdout is a PIPE, * Node writes it ASYNCHRONOUSLY, so whatever is still sitting in the buffer is * discarded. Any machine consumer that pipes our output gets truncated, * unparseable JSON — and the failure looks like a corrupt file, not a cut-off. * * Measured before the fix, `scan-orchestrator.mjs` against this repo: * to file: 246 854 bytes (complete) * to pipe: 65 536 bytes (cut mid-string) — and 131 072 on another run, * i.e. the cut point is a nondeterministic flush race. * * The other CLIs in the class were green only because their payloads happen to * fit inside the pipe buffer today: campaign-cli (926 B), campaign-export-cli, * campaign-write-cli, knowledge-refresh-cli (1 630 B), drift-cli and fix-cli * all called `process.exit()` on the SUCCESS path after writing stdout. Size is * not correctness, so the class test below holds the whole directory, not just * the one CLI where the bug was observable. * * Fix pattern (same one llm-security's orchestrator already uses): set * `process.exitCode` and return, letting Node exit naturally once stdout drains. */ import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { execFile } from 'node:child_process'; import { readFileSync, readdirSync, mkdtempSync, rmSync } 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'); /** Run a CLI, capturing stdout through a pipe. Exit codes 0-2 are normal. */ function runPiped(args, maxBuffer = 64 * 1024 * 1024) { return new Promise((res, rej) => { execFile(process.execPath, args, { cwd: ROOT, maxBuffer }, (err, stdout) => { // Non-zero exit is expected (verdict codes); only spawn failures are real. if (err && err.code === undefined) return rej(err); res(stdout); }); }); } /** * Two separate scans differ in `timestamp` and in every `duration_ms` — and a * duration changing digit width shifts the byte count. Comparing raw lengths * would be measuring the clock, not the flush, so normalise the volatile fields * and compare the payload structurally instead. */ function stable(json) { const walk = (node) => { if (Array.isArray(node)) return node.map(walk); if (node && typeof node === 'object') { return Object.fromEntries( Object.entries(node).map(([k, v]) => k === 'duration_ms' || k === 'durationMs' || k === 'timestamp' || k === 'generatedAt' ? [k, ''] : [k, walk(v)], ), ); } return node; }; return JSON.stringify(walk(JSON.parse(json))); } test('scan-orchestrator emits a complete envelope to a pipe, not just to a file', async () => { const dir = mkdtempSync(join(tmpdir(), 'ca-pipe-')); try { const outFile = join(dir, 'envelope.json'); await runPiped([resolve(SCANNERS_DIR, 'scan-orchestrator.mjs'), ROOT, '--output-file', outFile]); const viaFile = readFileSync(outFile, 'utf-8'); const viaPipe = await runPiped([resolve(SCANNERS_DIR, 'scan-orchestrator.mjs'), ROOT]); // The payload must be large enough that a lost buffer WOULD show up; // otherwise this test could pass without exercising the defect at all. assert.ok( viaFile.length > 128 * 1024, `guard: payload only ${viaFile.length} bytes — too small to detect truncation`, ); // Parse first: a truncated envelope fails here, with the clearest signal. assert.doesNotThrow( () => JSON.parse(viaPipe), 'piped stdout is not parseable JSON — process.exit() dropped the buffer', ); assert.equal( stable(viaPipe), stable(viaFile), 'piped stdout does not carry the same envelope the file does', ); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('no scanner CLI terminates with process.exit() — the whole class, not just the one that showed it', () => { const offenders = []; for (const file of readdirSync(SCANNERS_DIR).filter((f) => f.endsWith('.mjs'))) { const text = readFileSync(resolve(SCANNERS_DIR, file), 'utf-8'); text.split('\n').forEach((line, i) => { // Prose may legitimately name the banned call — this very rule is // documented in comments right above the code it replaced. Assert on the // plumbing, not the prose. const code = line.replace(/\/\/.*$/, '').replace(/^\s*\*.*$/, ''); if (/(?