Skip to content

A bare number follows JSON rules, and a leading zero is text - #415

Merged
tobert merged 30 commits into
mainfrom
fix/dash-zero-render
Aug 27, 2026
Merged

A bare number follows JSON rules, and a leading zero is text#415
tobert merged 30 commits into
mainfrom
fix/dash-zero-render

Conversation

@tobert

@tobert tobert commented Aug 24, 2026

Copy link
Copy Markdown
Owner

kaish -c '/bin/echo -0 007 010 0.10 1.0' printed 0 7 10 0.1 1. Each word was lexed to a typed number and rendered back from the value, so the text was lost before execve saw it. Any identifier made of digits — a UID, a mode, a zero-padded ID — arrived wrong, with no error.

The rule: a leading zero means text; where kaish needs a number, a leading zero is an error.

007, 010, and 00 are strings. fromjson '007' already refused them as invalid JSON, and a bare word now agrees. That keeps chmod 0644 file working exactly as typed, which is the common case and the one bash gets right.

-0, 0.10, and 1.0 are valid JSON numbers and stay numbers. Their argv fidelity comes from the source text carried alongside the value — on the AST literal, on ToolArgs so builtins keep it, and on redirect targets.

Where a real number is required, the error names the number to write:

break 007            error — write `break 7`
echo $((010 + 1))    error — kaish reads no octal; write `10`
xs=[10 20]
echo ${xs[007]}      error — a list is indexed by number; write ${xs[7]}

Arithmetic is the case worth stating plainly: bash reads 010 as octal and answers 9, decimal would answer 11, so kaish refuses rather than quietly picking a third answer.

A slice bound is a number position too, and that one was silent: ${xs[007:2]} parsed 007 as 7, inverted the range, and returned an empty list with exit 0. Both bounds refuse a leading zero now, and the four ordinary spellings ([0:2], [:2], [1:], [-2:]) are pinned by their own test.

Two things the rule gives back. A record key that is a leading-zero numeral now round-trips — ${r[007]} reads the "007" key instead of normalizing to index 7, and r[007]=v is no longer a parse error, so the read and the write finally name the same key. And --plan and execution agree on redirect targets: echo hi > -0 reported -0 in the plan document and created a file named 0.

A help numbers topic teaches the rule, and documents one limit: a variable copy does not carry the source text, so x=-0; echo $x prints 0.

Comparison follows from the same rule: [[ 007 == 7 ]] is false, as in bash, and a numeric comparison is a number position — [[ 010 -eq 10 ]] and test 08 -eq 8 are refused with the same message as $((010)). bash answers 8 there (octal) and kaish used to answer 10; refusing is the only answer that is not a third number. Two compat pins that asserted the old decimal reading are retired; the refusal is pinned in leading_zero_tests.

An integer past 64 bits is an error that names the limit and the fix — does not fit in a 64-bit integer (-9223372036854775808..9223372036854775807); quote it to keep the text — shared by the lexer, arithmetic, and [[ -eq ]]. That last one closed a silent hole: [[ "9223372036854775808" -eq 9223372036854775807 ]] was true because both sides rounded to the same f64; a string now falls to f64 only when it is spelled as a float. A leading-zero numeral that also overflows (09223372036854775808) is text, as any leading-zero word is.

Arithmetic refuses the numeral however it arrives, so x=010; echo $((x)) is refused the same way the literal is — it answered 10 where bash answers 8.

The break/continue message is derived after the grammar has already failed, and it is gated twice: the grammar's own error must sit on that numeral, and break must be in statement position. Either gate alone lets it answer for something it never judged — echo break 007 fails on the numeral too, and without the position gate an unrelated error elsewhere on the line got this message instead of its own.

Checked against a real corpus rather than only synthetic cases: kaijutsu's contrib/kai-parse-check.sh parses 71 committed .kai scripts — gate logic with heredocs, nested $(), jq filters and case ladders. 71/71 on main and 71/71 here, with a known-bad file run first to confirm the gate can fail.

One residual, not fixed here: about fifteen call sites hand-roll "prefer the source text, else stringify." The redirect target was a missed copy of that decision, and the next consumer of Expr can miss it the same way.

🤖 Generated with Claude Code

tobert and others added 6 commits August 24, 2026 07:14
Amy's report: `find -print0 | xargs -0 rm -f` planned AND executed
as `xargs 0 rm -f` — the `-0` null-delimiter flag silently became a
bare `0`. `-0` lexes as `Token::Int(-0)`; `i64` has no negative zero,
so the sign was gone the moment the literal was parsed, long before
any renderer touched it.

