Skip to content

fix: CLI-boundary arity guard at every engine entry#344

Merged
danieljohnmorris merged 3 commits into
mainfrom
fix/cli-arg-arity-enforcement
May 17, 2026
Merged

fix: CLI-boundary arity guard at every engine entry#344
danieljohnmorris merged 3 commits into
mainfrom
fix/cli-arg-arity-enforcement

Conversation

@danieljohnmorris
Copy link
Copy Markdown
Collaborator

Summary

ilo main.ilo add (the interactive-cli tracker called without the task text positional) silently wrote the literal string [ ] nil to tasks.txt and exited 0 on v0.11.6. Same shape inline: ilo 'f x:n>n;+x 1' returned nil exit 0 on default, --run-vm, and --run-cranelift. v0.11.5 errored loudly. Manifesto worst-class footgun: silent on-disk corruption, no signal.

Bisected to PR #336 (e8b66f5 vm: reshape OP_WINDOW / OP_WINDOW_VIEW). #336 changed JitCallError::NotEligible from "print error + exit 1" to "silently fall through to vm::run" — legitimate, because the listview reshape made OP_WINDOW JIT-bail and the VM is the correct fallback. But jit_cranelift::call_raw returns NotEligible ALSO for args.len() != param_count, and vm::setup_call had never validated arity — it pre-allocated registers with NanVal::nil(), silently nil-padding any missing arg. So missing args started running on a nil-padded frame and producing garbage. The tree interpreter alone caught it because of its dispatch-time check at interpreter/mod.rs:4152.

Repro

# v0.11.6 (broken)
$ rm -f tasks.txt && ilo /tmp/tracker.ilo add && cat tasks.txt
tasks.txt
[ ] nil       ← silent on-disk corruption

$ ilo 'f x:n>n;+x 1'
nil
$ echo $?
0

After this PR:

$ ilo /tmp/tracker.ilo add
{"code":"ILO-R004","message":"add: expected 1 args, got 0",...}
$ echo $?
1
$ ls tasks.txt
ls: tasks.txt: No such file or directory   ← never touched

What's in the diff

Three commits:

  1. vm: reject wrong-arity entry calls instead of nil-padding — adds VmError::Arity variant + check_entry_arity backstop in vm::run / vm::run_with_tools + parallel inline check in VmState::call. Internal VM::call (HOF callbacks, JIT bridge re-entry, OP_CALL dispatch) is deliberately untouched, so dynamic dispatch inside a running program pays no extra cost. Maps to ILO-R004 in the diagnostic registry. Includes 4 unit tests exercising the backstop directly.

  2. cli: arity guard at every engine entry, not just the tree interpreter — adds check_cli_arity helper that resolves the entry function the same way each engine does (caller-supplied name, else first declared fn), looks up declared params, and emits an ILO-R004 diagnostic with identical wording to the tree interpreter ({name}: expected {n} args, got {m}). Wired into all four entry points: run_default, run_cranelift_engine, and the Engine::Vm / Engine::Tree arms in dispatch_run. Fires BEFORE vm::compile, JIT codegen, or any IO.

  3. tests: cross-engine + file-dispatch coverage for the arity guard — 17 integration tests covering sub/super/exact arity across all four engines, inline + file dispatch, auto-main routing. The tracker test explicitly asserts tasks.txt is NOT created on the error path — the regression marker for the on-disk corruption. Plus examples/cli-arity-strict.ilo for harness coverage and agent learning.

I deliberately did not revert #336's NotEligible → VM silent fallback. That fallback is legitimate (OP_WINDOW is supposed to bail on the JIT and run on the VM). The bug was that the VM didn't check arity. Keeping the fallback preserves the listview perf win; the new guards make the engine choice irrelevant to the error contract.

Test plan

  • cargo test --release --features cranelift — 5637 tests, 161 suites, all green
  • Clippy clean
  • Manual repro of the original main.ilo add corruption — now errors loudly, tasks.txt never written
  • Manual repro of inline ilo 'f x:n>n;+x 1' across all 4 engines — all error with ILO-R004 exit 1
  • Happy path (ilo 'f x:n>n;+x 1' 5) returns 6 exit 0 on all engines
  • Auto-main file dispatch (ilo tracker.ilo with no positional and main taking 2 params) errors with main: expected 2 args, got 0
  • CI green

