Skip to content

A fault is never converted to a boolean - #425

Merged
tobert merged 3 commits into
mainfrom
fix/fault-never-becomes-boolean
Aug 31, 2026
Merged

A fault is never converted to a boolean#425
tobert merged 3 commits into
mainfrom
fix/fault-never-becomes-boolean

Conversation

@tobert

@tobert tobert commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Stacked on #424 — review that one first; this PR's diff is against it, not against main.

The plan was to make test abort in a condition the way [[ ]] does. Probing every position first showed [[ ]] was the wrong reference. It aborts inside an if and reads as false inside a ||, so it did not agree with itself, and (( )) had the same split.

                statement   if/while/!   && / || (left)
[[ ]]           exit 2      abort        reads false
test            exit 2      reads false  reads false
(( ))           exit 2      abort        reads false

The || row is the one that does damage. All three printed a conclusion drawn from a comparison that never happened:

x=abc; [[ "$x" -eq 1 ]] || echo "concluded: not one"

That is the silent coercion the number rules exist to refuse, reached through control flow instead of arithmetic.

The rule is now one sentence: a fault is never converted to a boolean. Where something consumes the result as a boolean — an if/while condition, a !, or the left operand of &&/|| — there is no true or false to give it, so the statement aborts. Where nothing does, including a chain's right operand whose value simply becomes the chain's value, it is exit 2 with the message and execution continues.

ExecResult::fault carries the distinction, because a command cannot say "I could not decide" through an exit code callers already read as false. It is set by the three fault sources and read at the two places a boolean is demanded.

A command that ran and failed is not a fault: grep matching nothing and false still select else and still drive ||. Every abort test is paired with a sound comparison in the same position, since a rule that aborted too eagerly would pass every abort test while destroying ordinary control flow.

Known follow-up, deliberately not in this PR: an abort still prints Error: execution failed with the real message under Caused by:. Parse and lexer failures were moved out of that wrapper previously and error_presentation_tests.rs pins the remaining classes as an explicit scope boundary, so moving faults out belongs in that work, not here.

Gates: test --all, clippy (0 warnings), rustdoc, no-default-features, wasi, and insta all green.

The plan was to make `test` abort in a condition like `[[ ]]` does.
Probing every position first showed `[[ ]]` was the wrong reference: it
aborts inside an `if` and reads as false inside a `||`, so it did not
agree with itself, and `(( ))` had the same split.

                    statement   if/while/!   && / || (left)
    [[ ]]           exit 2      abort        reads false
    test            exit 2      reads false  reads false
    (( ))           exit 2      abort        reads false

The `||` row is the one that does damage. All three printed a
conclusion drawn from a comparison that never happened:

    x=abc; [[ "$x" -eq 1 ]] || echo "concluded: not one"

That is the silent coercion the number rules exist to refuse, reached
through control flow instead of arithmetic.

The rule now is one sentence: a fault is never converted to a boolean.
Where something consumes the result as a boolean there is no true or
false to give it, so the statement aborts. Where nothing does — a
standalone statement, or a chain's RIGHT operand, whose value simply
becomes the chain's value — it is exit 2 with the message and execution
continues.

`ExecResult::fault` carries the distinction, because a command cannot
say "I could not decide" through an exit code that callers already read
as false. It is set by the three fault sources and read at the two
places a boolean is demanded: a condition's command, and a chain's left
operand. `accumulate_result` assigns it like `code`, so a fault reaching
an outer chain through an inner chain's tail is still seen.

A command that ran and failed is not a fault, and that boundary is
tested: `grep` matching nothing and `false` still select `else` and
still drive `||`. Only an operand that cannot be compared aborts.

The controls carry the weight here. Every abort test is paired with a
sound comparison in the same position, because a rule that aborts too
eagerly would pass every abort test and destroy ordinary control flow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… into fix/fault-never-becomes-boolean

# Conflicts:
#	CHANGELOG.md
tobert added a commit that referenced this pull request Aug 31, 2026
Independent of #421-#425 — this one is off main and touches only the
REPL binary and its presentation tests.

An earlier fix moved parse and lexer failures out of the `Error:
execution failed` / `Caused by:` wrapper so their diagnostic leads. It
stopped there, and `error_presentation_tests.rs` pinned the remaining
classes to record that boundary rather than to endorse it.

Everything else that fails during execution was still buried. A
validation failure produces the same `line:col [code]: message` shape a
parse error does, and it arrived three lines down:

```
Error: execution failed

Caused by:
    validation failed:
    error [E012]: bare variable in for loop iterates once …
      → wrap it in $(...) …
```

The suggested rewrite — the part that tells the reader what to do — was
the fifth line of output. Assume the context is truncated and the whole
message is gone. It now reads:

```
validation failed:
error [E012]: bare variable in for loop iterates once …
  → wrap it in $(...) …
```

`execution failed` named the phase and nothing else, and as the
outermost context anyhow makes it the headline. Removed. Callers print
the diagnostic directly rather than letting `main` re-wrap it with an
`Error:` prefix and a `Caused by:` split, the same way the parse path
already prints.

The boundary that remains is between a generic context and a specific
one. `Failed to read script: <path>` names the file and the action,
which the io error under it does not, so it stays and is still pinned.
The rule is not "no context", it is "no context that displaces the
message with less information than the message".

Untouched and still passing: parse, lexer, command-not-found, a nonzero
exit, and a builtin's own error, none of which went through the wrapper.
Exit codes are unchanged.

Gates: test --all, clippy (0 warnings), rustdoc, no-default-features,
wasi, and insta all green.
@tobert
tobert changed the base branch from fix/bracket-numeric-error-shape to main August 31, 2026 19:23
@tobert
tobert merged commit 4d02cd6 into main Aug 31, 2026
3 checks passed
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.

1 participant