A bare number follows JSON rules, and a leading zero is text - #415
Conversation
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>
# Conflicts: # CHANGELOG.md
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
left a comment
There was a problem hiding this comment.
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.
|
Addressed the review. The rule was restated at nine One piece of reasoning stayed: the bash-octal explanation in Gates green locally: |
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.
|
kaibo review (cast Two real problems, both fixed:
Both fixes have a test that fails without them. The Not treated as regressions, contrary to how the review framed them. The builtin path operands ( Also confirmed, both pre-existing: Gates green: |
`[[ 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>
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>
# Conflicts: # CHANGELOG.md
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>
kaish -c '/bin/echo -0 007 010 0.10 1.0'printed0 7 10 0.1 1. Each word was lexed to a typed number and rendered back from the value, so the text was lost beforeexecvesaw 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, and00are strings.fromjson '007'already refused them as invalid JSON, and a bare word now agrees. That keepschmod 0644 fileworking exactly as typed, which is the common case and the one bash gets right.-0,0.10, and1.0are valid JSON numbers and stay numbers. Their argv fidelity comes from the source text carried alongside the value — on the AST literal, onToolArgsso builtins keep it, and on redirect targets.Where a real number is required, the error names the number to write:
Arithmetic is the case worth stating plainly: bash reads
010as 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]}parsed007as 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, andr[007]=vis no longer a parse error, so the read and the write finally name the same key. And--planand execution agree on redirect targets:echo hi > -0reported-0in the plan document and created a file named0.A
help numberstopic teaches the rule, and documents one limit: a variable copy does not carry the source text, sox=-0; echo $xprints0.Comparison follows from the same rule:
[[ 007 == 7 ]]is false, as in bash, and a numeric comparison is a number position —[[ 010 -eq 10 ]]andtest 08 -eq 8are 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 inleading_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/continuemessage is derived after the grammar has already failed, and it is gated twice: the grammar's own error must sit on that numeral, andbreakmust be in statement position. Either gate alone lets it answer for something it never judged —echo break 007fails 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.shparses 71 committed.kaiscripts — gate logic with heredocs, nested$(), jq filters andcaseladders. 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
Exprcan miss it the same way.🤖 Generated with Claude Code