Mapped the class against the shipped 0.16.0 binary before touching
code: leading zeros (`007`, `010`, `00`, `-00`), negative zero for
both int and float (`-0`, `-0.0`, `-0.00`), and non-canonical
trailing fraction digits (`0.10`, `1.0`, and `0.0` itself — Rust's
`f64::to_string()` drops a bare `.0`). Canonical numerals (`-1`,
`-5`, `-0.5`, `+0`) were already fine and needed to stay that way.

Confirmed this is not only a `Plan.rendered` cosmetic issue: running
`/bin/echo -0 007 -0.0` for real against the unfixed tree printed
`0 7 -0` — `kernel.rs::build_args_flat`, the real external-command
argv builder, loses the same fidelity as the renderer, because both
re-serialize from the typed `Value` and the source text is already
gone by then.

Wrote failing tests first: `plan_builtin_tests.rs` asserts
`rendered`/`args[].plain` round-trip for the whole class (plus a
canonical-numeral regression guard), and a new
`external_command_argv_preserves_noncanonical_numeral_source_text`
in `external_command_tests.rs` spawns real `/bin/echo` and checks
the actual argv, not just the plan. Both reproduced the bug against
current main before any fix landed.

Fix keeps numbers typed — `kaish-types::Value` is untouched — and
adds a raw-text side channel at the AST layer only for the
non-canonical case. `lexer.rs` gains `Token::NumericLiteral`,
synthesized as the LAST step of `tokenize_impl::
preserve_numeric_source_text`, after the fusion passes: those match
`Int`/`Float` directly for colon- and glob-fusion decisions, so a
numeral must still look ordinary while fusion runs. `ast/types.rs`
gains the parallel `Expr::NumericLiteral { value, raw }`. Every
downstream consumer of a positional/named/wordassign literal now
prefers `raw` over `value.to_string()`: `ast/plan.rs::render_expr`
(the plan/classifier path), `kernel.rs::format_expr` and
`build_args_flat` (the real execve argv), and the dispatch.rs
test-mirror kept in sync with it. `eval_expr`/`eval_expr_async`
unwrap straight to the typed `value`, so arithmetic, comparisons,
and `--json` never see this variant at all — the canonical case
(everything that already round-tripped) never even builds the new
token, paying one string comparison per numeral.

Scope decision: this closes the bug report's own case — Plan
rendering and real external-command execution. It does NOT close a
same-class gap found along the way: a BUILTIN tool's argv (`echo -0`
without an absolute path) still prints `0`, because
`kernel.rs::bind_tool_args` pushes a typed `Value` into
`ToolArgs::positional` and the text-sink formatting that would need
`raw` happens later, inside each builder's own clap-argv assembly.
Closing that needs either a raw-text side channel on `ToolArgs`
itself or accepting that a non-canonical literal reaches a builtin
as `Value::String` instead of `Value::Int`/`Float` — a real
tradeoff, not a mechanical extension of this fix, so it's left as a
follow-up rather than decided here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clippy caught this and the test suite could not: doc_lazy_continuation
fired on three lines that read as a broken bullet list. The cause was
worse than the symptom. NumericLiteralData had been inserted between
HereDocData's 22-line doc comment and HereDocData itself, so rustdoc
attached the whole here-doc explanation -- literal, strip_tabs,
body_start_offset, delimiter, source_body -- to the new numeric struct,
and left HereDocData with no documentation at all.

Nothing about behavior changed, which is exactly why no test moved. The
gate that found it was `cargo clippy --all-targets -- -D warnings`, and
it found it as a formatting complaint, not as the documentation loss it
actually was.

Moved the struct below HereDocData so each type owns its own doc, and
turned its "same shape as HereDocData, below" into "above" to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Amy's ruling, after checking the JSON grammar: RFC 8259 defines
int = zero / (digit1-9 *DIGIT), which excludes a leading zero
followed by another digit. kaish's own fromjson already enforces
this (fromjson '007' is a parse error), but the lexer disagreed and
typed the same text as Int(7) everywhere else - two parts of one
shell answering "is 007 a number" differently. The lexer was the
one that was wrong.

The user-facing reason to lead with is not the spec: nobody writing
007 expects the number 7. The spec citation is the engineering
argument for why the lexer, not fromjson, was the party to fix, and
belongs here rather than in help text or the changelog.

