|
| 1 | +// PRD 0004 R8 — the branchable result contract, under --dry-run. |
| 2 | +// |
| 3 | +// cli.mjs documents runMoshcode as "Returns { ok, code } — always", and the |
| 4 | +// shell() verb documents "Returns { ok, code } so scripts can branch on the |
| 5 | +// exit status". Under --dry-run both used to return { ok, dryRun } with NO |
| 6 | +// `code`, so the documented `.code` branch read undefined and a dry run took |
| 7 | +// the failure path even though nothing ran and nothing failed. |
| 8 | +// |
| 9 | +// The bug tests assert `code` is present and 0 in dry-run. The control tests |
| 10 | +// assert the OPPOSITE direction — that real (non-dry) runs still report the |
| 11 | +// TRUE exit status, and that dry-run still narrates instead of spawning — so |
| 12 | +// the fix cannot buy a passing `.code` by pretending every command succeeded |
| 13 | +// or by quietly executing the command for real. |
| 14 | +import { test } from "node:test"; |
| 15 | +import assert from "node:assert/strict"; |
| 16 | +import { runMoshcode } from "../src/cli.mjs"; |
| 17 | +import { moshVocabulary } from "../src/commands.mjs"; |
| 18 | +import { runScript } from "../src/runtime.mjs"; |
| 19 | + |
| 20 | +function dryCtx() { |
| 21 | + return { dryRun: true, lines: [], out(l) { this.lines.push(l); } }; |
| 22 | +} |
| 23 | +function realCtx() { |
| 24 | + return { dryRun: false, lines: [], out(l) { this.lines.push(l); } }; |
| 25 | +} |
| 26 | + |
| 27 | +// ---------------------------------------------------------------- the bug --- |
| 28 | + |
| 29 | +test("runMoshcode returns a numeric code: 0 under dry-run", () => { |
| 30 | + const res = runMoshcode("agents", ["claude"], dryCtx()); |
| 31 | + assert.equal(res.ok, true); |
| 32 | + assert.equal(res.code, 0, "dry-run must carry `code` — the JSDoc says always"); |
| 33 | + assert.equal(typeof res.code, "number"); |
| 34 | +}); |
| 35 | + |
| 36 | +test("the documented `.code !== 0` branch does not misfire under dry-run", () => { |
| 37 | + // This is the exact expression the JSDoc invites scripts to write. |
| 38 | + const res = runMoshcode("install", ["claude"], dryCtx()); |
| 39 | + assert.ok(!(res.code !== 0), "a dry run must not look like a non-zero exit"); |
| 40 | +}); |
| 41 | + |
| 42 | +test("shell() returns a numeric code: 0 under dry-run", () => { |
| 43 | + const shell = moshVocabulary().get("shell"); |
| 44 | + const res = shell.run(dryCtx(), "npm", "test"); |
| 45 | + assert.equal(res.ok, true); |
| 46 | + assert.equal(res.code, 0, "shell()'s own comment promises { ok, code }"); |
| 47 | + assert.equal(typeof res.code, "number"); |
| 48 | +}); |
| 49 | + |
| 50 | +test("every CLI verb carries `code` under dry-run, not just the ones spot-checked", () => { |
| 51 | + // Derived from the vocabulary rather than hardcoded, so a newly added CLI |
| 52 | + // verb is covered automatically. |
| 53 | + const names = ["agents", "start", "install", "upgrade", "mcp", "skill", "prd", |
| 54 | + "ugig", "coinpay", "c0mpute", "secrets", "pwd", "run"]; |
| 55 | + for (const name of names) { |
| 56 | + const res = moshVocabulary().get(name).run(dryCtx(), "test-arg"); |
| 57 | + assert.equal(res.code, 0, `${name}() must return code: 0 in dry-run`); |
| 58 | + } |
| 59 | +}); |
| 60 | + |
| 61 | +test("end-to-end through the runtime: a dry run reports success, not failure", async () => { |
| 62 | + const seen = []; |
| 63 | + await runScript( |
| 64 | + `const r = agents("claude"); say(r.code === 0 ? "SUCCESS" : "FAILURE:" + r.code);`, |
| 65 | + { commands: moshVocabulary(), dryRun: true, out: (s) => seen.push(s) } |
| 66 | + ); |
| 67 | + const out = seen.join("\n"); |
| 68 | + assert.match(out, /SUCCESS/); |
| 69 | + assert.doesNotMatch(out, /FAILURE/); |
| 70 | +}); |
| 71 | + |
| 72 | +// --------------------------------------------------------------- controls --- |
| 73 | +// These pass BOTH before and after the fix, and assert the opposite direction. |
| 74 | + |
| 75 | +test("control: a real successful run still reports code 0", () => { |
| 76 | + const res = runMoshcode("--version", [], realCtx()); |
| 77 | + assert.deepEqual({ ok: res.ok, code: res.code }, { ok: true, code: 0 }); |
| 78 | +}); |
| 79 | + |
| 80 | +test("control: a real failing run still reports the true non-zero code", () => { |
| 81 | + const res = runMoshcode("definitely-not-a-command", [], realCtx()); |
| 82 | + assert.equal(res.ok, false); |
| 83 | + assert.notEqual(res.code, 0, "the fix must not flatten real failures to 0"); |
| 84 | +}); |
| 85 | + |
| 86 | +test("control: dry-run still narrates and never spawns", () => { |
| 87 | + const ctx = dryCtx(); |
| 88 | + runMoshcode("upgrade", ["self", 2], ctx); |
| 89 | + assert.match(ctx.lines.join("\n"), /would run: moshcode upgrade self 2/); |
| 90 | +}); |
| 91 | + |
| 92 | +test("control: shell() dry-run still narrates instead of executing", () => { |
| 93 | + const ctx = dryCtx(); |
| 94 | + // If this ever really ran, the marker file path would be touched. Narrating |
| 95 | + // is the only acceptable behaviour. |
| 96 | + const res = moshVocabulary().get("shell").run(ctx, "exit 3"); |
| 97 | + assert.match(ctx.lines.join("\n"), /would run: \$SHELL -c/); |
| 98 | + assert.equal(res.ok, true, "a narrated command has no exit status to fail on"); |
| 99 | +}); |
| 100 | + |
| 101 | +test("control: shell() in a real run still surfaces a non-zero exit", () => { |
| 102 | + const res = moshVocabulary().get("shell").run(realCtx(), "exit 3"); |
| 103 | + assert.equal(res.ok, false); |
| 104 | + assert.equal(res.code, 3); |
| 105 | +}); |
| 106 | + |
| 107 | +test("control: dryRun flag is still set, so callers keying off it keep working", () => { |
| 108 | + assert.equal(runMoshcode("agents", ["claude"], dryCtx()).dryRun, true); |
| 109 | + assert.equal(moshVocabulary().get("shell").run(dryCtx(), "ls").dryRun, true); |
| 110 | +}); |
0 commit comments