fix(interpreter): give a bare assignment exit status 0, not the previous command's (#400)
* fix(interpreter): give a bare assignment exit status 0, not the previous command's
A command with no command word — `x=1`, `arr=(a b)`, `> file`, a `$empty` that
expands to nothing — returned `ctx.state.lastExitCode`, which is whatever the
previous command left behind. Bash gives such a command status 0 unless a
command substitution in one of its assigned values set the status.
The leak stays invisible until something reads `$?`, and an `else` branch is
where it bites: the branch runs with `$?` set to 1 by the condition that just
failed, so an `else` branch ending in an assignment made the whole `if` report
failure. Under `set -e` that ended the script with no output and no diagnostic:
set -e
if false; then :; else x=1; fi
echo done # never ran
`then` branches hid it because a true condition leaves `$?` at 0, and so did
`case`, which is entered with the status of whatever preceded it.
Command substitutions now record their status through one helper that moves
`$?` and a `lastSubstitutionExitCode` marker together. Each simple command
clears the marker before expanding, so a no-command-word command reports the
last substitution that ran inside it and 0 when none did. The marker is read
before redirection targets are expanded, because a substitution in a target
does not set `$?` in bash, and process substitution restores it alongside `$?`
for the same reason.
Also fixed by the same invariant: `x=$(exit 7) > file` reported 0 rather than
7, and `$empty` after a failed command reported the failure rather than 0.
Signed-off-by: Lars Trieloff <lars@trieloff.net>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(interpreter): count redirection words, and not PS4, in a null command's status
Review of the parent commit found the redirection rule backwards. The probe it
rested on — `> $(sh -c "exit 5"; echo f)` being 0 in bash — proved nothing: that
substitution's own status is 0 because `echo` is its last command. With a
substitution that actually exits nonzero, bash counts it:
true; > /dev/null$(exit 5); echo $? # 5, not 0
So the pre-snapshot taken before redirection expansion is wrong, and it
regressed `$empty > /dev/null$(exit 5)` from 5 to 0 — the same swallowed-failure
class this branch set out to fix, pointed the other way. The status is now read
after the redirection words are expanded: assignments first, redirections in
written order, last substitution wins.
One exception, which the old code got right by accident and the snapshot also
got wrong: a redirection onto fd 0 reports 0 regardless. bash performs a null
command's redirections in a forked child when one of them reads stdin, so the
substitution status is discarded — `x=$(exit 7) < /dev/null` is 0 while
`x=$(exit 7) 3< /dev/null` is 7. This follows bash 5.x; bash 3.2 predates the
fork and reports 7 for both.
Separately, `PS4` is expanded for the trace line only, so a substitution in it
must not become the traced command's status. Under `set -x` with a substituting
`PS4`, `x=1` reported the prompt's status instead of 0. `$?` and the marker are
now restored after prompt expansion, alongside the `xtrace` flag that was
already saved there.
The fuzz-discovered `while prototype={4..14} | H08OO9hF[constructor]=PI8` case
gets an explicit timeout. That pipeline is status 0 in bash, so the loop never
terminates on its own and the execution limits are what stop it, which takes
longer than the 5s default on the slower runners.
Signed-off-by: Lars Trieloff <lars@trieloff.net>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(interpreter): pin the assignment-status shapes reported from the field
The SLICC integration that hit this in production contributed the shapes that
actually bit it, which are narrower than the repro this branch started from.
Each one isolates a different reason the bug was hard to see, so they are kept
as reported rather than folded into the existing cases:
- `false; x=1; y=2` and `( false; x=1 )` reproduce it with no `if` anywhere.
The `else` branch is the delivery mechanism, not the defect.
- A defaults helper whose `else` ends in an assignment — `cfg() { if [ -n "$1" ];
then val="$1"; else val="fallback"; fi; }` — is the production shape. The
assignment is the natural last statement, so its status becomes the
function's and, under `set -e`, the script's fate.
- `local`, `export` and `declare` were never affected, because they are real
command words and take the normal path. Re-declaring with `local` was the
field workaround, found without knowing why it worked; the test pins that
both paths now agree rather than that one is right by accident.
- Adding any statement after the assignment masked it completely, so whether a
script failed depended on where the assignment happened to fall.
All verified against GNU bash 5.3.15 and bash 3.2.57, which agree on every one.
34 of the file's 48 cases fail against main.
Signed-off-by: Lars Trieloff <lars@trieloff.net>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Lars Trieloff <lars@trieloff.net>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> L
Lars Trieloff committed
f559fc1baadc6626fb88cb0446ac7740babb0d59
Parent: b7f556f
Committed by GitHub <noreply@github.com>
on 9/7/2026, 7:41:01 PM