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/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 ---- 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_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" + ); +} 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