has_invalid_leading_zero (lexer.rs) checks the numeral's own integer
part - more than one digit, leading '0' - which also covers a
leading-zero float's integer part (007.5) since JSON's int grammar
is shared by the float production. A lone 0 (0, -0, 0.5) is the
zero alternative and stays a number; -0, 0.10, and 1.0 remain valid
JSON numbers and are untouched by this change (that class is Ruling
1's territory: they stay typed and get their source text back at
render time).

The check runs inside the same LAST-step pass Ruling 1 already
added (preserve_numeric_source_text), ahead of the raw-text
substitution: a leading zero reclassifies to Token::NumberIdent -
the same bareword-string shape a digit run with a trailing letter
(019dda1c) already gets - so every existing consumer of that token
already knows what to do with it. No new Token or Expr variant, no
new match arms anywhere downstream.

Verified against the shipped binary, not by reasoning: x=007;
echo $((x+1)) still evaluates to 8 (arithmetic's own string-to-int
coercion tolerates leading zeros, unlike JSON); test 08 -eq 8 still
passes (test's numeric comparison coerces the string the same way);
fromjson '007' still errors. Ran the full test_builtin_tests,
lexer_pipeline_tests, and lexer_idiom_tests suites specifically for
this - the two Bug-4 fusion tests (a:007, 007*) are unaffected
because a leading zero fused into a larger word never reaches this
pass as a standalone Int/Float token in the first place.

Updated the one stale comment found asserting "pure digit sequences
still lex as Int" (lexer_tests.rs) - true only of the NumberIdent
regex itself (it requires an alpha character), not of the numeral's
final classification once this pass runs. No test asserted 007-as-
Int as intended behavior; the only close call (test 08 -eq 8) is
carried by test's own tolerant string coercion, unaffected by what
type the literal started as.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Amy's ruling on the residual the first fix flagged and declined to
decide: echo -0 still printed 0 even after external-command argv
and plan rendering were both fixed, because echo is a builtin, not
a spawned process, and never goes near build_args_flat at all.
"Give ToolArgs its own raw-text field" - a first-class fact earns a
first-class field, not an overload of the existing typed Value.

Traced why echo was still wrong: value_to_argv_token (kaish-types)
looked like the single choke point, but it turned out to be a
validation-only sink for positionals - echo, like most builtins
with positional args, reads args.positional directly as a typed
Value (never the clap-parsed field) and stringifies it itself via
value_to_text_sink. A plain Value::Int(0) has no way back to "-0"
no matter which stringifier touches it; the fix has to intercept
before that Expr::NumericLiteral{value, raw} node collapses to a
bare Value at all, and it has to reach every place bind_tool_args
hands a typed value on to something that will eventually print it.

Design: ToolArgs::positional_raw and words_raw (index-keyed) plus
named_raw (key-keyed) - sparse maps, not parallel Vecs, so a
push/insert site that doesn't know about raw text just doesn't
touch them and nothing breaks. bind_tool_args populates them in all
three arg-binding modes (Verbatim, raw_argv, typed) wherever a
non-canonical Expr::NumericLiteral reaches a positional or named
slot; the typed Value pushed alongside is unchanged, so test's
numeric -eq/-gt still gets a real Int. Named/WordAssign compose
their "key=value" text immediately in bind_tool_args rather than
deferring it, so those get the raw substitution inline instead of
through a side-channel. to_argv/to_argv_excluding/words_argv
(kaish-types) prefer the raw field when rendering, so a named
value's fix reaches its usual home too: the clap-parsed struct,
which is what most builtins read for a NAMED value (the inverse of
the positional case).

Found and fixed one correctness trap while wiring this up: the
map_positionals reindexing pass at the end of bind_tool_args drains
and redistributes tool_args.positional (backend/MCP tools with
computed positional-to-named mapping), which would have silently
misattributed positional_raw entries to the wrong index or the
wrong named key after the reshuffle - a second bug of exactly the
kind this whole fix exists to close, caught before it shipped by
re-deriving positional_raw and named_raw alongside the redistribution
instead of leaving the old index-keyed map stale.

echo.rs is the concrete fix: it now checks positional_raw before
falling back to value_to_text_sink. Also fixed, because they read
tool_args.positional through the identical value_to_string pattern:
function-call and script positional parameters ($1, $2, ...) - an
unrelated bug in the same shape, found by grep while tracing every
consumer of tool_args.positional, not something Amy asked for by
name.

Verified end to end against the built binary: echo -0 007 010 0.10
1.0 022 now prints exactly the table Amy's ruling asked for
(-0 007 010 0.10 1.0 022), matching what /bin/echo already printed
via the external-command fix. function f { echo $1 }; f -0 also
prints -0.

Explicitly out of scope, left as a residual: a repeatable flag's
accumulated value (push_repeatable_value's Json(Array(Array(...)))
shape) is not itself a bare numeral literal at the point it
accumulates, so a non-canonical numeral inside a repeated
--flag=value was not threaded through - a rare intersection, not
touched here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Amy: agents already know shell and already know JSON; the help text
should let them find the seam between the two quickly instead of
explaining kaish's whole type system to get there. Written after
the leading-zero and ToolArgs-raw-text rulings landed, since the two
now read as similar but are not: one decides what TYPE a bare word
gets, the other decides what TEXT a typed word renders back as, and
a reader who conflates them will reach for quoting to fix the wrong
one.

New "Numbers" section, placed right after "Variables" in both
docs/LANGUAGE.md and the kaish-help Syntax fragments (which single-
source content/en/syntax.md and `help numbers`) - the same spot a
reader already goes to learn what a bare word means. Three lines
carry the whole rule, per house style: 007 is a string (no user
action needed, kaish already agrees with the reader's expectation),
-0 is a number that reprints as -0 in argv/plan but collapses to
canonical 0 once it moves through a variable or arithmetic, and
"-0" is how you keep the string on purpose. Led with expectation,
not RFC 8259 - the spec citation stays in the Ruling 2 commit, where
it is the engineering argument for the fix, not the user's reason to
care.

Every claim in the example was checked against the built binary
before writing it down, including the one most likely to be assumed
rather than tested: x=-0; echo $x prints 0, not -0 - a variable copy
is a plain typed value with no memory of the literal it came from,
so the fidelity the first two commits added does not extend past
the first read.

content/en/syntax.md is generated, not hand-edited - added the
section to fragments.rs's registry (between the "variables" and
"expansion" keys, so it renders in that position) and ran
`cargo run -p kaish-help --example regen_syntax`. Ran the drift test
(syntax_md_matches_fragments) and the LANGUAGE.md coverage test
(language_md_still_covers_the_syntax_surface) after regenerating;
both pass unchanged, and `help numbers` resolves the new section
through the existing key-lookup mechanism with no additional
plumbing.

No CHANGELOG entry: this documents behavior the two Ruling commits
already logged, and the project's Keep a Changelog convention does
not carry a Documentation category for content updates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tobert
tobert marked this pull request as draft August 24, 2026 13:13
tobert and others added 5 commits August 25, 2026 18:26
The lexer change alone left the rule half-taught. `007` became text in
argv, which is what bash does and what the mode operand `chmod 0644`
needs, but the positions that require a real number each answered
differently: `break 007` and `xs[007]=v` failed with a shape mismatch
against the whole statement alternative set, `${xs[007]}` silently
resolved to index 7, and `$((010 + 1))` silently answered 11 where bash
answers 9. Three spellings of the same numeral, three different answers,
two of them silent.

Amy's ruling: a leading zero means text; where kaish needs a number, a
leading zero is an error. That splits cleanly, because the ambiguity is
only ever in the position, never in the word:

  echo 007, chmod 0644     text, exactly as typed
  break 007, $((010))      error naming the leading zero and the fix
  ${xs[007]} on a list     error naming the fix, ${xs[7]}
  ${r[007]} on a record    the "007" key, which is what it says

That last one was a bug the rule fixes rather than creates. A record
stored under "007" could not be read back by the name it was stored
under, because the subscript normalized to 7 first.

The write side gets the read side's classification (`NumberIdent` in
`lvalue_subscript_parser`), so `r[007]=v` and `${r[007]}` finally name
the same key. `break`/`continue` are diagnosed after the grammar has
already failed, on the #413 pattern, so no passing program reaches the
new message. Arithmetic refuses rather than reading decimal: silently
disagreeing with bash by two is worse than stopping.

