Skip to content

Reserve exit 1 for results, exit 2 for mistakes - #444

Merged
tobert merged 2 commits into
mainfrom
fix/regex-validation-exit-code
Sep 11, 2026
Merged

Reserve exit 1 for results, exit 2 for mistakes#444
tobert merged 2 commits into
mainfrom
fix/regex-validation-exit-code

Conversation

@tobert

@tobert tobert commented Sep 11, 2026

Copy link
Copy Markdown
Owner

An agent ran a correctly quoted grep -v '[cast:' lines.txt and read the failure as "no lines matched". The quoting was fine — the pattern reached the regex engine intact and failed to compile there, and grep reported that with exit 1, the code it also uses for a search that found nothing.

grep zzz lines.txt          # 1 — no match
grep -v '[cast:' lines.txt  # 1 — unclosed character class, indistinguishable

cmp and diff already got this right. The rule now holds everywhere it applies, and docs/LANGUAGE.md states it: a builtin that answers a question spends exit 1 on the negative answer and nothing else, so every error in grep, test, cmp, and diff exits 2. Where 1 is free the familiar split stands, so cat missing.txt is unchanged. A computed pattern needed the same fix — the validator only sees literals, so p='[cast:'; grep "$p" f failed further in — and a pipe read error no longer arrives as "no match".

The split was missing a layer up too: kaish -c 'if' exited 1 where --plan 'if' exited 2. Both are 2 now. --plan also never validated, so it green-lit programs the kernel refuses.

kaish --plan "grep '[cast:' f"   # was: a clean plan, exit 0
                                 # now: {"errors": [...]}, exit 2

The refusal names the fix rather than linking our regex crate: \[ for a literal [, [(] and [{] where a backslash would be a BRE operator. It compiles its own suggestion first, so a two-fault pattern gets no hint instead of a bad one.

Finally, 98 builtins returned exit 1 with an internal message when handed a context that was not the kernel's. ToolCtx is sealed now, making that branch unreachable so the builtins can assert. ToolRegistry::get and Tool::execute are public, so type privacy alone had left it open.

BREAKING (kaish-tool-api): an out-of-tree ToolCtx impl no longer compiles. Tool authors receive a ToolCtx and never implement one, so no supported use changes.

🤖 Generated with Claude Code

tobert and others added 2 commits September 11, 2026 08:07
An agent ran `grep -v '[cast:' lines.txt` and read the failure as "no
lines matched". The quoting was correct: the pattern reached the regex
engine intact and failed to compile there, and grep reported that with
exit 1 — the code it also uses for a search that found nothing. A caller
branching on $? cannot tell those apart.

The survey showed the rule already existed, half-applied. cmp and diff
spend exit 1 on "the inputs differ" and report every error as 2. grep did
not: seven sites (missing pattern, file read errors, invalid UTF-8)
returned 1, as did two operand checks in diff sitting next to siblings
returning 2.

The rule now in force: a builtin that answers a question spends exit 1 on
the negative answer and nothing else, so every error in grep, test, cmp,
and diff exits 2. A builtin where 1 is free keeps the familiar split — 2
for usage, 1 for an operational failure — so `cat missing.txt` is
unchanged.

The same split was missing a layer up. `kaish --plan 'if'` exited 2 and
documented it as "the same usage-error code a builtin returns for bad
argv", while `kaish -c 'if'` exited 1 for the identical rejection. Both
now exit 2, keyed off KernelError::is_rejected, which already existed to
name this class.

Probing that boundary found a worse gap: --plan never validated, so it
printed a clean plan and exited 0 for a program the kernel then refused.
A dry run that green-lights an unrunnable command is worse than none.
--plan now runs the validator through a new pure
validator::validate_program, filtered to Error to match what the kernel
refuses on.

The error text pointed at docs.rs/regex — our implementation crate, and
nothing a model can act on. It now names the escape: `\[` for a literal
`[`, and `[(]` / `[{]` where a backslash would instead be read as a BRE
operator, a distinction bre_metas_to_ere creates and the hint respects.

Last, 98 builtins opened by downcasting to ExecContext and returned exit
1 with "internal error: kernel builtin requires ExecContext" if it
failed. The builtin types are private to the crate and reachable only
through the kernel's registry, which always dispatches an ExecContext, so
the branch cannot be taken. An impossible error must still panic rather
than hand a script a number it reads as data; they now call
tools::exec_context, which does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three holes, found by auditing the change rather than the diff.

grep still returned 1 for an uncompilable pattern. validate() compiles
the pattern before the run, but it skips any pattern holding a
`<dynamic>` marker, which is what a variable or command substitution
becomes. So `p='[cast:'; grep "$p" f` sailed past validation and failed
inside execute(), at two builders the first pass never touched — they
spell the code on the line after `failure(`, where a single-line search
for `failure(1` cannot see it. Both now exit 2, with a test that drives
the pattern through a variable.

stream_grep discarded a read error: `Err(_) => break` fell through to
the same "no matches" exit 1 that a clean empty search returns. A read
failure now exits 2. A *write* failure still breaks quietly, because a
closed downstream pipe is ordinary — `grep x | head -1` must not become
an error.

regex_fix_hint could name a fix that does not compile. `[)` opens a
class and leaves a group unopened; naming `\[` sends the reader back
with `\[)`, still broken. Rather than enumerate the combinations, the
hint now applies its own spelling at the site the scan found and
compiles the result, and offers nothing when that still fails. The
scanner tracks byte offsets so the fix lands where the fault is, not at
the first matching character.

The audit that found these also corrected the reachability claim behind
the exec_context panic. Builtin types are private, but ToolRegistry::get
hands out an Arc<dyn Tool>, Tool::execute is public, and ToolCtx was
implementable, so an embedder could dispatch a builtin with a context of
its own and hit the panic where an exit code used to be. ToolCtx is now
sealed. ExecContext is its only implementor, the branch is unreachable
by construction, and the assertion is honest. The in-crate test double
has to opt into the seal to exist, which is the seal written as code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tobert
tobert merged commit d0956b3 into main Sep 11, 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