From a5b87fac807c519d3c84fe98126be06c69183615 Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Wed, 13 May 2026 23:38:22 +0100 Subject: [PATCH 1/3] jit: route lst/index/slc/jpth/listget failures through JIT_RUNTIME_ERROR Batch 1 of the Cranelift permissive-nil sweep, following #259 (batch 2) and #254 (the TLS channel itself). These helpers previously collapsed type and range failures into a silent TAG_NIL return, hiding bugs that tree/VM already diagnose. Per-helper decisions: - jit_lst: error on OOB, negative idx, non-integer idx, non-number idx, non-list. Message text matches the tree/VM diagnostic so the cross-engine assertions stay tight. - jit_index: error on OOB and on non-list. The non-list arm is largely verifier-blocked but kept defensive. - jit_slc: error on non-number indices and non-list/non-text args only. OOB clamping is the documented contract on tree/VM (slc saturates), so do NOT surface a runtime error for out-of-range start/end. Inline comment notes the asymmetry. - jit_jpth: error on non-string args. Path miss correctly returns Err(...) as a Result value, not a runtime error, so that path is unchanged. - jit_listget: error on non-list collection and non-number idx. OOB stays TAG_NIL because it is the foreach loop-done sentinel that matches OP_LISTGET's fall-through-to-JMP semantic. Inline comment documents the split. Existing unit tests for jit_lst that asserted the old passthrough behaviour are updated to take the JIT_RUNTIME_ERROR cell and assert both the TAG_NIL return and the diagnostic message. Helpers in this batch can adopt the span-id immediate pattern when that PR lands; the pattern is +1 u64 arg per helper + 1 iconst per call site. --- src/vm/mod.rs | 82 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 67 insertions(+), 15 deletions(-) diff --git a/src/vm/mod.rs b/src/vm/mod.rs index a2ad4d8a..9b195139 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -9814,18 +9814,21 @@ pub(crate) extern "C" fn jit_lst(list: u64, idx: u64, val: u64) -> u64 { let i = NanVal(idx); let new_val = NanVal(val); if !i.is_number() { - return list; + jit_set_runtime_error(VmError::Type("lst: index must be a number")); + return TAG_NIL; } let n = i.as_number(); if n < 0.0 || n.fract() != 0.0 { - return list; + jit_set_runtime_error(VmError::Type("lst: index must be a non-negative integer")); + return TAG_NIL; } let pos = n as usize; if v.is_heap() && let HeapObj::List(items) = unsafe { v.as_heap_ref() } { if pos >= items.len() { - return list; + jit_set_runtime_error(VmError::Type("lst: index out of range")); + return TAG_NIL; } let mut new_items: Vec = Vec::with_capacity(items.len()); for (i, item) in items.iter().enumerate() { @@ -9839,6 +9842,7 @@ pub(crate) extern "C" fn jit_lst(list: u64, idx: u64, val: u64) -> u64 { } return NanVal::heap_list(new_items).0; } + jit_set_runtime_error(VmError::Type("lst requires a list")); TAG_NIL } @@ -10572,8 +10576,11 @@ pub(crate) extern "C" fn jit_slc(a: u64, start: u64, end: u64) -> u64 { let vc = NanVal(start); let vd = NanVal(end); if !vc.is_number() || !vd.is_number() { + jit_set_runtime_error(VmError::Type("slc: indices must be numbers")); return TAG_NIL; } + // OOB is deliberately clamped on tree/VM (slc is documented to saturate), + // so do not surface a runtime error for out-of-range start/end. let s_idx = vc.as_number() as usize; let e_idx = vd.as_number() as usize; if vb.is_string() { @@ -10600,6 +10607,7 @@ pub(crate) extern "C" fn jit_slc(a: u64, start: u64, end: u64) -> u64 { } return NanVal::heap_list(sliced).0; } + jit_set_runtime_error(VmError::Type("slc requires a list or text")); TAG_NIL } @@ -10893,6 +10901,7 @@ pub(crate) extern "C" fn jit_index(a: u64, idx: u64) -> u64 { let obj = NanVal(a); let i = idx as usize; if !obj.is_heap() { + jit_set_runtime_error(VmError::Type("index access on non-list")); return TAG_NIL; } match unsafe { obj.as_heap_ref() } { @@ -10901,10 +10910,14 @@ pub(crate) extern "C" fn jit_index(a: u64, idx: u64) -> u64 { items[i].clone_rc(); items[i].0 } else { + jit_set_runtime_error(VmError::Type("list index out of bounds")); TAG_NIL } } - _ => TAG_NIL, + _ => { + jit_set_runtime_error(VmError::Type("index access on non-list")); + TAG_NIL + } } } @@ -11455,12 +11468,22 @@ pub(crate) extern "C" fn jit_listnew(regs: *const u64, n: u64) -> u64 { } /// LISTGET for foreach loops: returns Ok(item) if found, TAG_NIL if out of bounds. +/// +/// OOB returning TAG_NIL is intentional: it is the loop-done sentinel that +/// matches VM OP_LISTGET's fall-through-to-JMP semantic. Type errors +/// (non-list collection, non-number index) are surfaced as runtime errors +/// to match tree/VM behaviour. #[cfg(feature = "cranelift")] #[unsafe(no_mangle)] pub(crate) extern "C" fn jit_listget(list: u64, idx: u64) -> u64 { let lv = NanVal(list); let iv = NanVal(idx); - if !lv.is_heap() || !iv.is_number() { + if !lv.is_heap() { + jit_set_runtime_error(VmError::Type("foreach requires a list")); + return TAG_NIL; + } + if !iv.is_number() { + jit_set_runtime_error(VmError::Type("list index must be a number")); return TAG_NIL; } let i = iv.as_number() as usize; @@ -11470,10 +11493,14 @@ pub(crate) extern "C" fn jit_listget(list: u64, idx: u64) -> u64 { items[i].clone_rc(); NanVal::heap_ok(items[i]).0 } else { + // OOB: loop-done sentinel, not an error. TAG_NIL } } - _ => TAG_NIL, + _ => { + jit_set_runtime_error(VmError::Type("foreach requires a list")); + TAG_NIL + } } } @@ -11483,6 +11510,7 @@ pub(crate) extern "C" fn jit_jpth(a: u64, b: u64) -> u64 { let av = NanVal(a); let bv = NanVal(b); if !av.is_string() || !bv.is_string() { + jit_set_runtime_error(VmError::Type("jpth requires two strings")); return TAG_NIL; } let json_str = unsafe { @@ -25805,44 +25833,68 @@ f>n;r=mk 10 20;+r.x r.y"; assert_eq!(items[2].as_number(), 30.0); } + // NOTE: these tests used to assert that jit_lst silently returned the + // input list (or TAG_NIL) on failure modes. The cross-engine permissive- + // nil sweep aligned the JIT helper with the tree/VM behaviour: every + // failure path now sets the JIT_RUNTIME_ERROR TLS cell and returns + // TAG_NIL, which `jit_cranelift::call` then surfaces as a runtime error. + // Each helper test therefore takes the error cell after the call and + // asserts (a) the return value is TAG_NIL and (b) the error message + // matches the tree/VM diagnostic. + #[cfg(feature = "cranelift")] #[test] - fn jit_lst_out_of_range_returns_original() { + fn jit_lst_out_of_range_errors() { + let _g = JitRuntimeErrorGuard::new(); let list = NanVal::heap_list(vec![NanVal::number(1.0), NanVal::number(2.0)]); let bits = jit_lst(list.0, NanVal::number(5.0).0, NanVal::number(99.0).0); - assert_eq!(bits, list.0); + assert_eq!(bits, TAG_NIL); + let err = jit_take_runtime_error().expect("expected runtime error"); + assert!(format!("{err:?}").contains("out of range"), "got: {err:?}"); } #[cfg(feature = "cranelift")] #[test] - fn jit_lst_negative_index_returns_original() { + fn jit_lst_negative_index_errors() { + let _g = JitRuntimeErrorGuard::new(); let list = NanVal::heap_list(vec![NanVal::number(1.0), NanVal::number(2.0)]); let bits = jit_lst(list.0, NanVal::number(-1.0).0, NanVal::number(99.0).0); - assert_eq!(bits, list.0); + assert_eq!(bits, TAG_NIL); + let err = jit_take_runtime_error().expect("expected runtime error"); + assert!(format!("{err:?}").contains("non-negative"), "got: {err:?}"); } #[cfg(feature = "cranelift")] #[test] - fn jit_lst_fractional_index_returns_original() { + fn jit_lst_fractional_index_errors() { + let _g = JitRuntimeErrorGuard::new(); let list = NanVal::heap_list(vec![NanVal::number(1.0), NanVal::number(2.0)]); let bits = jit_lst(list.0, NanVal::number(0.5).0, NanVal::number(99.0).0); - assert_eq!(bits, list.0); + assert_eq!(bits, TAG_NIL); + let err = jit_take_runtime_error().expect("expected runtime error"); + assert!(format!("{err:?}").contains("integer"), "got: {err:?}"); } #[cfg(feature = "cranelift")] #[test] - fn jit_lst_non_number_index_returns_original() { + fn jit_lst_non_number_index_errors() { + let _g = JitRuntimeErrorGuard::new(); let list = NanVal::heap_list(vec![NanVal::number(1.0)]); let bits = jit_lst(list.0, NanVal::boolean(true).0, NanVal::number(99.0).0); - assert_eq!(bits, list.0); + assert_eq!(bits, TAG_NIL); + let err = jit_take_runtime_error().expect("expected runtime error"); + assert!(format!("{err:?}").contains("number"), "got: {err:?}"); } #[cfg(feature = "cranelift")] #[test] - fn jit_lst_non_list_returns_nil() { + fn jit_lst_non_list_errors() { + let _g = JitRuntimeErrorGuard::new(); let s = NanVal::heap_string("abc".to_string()); let bits = jit_lst(s.0, NanVal::number(0.0).0, NanVal::number(99.0).0); assert_eq!(bits, TAG_NIL); + let err = jit_take_runtime_error().expect("expected runtime error"); + assert!(format!("{err:?}").contains("list"), "got: {err:?}"); } // ---- VM compile coverage for 3-arg `wr` overload ---- From 0fdb8384fbe0c1c77cc2b3ecb03fd35ac3ea4da6 Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Wed, 13 May 2026 23:38:29 +0100 Subject: [PATCH 2/3] test + example: cross-engine regression for jit nil-sweep batch 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin parity across tree, VM, and Cranelift for the helpers in this batch: - jit_lst OOB / negative-index / happy - jit_index (xs.N) OOB / happy - slc OOB clamping (list + text) — deliberately NOT errors - jpth path-miss returns Err(...), not a runtime error - jpth happy path Also pins the stale-error-leak guard from #254 for the new helpers: a successful run after an erroring one must not inherit the TLS error cell. The example file exercises the positive paths so the examples_engines harness runs them on every build. Error paths are covered by the regression file; an example file that errors would need -- err: annotations and adds little over the test. --- examples/jit-nil-sweep-batch1.ilo | 32 +++ tests/regression_jit_nil_sweep_batch1.rs | 335 +++++++++++++++++++++++ 2 files changed, 367 insertions(+) create mode 100644 examples/jit-nil-sweep-batch1.ilo create mode 100644 tests/regression_jit_nil_sweep_batch1.rs diff --git a/examples/jit-nil-sweep-batch1.ilo b/examples/jit-nil-sweep-batch1.ilo new file mode 100644 index 00000000..7a125743 --- /dev/null +++ b/examples/jit-nil-sweep-batch1.ilo @@ -0,0 +1,32 @@ +-- lst / slc / jpth: cross-engine harmonisation, positive paths. +-- +-- Companion to examples/at-hd-tl-oob-parity.ilo. This file exercises the +-- positive paths for the helpers covered by batch 1 of the Cranelift +-- permissive-nil sweep. The error paths are covered by the regression +-- test suite (regression_jit_nil_sweep_batch1.rs); the example exists so +-- the examples_engines harness pins the success-path behaviour across +-- tree, VM, and Cranelift on every build. +-- +-- Cranelift used to silently return nil (or the input list) on these +-- helpers' failure modes. After this batch every engine surfaces a +-- runtime error with matching shape: lst OOB / negative idx / non-list, +-- index OOB / non-list, slc non-number indices / non-list-non-text, +-- jpth non-string args. slc OOB-clamp and jpth path-miss remain the +-- documented contract (saturate / Err-wrap respectively). + +set-middle xs:L n>L n;lst xs 1 99 +slice-clamp xs:L n>L n;slc xs 1 999 +slice-text s:t>t;slc s 1 999 +parse-ok>R t t;jpth "{\"a\":1}" "a" + +-- run: set-middle [10,20,30] +-- out: [10, 99, 30] + +-- run: slice-clamp [10,20,30] +-- out: [20, 30] + +-- run: slice-text "hello" +-- out: ello + +-- run: parse-ok +-- out: ~1 diff --git a/tests/regression_jit_nil_sweep_batch1.rs b/tests/regression_jit_nil_sweep_batch1.rs new file mode 100644 index 00000000..f6d818d6 --- /dev/null +++ b/tests/regression_jit_nil_sweep_batch1.rs @@ -0,0 +1,335 @@ +// Regression tests for the Cranelift JIT-helper permissive-nil sweep, batch 1. +// +// Helpers in scope: jit_lst, jit_index, jit_slc, jit_jpth, jit_listget. +// +// Before this PR these helpers silently returned TAG_NIL (or the input list) +// on failure paths where tree/VM raise runtime errors. The fix routes the +// failure paths through the same `JIT_RUNTIME_ERROR` TLS cell introduced in +// #254, so every engine now surfaces a runtime error with matching shape. +// +// Note on slc: tree and VM deliberately clamp out-of-range start/end indices +// (slc is documented to saturate), so OOB on slc is NOT an error on any +// engine. Only type errors are surfaced. The OOB-clamp tests below pin that +// the JIT continues to clamp rather than newly erroring. +// +// Note on listget: OOB-nil is the foreach loop-done sentinel and is left in +// place. Only the type-error paths are surfaced as runtime errors. + +use std::process::Command; + +fn ilo() -> Command { + Command::new(env!("CARGO_BIN_EXE_ilo")) +} + +fn check_runtime_error(engine: &str, src: &str, kw_any: &[&str]) { + let out = ilo() + .args([src, engine, "f"]) + .output() + .expect("failed to run ilo"); + assert!( + !out.status.success(), + "engine={engine}: expected runtime error for `{src}`, got stdout={}", + String::from_utf8_lossy(&out.stdout) + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + kw_any.iter().any(|k| stderr.contains(k)), + "engine={engine}: expected one of {:?} in stderr, got stderr={stderr}", + kw_any + ); +} + +fn check_stdout(engine: &str, src: &str, expected: &str) { + let out = ilo() + .args([src, engine, "f"]) + .output() + .expect("failed to run ilo"); + assert!( + out.status.success(), + "engine={engine}: expected success for `{src}`, got stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + expected, + "engine={engine}: stdout mismatch for `{src}`" + ); +} + +// ── lst: OOB ────────────────────────────────────────────────────────────── + +#[test] +fn lst_oob_tree() { + check_runtime_error( + "--run-tree", + "f>L n;lst [1,2,3] 5 99", + &["lst", "out of range", "ILO-R009"], + ); +} + +#[test] +fn lst_oob_vm() { + check_runtime_error( + "--run-vm", + "f>L n;lst [1,2,3] 5 99", + &["lst", "out of range", "ILO-R004"], + ); +} + +#[test] +#[cfg(feature = "cranelift")] +fn lst_oob_cranelift() { + check_runtime_error( + "--run-cranelift", + "f>L n;lst [1,2,3] 5 99", + &["lst", "out of range", "ILO-R004"], + ); +} + +// ── lst: negative index ─────────────────────────────────────────────────── + +#[test] +fn lst_negative_tree() { + check_runtime_error( + "--run-tree", + "f>L n;lst [1,2,3] -1 99", + &["lst", "non-negative", "integer", "ILO-R009"], + ); +} + +#[test] +fn lst_negative_vm() { + check_runtime_error( + "--run-vm", + "f>L n;lst [1,2,3] -1 99", + &["lst", "non-negative", "integer", "ILO-R004"], + ); +} + +#[test] +#[cfg(feature = "cranelift")] +fn lst_negative_cranelift() { + check_runtime_error( + "--run-cranelift", + "f>L n;lst [1,2,3] -1 99", + &["lst", "non-negative", "integer", "ILO-R004"], + ); +} + +// ── lst: happy path (regression — make sure we did not break the success +// case) ─────────────────────────────────────────────────────────────────── + +#[test] +fn lst_ok_tree() { + check_stdout("--run-tree", "f>L n;lst [1,2,3] 1 99", "[1, 99, 3]"); +} + +#[test] +fn lst_ok_vm() { + check_stdout("--run-vm", "f>L n;lst [1,2,3] 1 99", "[1, 99, 3]"); +} + +#[test] +#[cfg(feature = "cranelift")] +fn lst_ok_cranelift() { + check_stdout("--run-cranelift", "f>L n;lst [1,2,3] 1 99", "[1, 99, 3]"); +} + +// ── slc: type error on non-number index ────────────────────────────────── +// +// We cannot easily express a non-number index in surface ilo (verify will +// reject it), so this case is covered by the VM-level type-mismatch path +// that we know fires when the helper is called with non-number bits. The +// happy path + OOB-clamp tests below confirm that the type-error change +// did not regress the documented saturation semantic. + +// ── slc: OOB is deliberately clamped on every engine ───────────────────── + +#[test] +fn slc_oob_clamps_tree() { + check_stdout("--run-tree", "f>L n;slc [1,2,3] 1 999", "[2, 3]"); +} + +#[test] +fn slc_oob_clamps_vm() { + check_stdout("--run-vm", "f>L n;slc [1,2,3] 1 999", "[2, 3]"); +} + +#[test] +#[cfg(feature = "cranelift")] +fn slc_oob_clamps_cranelift() { + check_stdout("--run-cranelift", "f>L n;slc [1,2,3] 1 999", "[2, 3]"); +} + +#[test] +fn slc_text_oob_clamps_tree() { + check_stdout("--run-tree", "f>t;slc \"hello\" 1 999", "ello"); +} + +#[test] +fn slc_text_oob_clamps_vm() { + check_stdout("--run-vm", "f>t;slc \"hello\" 1 999", "ello"); +} + +#[test] +#[cfg(feature = "cranelift")] +fn slc_text_oob_clamps_cranelift() { + check_stdout("--run-cranelift", "f>t;slc \"hello\" 1 999", "ello"); +} + +// ── jpth: path miss returns Err(...) on every engine (regression) ──────── +// +// This is the existing documented contract — path miss is wrapped in a +// Result, NOT a runtime error. Pin it across engines so the type-error +// change below does not accidentally widen the error surface. + +// We wrap the call in `prnt v;0` so that the returned Result reaches stdout +// without making `main` exit 1 (that would conflate Err-return with helper +// error). The stdout assertion below pins the rendered Result. + +#[test] +fn jpth_path_miss_tree() { + check_stdout( + "--run-tree", + "f>n;v=jpth \"{\\\"a\\\":1}\" \"b\";prnt v;0", + "^key not found: b\n0", + ); +} + +#[test] +fn jpth_path_miss_vm() { + check_stdout( + "--run-vm", + "f>n;v=jpth \"{\\\"a\\\":1}\" \"b\";prnt v;0", + "^key not found: b\n0", + ); +} + +#[test] +#[cfg(feature = "cranelift")] +fn jpth_path_miss_cranelift() { + check_stdout( + "--run-cranelift", + "f>n;v=jpth \"{\\\"a\\\":1}\" \"b\";prnt v;0", + "^key not found: b\n0", + ); +} + +// ── jpth: happy path ───────────────────────────────────────────────────── + +#[test] +fn jpth_ok_tree() { + check_stdout( + "--run-tree", + "f>n;v=jpth \"{\\\"a\\\":1}\" \"a\";prnt v;0", + "~1\n0", + ); +} + +#[test] +fn jpth_ok_vm() { + check_stdout( + "--run-vm", + "f>n;v=jpth \"{\\\"a\\\":1}\" \"a\";prnt v;0", + "~1\n0", + ); +} + +#[test] +#[cfg(feature = "cranelift")] +fn jpth_ok_cranelift() { + check_stdout( + "--run-cranelift", + "f>n;v=jpth \"{\\\"a\\\":1}\" \"a\";prnt v;0", + "~1\n0", + ); +} + +// ── index (xs.N literal-index OP_INDEX): OOB ───────────────────────────── +// +// `xs.5` on a 3-element list goes through OP_INDEX, which the Cranelift +// backend lowers to a `jit_index` call. Before this PR the JIT path +// silently returned nil; tree/VM both surface a runtime error. Pin parity +// across engines. + +#[test] +fn index_oob_tree() { + check_runtime_error( + "--run-tree", + "f>n;xs=[10,20,30];xs.5", + &["out of bounds", "ILO-R006"], + ); +} + +#[test] +fn index_oob_vm() { + check_runtime_error( + "--run-vm", + "f>n;xs=[10,20,30];xs.5", + &["out of bounds", "ILO-R004"], + ); +} + +#[test] +#[cfg(feature = "cranelift")] +fn index_oob_cranelift() { + check_runtime_error( + "--run-cranelift", + "f>n;xs=[10,20,30];xs.5", + &["out of bounds", "ILO-R004"], + ); +} + +// ── index: happy path (regression on the new error-path edits) ─────────── + +#[test] +fn index_ok_tree() { + check_stdout("--run-tree", "f>n;xs=[10,20,30];xs.1", "20"); +} + +#[test] +fn index_ok_vm() { + check_stdout("--run-vm", "f>n;xs=[10,20,30];xs.1", "20"); +} + +#[test] +#[cfg(feature = "cranelift")] +fn index_ok_cranelift() { + check_stdout("--run-cranelift", "f>n;xs=[10,20,30];xs.1", "20"); +} + +// ── No stale-error leak after the new failure paths ────────────────────── +// +// Same shape as the #254 stale-error-leak guard, but pinned for the +// helpers added in this batch. If the JitRuntimeErrorGuard ever regressed, +// a successful call following an erroring one would inherit the stale +// error and spuriously fail. +#[test] +#[cfg(feature = "cranelift")] +fn no_stale_jit_error_leak_after_lst_oob() { + // First call: lst OOB → runtime error. + let out = ilo() + .args(["f>L n;lst [1,2,3] 5 99", "--run-cranelift", "f"]) + .output() + .expect("failed to run ilo"); + assert!( + !out.status.success(), + "first call: expected runtime error from lst OOB" + ); + + // Second call in a fresh process: must succeed cleanly. + let out = ilo() + .args(["f>L n;lst [1,2,3] 1 99", "--run-cranelift", "f"]) + .output() + .expect("failed to run ilo"); + assert!( + out.status.success(), + "second call: expected success, got stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + "[1, 99, 3]", + "second call should produce the updated list" + ); +} From f5e35d14501aac9cb9bab85805080a805957a5b5 Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Wed, 13 May 2026 23:38:37 +0100 Subject: [PATCH 3/3] test: update suites that pinned the old permissive-nil contract Four pre-existing tests asserted the Cranelift JIT's now-removed behaviour of silently returning nil (or the input list) on lst OOB, lst negative idx, xs.N OOB, and lset OOB. Each one is updated to expect the runtime error that all three engines now surface. - eval_inline::run_default_interpreter_error: xs.0 on [] - regression_dot_list_index::dot_index_out_of_range_cranelift: xs.5 - regression_list_mutation::lst_out_of_range_cranelift + lst_negative_cranelift: collapsed onto the same check_oor_error / shared assertion shape as the tree and VM siblings. - regression_lset_alias::lset_oob_cranelift_passthrough renamed to lset_oob_cranelift_errors. lset is the surface alias of lst so the parity must hold. No behaviour change; these were contract pins on what is now the old behaviour. Catching them in this PR rather than in a follow-up keeps the suite green. --- tests/eval_inline.rs | 18 ++++++++-------- tests/regression_dot_list_index.rs | 13 +++++++++--- tests/regression_list_mutation.rs | 33 ++++++++++-------------------- tests/regression_lset_alias.rs | 24 ++++++++++++++-------- 4 files changed, 47 insertions(+), 41 deletions(-) diff --git a/tests/eval_inline.rs b/tests/eval_inline.rs index 27785946..91929cf0 100644 --- a/tests/eval_inline.rs +++ b/tests/eval_inline.rs @@ -1368,21 +1368,23 @@ fn run_cranelift_not_eligible() { // L441-443: run_default interpreter fallback error #[test] fn run_default_interpreter_error() { - // f xs:L n>n;xs.0 with empty list [] — JIT now handles this, - // returns nil for out-of-bounds index + // `f xs:L n>n;xs.0` with empty list [] — OP_INDEX on an out-of-bounds + // literal is a runtime error on every engine after the JIT permissive- + // nil sweep (batch 1). Tree/VM already surfaced this; the JIT helper + // now matches via JIT_RUNTIME_ERROR. let out = ilo() .args(["f xs:L n>n;xs.0", "f", "[]"]) .output() .expect("failed to run ilo"); assert!( - out.status.success(), - "JIT handles empty list index, stderr: {}", - String::from_utf8_lossy(&out.stderr) + !out.status.success(), + "expected runtime error for OOB list index, got stdout: {}", + String::from_utf8_lossy(&out.stdout) ); - let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); assert!( - stdout.trim() == "nil", - "expected nil for empty list index, got: {stdout}" + stderr.contains("out of bounds") || stderr.contains("ILO-R004"), + "expected list-index-out-of-bounds diagnostic, got stderr: {stderr}" ); } diff --git a/tests/regression_dot_list_index.rs b/tests/regression_dot_list_index.rs index 8bdae4f3..689534c3 100644 --- a/tests/regression_dot_list_index.rs +++ b/tests/regression_dot_list_index.rs @@ -77,15 +77,22 @@ fn dot_index_out_of_range_tree_vm() { #[test] #[cfg(feature = "cranelift")] fn dot_index_out_of_range_cranelift() { + // After the JIT permissive-nil sweep (batch 1), Cranelift surfaces + // a runtime error for OOB literal-index OP_INDEX, matching tree/VM. let src = "f>n;xs=[10,20,30];xs.5"; let out = ilo() .args([src, "--run-cranelift", "f"]) .output() .expect("failed to run ilo"); assert!( - out.status.success(), - "cranelift: expected success returning nil for xs.5, got stderr={}", - String::from_utf8_lossy(&out.stderr) + !out.status.success(), + "cranelift: expected runtime error for xs.5, got stdout={}", + String::from_utf8_lossy(&out.stdout) + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("out of bounds") || stderr.contains("ILO-R004"), + "cranelift: expected out-of-bounds diagnostic, got stderr={stderr}" ); } diff --git a/tests/regression_list_mutation.rs b/tests/regression_list_mutation.rs index f8c1d51d..e4c42345 100644 --- a/tests/regression_list_mutation.rs +++ b/tests/regression_list_mutation.rs @@ -160,25 +160,13 @@ fn lst_out_of_range_vm() { check_oor_error("--run-vm"); } -// Cranelift JIT mirrors `at`'s permissive pattern: returns the original list -// unchanged on out-of-range. Safer than nil because the caller can still chain. +// Cranelift now matches tree/VM after the JIT permissive-nil sweep (batch 1): +// lst OOB raises a runtime error rather than silently returning the original +// list. The diagnostic is delivered via JIT_RUNTIME_ERROR + jit_cranelift::call. #[test] #[cfg(feature = "cranelift")] fn lst_out_of_range_cranelift() { - let out = ilo() - .args([OOR_SRC, "--run-cranelift", "f"]) - .output() - .expect("failed to run ilo"); - assert!( - out.status.success(), - "cranelift: expected success returning original list, got stderr={}", - String::from_utf8_lossy(&out.stderr) - ); - let stdout = String::from_utf8_lossy(&out.stdout); - assert!( - stdout.contains("[10, 20, 30]"), - "cranelift: expected original list [10, 20, 30], got {stdout}" - ); + check_oor_error("--run-cranelift"); } // Negative index: tree/vm error, cranelift returns original list unchanged. @@ -223,19 +211,20 @@ fn lst_negative_vm() { #[test] #[cfg(feature = "cranelift")] fn lst_negative_cranelift() { + // Cranelift now errors on a negative `lst` index to match tree/VM. let out = ilo() .args([NEG_SRC, "--run-cranelift", "f"]) .output() .expect("failed to run ilo"); assert!( - out.status.success(), - "cranelift: expected success returning original list, got stderr={}", - String::from_utf8_lossy(&out.stderr) + !out.status.success(), + "cranelift: expected error for lst xs -1 v, got stdout={}", + String::from_utf8_lossy(&out.stdout) ); - let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); assert!( - stdout.contains("[10, 20, 30]"), - "cranelift: expected original list, got {stdout}" + stderr.contains("non-negative") || stderr.contains("lst") || stderr.contains("ILO-R"), + "cranelift: expected non-negative/lst/ILO-R error, got stderr={stderr}" ); } diff --git a/tests/regression_lset_alias.rs b/tests/regression_lset_alias.rs index d233c14d..0a4fa58d 100644 --- a/tests/regression_lset_alias.rs +++ b/tests/regression_lset_alias.rs @@ -120,9 +120,10 @@ fn lset_last_cranelift() { assert_eq!(run("--run-cranelift", LAST_SRC, "f"), "[1, 2, 9]"); } -// Out-of-range index: tree/vm raise a runtime error; Cranelift returns the -// original list unchanged. Same split as the underlying `lst` builtin (see -// regression_list_mutation.rs). +// Out-of-range index: every engine now raises a runtime error after the +// JIT permissive-nil sweep (batch 1) brought Cranelift's `lst` helper into +// parity with tree/VM. `lset` is the surface alias of `lst`, so the same +// split applies. See regression_list_mutation.rs. const OOB_SRC: &str = "f>L n;lset [1,2,3] 5 99"; #[test] @@ -155,11 +156,18 @@ fn lset_oob_vm_errors() { #[test] #[cfg(feature = "cranelift")] -fn lset_oob_cranelift_passthrough() { - // Cranelift JIT mirrors `lst`'s permissive behaviour: returns the list - // unchanged rather than erroring. This is documented in the example - // header and in regression_list_mutation.rs; the alias must match. - assert_eq!(run("--run-cranelift", OOB_SRC, "f"), "[1, 2, 3]"); +fn lset_oob_cranelift_errors() { + // Cranelift now errors on OOB to match tree/VM (JIT nil-sweep batch 1). + let (stdout, stderr) = run_expect_fail("--run-cranelift", OOB_SRC, "f"); + let combined = format!("{stdout}{stderr}"); + assert!( + combined.contains("ILO-R004") || combined.contains("ILO-R009"), + "cranelift should raise R004/R009 on oob lset; got: {combined}" + ); + assert!( + combined.contains("out of range"), + "cranelift error should mention 'out of range'; got: {combined}" + ); } // Empty list with index 0: every engine treats this as out-of-range. Tree