From 418aac6ec5e054c22551e491af55469bfd3c9abf Mon Sep 17 00:00:00 2001 From: EauDoon <47585778+EauDoon@users.noreply.github.com> Date: Sun, 30 Aug 2026 03:26:35 +0800 Subject: [PATCH] fix: treat nonzero child JSON as failed stages MandateBound writes {ok:false} to stdout and exits nonzero on CLI errors. Parsing that payload keeps the proof as a failed stage with an artifact instead of an opaque child-process error. Decide now uses the same logged-JSON parser on nonzero exits. --- bin/aas.mjs | 22 ++++++++------ test/stack.test.mjs | 70 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/bin/aas.mjs b/bin/aas.mjs index 9d12e43..3dbe3ac 100644 --- a/bin/aas.mjs +++ b/bin/aas.mjs @@ -141,6 +141,15 @@ function booleanField(payload, field, label) { return payload[field]; } +function parseStageJson(label, result) { + try { + return parseJsonOutput(result.stdout, label); + } catch (error) { + if (result.status !== 0) throw childProcessError(label, result); + throw error; + } +} + function pythonCandidates() { if (process.platform === "win32") { return [["py", ["-3"]], ["python", []], ["python3", []]]; @@ -195,14 +204,10 @@ export function runDecide( lastError = result.error; continue; } + const evaluation = parseStageJson("decide", result); if (result.status !== 0) { - const payload = (result.stdout || "").trim(); - if (payload.startsWith("{")) { - return { ok: false, raw: parseJsonOutput(payload, "decide"), status: result.status }; - } - throw childProcessError("decide", result); + return { ok: false, raw: evaluation, status: result.status }; } - const evaluation = parseJsonOutput(result.stdout, "decide"); return { ok: booleanField(evaluation, "passed", "decide"), raw: evaluation, status: 0 }; } throw new Error(`Python not found for decide stage${lastError ? ` (${lastError.code ?? "spawn-error"})` : ""}`); @@ -237,8 +242,9 @@ export function runProve( [cli, "simulate", "--scenario", scenario], { cwd: join(depsDir, "mandatebound") }, ); - if (result.status !== 0) throw childProcessError("prove", result); - const payload = parseJsonOutput(result.stdout, "prove"); + if (result.error) throw childProcessError("prove", result); + const payload = parseStageJson("prove", result); + if (result.status !== 0) return { ok: false, raw: payload, status: result.status }; return { ok: booleanField(payload, "ok", "prove"), raw: payload, status: 0 }; } diff --git a/test/stack.test.mjs b/test/stack.test.mjs index 5e8e4d3..37f1a13 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -153,6 +153,56 @@ test("decide and prove reject non-boolean success fields", () => { assert.throws(() => runProve("unused", { runner }), /boolean ok field/); }); +test("decide reads logged JSON on a nonzero exit", () => { + const result = runDecide("unused", { + runner: () => ({ + status: 1, + stdout: 'evaluating policy\n{\n "passed": false,\n "policy_id": "refund-v1"\n}\n', + stderr: "", + error: null, + }), + }); + assert.equal(result.ok, false); + assert.equal(result.status, 1); + assert.equal(result.raw.policy_id, "refund-v1"); +}); + +test("prove treats nonzero JSON as an unsuccessful proof", () => { + const result = runProve("unused", { + runner: () => ({ + status: 2, + stdout: '{"ok":false,"error":{"code":"ALB_CLI_USAGE","message":"Simulate accepts one scenario."}}\n', + stderr: '{"level":"error","code":"ALB_CLI_USAGE"}\n', + error: null, + }), + }); + assert.equal(result.ok, false); + assert.equal(result.status, 2); + assert.equal(result.raw.error.code, "ALB_CLI_USAGE"); +}); + +test("prove fail-closes a nonzero payload that claims success", () => { + const result = runProve("unused", { + runner: () => ({ + status: 5, + stdout: '{"ok":true,"result":{}}\n', + stderr: "", + error: null, + }), + }); + assert.equal(result.ok, false); + assert.equal(result.status, 5); +}); + +test("prove still throws when a nonzero child has no JSON", () => { + assert.throws( + () => runProve("unused", { + runner: () => ({ status: 2, stdout: "simulate failed\n", stderr: "", error: null }), + }), + /exited with status 2/, + ); +}); + test("child output parser accepts logged pretty-printed JSON", () => { assert.deepEqual( parseJsonOutput('starting child\n{\n "ok": true,\n "result": { "count": 2 }\n}\n', "fixture"), @@ -233,6 +283,26 @@ test("an unsuccessful proof fails the run", async () => { assert.equal(result.manifest.stages.prove.status, "failed"); }); +test("nonzero prove JSON is recorded as a failed proof, not a child-process error", async () => { + const outputRoot = tempRoot(); + const options = stubOptions(outputRoot, { runId: "prove-json-fail-run" }); + delete options.runProveFn; + options.runner = () => ({ + status: 2, + stdout: '{"ok":false,"error":{"code":"ALB_CLI_USAGE","message":"Simulate accepts one scenario."}}\n', + stderr: "", + error: null, + }); + const result = await runDemo(["--response", "pass", "--dispute"], options); + assert.equal(result.exitCode, 1); + assert.equal(result.manifest.stages.prove.status, "failed"); + assert.equal(result.report.stages.prove.ok, false); + assert.deepEqual( + JSON.parse(readFileSync(join(result.bundleDir, "stages", "prove.json"), "utf8")).error, + { code: "ALB_CLI_USAGE", message: "Simulate accepts one scenario." }, + ); +}); + test("child-process errors are visible as safe stage errors and downstream skips", async () => { const outputRoot = tempRoot(); const result = await runDemo(["--response", "pass"], stubOptions(outputRoot, {