Redirect targets read the source text the way `plan_redirect_target`
already does. `--plan` reported `-0` while the run created a file named
`0` — a plan that describes a write that never happens, which is exactly
what #414 asked plan consumers to trust.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Numbers section taught what a leading zero MEANS and stopped there,
which was the whole rule when a leading zero only ever produced a string.
Now that the number-demanded positions refuse it, a reader who learns
only the first half meets an error the docs never mentioned.

Added the positions and their fixes to both surfaces, and led with the
case that decides it for a bash reader: $((010 + 1)) is an error, not 9
and not 11. Naming both wrong answers is the point — a reader who knows
bash expects 9 and would otherwise assume kaish silently agrees.

Named the deliberate conversions in the same breath, since "no octal"
without an escape hatch reads as a missing feature: printf "%o"/"%x"
format one, xxd dumps bytes. Nothing parses a base yet.

The record-key example is in the docs because it is the one place the
rule GIVES something back: ${r[007]} now reads the "007" key, which the
old normalize-to-7 read could not reach at all.

fragments.rs is the source for content/en/syntax.md — edited the
fragment and ran `cargo run -p kaish-help --example regen_syntax`.
LANGUAGE.md is hand-maintained and got the longer treatment. Every
example was run against the built binary before it was written down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Probing read-and-write agreement across every subscript spelling turned
up the one the rule had missed, and it was the silent kind: a slice
bound parsed `007` as 7, so `${xs[007:2]}` sliced from 7, inverted the
range, and returned an empty list. Exit 0, no message. An empty result
is the one wrong answer a caller cannot tell from a correct one.

