Skip to content

🐛 fix: decide command failure by the exit code alone - #310

Merged
taras merged 2 commits into
mainfrom
fix/issue-307-nonzero-exit
Aug 3, 2026
Merged

🐛 fix: decide command failure by the exit code alone#310
taras merged 2 commits into
mainfrom
fix/issue-307-nonzero-exit

Conversation

@taras

@taras taras commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Closes #307. Cross-references #309.

What was wrong

expand.ts raised a failure for a code block only when the command exited non-zero and wrote nothing to stdout:

if (codeResult.exitCode !== 0 && codeResult.output === "") {

A command that printed anything before failing took the else if branch — an execOutput segment carrying a non-zero exitCode that nothing downstream reads (render.ts renders only result.stdout). No diagnostic, and under the throwing policy documentation regions use, fail-fast was defeated because there was no failure to throw. A failed preview reached the step after it.

silentFactory violated the same invariant directly: it discarded the inner result and returned { output: "", exitCode: 0, stderr: "" }, so a silent exec failure was invisible to any exit-code check, before and after the fix above.

What changed

packages/core/src/expand.ts — output and outcome are now two independent decisions. What the command printed is kept as its execOutput segment; a non-zero exit raises the diagnostic that follows it. Order is part of the contract: the output precedes the diagnostic, because a command that prints before it fails is usually explaining itself.

Preserved by construction: non-zero + empty stdout → the same single ErrorSegment with the same message; exit 0 + stdout → the same single execOutput; exit 0 + empty stdout → nothing.

packages/core/src/execute.tssilent retains the inner chain's result and replaces only the output. Exit code and stderr are the inner chain's, so a silenced failure is still a failure, and replay reproduces it from the journaled exec result rather than a hardcoded zero.

The capture contract is unchanged, and now pinned against the new segment shape: <Capture as>, component as=, and <Each as> leave the binding unset and report the failure once — printed output never masquerades as a captured value. #309 owns the broader fail-fast <Output> and partial-output contract; this PR does not redesign failed-capture output visibility.

Specsilent prose and its inline listing, Tier D (D3 sharpened, D3b/D6b/D7b added, D6/D10 corrected), Tier E (E8), Tier C (C24b), Tier Each (EA7b).

Tests

Ten new tests, each confirmed failing against the unfixed code first:

  • collecting policy: exact render partial\n<!-- ERROR: Command failed (exit 1): boom -->, plus a segment-level assertion that the order is ["execOutput", "error"];
  • throwing policy: the thrown failure is a DocumentationError, the later sibling never ran, and nothing came back as a successful result;
  • silent: failing-with-stdout suppresses the stdout and still reports the failure; in documentation it aborts before the next block (one exec in the journal); replay preserves the failure;
  • capture: <Capture as>, component as=, <Each as> over a failed-after-printing block;
  • a real-subprocess echo partial; exit 1 so the exit code travels through execFactory, not only a stub.

Verification

Runtimes selected and asserted first (Deno 2.9.1, Node 22, Bun 1.3.14), deno task setup run once, everything below against that one prepared tree:

Gate Result
deno task fmt / lint clean / 0 errors
deno task check pass
deno task test 324 passed, 0 failed
deno task check:jsr Success
tsc --project tsconfig.node.json pass
pnpm test:node 2212 pass, 0 fail
bun run test:bun 2212 pass, 0 fail
Bun entrypoint smoke pass
deno task build + ./dist/xmd test packages/core/src --raw pass
git diff --summary / --check no mode entries / clean

A code block raised a failure only when the command exited non-zero *and*
wrote nothing to stdout, so anything that printed before failing was
reported as a success: no diagnostic, and an `execOutput` segment carrying
a non-zero exit code that nothing downstream reads. Under the throwing
policy documentation regions use, fail-fast was defeated the same way —
there was no failure to throw, so a failed preview reached the step after
it.

Output and outcome are now two separate decisions. What the command
printed is kept as its `execOutput` segment, and a non-zero exit raises
the diagnostic that follows it, whatever it printed.

`silent` violated the same invariant directly: it discarded the inner
result and returned exit code 0, so a silenced failure was invisible to
any exit-code check, before and after this fix. It now hands back the
inner chain's outcome with only the output removed — it suppresses what a
command printed, not whether it worked. Replay reproduces the failure,
because the exit code comes from the journaled exec result rather than a
hardcoded zero.

The capture contract is unchanged and now pinned against the new segment
shape: `<Capture as>`, component `as=`, and `<Each as>` leave the binding
unset and report the failure once, so printed output never masquerades as
a captured value. #309 owns the broader fail-fast `<Output>` and
partial-output contract.

Closes #307
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

PR #310: 🐛 fix: decide command failure by the exit code alone

6 files, +319 / -26

Scope

✅ PR scope looks good.

Structural

✅ No structural bloat detected.

Slop

✅ Slop indicators look low.

Static Analysis

✅ Oxlint found no issues.

Correctness

No extraneous code patterns detected.

Repository rule 6 forbids `as` casts. The documentation fail-fast test
now narrows with `instanceof` and throws a named error when the value is
something else, which is also a better failure message than a cast that
would have silently accessed `.message` on whatever came out.
@taras
taras merged commit 193da3c into main Aug 3, 2026
10 checks passed
@taras
taras deleted the fix/issue-307-nonzero-exit branch August 3, 2026 02:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A non-zero code block that printed to stdout raises no failure

1 participant