diff --git a/proofkit/witness-plan.json b/proofkit/witness-plan.json index 147099ae..b5eedc6b 100644 --- a/proofkit/witness-plan.json +++ b/proofkit/witness-plan.json @@ -2160,6 +2160,7 @@ "proofkit/cli-contract.v2.json", "proofkit/requirement-bindings.json", "proofkit/witness-plan.json", + "scripts", "scripts/admission-records-structure.test.mjs", "scripts/adoption-envelope-structure.test.mjs", "scripts/agent-workflow-structure.test.mjs", @@ -2196,7 +2197,8 @@ "scripts/selective-proof-structure.test.mjs", "scripts/source-conversion-structure.test.mjs", "scripts/stable-json.mjs", - "scripts/workspace-structure.test.mjs" + "scripts/workspace-structure.test.mjs", + "tests" ], "outputSelectors": [], "resourceReads": [ diff --git a/scripts/admission-records-structure.test.mjs b/scripts/admission-records-structure.test.mjs index fd63159b..afc65c6a 100644 --- a/scripts/admission-records-structure.test.mjs +++ b/scripts/admission-records-structure.test.mjs @@ -1,11 +1,11 @@ import assert from 'node:assert/strict'; -import {execFileSync, spawnSync} from 'node:child_process'; +import {execFileSync} from 'node:child_process'; import {mkdtempSync, readFileSync, rmSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import test, {after, before} from 'node:test'; import Ajv2020 from 'ajv/dist/2020.js'; -import {assertCLITransport} from './cli-process.mjs'; +import {assertCLITransport, spawnWithInputFile} from './cli-process.mjs'; import {expandSharedDefinitions} from './schema-definitions.mjs'; const root = new URL('../', import.meta.url); @@ -94,7 +94,7 @@ function branchIndex(command, value) { return matches[0]; } function run(command, input, flags = []) { - const result = spawnSync(binary, [command, '--input', '-', ...flags], { + const result = spawnWithInputFile(binary, [command, '--input', '-', ...flags], { input: typeof input === 'string' ? input : JSON.stringify(input), encoding: 'utf8', timeout: 30000, maxBuffer: 32 << 20, }); assertCLITransport(result); diff --git a/scripts/adoption-envelope-structure.test.mjs b/scripts/adoption-envelope-structure.test.mjs index ad1f842a..73677b34 100644 --- a/scripts/adoption-envelope-structure.test.mjs +++ b/scripts/adoption-envelope-structure.test.mjs @@ -1,11 +1,11 @@ import assert from 'node:assert/strict'; -import {execFileSync, spawnSync} from 'node:child_process'; +import {execFileSync} from 'node:child_process'; import {mkdtempSync, readFileSync, rmSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import test, {after, before} from 'node:test'; import Ajv2020 from 'ajv/dist/2020.js'; -import {assertCLITransport} from './cli-process.mjs'; +import {assertCLITransport, spawnWithInputFile} from './cli-process.mjs'; import {impactInput} from './impact-fixtures.mjs'; const root = new URL('../', import.meta.url); @@ -234,7 +234,7 @@ const stateOf = value => Array.isArray(value) ? (value.every(row => row.state == : value.state ?? value.report?.state ?? value.sourceReport?.state; function run(command, input, flags = [], env = {}) { - const result = spawnSync(binary, [command, '--input', '-', ...flags], { + const result = spawnWithInputFile(binary, [command, '--input', '-', ...flags], { input: typeof input === 'string' ? input : JSON.stringify(input), encoding: 'utf8', timeout: 20000, maxBuffer: 16 << 20, env: {...process.env, AGENTIC_PROOFKIT_LAUNCHER_PROFILE: 'path', AGENTIC_PROOFKIT_PYTHON_EXECUTABLE: '', ...env}, }); diff --git a/scripts/agent-workflow-structure.test.mjs b/scripts/agent-workflow-structure.test.mjs index bf7061e0..eff76e42 100644 --- a/scripts/agent-workflow-structure.test.mjs +++ b/scripts/agent-workflow-structure.test.mjs @@ -6,7 +6,7 @@ import {tmpdir} from 'node:os'; import {join} from 'node:path'; import test, {after, before} from 'node:test'; import Ajv2020 from 'ajv/dist/2020.js'; -import {assertCLITransport} from './cli-process.mjs'; +import {assertCLITransport, spawnWithInputFile} from './cli-process.mjs'; import {stableJSONStringify} from './stable-json.mjs'; // Independent oracle for agent-route, change-workflow-plan and @@ -64,7 +64,7 @@ after(() => {if (directory) rmSync(directory, {recursive: true, force: true});}) const pathLauncher = {AGENTIC_PROOFKIT_LAUNCHER_PROFILE: 'path', AGENTIC_PROOFKIT_PYTHON_EXECUTABLE: ''}; function run(args, input, {env = pathLauncher, cwd} = {}) { - const result = spawnSync(binary, args, { + const result = spawnWithInputFile(binary, args, { input: input === undefined ? undefined : typeof input === 'string' ? input : JSON.stringify(input), encoding: 'utf8', timeout: 20000, maxBuffer: 16 << 20, env: {...process.env, ...env}, cwd, }); diff --git a/scripts/authority-guard-structure.test.mjs b/scripts/authority-guard-structure.test.mjs index 6f32f5f6..d402935f 100644 --- a/scripts/authority-guard-structure.test.mjs +++ b/scripts/authority-guard-structure.test.mjs @@ -1,12 +1,12 @@ import assert from "node:assert/strict"; import {createHash} from "node:crypto"; -import {execFileSync, spawnSync} from "node:child_process"; +import {execFileSync} from "node:child_process"; import {mkdtempSync, readFileSync, rmSync} from "node:fs"; import {tmpdir} from "node:os"; import {join} from "node:path"; import test, {before, after} from "node:test"; import Ajv2020 from "ajv/dist/2020.js"; -import {assertCLITransport} from "./cli-process.mjs"; +import {assertCLITransport, spawnWithInputFile} from "./cli-process.mjs"; const root = new URL("../", import.meta.url), producer = "producer-policy-self-proof", migration = "migration-parity-admission"; const baseline = JSON.parse(readFileSync(new URL("internal/app/testdata/authority-guard-native-observations.json", root))); @@ -41,7 +41,7 @@ before(() => { after(() => {if (directory) rmSync(directory, {recursive: true, force: true});}); const hash = bytes => createHash("sha256").update(bytes).digest("hex"); function invoke(command, input, argv = [command, "--input", "-"]) { - const result = spawnSync(binary, argv, {input, encoding: "utf8", timeout: 10000, maxBuffer: 2 << 20}); + const result = spawnWithInputFile(binary, argv, {input, encoding: "utf8", timeout: 10000, maxBuffer: 2 << 20}); return assertCLITransport(result); } const utf8Order = (a, b) => Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); diff --git a/scripts/bootstrap-structure.test.mjs b/scripts/bootstrap-structure.test.mjs index 9f35c13a..eab77466 100644 --- a/scripts/bootstrap-structure.test.mjs +++ b/scripts/bootstrap-structure.test.mjs @@ -1,12 +1,12 @@ import assert from "node:assert/strict"; import {createHash} from "node:crypto"; -import {execFileSync, spawnSync} from "node:child_process"; +import {execFileSync} from "node:child_process"; import {mkdtempSync, readFileSync, rmSync} from "node:fs"; import {tmpdir} from "node:os"; import {join} from "node:path"; import test, {before, after} from "node:test"; import Ajv2020 from "ajv/dist/2020.js"; -import {assertCLITransport} from "./cli-process.mjs"; +import {assertCLITransport, spawnWithInputFile} from "./cli-process.mjs"; const root = new URL("../", import.meta.url); const contract = JSON.parse(readFileSync(process.env.PROOFKIT_BOOTSTRAP_CONTRACT || new URL("proofkit/cli-contract.v2.json", root), "utf8")); @@ -27,7 +27,7 @@ before(() => { after(() => { if (directory) rmSync(directory, {recursive: true, force: true}); }); const hash = text => createHash("sha256").update(text).digest("hex"); function invoke(inputJSON, args = []) { - const r = spawnSync(binary, ["self-check", "--input", "-", ...args], {input: inputJSON, encoding: "utf8", timeout: 10000, maxBuffer: 1 << 20}); + const r = spawnWithInputFile(binary, ["self-check", "--input", "-", ...args], {input: inputJSON, encoding: "utf8", timeout: 10000, maxBuffer: 1 << 20}); return assertCLITransport(r); } function expected(kind) { diff --git a/scripts/boundary-structure.test.mjs b/scripts/boundary-structure.test.mjs index 9eabe3c2..9aa923c8 100644 --- a/scripts/boundary-structure.test.mjs +++ b/scripts/boundary-structure.test.mjs @@ -1,11 +1,11 @@ import assert from "node:assert/strict"; -import {execFileSync, spawnSync} from "node:child_process"; +import {execFileSync} from "node:child_process"; import {mkdtempSync, readFileSync, rmSync} from "node:fs"; import {tmpdir} from "node:os"; import {join} from "node:path"; import test, {before, after} from "node:test"; import Ajv2020 from "ajv/dist/2020.js"; -import {assertCLITransport} from "./cli-process.mjs"; +import {assertCLITransport, spawnWithInputFile} from "./cli-process.mjs"; const root = new URL("../", import.meta.url); const contract = JSON.parse(readFileSync(process.env.PROOFKIT_BOUNDARY_CONTRACT || new URL("proofkit/cli-contract.v2.json", root), "utf8")); @@ -46,7 +46,7 @@ before(() => { after(() => { if (directory) rmSync(directory, {recursive: true, force: true}); }); function native(family, input, expected, label) { - const result = spawnSync(binary, [family.command, "--input", "-"], { + const result = spawnWithInputFile(binary, [family.command, "--input", "-"], { input: typeof input === "string" ? input : JSON.stringify(input), encoding: "utf8", timeout: 10_000, maxBuffer: 2 << 20, }); assertCLITransport(result, label); diff --git a/scripts/browser-server-structure.test.mjs b/scripts/browser-server-structure.test.mjs index bb5f85be..fa205c88 100644 --- a/scripts/browser-server-structure.test.mjs +++ b/scripts/browser-server-structure.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import {execFileSync, spawn, spawnSync} from 'node:child_process'; +import {execFileSync, spawn} from 'node:child_process'; import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; import {get, request as httpRequest} from 'node:http'; import {tmpdir} from 'node:os'; @@ -7,7 +7,7 @@ import {delimiter, join} from 'node:path'; import test, {after, before} from 'node:test'; import {setTimeout as sleep} from 'node:timers/promises'; import Ajv2020 from 'ajv/dist/2020.js'; -import {assertCLITransport} from './cli-process.mjs'; +import {assertCLITransport, spawnWithInputFile} from './cli-process.mjs'; import {bindingInput, resolverInput} from './proof-routing-fixtures.mjs'; import {directComposeInput, inventoryInput, normalizedComposeInput, requirementSource} from './inventory-coverage-fixtures.mjs'; @@ -116,7 +116,7 @@ const flipped = value => typeof value === 'string' ? 7 : typeof value === 'numbe let directory, binary; const fixtures = {}; function spawnCLI(args, input, options = {}) { - return spawnSync(binary, args, {input: input === undefined ? '' : JSON.stringify(input), + return spawnWithInputFile(binary, args, {input: input === undefined ? '' : JSON.stringify(input), encoding: 'utf8', timeout: 30000, maxBuffer: 32 << 20, ...options}); } function run(args, input, options = {}) { @@ -437,11 +437,11 @@ test('serving browse mode emits one non-JSON URL record and terminates on SIGTER }); test('a CLI that exits before reading its stdin is accepted only as a refusal', () => { - // 8 MiB exceeds every pipe buffer; the flag refusal exits before reading stdin, so writing the payload must report EPIPE. + // The flag refusal exits before reading stdin, so the child leaves every byte of the payload unread. const payload = {...clone(fixtures.workspace), padding: 'x'.repeat(8 << 20)}; const args = browserArgs('workspace', ['--serve', '--session-mode', 'one-shot-question']); - assert.equal(refused(args, payload, 'oversized one-shot without --open').error?.code, 'EPIPE'); - // The closed pipe passes the transport check, so a caller that expects success fails on the refusal status. + assert.equal(refused(args, payload, 'oversized one-shot without --open').stdinUnreadBytes, Buffer.byteLength(JSON.stringify(payload))); + // Unread input passes the transport check only with a refusal status, so a caller that expects success fails on that status. assert.throws(() => json(args, payload), error => error.code === 'ERR_ASSERTION' && error.actual === 1 && error.expected === 0); }); diff --git a/scripts/cli-process.mjs b/scripts/cli-process.mjs index d021982f..8a070df0 100644 --- a/scripts/cli-process.mjs +++ b/scripts/cli-process.mjs @@ -1,20 +1,132 @@ -// Transport check for oracles that run the Proofkit CLI through spawnSync. +// Transport for oracles that run the Proofkit CLI, or another child process, +// synchronously. // -// A CLI that refuses its flags exits before it reads stdin, so spawnSync -// can report EPIPE for the input it could not write. As in -// internal/tools/workflowsmoke/process_streams.go, that is an ordinary early -// exit and not a transport failure. EPIPE is accepted only when the child -// exited normally with a non-zero status and no signal. Any other spawn error, -// a timeout, a signal, or EPIPE with status 0 (a success that did not consume -// its input) fails. Callers keep their own status, stdout and stderr checks. +// spawnWithInputFile(file, args, options) is spawnSync(file, args, options), +// except that options.input reaches the child as a standard input opened on +// a temporary regular file instead of through a pipe. For the `input` option, +// Node's spawnSync gives the child one end of a Unix-domain socket pair: it +// writes the input, calls shutdown(SHUT_WR) on its end and keeps that end open +// until the child exits or the timeout kills it. On macOS, a child blocked in +// read(2) on the socket is sometimes not woken by the shutdown although the +// socket already holds end of file, so the call ends only at its timeout. The +// same syscall sequence stalls without Node; a pipe, a close or a regular file +// does not. A regular file ends at its size, so the child always reads end of +// file. +// +// args must be an array. spawnSync(file, options) would take the options from +// the args position and drop the third argument, so the two-argument form +// would feed the input through the socket again; it is refused. +// +// The input is written to a file of mode 0600 in a fresh directory of mode +// 0700 and opened read-only. The directory is removed as soon as the file is +// open, before the child starts: the open descriptor keeps the file, so while +// the child runs no input remains on disk, and a signal that ends this process +// then leaves none behind. A throw while the file is written or opened removes +// the directory too. The removal uses path calls only; if it fails, the input +// descriptor is closed and the call throws. +// +// The result is spawnSync's result (status, signal, error, stdout, stderr, +// output, pid) plus stdinUnreadBytes: the input bytes past the file offset +// that the child shares with this process, read when the child has exited. +// read(2) and lseek(2) on the inherited descriptor move that offset; pread(2), +// mmap(2) and, on Linux, reopening /dev/stdin do not. A child that reads its +// input only those ways reports unread bytes although it read them, and a +// child that seeks past bytes it did not read reports them as read. No current +// caller depends on those readings: its children are Proofkit CLI builds and +// the Node probes of cli-process.test.mjs, which read their standard input +// with read(2) or not at all. An empty or absent input is an empty file. With +// stdio ["ignore", "pipe", "pipe"] the child's standard input is the null +// device, no input is allowed and stdinUnreadBytes is absent. +// +// assertCLITransport checks the transport of a result. A CLI that refuses its +// flags exits before it reads stdin. Through a pipe (an asynchronous spawn +// that writes stdin), the parent can then report EPIPE for the input it could +// not write; through spawnWithInputFile, stdinUnreadBytes is not zero. As in +// internal/tools/workflowsmoke/process_streams.go, either is an ordinary early +// exit and not a transport failure only when the child exited normally with a +// non-zero status and no signal. Any other spawn error, a timeout, a signal, +// and EPIPE or a non-zero stdinUnreadBytes with status 0 (a success that did +// not consume its input) fail. Callers keep their own status, stdout and +// stderr checks. import assert from "node:assert/strict"; +import {spawnSync} from "node:child_process"; +import {closeSync, mkdtempSync, openSync, readSync, rmdirSync, rmSync, writeFileSync} from "node:fs"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; + +const passedOptions = new Set(["cwd", "encoding", "env", "killSignal", "maxBuffer", "timeout"]); +const ignoredStdin = ["ignore", "pipe", "pipe"]; + +export function spawnWithInputFile(file, args, options = {}) { + if (!Array.isArray(args)) { + throw new TypeError("spawnWithInputFile: args must be an array; spawnWithInputFile(file, options) is refused"); + } + const {input, stdio, ...passed} = options; + for (const key of Object.keys(passed)) { + if (!passedOptions.has(key)) throw new TypeError(`spawnWithInputFile: unsupported option ${key}`); + } + if (stdio !== undefined) { + if (!Array.isArray(stdio) || stdio.length !== ignoredStdin.length || stdio.some((value, index) => value !== ignoredStdin[index])) { + throw new TypeError('spawnWithInputFile: stdio must be absent or ["ignore", "pipe", "pipe"]'); + } + if (input !== undefined) throw new TypeError("spawnWithInputFile: input with an ignored standard input"); + return spawnSync(file, args, {...passed, stdio}); + } + const bytes = inputBytes(input, passed.encoding); + const directory = mkdtempSync(join(tmpdir(), "proofkit-cli-stdin-")); + const path = join(directory, "stdin"); + let descriptor; + try { + writeFileSync(path, bytes, {flag: "wx", mode: 0o600}); + descriptor = openSync(path, "r"); + } finally { + // Path calls only: the removal needs no descriptor of its own. If it + // fails, the input descriptor is closed before the throw. + try { + rmSync(path, {force: true}); + rmdirSync(directory); + } catch (error) { + if (descriptor !== undefined) closeSync(descriptor); + throw error; + } + } + try { + // Only the caller's options pass: an explicit undefined maxBuffer would lift spawnSync's default bound. + const result = spawnSync(file, args, {...passed, stdio: [descriptor, "pipe", "pipe"]}); + result.stdinUnreadBytes = unreadBytes(descriptor); + return result; + } finally { + closeSync(descriptor); + } +} + +// As spawnSync does, a falsy input is no input and a string is encoded with +// options.encoding. +function inputBytes(input, encoding) { + if (!input) return Buffer.alloc(0); + if (typeof input === "string") return Buffer.from(input, encoding); + if (ArrayBuffer.isView(input)) return input; + throw new TypeError("spawnWithInputFile: input must be a string, Buffer, TypedArray or DataView"); +} + +// The child inherited this open file description, so its read(2) calls +// advanced the shared offset; reading from that offset counts the bytes past +// it. +function unreadBytes(descriptor) { + const buffer = Buffer.allocUnsafe(1 << 16); + let total = 0; + for (let count; (count = readSync(descriptor, buffer, 0, buffer.length, null)) > 0;) total += count; + return total; +} export function assertCLITransport(result, label = "CLI") { - const observed = `${label}: transport error ${result.error}, status ${result.status}, signal ${result.signal}`; + const observed = `${label}: transport error ${result.error}, status ${result.status}, signal ${result.signal}, unread input bytes ${result.stdinUnreadBytes}`; + const refusal = Number.isInteger(result.status) && result.status !== 0; assert.equal(result.signal, null, observed); if (result.error !== undefined) { assert.equal(result.error.code, "EPIPE", observed); - assert.ok(Number.isInteger(result.status) && result.status !== 0, observed); + assert.ok(refusal, observed); } + if (result.stdinUnreadBytes !== undefined && result.stdinUnreadBytes !== 0) assert.ok(refusal, observed); return result; } diff --git a/scripts/cli-process.test.mjs b/scripts/cli-process.test.mjs index aad33e9c..5d80be47 100644 --- a/scripts/cli-process.test.mjs +++ b/scripts/cli-process.test.mjs @@ -1,13 +1,15 @@ import assert from "node:assert/strict"; -import {execFileSync, spawnSync} from "node:child_process"; -import {mkdtempSync, rmSync} from "node:fs"; +import {execFileSync, spawn} from "node:child_process"; +import fs, {mkdtempSync, readFileSync, readdirSync, rmSync, statSync} from "node:fs"; +import {syncBuiltinESMExports} from "node:module"; import {tmpdir} from "node:os"; -import {join} from "node:path"; +import {dirname, join, sep} from "node:path"; import test, {after, before} from "node:test"; -import {assertCLITransport} from "./cli-process.mjs"; +import {fileURLToPath} from "node:url"; +import {assertCLITransport, spawnWithInputFile} from "./cli-process.mjs"; // 8 MiB exceeds every pipe buffer, so a child that exits without reading stdin -// deterministically leaves spawnSync with an unwritten payload and EPIPE. +// deterministically leaves a pipe writer with an unwritten payload and EPIPE. const payload = "x".repeat(8 << 20); const root = new URL("../", import.meta.url); let directory, binary; @@ -19,33 +21,56 @@ before(() => { }); }); after(() => { if (directory) rmSync(directory, {recursive: true, force: true}); }); -const invoke = (file, args, input, timeout = 20000, maxBuffer = 2 << 20) => spawnSync(file, args, {input, encoding: "utf8", timeout, maxBuffer}); +const invoke = (file, args, input, timeout = 20000, maxBuffer = 2 << 20) => spawnWithInputFile(file, args, {input, encoding: "utf8", timeout, maxBuffer}); const fakeChild = (source, input = payload, maxBuffer) => invoke(process.execPath, ["-e", source], input, 20000, maxBuffer); const rejected = (result, label) => assert.throws(() => assertCLITransport(result, label), {code: "ERR_ASSERTION"}, label); +// The asynchronous pipe transport of scripts/schema-completeness.test.mjs: the +// parent writes stdin through a pipe and reports EPIPE when the child is gone. +const pipeChild = (file, args, input) => new Promise(resolve => { + const child = spawn(file, args); + let failure, stdinError; + child.on("error", error => { failure ??= error; }); + child.stdin.on("error", error => { stdinError ??= error; }); + child.stdout.resume(); child.stderr.resume(); + child.on("close", (status, signal) => resolve({status, signal, error: failure ?? stdinError})); + child.stdin.end(input); +}); -test("a CLI flag refusal that exits before reading stdin is accepted as a closed pipe", () => { +test("a CLI flag refusal that exits before reading stdin is accepted with its input unread", async () => { const args = ["change", "plan", "--input", "-", "--agent-envelope", "--format", "text"]; const refusal = invoke(binary, args, payload); - assert.equal(refusal.error?.code, "EPIPE", "the oversized payload must outlast the refusing child"); + assert.equal(refusal.error, undefined); assert.equal(refusal.stdinUnreadBytes, payload.length, "the refusal must read none of its input"); assert.equal(assertCLITransport(refusal, "early refusal"), refusal); assert.equal(refusal.status, 1); assert.equal(refusal.stdout, ""); assert.notEqual(refusal.stderr, ""); const small = assertCLITransport(invoke(binary, args, "{}"), "small refusal"); - assert.equal(small.status, 1); assert.equal(small.stderr, refusal.stderr); + assert.equal(small.status, 1); assert.equal(small.stderr, refusal.stderr); assert.equal(small.stdinUnreadBytes, 2); + const piped = await pipeChild(binary, args, payload); + assert.equal(piped.error?.code, "EPIPE", "the oversized payload must outlast the refusing child"); + assert.equal(assertCLITransport(piped, "early refusal through a pipe"), piped); assert.equal(piped.status, 1); }); -test("EPIPE from a child that exits 0 without reading its required input is rejected", () => { +test("unread input or EPIPE from a child that exits 0 is rejected", async () => { const result = fakeChild("process.exit(0)"); - assert.equal(result.error?.code, "EPIPE"); assert.equal(result.status, 0); assert.equal(result.signal, null); + assert.equal(result.error, undefined); assert.equal(result.status, 0); assert.equal(result.stdinUnreadBytes, payload.length); rejected(result, "unread input with status 0"); + const partial = fakeChild("require('node:fs').readSync(0, Buffer.alloc(1024)); process.exit(0)"); + assert.equal(partial.status, 0); assert.equal(partial.stdinUnreadBytes, payload.length - 1024); + rejected(partial, "partly read input with status 0"); + const piped = await pipeChild(process.execPath, ["-e", "process.exit(0)"], payload); + assert.equal(piped.error?.code, "EPIPE"); assert.equal(piped.status, 0); assert.equal(piped.signal, null); + rejected(piped, "EPIPE with status 0"); }); -test("a child killed by a signal is rejected with or without EPIPE", () => { +test("a child killed by a signal is rejected with or without unread input", async () => { const result = fakeChild("process.kill(process.pid, 'SIGKILL')"); - assert.equal(result.error?.code, "EPIPE"); assert.equal(result.status, null); assert.equal(result.signal, "SIGKILL"); - rejected(result, "signal with EPIPE"); + assert.equal(result.status, null); assert.equal(result.signal, "SIGKILL"); assert.equal(result.stdinUnreadBytes, payload.length); + rejected(result, "signal with unread input"); const withoutInput = fakeChild("process.kill(process.pid, 'SIGKILL')", ""); - assert.equal(withoutInput.error, undefined); assert.equal(withoutInput.signal, "SIGKILL"); - rejected(withoutInput, "signal without EPIPE"); + assert.equal(withoutInput.error, undefined); assert.equal(withoutInput.signal, "SIGKILL"); assert.equal(withoutInput.stdinUnreadBytes, 0); + rejected(withoutInput, "signal without input"); + const piped = await pipeChild(process.execPath, ["-e", "process.kill(process.pid, 'SIGKILL')"], payload); + assert.equal(piped.error?.code, "EPIPE"); assert.equal(piped.signal, "SIGKILL"); + rejected(piped, "signal with EPIPE"); }); test("spawn errors other than EPIPE and timeouts are rejected", () => { @@ -60,3 +85,257 @@ test("spawn errors other than EPIPE and timeouts are rejected", () => { assert.equal(timeout.error?.code, "ETIMEDOUT"); rejected(timeout, "timeout"); }); + +// The child reports the kind of its standard input and the bytes it read. +const stdinProbe = "const fs = require('node:fs'); const stat = fs.fstatSync(0); const bytes = fs.readFileSync(0);" + + " process.stdout.write(JSON.stringify({file: stat.isFile(), socket: stat.isSocket(), fifo: stat.isFIFO(), device: stat.isCharacterDevice(), hex: bytes.toString('hex')}))"; + +test("input reaches the child as a regular file that it reads to the end", () => { + const cases = [ + ["string", "caf\u00e9 \u{1F600}\n", Buffer.from("caf\u00e9 \u{1F600}\n", "utf8")], + ["Buffer", Buffer.from([0, 255, 10, 13]), Buffer.from([0, 255, 10, 13])], + ["Uint8Array", new Uint8Array([1, 2, 3]), Buffer.from([1, 2, 3])], + ["empty string", "", Buffer.alloc(0)], + ["absent", undefined, Buffer.alloc(0)], + ]; + for (const [label, input, expected] of cases) { + const result = assertCLITransport(invoke(process.execPath, ["-e", stdinProbe], input), label); + assert.equal(result.status, 0, `${label}: ${result.stderr}`); assert.equal(result.stdinUnreadBytes, 0, label); + assert.deepEqual(JSON.parse(result.stdout), {file: true, socket: false, fifo: false, device: false, hex: expected.toString("hex")}, label); + } + const latin1 = spawnWithInputFile(process.execPath, ["-e", stdinProbe], {input: "\u00e9", encoding: "latin1"}); + assert.equal(JSON.parse(latin1.stdout).hex, "e9", "a string input is encoded with options.encoding, as spawnSync does"); + const ignored = spawnWithInputFile(process.execPath, ["-e", stdinProbe], {stdio: ["ignore", "pipe", "pipe"], encoding: "utf8"}); + assert.equal(ignored.status, 0, ignored.stderr); assert.equal(ignored.stdinUnreadBytes, undefined); + assert.deepEqual(JSON.parse(ignored.stdout), {file: false, socket: false, fifo: false, device: true, hex: ""}); +}); + +test("the helper keeps spawnSync's options and refuses what it cannot keep", () => { + // Without maxBuffer the child still has spawnSync's default bound. + const bounded = spawnWithInputFile(process.execPath, ["-e", "process.stdout.write('x'.repeat(2 << 20))"], {input: "{}", encoding: "utf8"}); + assert.equal(bounded.error?.code, "ENOBUFS"); + const options = spawnWithInputFile(process.execPath, ["-e", "process.stdout.write(process.cwd() + ' ' + process.env.PROOFKIT_PROBE)"], + {cwd: directory, env: {...process.env, PROOFKIT_PROBE: "kept"}, encoding: "utf8", timeout: 20000, killSignal: "SIGKILL", maxBuffer: 1 << 20}); + assert.equal(options.status, 0, options.stderr); assert.match(options.stdout, / kept$/); + assert.throws(() => spawnWithInputFile(process.execPath, [], {shell: true}), /unsupported option shell/); + assert.throws(() => spawnWithInputFile(process.execPath, [], {stdio: "pipe"}), /stdio must be absent/); + assert.throws(() => spawnWithInputFile(process.execPath, [], {stdio: ["pipe", "pipe", "pipe"]}), /stdio must be absent/); + assert.throws(() => spawnWithInputFile(process.execPath, [], {stdio: ["ignore", "pipe", "pipe"], input: "{}"}), /ignored standard input/); + assert.throws(() => spawnWithInputFile(process.execPath, [], {input: 7}), /input must be/); + // spawnSync(file, options) would take {input} as its options and drop the third argument. + assert.throws(() => spawnWithInputFile(process.execPath, {input: "{}"}), /args must be an array/, "the two-argument form"); + assert.throws(() => spawnWithInputFile(process.execPath, undefined, {input: "{}"}), /args must be an array/); + assert.throws(() => spawnWithInputFile(process.execPath, "-e", {input: "{}"}), /args must be an array/); +}); + +// The helper's temporary root is a fresh TMPDIR, so the test sees every entry +// the helper leaves there. A wrapper of fs.openSync records the modes of the +// input file and of its directory at the read-only open (writeFileSync's own +// open for writing passes through), and can make that open fail; the child +// reports what it sees while it runs. Wrappers of fs.writeFileSync, fs.rmSync +// and fs.rmdirSync can make the write fail after it created the file, or the +// first removal call fail; the open descriptors of this process are the +// entries of /dev/fd. +test("the input file leaves the disk before the child runs, and no file remains after a call or a throw", () => { + const root = mkdtempSync(join(tmpdir(), "proofkit-cli-process-tmp-")); + const savedTMPDIR = process.env.TMPDIR, opened = []; + const original = {openSync: fs.openSync, writeFileSync: fs.writeFileSync, rmSync: fs.rmSync, rmdirSync: fs.rmdirSync}; + const inRoot = path => typeof path === "string" && path.startsWith(root + sep); + const simulated = (message, code) => Object.assign(new Error(message), {code}); + let failOpen = false, failWrite = false, failRemoval = false; + fs.openSync = (path, ...rest) => { + if (inRoot(path) && rest[0] === "r") { + opened.push({directory: statSync(dirname(path)).mode & 0o777, file: statSync(path).mode & 0o777}); + if (failOpen) throw simulated("simulated open failure", "EMFILE"); + } + return original.openSync(path, ...rest); + }; + fs.writeFileSync = (path, data, ...rest) => { + if (!inRoot(path) || !failWrite) return original.writeFileSync(path, data, ...rest); + original.writeFileSync(path, "", ...rest); + throw simulated("simulated write failure", "ENOSPC"); + }; + const removal = name => (path, ...rest) => { + if (inRoot(path) && failRemoval) { failRemoval = false; throw simulated(`simulated ${name} failure`, "EBUSY"); } + return original[name](path, ...rest); + }; + fs.rmSync = removal("rmSync"); fs.rmdirSync = removal("rmdirSync"); + syncBuiltinESMExports(); + process.env.TMPDIR = root; + try { + const probe = "const fs = require('node:fs'); const stat = fs.fstatSync(0);" + + " process.stdout.write(JSON.stringify({mode: stat.mode & 0o777, links: stat.nlink, entries: fs.readdirSync(process.env.TMPDIR), bytes: fs.readFileSync(0).length}))"; + const result = assertCLITransport(spawnWithInputFile(process.execPath, ["-e", probe], {input: '{"a":1}', encoding: "utf8", timeout: 20000}), "residue probe"); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), {mode: 0o600, links: 0, entries: [], bytes: 7}, "the child must read an unlinked 0600 file, with no input left on disk"); + assert.deepEqual(opened, [{directory: 0o700, file: 0o600}], "the directory is 0700 and the file 0600 up to the open"); + assert.deepEqual(readdirSync(root), [], "no file may remain after a call"); + assert.throws(() => spawnWithInputFile(process.execPath, ["-e", ""], {input: "{}", timeout: -1}), {code: "ERR_OUT_OF_RANGE"}); + assert.deepEqual(readdirSync(root), [], "no file may remain after spawnSync throws"); + failOpen = true; + assert.throws(() => spawnWithInputFile(process.execPath, ["-e", ""], {input: "{}"}), {code: "EMFILE"}); + assert.deepEqual(readdirSync(root), [], "no file may remain after the open throws"); + assert.equal(opened.length, 3); + failOpen = false; failWrite = true; + assert.throws(() => spawnWithInputFile(process.execPath, ["-e", ""], {input: "{}"}), {code: "ENOSPC"}); + assert.deepEqual(readdirSync(root), [], "no file may remain after the write throws"); + failWrite = false; failRemoval = true; + const descriptors = readdirSync("/dev/fd").length; + assert.throws(() => spawnWithInputFile(process.execPath, ["-e", ""], {input: "{}"}), {code: "EBUSY"}); + assert.equal(readdirSync("/dev/fd").length, descriptors, "a failed removal must close the input descriptor"); + } finally { + Object.assign(fs, original); + syncBuiltinESMExports(); + if (savedTMPDIR === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = savedTMPDIR; + rmSync(root, {recursive: true, force: true}); + } +}); + +// Source check: a lexical scan of the .js, .cjs, .mjs, .ts, .cts and .mts files +// under scripts/ and tests/ (node_modules excluded) that refuses an `input` +// member in the arguments of a direct spawnSync, execFileSync or execSync +// call; spawnWithInputFile carries input instead. It skips comments, strings, +// template text and regular expressions, finds each name of the three +// functions followed by "(", also as a member (x.spawnSync(...)), and reports +// every `input` member of an object literal anywhere in that call's +// arguments: a plain or quoted key, a string-literal computed key +// (["input"]), a shorthand, or a method with a plain or quoted name. It does +// not see options assembled outside the call, a synchronous spawn function +// under another name, calls through .call, .apply, Reflect.apply or an +// optional call ?.(), a bracketed callee (cp["spawnSync"](...)), a call with +// type arguments (spawnSync(...)), getter, setter, async or generator +// members, methods with a computed key (["input"]() {...}), computed keys +// other than string literals, or escaped key names. Its lexer chooses between +// a division and a regular expression from the previous token alone, so it +// misreads some code: for example a / after ++, --, a template literal, an +// object literal, a property named like a keyword that a regular expression +// can follow (x.in / y) or a name with a non-ASCII letter, a regular +// expression after ) or export default (export default /'/), and a hashbang +// line with an unbalanced quote. A misreading can hide the code after it. +const synchronousSpawns = new Set(["spawnSync", "execFileSync", "execSync"]); +const regexAfter = new Set(["await", "case", "delete", "do", "else", "in", "instanceof", "new", "of", "return", "throw", "typeof", "void", "yield"]); + +function tokenize(source) { + const tokens = [], frames = []; + let index = 0, line = 1; + const push = (type, value) => tokens.push({type, value, line}); + const take = end => { line += source.slice(index, end).split("\n").length - 1; const text = source.slice(index, end); index = end; return text; }; + const quoted = quote => { + let end = index + 1; + while (end < source.length && source[end] !== quote) end += source[end] === "\\" ? 2 : 1; + return take(end + 1).slice(1, -1); + }; + const templateText = () => { + let end = index; + while (end < source.length && source[end] !== "`" && !(source[end] === "$" && source[end + 1] === "{")) end += source[end] === "\\" ? 2 : 1; + take(end); + if (source[index] === "`") { take(index + 1); return; } + take(index + 2); frames.push(0); + }; + const regexAllowed = () => { + const previous = tokens.at(-1); + if (previous === undefined) return true; + if (previous.type === "name") return regexAfter.has(previous.value); + return previous.type === "punct" && previous.value !== ")" && previous.value !== "]"; + }; + while (index < source.length) { + const char = source[index], next = source[index + 1]; + if (/\s/.test(char)) { take(index + 1); continue; } + if (char === "/" && next === "/") { const end = source.indexOf("\n", index); take(end < 0 ? source.length : end); continue; } + if (char === "/" && next === "*") { const end = source.indexOf("*/", index + 2); take(end < 0 ? source.length : end + 2); continue; } + if (char === "'" || char === "\"") { push("string", quoted(char)); continue; } + if (char === "`") { take(index + 1); templateText(); continue; } + if (char === "}" && frames.length > 0 && frames.at(-1) === 0) { frames.pop(); take(index + 1); templateText(); continue; } + if (char === "/" && regexAllowed()) { + let end = index + 1, inClass = false; + while (end < source.length && (inClass || source[end] !== "/")) { + if (source[end] === "\\") end++; + else if (source[end] === "[") inClass = true; + else if (source[end] === "]") inClass = false; + end++; + } + end++; + while (/[a-z]/i.test(source[end] ?? "")) end++; + push("regex", take(end)); + continue; + } + const name = /^[A-Za-z_$][\w$]*/.exec(source.slice(index, index + 256)); + if (name) { push("name", take(index + name[0].length)); continue; } + const number = /^[0-9][\w.]*/.exec(source.slice(index, index + 64)); + if (number) { push("number", take(index + number[0].length)); continue; } + const punct = source.startsWith("...", index) ? "..." : char; + if (frames.length > 0 && punct === "{") frames[frames.length - 1]++; + if (frames.length > 0 && punct === "}") frames[frames.length - 1]--; + push("punct", take(index + punct.length)); + } + return tokens; +} + +function inputPipeViolations(source) { + const tokens = tokenize(source), violations = []; + const opens = {"(": ")", "[": "]", "{": "}"}; + for (let start = 0; start < tokens.length - 1; start++) { + const callee = tokens[start]; + if (callee.type !== "name" || !synchronousSpawns.has(callee.value) || tokens[start + 1].value !== "(") continue; + if (tokens[start - 1]?.value === "function") continue; + const stack = []; + for (let at = start + 1; at < tokens.length; at++) { + const token = tokens[at]; + if (token.type === "punct" && Object.hasOwn(opens, token.value)) { stack.push(token.value); continue; } + if (token.type === "punct" && Object.values(opens).includes(token.value)) { + stack.pop(); + if (stack.length === 0) break; + continue; + } + if (token.value !== "input" || (token.type !== "name" && token.type !== "string")) continue; + const before = tokens[at - 1].value, after = tokens[at + 1]?.value; + const member = stack.at(-1) === "{" && (before === "{" || before === ",") && [":", ",", "}", "("].includes(after); + const computed = token.type === "string" && before === "[" && after === "]" && tokens[at + 2]?.value === ":"; + if (member || computed) violations.push(`line ${token.line}: ${callee.value} passes input`); + } + } + return violations; +} + +test("the source check finds the input option in synchronous spawn calls", () => { + const found = [ + "spawnSync(binary, args, {input: text, encoding: 'utf8'});", + "spawnSync(binary, args, {\n encoding: 'utf8',\n input,\n});", + "child_process.spawnSync(binary, args, {'input': text});", + "execFileSync(binary, args, {\"input\": text});", + "execFileSync(binary, {input: text});", + "execSync(`cat ${file}`, {[\"input\"]: text});", + "spawnSync(file, args, {...(argvOnly ? {stdio: ['ignore', 'pipe', 'pipe']} : {input}), timeout: 1});", + "const re = /\\)/; spawnSync(file, [`${x}`, re.source], {env: {...process.env}, input: x});", + "spawnSync(binary, args, {input() { return text; }});", + ]; + for (const source of found) assert.equal(inputPipeViolations(source).length, 1, source); + const clean = [ + "spawnSync(binary, ['--input', '-'], {encoding: 'utf8'});", + "spawnWithInputFile(binary, args, {input: text});", + "spawnSync(binary, args, {env: {...process.env, X: input}});", + "spawnSync(make(a, input), args, {timeout: input ? 1 : 2});", + "// spawnSync(binary, args, {input: text});\nspawnSync(binary, args);", + "const text = 'spawnSync(binary, args, {input: text})'; const t = `spawnSync(b, a, {input: ${'x'}})`;", + "const help = {input: 1}; spawnSync(binary, args, {stdio: 'ignore'});", + "function spawnSync(file, args, {input} = {}) {}", + ]; + for (const source of clean) assert.deepEqual(inputPipeViolations(source), [], source); +}); + +test("no direct synchronous spawn call under scripts or tests passes an input member", () => { + const rootPath = fileURLToPath(root), violations = []; + let scanned = 0; + for (const top of ["scripts", "tests"]) { + for (const entry of readdirSync(join(rootPath, top), {recursive: true, withFileTypes: true})) { + if (!entry.isFile() || !/\.[cm]?[jt]s$/.test(entry.name)) continue; + const path = join(entry.parentPath, entry.name); + if (path.split(/[\\/]/).includes("node_modules")) continue; + const source = readFileSync(path, "utf8"); + scanned += tokenize(source).filter(token => token.type === "name" && synchronousSpawns.has(token.value)).length; + for (const violation of inputPipeViolations(source)) violations.push(`${path.slice(rootPath.length)} ${violation}`); + } + } + assert.ok(scanned > 0, "the scan must see the synchronous spawn calls of the build steps"); + assert.deepEqual(violations, [], "use spawnWithInputFile from scripts/cli-process.mjs for stdin input"); +}); diff --git a/scripts/compact-proof-structure.test.mjs b/scripts/compact-proof-structure.test.mjs index 474e8c53..a4d5c514 100644 --- a/scripts/compact-proof-structure.test.mjs +++ b/scripts/compact-proof-structure.test.mjs @@ -1,11 +1,11 @@ import assert from "node:assert/strict"; -import {execFileSync, spawnSync} from "node:child_process"; +import {execFileSync} from "node:child_process"; import {mkdtempSync, readFileSync, rmSync} from "node:fs"; import {tmpdir} from "node:os"; import {join} from "node:path"; import test, {after, before} from "node:test"; import Ajv2020 from "ajv/dist/2020.js"; -import {assertCLITransport} from "./cli-process.mjs"; +import {assertCLITransport, spawnWithInputFile} from "./cli-process.mjs"; const root = new URL("../", import.meta.url); const contract = JSON.parse(readFileSync(new URL("proofkit/cli-contract.v2.json", root), "utf8")); @@ -34,7 +34,7 @@ before(() => { after(() => { if (directory) rmSync(directory, {recursive: true, force: true}); }); function native(input, accepted, label) { - const result = spawnSync(binary, ["requirement-proof-resolver", "--input", "-", "--local-environment-class", "local-go"], { + const result = spawnWithInputFile(binary, ["requirement-proof-resolver", "--input", "-", "--local-environment-class", "local-go"], { input: typeof input === "string" ? input : JSON.stringify(input), encoding: "utf8", timeout: 10_000, maxBuffer: 2 << 20, }); diff --git a/scripts/consumer-evidence-structure.test.mjs b/scripts/consumer-evidence-structure.test.mjs index 32022772..f609a110 100644 --- a/scripts/consumer-evidence-structure.test.mjs +++ b/scripts/consumer-evidence-structure.test.mjs @@ -1,12 +1,12 @@ import assert from 'node:assert/strict'; import {createHash} from 'node:crypto'; -import {execFileSync, spawnSync} from 'node:child_process'; +import {execFileSync} from 'node:child_process'; import {mkdtempSync, readFileSync, rmSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import test, {before, after} from 'node:test'; import Ajv2020 from 'ajv/dist/2020.js'; -import {assertCLITransport} from './cli-process.mjs'; +import {assertCLITransport, spawnWithInputFile} from './cli-process.mjs'; const root = new URL('../', import.meta.url); const external = 'external-consumer', registry = 'registry-consumer', composer = 'registry-consumer-proof-input-compose'; @@ -65,7 +65,7 @@ const conservesPredecessor = (row, stdout) => { return predecessor !== null && hash(predecessor) === row.stdoutSHA256; }; function invoke(command, input, argv = [command, '--input', '-']) { - const result = spawnSync(binary, argv, {input: typeof input === 'string' ? input : JSON.stringify(input), encoding: 'utf8', timeout: 10000, maxBuffer: 2 << 20}); + const result = spawnWithInputFile(binary, argv, {input: typeof input === 'string' ? input : JSON.stringify(input), encoding: 'utf8', timeout: 10000, maxBuffer: 2 << 20}); return assertCLITransport(result); } const seed = command => JSON.parse(baseline.observations.find(x => x.command === command && x.name === 'valid').input); diff --git a/scripts/explicit-input-structure.test.mjs b/scripts/explicit-input-structure.test.mjs index 554db122..da1a3808 100644 --- a/scripts/explicit-input-structure.test.mjs +++ b/scripts/explicit-input-structure.test.mjs @@ -1,12 +1,12 @@ import assert from "node:assert/strict"; import {createHash} from "node:crypto"; -import {execFileSync, spawnSync} from "node:child_process"; +import {execFileSync} from "node:child_process"; import {mkdtempSync, readFileSync, rmSync} from "node:fs"; import {tmpdir} from "node:os"; import {join} from "node:path"; import test, {before, after} from "node:test"; import Ajv2020 from "ajv/dist/2020.js"; -import {assertCLITransport} from "./cli-process.mjs"; +import {assertCLITransport, spawnWithInputFile} from "./cli-process.mjs"; const root = new URL("../", import.meta.url); const contract = JSON.parse(readFileSync(process.env.PROOFKIT_EXPLICIT_INPUT_CONTRACT || new URL("proofkit/cli-contract.v2.json", root), "utf8")); @@ -40,7 +40,7 @@ function containers(value, path = []) { return [path, ...Object.entries(value).flatMap(([key, child]) => containers(child, [...path, key]))]; } function invoke(command, input, args = []) { - const r = spawnSync(binary, [command, "--input", "-", ...args], {input: typeof input === "string" ? input : JSON.stringify(input), encoding: "utf8", timeout: 10000, maxBuffer: 4 << 20}); + const r = spawnWithInputFile(binary, [command, "--input", "-", ...args], {input: typeof input === "string" ? input : JSON.stringify(input), encoding: "utf8", timeout: 10000, maxBuffer: 4 << 20}); assertCLITransport(r, command); return r; } diff --git a/scripts/gradual-adoption-structure.test.mjs b/scripts/gradual-adoption-structure.test.mjs index ae7d8872..568f3f73 100644 --- a/scripts/gradual-adoption-structure.test.mjs +++ b/scripts/gradual-adoption-structure.test.mjs @@ -1,11 +1,11 @@ import assert from 'node:assert/strict'; -import {execFileSync, spawnSync} from 'node:child_process'; +import {execFileSync} from 'node:child_process'; import {mkdtempSync, readFileSync, rmSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import test, {after, before} from 'node:test'; import Ajv2020 from 'ajv/dist/2020.js'; -import {assertCLITransport} from './cli-process.mjs'; +import {assertCLITransport, spawnWithInputFile} from './cli-process.mjs'; import {expandSharedDefinitions} from './schema-definitions.mjs'; const root = new URL('../', import.meta.url); @@ -113,7 +113,7 @@ function schema(command, direction, index, value, expected = true, label = '') { const pathLauncher = {AGENTIC_PROOFKIT_LAUNCHER_PROFILE: 'path', AGENTIC_PROOFKIT_PYTHON_EXECUTABLE: ''}; function invoke(command, input, phase = 'passed', flags = [], inputIndex = 0, launcher = pathLauncher) { if (typeof input !== 'string' && phase !== 'refused' && phase !== 'repair') schema(command, 'input', inputIndex, input); - const result = spawnSync(binary, [command, '--input', '-', ...flags], { + const result = spawnWithInputFile(binary, [command, '--input', '-', ...flags], { input: typeof input === 'string' ? input : JSON.stringify(input), encoding: 'utf8', timeout: 10000, maxBuffer: 8 << 20, env: {...process.env, ...launcher}, }); @@ -602,7 +602,7 @@ test('populated, blocked, failed-reference and normalized payload output domains const value = invoke('gradual-adoption-bootstrap', input, phase); carrierPredicates('gradual-adoption-bootstrap', 0, value, 'payload'); invoke('gradual-adoption', value.payloads.adoptionProfile, phase); - const guidanceResult = spawnSync(binary, ['gradual-adoption-guidance', '--input', '-'], {input: JSON.stringify(value.payloads.adoptionGuidance), encoding: 'utf8', timeout: 10000}); + const guidanceResult = spawnWithInputFile(binary, ['gradual-adoption-guidance', '--input', '-'], {input: JSON.stringify(value.payloads.adoptionGuidance), encoding: 'utf8', timeout: 10000}); assertCLITransport(guidanceResult); assert.equal(guidanceResult.stderr, ''); schema('gradual-adoption-guidance', 'output', 0, JSON.parse(guidanceResult.stdout)); const manifest = invoke('gradual-adoption-bootstrap', input, phase, ['--materialization-manifest']); @@ -795,12 +795,12 @@ test('post-redaction identity and all starter consumers preserve native wire dom schema('gradual-adoption-guidance', 'output', 1, {...envelope, envelopeId: 'foreign-fixture-value'}, false); const execute = (command, input, pointer, exit) => { const flags = pointer ? ['--input-pointer', pointer] : []; - const result = spawnSync(binary, [command, '--input', '-', ...flags], {input, encoding: 'utf8', timeout: 10000, maxBuffer: 8 << 20}); + const result = spawnWithInputFile(binary, [command, '--input', '-', ...flags], {input, encoding: 'utf8', timeout: 10000, maxBuffer: 8 << 20}); assertCLITransport(result); assert.equal(result.status, exit, result.stderr); assert.equal(result.stderr, ''); return result; }; - const normal = spawnSync(binary, ['gradual-adoption-bootstrap', '--input', '-'], {input: JSON.stringify(bootstrap()), encoding: 'utf8', timeout: 10000}); + const normal = spawnWithInputFile(binary, ['gradual-adoption-bootstrap', '--input', '-'], {input: JSON.stringify(bootstrap()), encoding: 'utf8', timeout: 10000}); assertCLITransport(normal); assert.equal(normal.status, 0); assert.equal(normal.stderr, ''); const payloads = JSON.parse(normal.stdout); assert.equal(payloads.nextCommands[2], 'agentic-proofkit witness-plan --input proofkit/witness.json'); @@ -809,14 +809,14 @@ test('post-redaction identity and all starter consumers preserve native wire dom for (const token of ['9007199254740993', '9223372036854775807']) { const input = JSON.stringify(bootstrap()).replace('"profileLines":42', '"profileLines":' + token); assert(input.includes('"profileLines":' + token)); - const producer = spawnSync(binary, ['gradual-adoption-bootstrap', '--input', '-'], {input, encoding: 'utf8', timeout: 10000, maxBuffer: 8 << 20}); + const producer = spawnWithInputFile(binary, ['gradual-adoption-bootstrap', '--input', '-'], {input, encoding: 'utf8', timeout: 10000, maxBuffer: 8 << 20}); assertCLITransport(producer); assert.equal(producer.status, 1); assert.equal(producer.stderr, ''); assert(new RegExp('"profileLines"\\s*:\\s*' + token + '(?=[,}\\s])').test(producer.stdout)); const profile = execute('gradual-adoption', producer.stdout, '/payloads/adoptionProfile', 1); assert(new RegExp('"profileLineCount"\\s*:\\s*' + token + '(?=[,}\\s])').test(profile.stdout)); execute('gradual-adoption-guidance', producer.stdout, '/payloads/adoptionGuidance', 0); execute('witness-plan', producer.stdout, '/payloads/witnessPlanInput', 0); - const manifest = spawnSync(binary, ['gradual-adoption-bootstrap', '--input', '-', '--materialization-manifest'], {input, encoding: 'utf8', timeout: 10000, maxBuffer: 8 << 20}); + const manifest = spawnWithInputFile(binary, ['gradual-adoption-bootstrap', '--input', '-', '--materialization-manifest'], {input, encoding: 'utf8', timeout: 10000, maxBuffer: 8 << 20}); assertCLITransport(manifest); assert.equal(manifest.status, 1); assert.equal(manifest.stderr, ''); const content = JSON.parse(manifest.stdout).files.find(file => file.payloadKey === 'adoptionProfile').content; assert(new RegExp('"profileLines"\\s*:\\s*' + token + '(?=[,}\\s])').test(content)); diff --git a/scripts/impact-structure.test.mjs b/scripts/impact-structure.test.mjs index f8374890..0c968009 100644 --- a/scripts/impact-structure.test.mjs +++ b/scripts/impact-structure.test.mjs @@ -1,12 +1,12 @@ import assert from "node:assert/strict"; -import {execFileSync, spawnSync} from "node:child_process"; +import {execFileSync} from "node:child_process"; import {createHash} from "node:crypto"; import {mkdtempSync, readFileSync, rmSync, writeFileSync} from "node:fs"; import {tmpdir} from "node:os"; import {join} from "node:path"; import test, {before, after} from "node:test"; import Ajv2020 from "ajv/dist/2020.js"; -import {assertCLITransport} from "./cli-process.mjs"; +import {assertCLITransport, spawnWithInputFile} from "./cli-process.mjs"; import {compileVariantUnion} from "./schema-definitions.mjs"; import {composeInput, impactInput, obligation} from "./impact-fixtures.mjs"; @@ -58,7 +58,7 @@ function native(name, value, {exit = 0, report = true, carrier = "stdin", extra const path = join(directory, "input.json"); writeFileSync(path, JSON.stringify({payload: value})); args = [name, "--input", path, "--input-pointer", "/payload", ...extra]; input = ""; } else if (carrier === "compact") args.unshift("--json-layout", "compact"); - const invoke = file => spawnSync(file, args, {input, encoding: "utf8", timeout: 10000, maxBuffer: 4 << 20}); + const invoke = file => spawnWithInputFile(file, args, {input, encoding: "utf8", timeout: 10000, maxBuffer: 4 << 20}); const result = invoke(binary); assertCLITransport(result); assert.equal(result.status, exit, `${name}: ${result.stderr}`); diff --git a/scripts/inventory-coverage-structure.test.mjs b/scripts/inventory-coverage-structure.test.mjs index de352ca0..54f34b4c 100644 --- a/scripts/inventory-coverage-structure.test.mjs +++ b/scripts/inventory-coverage-structure.test.mjs @@ -1,11 +1,11 @@ import assert from "node:assert/strict"; -import {execFileSync, spawnSync} from "node:child_process"; +import {execFileSync} from "node:child_process"; import {mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from "node:fs"; import {tmpdir} from "node:os"; import {join} from "node:path"; import test, {before, after} from "node:test"; import Ajv2020 from "ajv/dist/2020.js"; -import {assertCLITransport} from "./cli-process.mjs"; +import {assertCLITransport, spawnWithInputFile} from "./cli-process.mjs"; import {compileVariantUnion} from "./schema-definitions.mjs"; import {annotatedInventoryInput, directComposeInput, discoveryInput, inventoryInput, normalizedComposeInput, ownerInvariantRegistry, proofInventoryInput, requirementSource, sourceSetInput, twoSourceSetInput, wrappedInventory} from "./inventory-coverage-fixtures.mjs"; @@ -43,7 +43,7 @@ function native(command, input, {flags = [], exit = 0, report = true, carrier = writeFileSync(path, JSON.stringify({payload: input})); args = [command, "--input", path, "--input-pointer", "/payload", ...flags]; bytes = ""; } else if (carrier === "compact") args.unshift("--json-layout", "compact"); - const invoke = path => spawnSync(path, args, {input: bytes, encoding: "utf8", timeout: 10000, maxBuffer: 4 << 20}); + const invoke = path => spawnWithInputFile(path, args, {input: bytes, encoding: "utf8", timeout: 10000, maxBuffer: 4 << 20}); const result = invoke(binary); assertCLITransport(result); for (const value of absent) assert.equal((result.stdout + result.stderr).includes(value), false, "caller value disclosed"); @@ -126,7 +126,7 @@ test("context compose output requires the proof-binding selection members its pr writeFileSync(join(repository, "proofkit/bindings.json"), JSON.stringify(binding)); const catalog = {schemaVersion: 2, catalogId: "catalog.selection", specTree: {path: "specs/tree.json"}, requirementSources: [{path: "specs/requirements.v2.json", nodeId: "spec.root"}], proofBinding: {path: "proofkit/bindings.json"}}; - const result = assertCLITransport(spawnSync(binary, [contextComposer, "--repo-root", repository, "--input", "-"], + const result = assertCLITransport(spawnWithInputFile(binary, [contextComposer, "--repo-root", repository, "--input", "-"], {input: JSON.stringify(catalog), encoding: "utf8", timeout: 10000, maxBuffer: 4 << 20})); assert.equal(result.status, 0, result.stderr); assert.equal(result.stderr, ""); const output = JSON.parse(result.stdout), slice = context => ({schemaVersion: 2, sliceId: "slice.one", context, query: {profile: "proof"}}); diff --git a/scripts/materialization-structure.test.mjs b/scripts/materialization-structure.test.mjs index ff213243..a3212126 100644 --- a/scripts/materialization-structure.test.mjs +++ b/scripts/materialization-structure.test.mjs @@ -1,11 +1,11 @@ import assert from 'node:assert/strict'; -import {execFileSync, spawnSync} from 'node:child_process'; +import {execFileSync} from 'node:child_process'; import {mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import test, {after, before} from 'node:test'; import Ajv2020 from 'ajv/dist/2020.js'; -import {assertCLITransport} from './cli-process.mjs'; +import {assertCLITransport, spawnWithInputFile} from './cli-process.mjs'; import {compileVariantUnion} from './schema-definitions.mjs'; const root = new URL('../', import.meta.url); @@ -38,7 +38,7 @@ function native(command, repo, value, flags = [], executable = binary) { const descriptor = contract.commands.find(row => row.command === command); const argv = [...(descriptor.route ?? [command]), '--repo-root', repo, ...flags]; if (value !== undefined) argv.push('--input', '-'); - const result = spawnSync(executable, argv, {input: value === undefined ? undefined : JSON.stringify(value), + const result = spawnWithInputFile(executable, argv, {input: value === undefined ? undefined : JSON.stringify(value), encoding: 'utf8', timeout: 10000, maxBuffer: 2 << 20}); assertCLITransport(result); return result; diff --git a/scripts/overview-claims-structure.test.mjs b/scripts/overview-claims-structure.test.mjs index 99e8a7c9..6e98b851 100644 --- a/scripts/overview-claims-structure.test.mjs +++ b/scripts/overview-claims-structure.test.mjs @@ -1,12 +1,12 @@ import assert from "node:assert/strict"; import {createHash} from "node:crypto"; -import {execFileSync, spawnSync} from "node:child_process"; +import {execFileSync} from "node:child_process"; import {mkdtempSync, readFileSync, rmSync} from "node:fs"; import {tmpdir} from "node:os"; import {join} from "node:path"; import test, {before, after} from "node:test"; import Ajv2020 from "ajv/dist/2020.js"; -import {assertCLITransport} from "./cli-process.mjs"; +import {assertCLITransport, spawnWithInputFile} from "./cli-process.mjs"; const root = new URL("../", import.meta.url), command = "spec-overview-claims"; const contract = JSON.parse(readFileSync(process.env.PROOFKIT_OVERVIEW_CONTRACT || new URL("proofkit/cli-contract.v2.json", root), "utf8")); @@ -44,7 +44,7 @@ before(() => { after(() => {if (directory) rmSync(directory, {recursive: true, force: true});}); const hash = bytes => createHash("sha256").update(bytes).digest("hex"); function invoke(input, argv = [command, "--input", "-"]) { - const result = spawnSync(binary, argv, {input, encoding: "utf8", timeout: 10000, maxBuffer: 2 << 20}); + const result = spawnWithInputFile(binary, argv, {input, encoding: "utf8", timeout: 10000, maxBuffer: 2 << 20}); return assertCLITransport(result); } function reportSemantics(input, output) { diff --git a/scripts/profile-registry-structure.test.mjs b/scripts/profile-registry-structure.test.mjs index d41969a2..d46b816c 100644 --- a/scripts/profile-registry-structure.test.mjs +++ b/scripts/profile-registry-structure.test.mjs @@ -1,11 +1,11 @@ import assert from 'node:assert/strict'; -import {execFileSync, spawnSync} from 'node:child_process'; +import {execFileSync} from 'node:child_process'; import {mkdtempSync, readFileSync, rmSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import test, {after, before} from 'node:test'; import Ajv2020 from 'ajv/dist/2020.js'; -import {assertCLITransport} from './cli-process.mjs'; +import {assertCLITransport, spawnWithInputFile} from './cli-process.mjs'; import {expandSharedDefinitions} from './schema-definitions.mjs'; // Independent oracle: expected inventories, vocabularies and fixtures are @@ -63,7 +63,7 @@ function schema(command, direction, index, value, expected = true, label = '') { assert.equal(check(value), expected, `${command}/${direction}/${index}/${label}: ${JSON.stringify(check.errors?.slice(0, 2))}`); } function run(command, input, flags = []) { - const result = spawnSync(binary, [command, '--input', '-', ...flags], { + const result = spawnWithInputFile(binary, [command, '--input', '-', ...flags], { input: typeof input === 'string' ? input : JSON.stringify(input), encoding: 'utf8', timeout: 10000, maxBuffer: 8 << 20, }); assertCLITransport(result); diff --git a/scripts/project-status-structure.test.mjs b/scripts/project-status-structure.test.mjs index bace1f45..ad038fba 100644 --- a/scripts/project-status-structure.test.mjs +++ b/scripts/project-status-structure.test.mjs @@ -1,12 +1,12 @@ import assert from 'node:assert/strict'; import {createHash} from 'node:crypto'; -import {execFileSync, spawnSync} from 'node:child_process'; +import {execFileSync} from 'node:child_process'; import {mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, lstatSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import test from 'node:test'; import Ajv2020 from 'ajv/dist/2020.js'; -import {assertCLITransport} from './cli-process.mjs'; +import {assertCLITransport, spawnWithInputFile} from './cli-process.mjs'; import {compileVariantUnion} from './schema-definitions.mjs'; const root = new URL('../', import.meta.url); @@ -134,7 +134,7 @@ test('actual navigation CLI preserves read-only states and its status-owned next const descriptor = contract.commands.find(row => row.command === command); const args = [...(descriptor.route ?? [command]), '--repo-root', repo, ...flags]; if (input !== undefined) args.push('--input', '-'); - const result = spawnSync(binary, args, {input: input === undefined ? undefined : JSON.stringify(input), encoding: 'utf8', timeout: 10000, maxBuffer: 2 << 20}); + const result = spawnWithInputFile(binary, args, {input: input === undefined ? undefined : JSON.stringify(input), encoding: 'utf8', timeout: 10000, maxBuffer: 2 << 20}); assertCLITransport(result); assert.equal(result.status, expected, result.stderr); return result; } diff --git a/scripts/proof-lookup-structure.test.mjs b/scripts/proof-lookup-structure.test.mjs index 65c51963..317d2dac 100644 --- a/scripts/proof-lookup-structure.test.mjs +++ b/scripts/proof-lookup-structure.test.mjs @@ -1,12 +1,12 @@ import assert from 'node:assert/strict'; import {createHash} from 'node:crypto'; -import {execFileSync, spawnSync} from 'node:child_process'; +import {execFileSync} from 'node:child_process'; import {mkdtempSync, readFileSync, rmSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import test, {before, after} from 'node:test'; import Ajv2020 from 'ajv/dist/2020.js'; -import {assertCLITransport} from './cli-process.mjs'; +import {assertCLITransport, spawnWithInputFile} from './cli-process.mjs'; const root = new URL('../', import.meta.url); const source = 'requirement-proof-source-set', view = 'requirement-proof-view'; @@ -62,7 +62,7 @@ before(() => { }); after(() => {if (directory) rmSync(directory, {recursive: true, force: true});}); function invoke(command, value, flags = []) { - const result = spawnSync(binary, [command, '--input', '-', ...flags], { + const result = spawnWithInputFile(binary, [command, '--input', '-', ...flags], { input: JSON.stringify(value), encoding: 'utf8', timeout: 10000, maxBuffer: 2 << 20}); return assertCLITransport(result); } @@ -76,7 +76,7 @@ function output(command, input, flags = []) { test('lookup contracts conserve73actual predecessor process and rendered streams', () => { for (const row of baseline.observations) { - const result = spawnSync(binary, row.argv, {input: row.carrier === 'stdin' ? row.input : undefined, + const result = spawnWithInputFile(binary, row.argv, {input: row.carrier === 'stdin' ? row.input : undefined, stdio: row.carrier === 'argv-only' ? ['ignore', 'pipe', 'pipe'] : undefined, encoding: 'utf8', timeout: 10000, maxBuffer: 2 << 20}); assertCLITransport(result, row.name); @@ -96,7 +96,7 @@ test('the KERNEL-02 stream delta is exact, positioned and limited to its rows', const rows = baseline.observations.filter(row => row.format === 'html'); assert.deepEqual(rows.map(row => row.name), [...delta.rows]); const row = rows[0]; - const current = assertCLITransport(spawnSync(binary, row.argv, {input: row.input, encoding: 'utf8', timeout: 10000, + const current = assertCLITransport(spawnWithInputFile(binary, row.argv, {input: row.input, encoding: 'utf8', timeout: 10000, maxBuffer: 2 << 20}), row.name).stdout; assert.ok(conservesPredecessor(row, current), 'current stdout must carry exactly the declared delta'); const rule = delta.insertion; @@ -108,7 +108,7 @@ test('the KERNEL-02 stream delta is exact, positioned and limited to its rows', assert.equal(conservesPredecessor(row, mutated), false, name); } const unlisted = baseline.observations.find(item => item.format === 'json' && item.exitCode === 0); - const unlistedCurrent = assertCLITransport(spawnSync(binary, unlisted.argv, {input: unlisted.input, encoding: 'utf8', + const unlistedCurrent = assertCLITransport(spawnWithInputFile(binary, unlisted.argv, {input: unlisted.input, encoding: 'utf8', timeout: 10000, maxBuffer: 2 << 20}), unlisted.name).stdout; assert.ok(conservesPredecessor(unlisted, unlistedCurrent), 'unlisted rows stay byte-exact'); assert.equal(conservesPredecessor(unlisted, unlistedCurrent + delta.anchor + rule), false, 'delta applied to an unlisted row'); diff --git a/scripts/proof-routing-structure.test.mjs b/scripts/proof-routing-structure.test.mjs index 9b417d0d..ae424e97 100644 --- a/scripts/proof-routing-structure.test.mjs +++ b/scripts/proof-routing-structure.test.mjs @@ -1,12 +1,12 @@ import assert from "node:assert/strict"; -import {execFileSync, spawnSync} from "node:child_process"; +import {execFileSync} from "node:child_process"; import {createHash} from "node:crypto"; import {mkdtempSync, readFileSync, rmSync, writeFileSync} from "node:fs"; import {tmpdir} from "node:os"; import {join} from "node:path"; import test, {before, after} from "node:test"; import Ajv2020 from "ajv/dist/2020.js"; -import {assertCLITransport} from "./cli-process.mjs"; +import {assertCLITransport, spawnWithInputFile} from "./cli-process.mjs"; import {compileVariantUnion, expandSharedDefinitions} from "./schema-definitions.mjs"; import {bindingInput, witnessInput, projectedWitnessInput, schedulerInput, populatedSchedulerInput, resolverInput} from "./proof-routing-fixtures.mjs"; @@ -62,7 +62,7 @@ function native(command, input, {exit = 0, report = true, carrier = "stdin", ext args = [command, "--input", path, "--input-pointer", "/payload", ...extra]; bytes = ""; } else if (carrier === "compact") args.unshift("--json-layout", "compact"); - const invoke = executable => spawnSync(executable, args, {input: bytes, encoding: "utf8", timeout: 10000, maxBuffer: 4 << 20}); + const invoke = executable => spawnWithInputFile(executable, args, {input: bytes, encoding: "utf8", timeout: 10000, maxBuffer: 4 << 20}); const result = invoke(binary); assertCLITransport(result); assert.equal(result.status, exit, `${command}: ${result.stderr}`); diff --git a/scripts/public-api-structure.test.mjs b/scripts/public-api-structure.test.mjs index 2b36cd31..3d434242 100644 --- a/scripts/public-api-structure.test.mjs +++ b/scripts/public-api-structure.test.mjs @@ -1,11 +1,11 @@ import assert from "node:assert/strict"; -import {execFileSync, spawnSync} from "node:child_process"; +import {execFileSync} from "node:child_process"; import {mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync} from "node:fs"; import {tmpdir} from "node:os"; import {dirname, join} from "node:path"; import test, {before, after} from "node:test"; import Ajv2020 from "ajv/dist/2020.js"; -import {assertCLITransport} from "./cli-process.mjs"; +import {assertCLITransport, spawnWithInputFile} from "./cli-process.mjs"; import {compileVariantUnion} from "./schema-definitions.mjs"; const root = new URL("../", import.meta.url); @@ -51,7 +51,7 @@ function native(input, {files = corpus.observations[0].files, carrier = "stdin"} bytes = ""; } if (carrier === "compact") argv = ["--json-layout", "compact", ...argv]; - const result = spawnSync(binary, argv, {input: bytes, encoding: "utf8", timeout: 10_000, maxBuffer: 4 << 20}); + const result = spawnWithInputFile(binary, argv, {input: bytes, encoding: "utf8", timeout: 10_000, maxBuffer: 4 << 20}); assertCLITransport(result, "native"); assert.ok(result.status === 0 || result.status === 1); if (result.stdout) { diff --git a/scripts/receipt-admission-structure.test.mjs b/scripts/receipt-admission-structure.test.mjs index 780f5f9d..f8d7c49d 100644 --- a/scripts/receipt-admission-structure.test.mjs +++ b/scripts/receipt-admission-structure.test.mjs @@ -1,11 +1,11 @@ import assert from "node:assert/strict"; -import {execFileSync, spawnSync} from "node:child_process"; +import {execFileSync} from "node:child_process"; import {mkdtempSync, readFileSync, rmSync} from "node:fs"; import {tmpdir} from "node:os"; import {join} from "node:path"; import test, {before, after} from "node:test"; import Ajv2020 from "ajv/dist/2020.js"; -import {assertCLITransport} from "./cli-process.mjs"; +import {assertCLITransport, spawnWithInputFile} from "./cli-process.mjs"; const root = new URL("../", import.meta.url); const contract = JSON.parse(readFileSync(process.env.PROOFKIT_RECEIPT_ADMISSION_CONTRACT || new URL("proofkit/cli-contract.v2.json", root), "utf8")); @@ -42,7 +42,7 @@ before(() => { after(() => { if (directory) rmSync(directory, {recursive: true, force: true}); }); function native(family, input, expected, label) { - const result = spawnSync(binary, [family.command, "--input", "-"], { + const result = spawnWithInputFile(binary, [family.command, "--input", "-"], { input: typeof input === "string" ? input : JSON.stringify(input), encoding: "utf8", timeout: 10_000, maxBuffer: 2 << 20, }); assertCLITransport(result, label); diff --git a/scripts/receipt-structure.test.mjs b/scripts/receipt-structure.test.mjs index 0e1bcbcc..f684aef1 100644 --- a/scripts/receipt-structure.test.mjs +++ b/scripts/receipt-structure.test.mjs @@ -1,11 +1,11 @@ import assert from "node:assert/strict"; -import {execFileSync, spawnSync} from "node:child_process"; +import {execFileSync} from "node:child_process"; import {mkdtempSync, readFileSync, rmSync} from "node:fs"; import {tmpdir} from "node:os"; import {join} from "node:path"; import test, {after, before} from "node:test"; import Ajv2020 from "ajv/dist/2020.js"; -import {assertCLITransport} from "./cli-process.mjs"; +import {assertCLITransport, spawnWithInputFile} from "./cli-process.mjs"; const root = new URL("../", import.meta.url); const contract = JSON.parse(readFileSync(new URL("proofkit/cli-contract.v2.json", root), "utf8")); @@ -37,7 +37,7 @@ before(() => { after(() => { if (directory) rmSync(directory, {recursive: true, force: true}); }); function native(family, input, outcome, label) { - const result = spawnSync(binary, [family.command, "--input", "-"], { + const result = spawnWithInputFile(binary, [family.command, "--input", "-"], { input: typeof input === "string" ? input : JSON.stringify(input), encoding: "utf8", timeout: 10_000, maxBuffer: 2 << 20, }); diff --git a/scripts/release-classification-structure.test.mjs b/scripts/release-classification-structure.test.mjs index b7b8222e..094b2bfb 100644 --- a/scripts/release-classification-structure.test.mjs +++ b/scripts/release-classification-structure.test.mjs @@ -1,12 +1,12 @@ import assert from "node:assert/strict"; import {createHash} from "node:crypto"; -import {execFileSync, spawnSync} from "node:child_process"; +import {execFileSync} from "node:child_process"; import {mkdtempSync, readFileSync, rmSync} from "node:fs"; import {tmpdir} from "node:os"; import {join} from "node:path"; import test, {before, after} from "node:test"; import Ajv2020 from "ajv/dist/2020.js"; -import {assertCLITransport} from "./cli-process.mjs"; +import {assertCLITransport, spawnWithInputFile} from "./cli-process.mjs"; import {compileVariantUnion} from "./schema-definitions.mjs"; const root = new URL("../", import.meta.url), release = "release-authority", readiness = "readiness-closeout"; @@ -42,7 +42,7 @@ const order = (a, b) => Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "u const trim = text => text.replace(/^\p{White_Space}+|\p{White_Space}+$/gu, ""); const mergedClaims = (command, values) => [...new Set([...builtin[command], ...values.map(trim)])].sort(order); function invoke(row) { - const result = spawnSync(binary, row.argv, {input: row.input, encoding: "utf8", timeout: 10000, maxBuffer: 2 << 20}); + const result = spawnWithInputFile(binary, row.argv, {input: row.input, encoding: "utf8", timeout: 10000, maxBuffer: 2 << 20}); return assertCLITransport(result); } function expectedHelp(row) { diff --git a/scripts/report-admission-structure.test.mjs b/scripts/report-admission-structure.test.mjs index 387d672c..0bdc547c 100644 --- a/scripts/report-admission-structure.test.mjs +++ b/scripts/report-admission-structure.test.mjs @@ -6,7 +6,7 @@ import {tmpdir} from "node:os"; import {join} from "node:path"; import test, {before, after} from "node:test"; import Ajv2020 from "ajv/dist/2020.js"; -import {assertCLITransport} from "./cli-process.mjs"; +import {assertCLITransport, spawnWithInputFile} from "./cli-process.mjs"; const root = new URL("../", import.meta.url); const contract = JSON.parse(readFileSync(process.env.PROOFKIT_REPORT_ADMISSION_CONTRACT || new URL("proofkit/cli-contract.v2.json", root), "utf8")); @@ -44,7 +44,7 @@ const objects = value => paths(value, value => !Array.isArray(value)); const arrays = value => paths(value, Array.isArray); const wrongType = value => typeof value === "string" ? 0 : typeof value === "number" || typeof value === "boolean" ? "wrong-type" : value === null ? false : Array.isArray(value) ? {} : []; function native(family, input, expected, label, extra = []) { - const result = spawnSync(binary, [family.command, "--input", "-", ...extra], { + const result = spawnWithInputFile(binary, [family.command, "--input", "-", ...extra], { input: typeof input === "string" ? input : JSON.stringify(input), encoding: "utf8", timeout: 10_000, maxBuffer: 8 << 20, }); assertCLITransport(result, label); diff --git a/scripts/scaffold-doctor-structure.test.mjs b/scripts/scaffold-doctor-structure.test.mjs index e1ab942a..37378460 100644 --- a/scripts/scaffold-doctor-structure.test.mjs +++ b/scripts/scaffold-doctor-structure.test.mjs @@ -1,11 +1,11 @@ import assert from 'node:assert/strict'; -import {execFileSync, spawnSync} from 'node:child_process'; +import {execFileSync} from 'node:child_process'; import {mkdtempSync, readFileSync, rmSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import test, {after, before} from 'node:test'; import Ajv2020 from 'ajv/dist/2020.js'; -import {assertCLITransport} from './cli-process.mjs'; +import {assertCLITransport, spawnWithInputFile} from './cli-process.mjs'; import {expandSharedDefinitions} from './schema-definitions.mjs'; // Independently authored expectations for adoption-doctor, scaffold-project-structure @@ -194,7 +194,7 @@ function selected(command, value) { } const pathLauncher = {AGENTIC_PROOFKIT_LAUNCHER_PROFILE: 'path', AGENTIC_PROOFKIT_PYTHON_EXECUTABLE: ''}; function run(command, input, flags = [], global = []) { - const result = spawnSync(binary, [...global, command, '--input', '-', ...flags], { + const result = spawnWithInputFile(binary, [...global, command, '--input', '-', ...flags], { input: typeof input === 'string' ? input : JSON.stringify(input), encoding: 'utf8', timeout: 20000, maxBuffer: 16 << 20, env: {...process.env, ...pathLauncher}, }); @@ -735,7 +735,7 @@ test('whole chains reuse the producing commands and their published structures', const bootstrapRun = run('gradual-adoption-bootstrap', input.bootstrap); assert.equal(bootstrapRun.status, 0); assert.equal(bootstrapRun.stderr, ''); assert.deepEqual(value.bootstrapReport, JSON.parse(bootstrapRun.stdout)); - const workflow = spawnSync(binary, ['adoption-workflow-plan', '--input', '-', '--input-pointer', '/adoptionWorkflowInput'], { + const workflow = spawnWithInputFile(binary, ['adoption-workflow-plan', '--input', '-', '--input-pointer', '/adoptionWorkflowInput'], { input: JSON.stringify(value), encoding: 'utf8', timeout: 20000, env: {...process.env, ...pathLauncher}}); assert.equal(workflow.status, 0, workflow.stderr); assert.equal(workflow.stderr, ''); assert.deepEqual(JSON.parse(workflow.stdout), value.adoptionWorkflowPlan); @@ -745,7 +745,7 @@ test('whole chains reuse the producing commands and their published structures', assert.deepEqual(JSON.parse(files[input.paths.workflowInputPath].content), value.adoptionWorkflowInput); assert.deepEqual(JSON.parse(files[input.repoProfileScaffold.repository.profilePath].content), value.repoProfileScaffoldPlan.repoProfileDraft); for (const path of [input.paths.repoProfileScaffoldInputPath, input.paths.bootstrapInputPath]) { - const replay = spawnSync(binary, [path === input.paths.bootstrapInputPath ? 'gradual-adoption-bootstrap' : 'scaffold-profile-plan', '--input', '-'], + const replay = spawnWithInputFile(binary, [path === input.paths.bootstrapInputPath ? 'gradual-adoption-bootstrap' : 'scaffold-profile-plan', '--input', '-'], {input: files[path].content, encoding: 'utf8', timeout: 20000, env: {...process.env, ...pathLauncher}}); assert.equal(replay.status, 0, replay.stderr); } diff --git a/scripts/selective-proof-structure.test.mjs b/scripts/selective-proof-structure.test.mjs index 8143353b..dece4677 100644 --- a/scripts/selective-proof-structure.test.mjs +++ b/scripts/selective-proof-structure.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import {execFileSync, spawnSync} from "node:child_process"; +import {execFileSync} from "node:child_process"; import {createHash} from "node:crypto"; import {mkdtempSync, readFileSync, rmSync, writeFileSync} from "node:fs"; import {tmpdir} from "node:os"; @@ -7,7 +7,7 @@ import {join, relative, isAbsolute} from "node:path"; import {fileURLToPath} from "node:url"; import test, {before, after} from "node:test"; import Ajv2020 from "ajv/dist/2020.js"; -import {assertCLITransport} from "./cli-process.mjs"; +import {assertCLITransport, spawnWithInputFile} from "./cli-process.mjs"; import {compileVariantUnion} from "./schema-definitions.mjs"; import {planInput, populatedPlanInput, evidenceInput, projectionInput, boundProjectionInput, decisionInput, decisionStates} from "./selective-proof-fixtures.mjs"; @@ -59,7 +59,7 @@ function native(name, value, {exit = 0, report = true, extra = [], carrier = "st const path = join(directory, "input.json"); writeFileSync(path, JSON.stringify({payload: value})); args = [name, "--input", path, "--input-pointer", "/payload", ...extra]; input = ""; } else if (carrier === "compact") args.unshift("--json-layout", "compact"); - const invoke = file => spawnSync(file, args, {...(carrier === "argv-only" ? {stdio: ["ignore", "pipe", "pipe"]} : {input}), + const invoke = file => spawnWithInputFile(file, args, {...(carrier === "argv-only" ? {stdio: ["ignore", "pipe", "pipe"]} : {input}), encoding: "utf8", timeout: 10000, maxBuffer: 4 << 20}); const result = invoke(binary); assertCLITransport(result, name); assert.equal(result.status, exit, `${name}: ${result.stderr}`); @@ -326,7 +326,7 @@ test("coverage sourcePath is required nullable across receipt and command-key re test("delimiter-bearing duplicate keys retain command text and only repair the declared legacy fields", () => { const invoke = (file, input) => { - const result = spawnSync(file, [names[1], "--input", "-"], {input: JSON.stringify(input), encoding: "utf8", timeout: 10000, maxBuffer: 4 << 20}); + const result = spawnWithInputFile(file, [names[1], "--input", "-"], {input: JSON.stringify(input), encoding: "utf8", timeout: 10000, maxBuffer: 4 << 20}); assertCLITransport(result); assert.equal(result.status, 1); assert.equal(result.stderr, ""); return result; @@ -386,7 +386,7 @@ test("repaired report hashes bind both envelope references across layouts and om const sourceRefId = "proofkit.agent.context.selective-evidence.001"; const invoke = (file, input, layout) => { const args = layout ? ["--json-layout", layout, names[1], "--input", "-", "--agent-envelope"] : [names[1], "--input", "-"]; - const result = spawnSync(file, args, + const result = spawnWithInputFile(file, args, {input: JSON.stringify(input), encoding: "utf8", timeout: 10000, maxBuffer: 4 << 20}); assertCLITransport(result); assert.equal(result.status, 1); assert.equal(result.stderr, ""); diff --git a/scripts/source-conversion-structure.test.mjs b/scripts/source-conversion-structure.test.mjs index c4c4bea5..ca176d03 100644 --- a/scripts/source-conversion-structure.test.mjs +++ b/scripts/source-conversion-structure.test.mjs @@ -1,12 +1,12 @@ import assert from 'node:assert/strict'; import {createHash} from 'node:crypto'; -import {execFileSync, spawnSync} from 'node:child_process'; +import {execFileSync} from 'node:child_process'; import {mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import test, {after, before} from 'node:test'; import Ajv2020 from 'ajv/dist/2020.js'; -import {assertCLITransport} from './cli-process.mjs'; +import {assertCLITransport, spawnWithInputFile} from './cli-process.mjs'; const root = new URL('../', import.meta.url); const contract = JSON.parse(readFileSync(process.env.PROOFKIT_SOURCE_CONVERSION_CONTRACT || new URL('proofkit/cli-contract.v2.json', root))); @@ -46,7 +46,7 @@ function valid(direction, target, value, expected = true, label = '') { } function run(input, args, options = {}) { - const result = spawnSync(binary, [command, '--input', '-', ...args], { + const result = spawnWithInputFile(binary, [command, '--input', '-', ...args], { input, encoding: 'utf8', timeout: 20000, maxBuffer: 16 << 20, ...options, }); return assertCLITransport(result); diff --git a/scripts/spec-tree-structure.test.mjs b/scripts/spec-tree-structure.test.mjs index 7068e202..8ce0e043 100644 --- a/scripts/spec-tree-structure.test.mjs +++ b/scripts/spec-tree-structure.test.mjs @@ -5,7 +5,7 @@ import path from "node:path"; import {spawnSync} from "node:child_process"; import test from "node:test"; import Ajv2020 from "ajv/dist/2020.js"; -import {assertCLITransport} from "./cli-process.mjs"; +import {assertCLITransport, spawnWithInputFile} from "./cli-process.mjs"; const contract = JSON.parse(fs.readFileSync(new URL("../proofkit/cli-contract.v2.json", import.meta.url), "utf8")); const observations = JSON.parse(fs.readFileSync(new URL("../internal/app/testdata/spec-tree-native-observations.json", import.meta.url), "utf8")); @@ -201,7 +201,7 @@ test("spec tree schemas accept native failed reports visiting undeclared child I input.nodes = input.nodes.filter(node => node.nodeId === input.rootNodeId); input.overlays = []; input.edges = Array.from({length: count}, (_, i) => ({parentNodeId: input.rootNodeId, childNodeId: `missing${String(i).padStart(5, "0")}`})); - const run = spawnSync(binary, ["requirement-spec-tree", "--input", "-"], {env, input: JSON.stringify(input), encoding: "utf8", timeout: 60000, maxBuffer: 32 << 20}); + const run = spawnWithInputFile(binary, ["requirement-spec-tree", "--input", "-"], {env, input: JSON.stringify(input), encoding: "utf8", timeout: 60000, maxBuffer: 32 << 20}); assertCLITransport(run); assert.equal(run.status, count === 0 ? 0 : 1); assert.equal(run.stderr, ""); diff --git a/scripts/workspace-structure.test.mjs b/scripts/workspace-structure.test.mjs index c2f6c60a..033c14cf 100644 --- a/scripts/workspace-structure.test.mjs +++ b/scripts/workspace-structure.test.mjs @@ -6,7 +6,7 @@ import {tmpdir} from "node:os"; import {join} from "node:path"; import test, {before, after} from "node:test"; import Ajv2020 from "ajv/dist/2020.js"; -import {assertCLITransport} from "./cli-process.mjs"; +import {assertCLITransport, spawnWithInputFile} from "./cli-process.mjs"; import {compileVariantUnion, expandSharedDefinitions} from "./schema-definitions.mjs"; const root = new URL("../", import.meta.url); @@ -55,7 +55,7 @@ function native(command, input, extra = [], carrier = "stdin") { bytes = ""; } if (carrier === "compact") argv = ["--json-layout", "compact", ...argv]; - const result = spawnSync(binary, argv, {input: bytes, encoding: "utf8", timeout: 10_000, maxBuffer: 4 << 20}); + const result = spawnWithInputFile(binary, argv, {input: bytes, encoding: "utf8", timeout: 10_000, maxBuffer: 4 << 20}); assertCLITransport(result, command); assert.ok(result.status === 0 || result.status === 1, `${command}/exit`); result.output = undefined;