Both bounds refuse a leading zero now and fall through to a bareword
key, which the container reports against. `without_leading_zeros` splits
on `:` so the suggested fix names the whole subscript rather than one
half of it: `${xs[007:2]}` says write `${xs[7:2]}`.

The four ordinary spellings — `[0:2]`, `[:2]`, `[1:]`, `[-2:]` — are
pinned by their own test, because refusing one spelling of a grammar is
an easy way to break the rest of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review of the first two commits found the new `break`/`continue` message
was the worst of the four defects it turned up, and it was mine rather
than the bug's: it scanned the whole token stream with no gate, so it
could answer a completely unrelated error.

  if true; then echo hi; done   <- the real error
  break 007                     <- my message answered this instead

That is the line #413 drew — a post-failure validator may reword a
diagnosis and must never author one — and this one was weaker than the
`validate_glued_args` it was modeled on. Two gates, because one was not
enough: the grammar's own error must sit on that numeral, AND `break`
must be in statement position. `echo break 007` fails on the numeral
too, so position in the stream alone still blamed an `echo` command for
a loop count.

Three more from the same review.

Arithmetic only refused the literal. `x=010; echo $((x))` answered 10
where bash answers 8 — the same silent divergence the rule exists to
stop, arriving by another road. Variables and positionals refuse it now.

Read and write still disagreed for `[-0]` and `[1.0]`. Neither is a
leading-zero numeral; they are numerals whose source text does not
round-trip, and they reach the subscript through the same token. Both
sides classify from the raw text now and produce the same message.

`break -022` suggested `break 22`. A suggestion that drops the sign is
worse than no suggestion, since it names a statement that is valid and
different.

What the review flagged that I did not change: env assignments, `for`
words and `case` subjects drop the source text. Those are variable
bindings, they behave exactly as `x=-0` does, and `help numbers` already
documents that limit. Left alone deliberately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@tobert tobert left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly LGTM. The comments seem to repeat a lot of what is in or should be in LANGUAGE.md. Please address then we can merge.

Amy on the review: "The comments seem to repeat a lot of what is in or
should be in LANGUAGE.md."

They did. The rule was restated at nine kernel.rs call sites, each one
re-explaining that a non-canonical numeral keeps its source text and that
007 is a different case. LANGUAGE.md already teaches both under "A bare
number follows JSON rules", so the rule is stated once on
Token::NumericLiteral and Expr::NumericLiteral, which point there, and the
call sites say only what is true at that site.

The bash-octal reasoning stays in arithmetic.rs -- refusing to answer a
third number is why that arm exists, and nothing else on the line says so.

378 comment lines against 772 of code, now 283.
@tobert

tobert commented Aug 27, 2026

Copy link
Copy Markdown
Owner Author

Addressed the review.

The rule was restated at nine kernel.rs call sites, each re-explaining that a non-canonical numeral keeps its source text and that 007 is a different case. docs/LANGUAGE.md already teaches both under "A bare number follows JSON rules", so it is stated once on Token::NumericLiteral and Expr::NumericLiteral, which point there, and each call site now says only what is true at that site. 378 comment lines against 772 of code, now 283.

One piece of reasoning stayed: the bash-octal explanation in arithmetic.rs. Refusing rather than answering a third number is why that arm exists, and nothing else on the line says so.

Gates green locally: cargo test --all, cargo clippy --all --all-targets -- -D warnings, and the rustdoc leg.