Follow-ups

  • Super-arity on --run-vm / --run-cranelift / --run-tree for inline snippets still surfaces as ILO-R002 undefined function: <extra> because the explicit engines treat the second positional as a function name. Pre-existing shape, not in scope for this regression; the silent-corruption surface is specifically the auto-resolved default-engine path.
  • LLVM JIT (--run-llvm) has its own numeric-only arg parser path that wasn't part of the regression and didn't get the new guard. Worth a follow-up if anyone is using it.

vm::setup_call has always pre-allocated register slots with NanVal::nil()
to size the call frame, but it never validated that the caller supplied
exactly `param_count` args. Missing args silently bound parameters to nil
and ran the function body anyway. Extras were silently ignored. The tree
interpreter has rejected this for years (interpreter/mod.rs:4152); the
VM has not.

Add a VmError::Arity variant and a check_entry_arity backstop in vm::run
and vm::run_with_tools, plus a parallel inline check in VmState::call
(the bench-mode reusable handle). All three are the CLI-boundary's
belt-and-braces — the new main.rs guard catches it first in normal use,
but if a future dispatch path bypasses the CLI (embedding, new engine
fallback chain), the VM itself surfaces the same ILO-R004 the tree
interpreter would have.

VmError::Arity maps to ILO-R004 in the diagnostic registry so cross-engine
error codes stay consistent. The message format matches the tree
interpreter exactly: "{name}: expected {n} args, got {m}".

Internal VM::call (HOF callbacks, JIT bridge re-entry, OP_CALL dispatch)
is deliberately untouched — only the public entry points get the check,
so dynamic dispatch inside a running program pays no extra cost.
PR #336's listview reshape made JitCallError::NotEligible silently fall
through to vm::run when the JIT bailed on an opcode (e.g. OP_WINDOW). The
fallback is legitimate — but call_raw returns NotEligible ALSO when
args.len() != param_count, so missing args started silently running on
the VM's nil-padded register frame. Worst-case shape from the
interactive-cli persona: `ilo main.ilo add` with no task text wrote the
literal string `[ ] nil` to tasks.txt and exited 0. Manifesto worst-class
footgun: silent on-disk corruption with no signal.

Add a CLI-boundary `check_cli_arity` helper that resolves the entry
function exactly the way each engine does (caller-supplied name, else
first declared fn), looks up its declared parameter count, and emits an
ILO-R004 diagnostic identical to the tree interpreter's existing wording
if the counts don't match. Wire it into all four engine entries:
run_default, run_cranelift_engine, and the Engine::Vm / Engine::Tree
match arms in dispatch_run. Fires BEFORE vm::compile, JIT codegen, or
any IO — the tracker test verifies tasks.txt is never written on the
error path.

Tree interpreter already enforced the contract at function dispatch
(interpreter/mod.rs:4152). Running the same check at the CLI boundary
keeps the diagnostic shape consistent across every engine and means the
contract no longer depends on which dispatch path the user happens to
land on. Pairs with the VM-side backstop in the previous commit.
17 integration tests that shell out to the binary and pin the contract:
sub-arity, super-arity, and exact-arity across all four engines
(default, --run-tree, --run-vm, --run-cranelift), inline source +
file dispatch, plus the auto-main routing the interactive-cli tracker
hit. The tracker test (`file_auto_main_sub_arity_default`) also
asserts tasks.txt is NOT created when the arity check fires — the
regression marker for the silent on-disk corruption that motivated
the fix.

examples/cli-arity-strict.ilo documents the contract for agents
encountering this surface and doubles as engine-harness happy-path
coverage via `-- run:` / `-- out:` lines.
@codecov
Copy link
Copy Markdown

codecov Bot commented May 17, 2026

Codecov Report

❌ Patch coverage is 97.22222% with 4 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/vm/mod.rs 96.42% 3 Missing ⚠️
src/diagnostic/mod.rs 0.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@danieljohnmorris danieljohnmorris merged commit 697caaa into main May 17, 2026
5 checks passed
@danieljohnmorris danieljohnmorris deleted the fix/cli-arg-arity-enforcement branch May 17, 2026 14:04
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