tobert added 3 commits August 26, 2026 20:16
Same slip as the one on #412: the trim linked has_invalid_leading_zero
from Token::NumericLiteral's public docs, and that fn is private. Caught
by RUSTDOCFLAGS=-D warnings, which cargo doc alone does not apply.
kaibo's review of this branch found it: the count grammar matches
Token::Int, and making -0 carry its source text moved it to
Token::NumericLiteral, so `break -0` and `continue -0` became parse
errors. Confirmed against a main build -- exit 0 there, exit 2 here --
and `break -1` and `break 0` were never affected, so it was exactly the
-0 spelling that this branch calls a valid number in LANGUAGE.md.

The count now accepts a NumericLiteral carrying an Int. The test fails
without the grammar change.
kaibo's review found exec building its argv at its own edge rather than
through build_args_flat, so it never got the source-text rule. Verified:
`/bin/echo -0 007 0.10` printed `-0 007 0.10` while `exec /bin/echo -0 007
0.10` printed `0 007 0.1` -- two spellings of one command disagreeing,
inside the branch whose whole point is that argv matches what was typed.

The test spawns the real binary. Written in-process first, it execve'd
over the test harness: 11 of 48 tests ran, the harness became /bin/echo,
and the run still exited 0. kaish-repl already has the spawn pattern for
frontend behavior, so the test lives there.

Also corrected a comment this branch's trim left overstated:
is_glob_mergeable matches Int, not Int and Float.
@tobert

tobert commented Aug 27, 2026

Copy link
Copy Markdown
Owner Author

kaibo review (cast deepseek, pointed at the worktree with this branch checked out). It had not had one. Every claim below was verified against built binaries for both main and this branch before acting on it.

Two real problems, both fixed:

break -0 was a regression this branch introduced. The count grammar matches only Token::Int, and carrying source text moved -0 to Token::NumericLiteral. main exits 0, this branch exited 2. break -1 and break 0 were never affected, so it was exactly the -0 spelling this branch's own LANGUAGE.md calls a valid number.

exec never got the rule. It builds argv at its own edge rather than through build_args_flat:

/bin/echo -0 007 0.10        -0 007 0.10
exec /bin/echo -0 007 0.10   0 007 0.1     <- disagreed

Both fixes have a test that fails without them. The exec test spawns the real binary: written in-process it execve'd over the test harness, so 11 of 48 tests ran, the harness became /bin/echo, and the run still exited 0.

Not treated as regressions, contrary to how the review framed them. The builtin path operands (rm, mkdir, cp, ~40 sites) lose -0/0.10 on main and on this branch alike — mkdir 0.10 makes 0.1 either way. That is the residual this PR body already names, not something it breaks. 007 is correct everywhere now because it became a string.

Also confirmed, both pre-existing: 0e5 is a string here while fromjson '0e5' is a number, because the lexer has no exponent rule; and is_glob_mergeable matches Int but not Float, so a comment overstated it — corrected.

Gates green: cargo test --all, cargo clippy --all --all-targets -- -D warnings, the rustdoc leg, and --no-default-features.

@tobert
tobert marked this pull request as ready for review August 27, 2026 00:51
tobert and others added 10 commits August 27, 2026 07:58
`[[ 010 -eq 10 ]]` and `test 010 -eq 10` answered true: value_to_num
string-parsed "010" as decimal 10. Arithmetic already refuses this
numeral (bash reads it as octal 8; kaish reads no octal), so the
comparison was quietly giving a third answer nothing else in the
language would give.

value_to_num backs both `[[ ]]` and the test builtin, so one guard
covers both. It now checks arithmetic::leading_zero_decimal before
parsing and refuses with the same "(leading zero)" / "no octal" /
"write `N`" wording arithmetic already uses, so a numeral is refused
the same way wherever it reaches a number position. leading_zero_decimal
moved to pub(crate) so eval.rs could reach it without duplicating the
suggestion logic.

Docs and help gained the comparison as a fourth listed number position,
alongside break/continue, arithmetic, and a list index.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`echo 9223372036854775808` failed the whole statement with "lexer
error: invalid number" — true, but it offers nothing to do about it.
The word is a valid JSON number; i64 is kaish's limit, not JSON's, and
the regex behind lex_int/parse_int admits only `-?[0-9]+`, so overflow
is the only way that parse ever fails there.

Added LexerError::IntegerOutOfRange, mapped from the same parse
failure lex_int and parse_int already handled, with a Display that
names the range and the fix: quote the numeral to keep it as text.
lex_float/parse_float are untouched — they still say "invalid number",
since a float overflow is a different, still-unnamed case.

Docs and help gained the 64-bit limit alongside the other JSON-number
rules for a bare numeral.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
numeric_eq_leading_zero_string and numeric_eq_handles_what_string_eq_does_not
pinned bash's octal reading of "01" as equal to 1. kaish now refuses a
leading-zero numeral in a number position, so the kaish side of each pin
panics on `.expect("kaish execute")` inside the shell_compat! macro, which
has no way to express a deliberate Err from the kaish side.

Deleted both pins and left a comment pointing at the refusal's real pin:
leading_zero_tests.rs, numeric_comparison_refuses_a_leading_zero_rather_
than_reading_decimal. Extended that test's source list with the quoted
spellings the two deleted pins used, and its accepted fix-substring chain
with `write \`1\`` so the quoted form stays covered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
echo 09223372036854775808 failed with the 64-bit message instead of
printing itself as text. lex_int parses during tokenization and only
afterward does preserve_numeric_source_text read the source span and
reclassify a leading-zero Int into NumberIdent — so an overflowing
leading-zero numeral hit the i64 parse failure first and never reached
the reclassification pass.

lex_int now checks has_invalid_leading_zero on its own slice before
parsing. A leading-zero numeral returns a placeholder Ok(0): the value
is discarded regardless, since preserve_numeric_source_text rebuilds the
token from the source text, not the parsed int. The rule stays: leading
zero decides the word is text before overflow ever gets a vote.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
echo \$((9223372036854775808)) said only "invalid number in arithmetic
expression" — arithmetic::parse_number parses its own numerals
separately from the lexer and had never been told the lexer's newer,
more specific IntegerOutOfRange wording.

Pulled the lexer's message into a shared pub(crate) const,
lexer::INTEGER_OUT_OF_RANGE, and pointed both call sites at it: the
LexerError::IntegerOutOfRange Display arm, and parse_number's overflow
context (the `[0-9]+` scan above it admits only digits, so overflow is
its only failure mode). One string, so the two callers cannot drift
apart again. Addition overflow keeps its own distinct wording.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
x=-0; echo \$x prints 0 was documented in docs/LANGUAGE.md and in a
comment in exec.rs, but no test pinned it, and no test pinned the
argv-vs-variable split behind it: -0 is a valid JSON number (only the
past-one-digit form is a leading-zero refusal), so argv keeps the typed
word (echo -0 -> -0) while a variable canonicalizes it (x=-0; echo $x
-> 0) the same way any other typed number does when it moves off argv.

This item is coverage only — the behavior it pins was already correct;
no code changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
break 007.5 said `write \`break 7.5\`` — the zero-trim keeps the
fraction, but the count grammar takes only a whole number, so the
suggested fix was itself a parse error.

The recovery now checks the trimmed value for a `.` before suggesting
it. A fraction gets its own wording naming the integer part instead
("takes a whole-number loop count ... write a whole number such as
`break 7`"); a genuine integer (`break 007`) is unaffected and keeps
the original "write `break 7`" message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The "Where kaish needs a number" bullet ran 51 words against the
project's <=40 rule (repeated feedback, per CLAUDE.md). Split it: the
number-position rule stays its own bullet (35 words), and the 64-bit
overflow message gets its own (23 words) rather than a trailing
sentence riding along.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
x="9223372036854775808"; [[ \$x -eq 9223372036854775807 ]] answered
true: value_to_num's String arm tried i64, and on any failure — overflow
included — fell straight to f64. Both operands round to 2^63 in f64, so
two numbers that are not equal compared equal.

The f64 fallback now only fires when the trimmed text actually looks
like a float (contains `.`, `e`, or `E`). An all-digit string (optional
leading `-`) that fails the i64 parse is refused instead, with the same
64-bit wording the lexer and \$(( )) now share (lexer::INTEGER_OUT_OF_
RANGE, wired in the arithmetic-overflow commit earlier in this branch).

Added a lexer_tests.rs case pinning that i64::MAX + 1 and i64::MIN - 1
lex as IntegerOutOfRange (the variant already existed; it had no direct
unit test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
numeric_integer pinned the OLD rule: "test 08 -eq 8" -> exit 0,
"leading zero is decimal, not octal". The branch inverts that rule on
purpose — where kaish needs a number, a leading zero is an error
naming the decimal to write, and test 08 -eq 8 is now the same
refusal as [[ 010 -eq 10 ]] and $((010)). cargo test --all caught the
stale pin as a real failure (exit 2, not 0) once the inversion landed.

Moved the assertion into its own test, numeric_leading_zero_operand_
is_refused, asserting exit 2 and that the test: err text names the
cause ("(leading zero)") and the fix ("write `8`"). The other eight
numeric_integer assertions are untouched. The number-position rule
itself is pinned in leading_zero_tests.rs; this test only pins that
the test builtin reports the same refusal with its own test: prefix
and exit 2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tobert and others added 3 commits August 27, 2026 08:54
names the limit

[[ "1e309" -gt 1 ]] answered true: value_to_num's f64 fallback parses
"1e309" to f64::INFINITY without an Err, so the earlier .{./e/E} gate
let an out-of-range float spelling through while a bare inf or nan
already refused (they contain no ./e/E, so they never reach the f64
parse — they fail as a non-numeric string instead). Deliberate
divergence, stated once here: kaish numbers are JSON numbers, and JSON
has neither infinity nor NaN, so both must refuse, not compare.

value_to_num now checks f.is_finite() after a successful f64 parse and
refuses a non-finite result by name ("outside the 64-bit float
range"), consistent with the leading-zero and i64-overflow refusals it
already carries.

return/exit's value_to_exit_code had the same overflow gap from the
other direction: an i64-shaped string that only overflowed said the
generic "numeric argument required", identical to what a non-numeric
string says. Pulled the digit-shape check into a shared helper,
is_i64_overflow_shape, and pointed both value_to_num and
value_to_exit_code at lexer::INTEGER_OUT_OF_RANGE for that case, so an
overflowing return/exit argument names the same 64-bit limit
$(( )) and [[ ]] do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
arithmetic.rs: "Simple recursive descent parser for arithmetic
expressions." opened leading_zero_decimal's doc comment, describing
ArithParser two items below it, which had no doc of its own. Moved
the sentence onto struct ArithParser; leading_zero_decimal keeps only
the prose that actually describes it.

eval.rs: "Coerce a value to a number for arithmetic test ops... parsed
as i64 then f64" sat on enum Num instead of value_to_num, the function
it describes, and the rule it stated was already stale (i64-then-f64
predates the leading-zero refusal, the finite check, and the overflow
naming added earlier on this branch). Gave enum Num a one-line doc of
its own and moved a restated, current rule onto value_to_num: leading
zero refuses; then i64; then f64 only for a float spelling and only
when finite; an all-digit i64 overflow names the 64-bit limit instead
of falling through to f64.

Neither misattachment could be caught by rustdoc; both were found by
reading the file next to the code they claim to describe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
While moving the value_to_num doc for the "Two doc comments" commit,
found a third one nearby: "Convert a Value to its string
representation for interpolation." opened value_to_exit_code's doc
block, describing value_to_string, a function ~190 lines further down
that had no doc comment of its own. Moved the sentence onto
value_to_string and left value_to_exit_code with only the prose that
describes it.

Not one of the two items asked for on this round; fixed alongside them
because it is the identical bug class, in the same file, found while
reading it for the requested fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@tobert tobert changed the title Numeric argv words keep the text you typed A bare number follows JSON rules, and a leading zero is text Aug 27, 2026
tobert and others added 2 commits August 27, 2026 09:10
seq 1 0.0 10 slipped past validation and only got caught by the
builtin's own runtime check. seq's validate() reads a positional's
typed Value, but validation builds its ToolArgs from AST Expr nodes
through expr_to_placeholder, which matched only Expr::Literal. A
numeral whose source text doesn't round-trip through its canonical
Display — 0.0, -0, 0.00, 007 — lexes as Expr::NumericLiteral, not
Literal, the same split kernel.rs's runtime binder already reads
(Expr::NumericLiteral { value, .. } => Ok(value.clone()) there).
expr_to_placeholder fell through to its <dynamic> string placeholder
for every one of these, so seq's Value::Float(0.0) match never fired
and the zero increment reached execute() instead of validate().

Fixed at the single conversion point, expr_to_placeholder in
crates/kaish-kernel/src/validator/walker.rs: added an
Expr::NumericLiteral arm returning the wrapped value, mirroring the
runtime binder. Swept every other Tool::validate override (grep, sed,
jq, diff, test, scatter, push, read, env, export, unset) for a similar
numeric match; seq's zero-increment check is the only validate() site
in the tree that reads a number, so it is also the only one this bug
could hide behind.

Added seq_zero_increment_is_caught_at_every_spelling next to the
existing seq-increment validation test in kernel_error_tests.rs,
pinning 0.0, -0, and 0.00 all raising SeqZeroIncrement at validation
time, the same as the canonical seq 1 0 10.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@tobert
tobert merged commit a4af215 into main Aug 27, 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