@tishlang/tish-format 2.39.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/Cargo.toml +4 -1
  2. package/bin/tish-format +0 -0
  3. package/crates/tish/src/cli_help.rs +28 -0
  4. package/crates/tish/src/main.rs +41 -5
  5. package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/src/lib.rs +21 -0
  6. package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/tish.d.tish +4 -0
  7. package/crates/tish/tests/fixtures/cargo_example_project/src/main.tish +5 -1
  8. package/crates/tish/tests/regr_typed_externs.rs +59 -0
  9. package/crates/tish/tests/scope_merge.rs +79 -0
  10. package/crates/tish_build_utils/src/lib.rs +27 -1
  11. package/crates/tish_builtins/Cargo.toml +14 -6
  12. package/crates/tish_builtins/src/array.rs +130 -54
  13. package/crates/tish_builtins/src/collections.rs +9 -5
  14. package/crates/tish_builtins/src/construct.rs +7 -1
  15. package/crates/tish_builtins/src/date.rs +15 -1
  16. package/crates/tish_builtins/src/globals.rs +65 -3
  17. package/crates/tish_builtins/src/helpers.rs +7 -3
  18. package/crates/tish_builtins/src/iterator.rs +5 -1
  19. package/crates/tish_builtins/src/lib.rs +3 -0
  20. package/crates/tish_builtins/src/math.rs +14 -0
  21. package/crates/tish_builtins/src/number.rs +72 -6
  22. package/crates/tish_builtins/src/object.rs +6 -1
  23. package/crates/tish_builtins/src/string.rs +13 -2
  24. package/crates/tish_builtins/src/symbol.rs +17 -17
  25. package/crates/tish_builtins/src/typedarrays.rs +9 -2
  26. package/crates/tish_bytecode/src/compiler.rs +76 -2
  27. package/crates/tish_bytecode/src/lib.rs +1 -1
  28. package/crates/tish_bytecode/src/opcode.rs +77 -2
  29. package/crates/tish_compile/src/codegen.rs +2154 -225
  30. package/crates/tish_compile/src/infer.rs +37 -41
  31. package/crates/tish_compile/src/lib.rs +13 -1
  32. package/crates/tish_compile/src/platform_resolve.rs +361 -0
  33. package/crates/tish_compile/src/resolve.rs +195 -11
  34. package/crates/tish_compile/src/schemes.rs +333 -0
  35. package/crates/tish_compile/src/types.rs +205 -34
  36. package/crates/tish_compile/tests/perf_codegen_320.rs +2 -2
  37. package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +3 -1
  38. package/crates/tish_compile/tests/platform_resolve_cli.rs +164 -0
  39. package/crates/tish_compile/tests/regr_556_param_name_collision.rs +40 -0
  40. package/crates/tish_compile/tests/regr_558_struct_array_write.rs +65 -0
  41. package/crates/tish_compile/tests/regr_558b_struct_field_read.rs +21 -0
  42. package/crates/tish_compile/tests/regr_558c_wrapped_struct_array.rs +64 -0
  43. package/crates/tish_compile/tests/regr_558d_native_vec_mutators.rs +156 -0
  44. package/crates/tish_compile/tests/regr_562_wrapped_struct_array_push.rs +50 -0
  45. package/crates/tish_compile/tests/regr_fixed_native_coercions.rs +69 -0
  46. package/crates/tish_compile/tests/regr_gba_numerics_gated.rs +58 -0
  47. package/crates/tish_compile/tests/regr_gba_struct_reflection.rs +106 -0
  48. package/crates/tish_compile/tests/regression/name_collision_lib.tish +6 -0
  49. package/crates/tish_compile/tests/regression/name_collision_main.tish +13 -0
  50. package/crates/tish_compile/tests/regression/struct_array_write.tish +22 -0
  51. package/crates/tish_compile/tests/regression/struct_field_return_read.tish +10 -0
  52. package/crates/tish_compiler_wasm/Cargo.toml +1 -0
  53. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +56 -6
  54. package/crates/tish_core/Cargo.toml +39 -4
  55. package/crates/tish_core/src/compat.rs +506 -0
  56. package/crates/tish_core/src/console_style.rs +23 -1
  57. package/crates/tish_core/src/json.rs +42 -10
  58. package/crates/tish_core/src/lib.rs +121 -25
  59. package/crates/tish_core/src/macros.rs +1 -1
  60. package/crates/tish_core/src/shape.rs +4 -2
  61. package/crates/tish_core/src/uri.rs +8 -1
  62. package/crates/tish_core/src/value.rs +305 -54
  63. package/crates/tish_core/src/vmref.rs +17 -5
  64. package/crates/tish_eval/Cargo.toml +2 -0
  65. package/crates/tish_eval/src/eval.rs +90 -3
  66. package/crates/tish_eval/src/value.rs +8 -0
  67. package/crates/tish_eval/src/value_convert.rs +316 -21
  68. package/crates/tish_fmt/Cargo.toml +1 -1
  69. package/crates/tish_lsp/Cargo.toml +2 -0
  70. package/crates/tish_lsp/src/import_goto.rs +41 -14
  71. package/crates/tish_lsp/src/main.rs +18 -0
  72. package/crates/tish_native/src/build.rs +246 -0
  73. package/crates/tish_native/src/config.rs +20 -0
  74. package/crates/tish_native/src/lib.rs +16 -6
  75. package/crates/tish_parser/src/lib.rs +28 -0
  76. package/crates/tish_parser/src/parser.rs +25 -4
  77. package/crates/tish_runtime/src/lib.rs +55 -9
  78. package/crates/tish_runtime_gba/Cargo.lock +837 -0
  79. package/crates/tish_runtime_gba/Cargo.toml +16 -0
  80. package/crates/tish_runtime_gba/src/gba.rs +211 -0
  81. package/crates/tish_runtime_gba/src/lib.rs +804 -0
  82. package/crates/tish_vm/src/jit.rs +1446 -164
  83. package/crates/tish_vm/src/vm.rs +583 -173
  84. package/justfile +9 -0
  85. package/package.json +1 -1
  86. package/platform/darwin-arm64/tish-fmt +0 -0
  87. package/platform/darwin-x64/tish-fmt +0 -0
  88. package/platform/linux-arm64/tish-fmt +0 -0
  89. package/platform/linux-x64/tish-fmt +0 -0
  90. package/platform/win32-x64/tish-fmt.exe +0 -0
@@ -693,6 +693,16 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
693
693
  math.insert("fround".into(), Value::native(|a: &[Value]| math_builtins::fround(a)));
694
694
  math.insert("PI".into(), Value::Number(std::f64::consts::PI));
695
695
  math.insert("E".into(), Value::Number(std::f64::consts::E));
696
+ // #539: the remaining ECMAScript Math value constants (were missing → `null`).
697
+ math.insert("LN2".into(), Value::Number(std::f64::consts::LN_2));
698
+ math.insert("LN10".into(), Value::Number(std::f64::consts::LN_10));
699
+ math.insert("LOG2E".into(), Value::Number(std::f64::consts::LOG2_E));
700
+ math.insert("LOG10E".into(), Value::Number(std::f64::consts::LOG10_E));
701
+ math.insert("SQRT2".into(), Value::Number(std::f64::consts::SQRT_2));
702
+ math.insert(
703
+ "SQRT1_2".into(),
704
+ Value::Number(std::f64::consts::FRAC_1_SQRT_2),
705
+ );
696
706
  g.insert("Math".into(), value_object_from_map(math));
697
707
 
698
708
  let mut json = ObjectMap::default();
@@ -860,6 +870,14 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
860
870
  "fromEntries".into(),
861
871
  Value::native(|args: &[Value]| globals_builtins::object_from_entries(args)),
862
872
  );
873
+ object_methods.insert(
874
+ "freeze".into(),
875
+ Value::native(|args: &[Value]| globals_builtins::object_freeze(args)),
876
+ );
877
+ object_methods.insert(
878
+ "isFrozen".into(),
879
+ Value::native(|args: &[Value]| globals_builtins::object_is_frozen(args)),
880
+ );
863
881
  object_methods.insert(
864
882
  "keys".into(),
865
883
  Value::native(|args: &[Value]| globals_builtins::object_keys(args)),
@@ -1382,7 +1400,14 @@ mod hof_fusion {
1382
1400
  #[inline]
1383
1401
  fn numeric_snapshot(arr: &Value) -> Option<Vec<f64>> {
1384
1402
  match arr {
1385
- Value::NumberArray(a) => Some(a.borrow().clone()),
1403
+ Value::NumberArray(a) => match a.borrow().as_packed() {
1404
+ Some(v) => Some(v.clone()),
1405
+ None => a
1406
+ .borrow()
1407
+ .as_boxed()
1408
+ .map(|vs| vs.iter().map(|v| v.as_number()).collect::<Option<Vec<f64>>>())
1409
+ .unwrap_or(None),
1410
+ },
1386
1411
  Value::Array(a) => {
1387
1412
  let b = a.borrow();
1388
1413
  let mut out = Vec::with_capacity(b.len());
@@ -1671,19 +1696,27 @@ impl tishlang_core::Callable for VmClosure {
1671
1696
  native_modules: self.native_modules.clone(),
1672
1697
  };
1673
1698
  #[cfg(not(target_arch = "wasm32"))]
1674
- let result = {
1675
- stacker::maybe_grow(128 * 1024, 2 * 1024 * 1024, || {
1676
- vm.run_chunk(self.chunk.as_ref(), &self.chunk.nested, args, false)
1677
- .unwrap_or(Value::Null)
1678
- })
1679
- };
1699
+ let run = stacker::maybe_grow(128 * 1024, 2 * 1024 * 1024, || {
1700
+ vm.run_chunk(self.chunk.as_ref(), &self.chunk.nested, args, false)
1701
+ });
1680
1702
  #[cfg(target_arch = "wasm32")]
1681
- let result = {
1682
- vm.run_chunk(&self.chunk, &self.chunk.nested, args, false)
1683
- .unwrap_or(Value::Null)
1684
- };
1703
+ let run = vm.run_chunk(&self.chunk, &self.chunk.nested, args, false);
1685
1704
  tishlang_core::dec_call_depth();
1686
- result
1705
+ match run {
1706
+ Ok(v) => v,
1707
+ // A catchable throw is already parked in the pending slot; the caller's
1708
+ // take_pending_throw() surfaces it, so just yield Null here (issue #60).
1709
+ Err(ref e) if e == PENDING_THROW_SENTINEL || tishlang_core::has_pending_throw() => {
1710
+ Value::Null
1711
+ }
1712
+ // A plain runtime error inside the body (Undefined variable, type error, bad index,
1713
+ // call-of-non-function, …) must NOT vanish to null — park it as a catchable error so the
1714
+ // caller propagates it, matching the interpreter and top-level VM.
1715
+ Err(e) => {
1716
+ set_pending_throw(construct_builtin::error_object("Error", &e));
1717
+ Value::Null
1718
+ }
1719
+ }
1687
1720
  }
1688
1721
  fn as_any(&self) -> &dyn std::any::Any {
1689
1722
  self
@@ -1891,15 +1924,91 @@ impl Vm {
1891
1924
  _ => return OsrResult::LiveInMiss, // a live slot is non-numeric → deopt, keep interpreting
1892
1925
  }
1893
1926
  }
1927
+ // #203: array live-ins → a scratch `Vec<f64>` per array (the matmul lever). `scratch` OWNS the
1928
+ // extracted data; the [`crate::jit::ArrayHandle`]s point into it and are built only AFTER it is
1929
+ // fully populated so no inner buffer reallocates under a live pointer. A live-in that is not an
1930
+ // all-`Number` `Value::Array` (a `NumberArray`, a boxed non-number element, a non-array) bails
1931
+ // to the interpreter — so a misclassified array slot is always correct, just not JIT'd.
1932
+ let mut scratch: Vec<Vec<f64>> = Vec::with_capacity(loopfn.array_slots.len());
1933
+ for &(slot, _writable) in &loopfn.array_slots {
1934
+ match slots.get(slot_base + slot as usize) {
1935
+ Some(Value::Array(a)) => {
1936
+ let b = a.borrow();
1937
+ let mut ebuf: Vec<f64> = Vec::with_capacity(b.len());
1938
+ for el in b.iter() {
1939
+ match el {
1940
+ Value::Number(n) => ebuf.push(*n),
1941
+ _ => return OsrResult::LiveInMiss,
1942
+ }
1943
+ }
1944
+ scratch.push(ebuf);
1945
+ }
1946
+ _ => return OsrResult::LiveInMiss,
1947
+ }
1948
+ }
1949
+ // #203: a WRITABLE array must not alias another array live-in — the region reads/writes a
1950
+ // PRIVATE scratch copy, so aliasing would make one array's writes invisible to the other (the
1951
+ // interpreter shares one backing store). Bail to the interpreter on any such overlap.
1952
+ for (k, &(slot_k, writable_k)) in loopfn.array_slots.iter().enumerate() {
1953
+ if !writable_k {
1954
+ continue;
1955
+ }
1956
+ let ak = match slots.get(slot_base + slot_k as usize) {
1957
+ Some(Value::Array(a)) => a,
1958
+ _ => return OsrResult::LiveInMiss,
1959
+ };
1960
+ for (j, &(slot_j, _)) in loopfn.array_slots.iter().enumerate() {
1961
+ if j == k {
1962
+ continue;
1963
+ }
1964
+ if let Some(Value::Array(aj)) = slots.get(slot_base + slot_j as usize) {
1965
+ if VmRef::ptr_eq(ak, aj) {
1966
+ return OsrResult::LiveInMiss;
1967
+ }
1968
+ }
1969
+ }
1970
+ }
1971
+ let mut handles: Vec<crate::jit::ArrayHandle> = scratch
1972
+ .iter_mut()
1973
+ .map(|b| crate::jit::ArrayHandle {
1974
+ ptr: b.as_mut_ptr(),
1975
+ len: b.len(),
1976
+ })
1977
+ .collect();
1894
1978
  let mut deopt: u8 = 0;
1895
- // SAFETY: `buf` is a valid `[f64; used_slots.len()]`; `deopt` is a valid `*mut u8`. The region
1896
- // was compiled for exactly this chunk+header (fingerprint-checked in the cache).
1897
- let exit_id = loopfn.call(&mut buf, &mut deopt);
1979
+ // SAFETY: `buf` is a valid `[f64; used_slots.len()]`, `handles` a valid `[ArrayHandle;
1980
+ // array_slots.len()]` whose scratch outlives the call, `deopt` a valid `*mut u8`. The region was
1981
+ // compiled for exactly this chunk+header (fingerprint-checked in the cache) and its ABI matches
1982
+ // `array_slots` (2-pointer when empty, 3-pointer otherwise).
1983
+ let exit_id = loopfn.call(&mut buf, &mut handles, &mut deopt);
1984
+ if deopt != 0 {
1985
+ // An int-slot live-in wasn't an exact in-range integer (#514), OR an array index went
1986
+ // out of bounds mid-region (#203). Either way the region bailed WITHOUT committing — `buf`
1987
+ // and the array scratch are discarded here, the caller's arrays are untouched — so re-
1988
+ // interpreting from the pristine pre-loop slots is correct (no partial result escapes).
1989
+ return OsrResult::LiveInMiss;
1990
+ }
1898
1991
  for (p, &slot) in loopfn.used_slots.iter().enumerate() {
1899
1992
  if let Some(d) = slots.get_mut(slot_base + slot as usize) {
1900
1993
  *d = Value::Number(buf[p]);
1901
1994
  }
1902
1995
  }
1996
+ // #203: copy each WRITABLE array's (possibly-mutated) scratch back into its `Value::Array`.
1997
+ // Length is unchanged (`SetIndex` can't grow the array; an OOB write deopts above), so this is
1998
+ // a same-length element overwrite — mirroring `try_call_array_jit`'s writeback.
1999
+ for (k, &(slot, writable)) in loopfn.array_slots.iter().enumerate() {
2000
+ if !writable {
2001
+ continue;
2002
+ }
2003
+ if let Some(Value::Array(a)) = slots.get(slot_base + slot as usize) {
2004
+ let mut b = a.borrow_mut();
2005
+ for (j, &v) in scratch[k].iter().enumerate() {
2006
+ if let Some(el) = b.get_mut(j) {
2007
+ *el = Value::Number(v);
2008
+ }
2009
+ }
2010
+ }
2011
+ }
1903
2012
  match loopfn.exits.get(exit_id as usize) {
1904
2013
  Some(&ip) => OsrResult::Compiled(ip),
1905
2014
  // Out-of-range exit id is impossible (the region returns an in-range id), but if it ever
@@ -2265,6 +2374,13 @@ impl Vm {
2265
2374
  std::collections::HashMap::new();
2266
2375
  #[cfg(not(target_arch = "wasm32"))]
2267
2376
  let mut osr_hot: (usize, u32) = (usize::MAX, 0);
2377
+ // #203: `trig header → back-edge count at last OSR attempt`, so an outermost (expanded) region
2378
+ // fires on the FIRST sound entry past the threshold and then re-attempts only every `OSR_RETRY`
2379
+ // back-edges (a `LiveInMiss` cooldown). Its own back-edge is too rare for the modulo cadence the
2380
+ // flag-off path uses. Unused when `TISH_JIT_OSR_ARRAY` is off.
2381
+ #[cfg(not(target_arch = "wasm32"))]
2382
+ let mut osr_last_attempt: std::collections::HashMap<usize, u32> =
2383
+ std::collections::HashMap::new();
2268
2384
  // Frame-local string builder for `acc += x` on a slot local (Opcode::AppendLocal): keeps the
2269
2385
  // accumulator in a growable `sb_buf` so appends are amortized O(1) instead of reallocating the
2270
2386
  // whole string each time. `sb_slot` is the slot currently buffered (at most one). The slot's
@@ -2455,6 +2571,23 @@ impl Vm {
2455
2571
  .ok_or_else(|| format!("Bad MathUnary id: {}", id))?;
2456
2572
  self.stack.push(Value::Number(mfn.apply(x)));
2457
2573
  }
2574
+ Opcode::MathBinary => {
2575
+ // #203 — `Math.<fn>(a, b)` on two numbers. Same reasoning as MathUnary (emitted
2576
+ // only when `Math` is the global builtin); each non-number coerces to NaN. Args
2577
+ // were pushed a-then-b, so pop b first.
2578
+ let id = Self::read_u16(code, &mut ip);
2579
+ let b = match self.stack.pop() {
2580
+ Some(Value::Number(n)) => n,
2581
+ Some(_) | None => f64::NAN,
2582
+ };
2583
+ let a = match self.stack.pop() {
2584
+ Some(Value::Number(n)) => n,
2585
+ Some(_) | None => f64::NAN,
2586
+ };
2587
+ let bfn = tishlang_bytecode::MathBinaryFn::from_u16(id)
2588
+ .ok_or_else(|| format!("Bad MathBinary id: {}", id))?;
2589
+ self.stack.push(Value::Number(bfn.apply(a, b)));
2590
+ }
2458
2591
  Opcode::LoadConst => {
2459
2592
  let idx = Self::read_u16(code, &mut ip);
2460
2593
  let c = constants
@@ -2967,8 +3100,10 @@ impl Vm {
2967
3100
  if chunk.slot_based && Self::osr_enabled() {
2968
3101
  let region_end = ip;
2969
3102
  let header_ip = ip.saturating_sub(dist);
2970
- // Switch the fast slot only when the active loop changes: stash the old count,
2971
- // load this header's (default 0). The hot loop keeps `header_ip == osr_hot.0`.
3103
+ // Count on the RAW header via the single-entry fast slot — byte-identical to the
3104
+ // pre-#203 baseline. ALL #203 expansion work is gated behind the threshold below,
3105
+ // so a loop that is cold *per enclosing call* (e.g. nbody's `advance`, entered
3106
+ // 200k× with ~5-iteration loops) never reaches the gate and pays ZERO extra cost.
2972
3107
  if header_ip != osr_hot.0 {
2973
3108
  if osr_hot.0 != usize::MAX {
2974
3109
  osr_counters.insert(osr_hot.0, osr_hot.1);
@@ -2980,25 +3115,87 @@ impl Vm {
2980
3115
  }
2981
3116
  if osr_hot.1 != u32::MAX {
2982
3117
  osr_hot.1 = osr_hot.1.saturating_add(1);
2983
- if osr_hot.1 >= OSR_THRESHOLD
2984
- && osr_hot
3118
+ if osr_hot.1 >= OSR_THRESHOLD {
3119
+ if crate::jit::osr_array_enabled() {
3120
+ // #203: this loop is confirmed hot. Find the OUTERMOST enclosing
3121
+ // array-indexing loop so an array nest (matmul) marshals its arrays
3122
+ // ONCE and runs the whole nest natively, instead of re-marshalling
3123
+ // O(array) per inner entry (a wash).
3124
+ let (th, te, has_arr, worthy) = crate::jit::osr_expand_cached(
3125
+ chunk, header_ip, region_end,
3126
+ );
3127
+ if th != header_ip {
3128
+ // Expanded to an enclosing array loop: THIS inner loop is a
3129
+ // marshalling wash. Give up on it and mark the OUTER hot, so the
3130
+ // outer fires at its OWN (sound) back-edge on a later iteration —
3131
+ // restarting the outer from this inner point would re-run
3132
+ // already-completed iterations.
3133
+ osr_counters.insert(th, OSR_THRESHOLD);
3134
+ osr_hot.1 = u32::MAX;
3135
+ } else if has_arr && !worthy {
3136
+ // An array loop nested in a NON-array outer loop (k_nucleotide's
3137
+ // `seq[i+j]` inner loop): re-entered per outer iteration, so
3138
+ // array-OSR would re-marshal the whole array each time — a wash.
3139
+ // Leave it to the interpreter, exactly as the flag-off path does
3140
+ // (its `GetIndex` bails the numeric OSR there).
3141
+ osr_hot.1 = u32::MAX;
3142
+ } else {
3143
+ // This loop IS the trigger region. An array-WORTHY (outermost)
3144
+ // region fires on the FIRST attempt past the threshold, then
3145
+ // re-attempts only every OSR_RETRY back-edges (a LiveInMiss
3146
+ // cooldown); a pure-numeric region keeps the original modulo
3147
+ // sampling cadence.
3148
+ let due = if worthy {
3149
+ let last = osr_last_attempt
3150
+ .get(&header_ip)
3151
+ .copied()
3152
+ .unwrap_or(0);
3153
+ last == 0
3154
+ || osr_hot.1.saturating_sub(last) >= OSR_RETRY
3155
+ } else {
3156
+ osr_hot
3157
+ .1
3158
+ .saturating_sub(OSR_THRESHOLD)
3159
+ .is_multiple_of(OSR_RETRY)
3160
+ };
3161
+ if due {
3162
+ if worthy {
3163
+ osr_last_attempt.insert(header_ip, osr_hot.1);
3164
+ }
3165
+ match Self::run_osr(
3166
+ chunk, header_ip, te, &mut slot_locals, 0,
3167
+ ) {
3168
+ OsrResult::Compiled(exit_ip) => {
3169
+ ip = exit_ip;
3170
+ continue;
3171
+ }
3172
+ OsrResult::LiveInMiss => {}
3173
+ OsrResult::NotCompilable => {
3174
+ osr_hot.1 = u32::MAX
3175
+ }
3176
+ }
3177
+ }
3178
+ }
3179
+ } else if osr_hot
2985
3180
  .1
2986
3181
  .saturating_sub(OSR_THRESHOLD)
2987
3182
  .is_multiple_of(OSR_RETRY)
2988
- {
2989
- match Self::run_osr(
2990
- chunk,
2991
- header_ip,
2992
- region_end,
2993
- &mut slot_locals,
2994
- 0,
2995
- ) {
2996
- OsrResult::Compiled(exit_ip) => {
2997
- ip = exit_ip;
2998
- continue;
3183
+ {
3184
+ // Flag OFF: exact pre-#203 threshold+retry-modulo behavior.
3185
+ match Self::run_osr(
3186
+ chunk,
3187
+ header_ip,
3188
+ region_end,
3189
+ &mut slot_locals,
3190
+ 0,
3191
+ ) {
3192
+ OsrResult::Compiled(exit_ip) => {
3193
+ ip = exit_ip;
3194
+ continue;
3195
+ }
3196
+ OsrResult::LiveInMiss => {}
3197
+ OsrResult::NotCompilable => osr_hot.1 = u32::MAX,
2999
3198
  }
3000
- OsrResult::LiveInMiss => {}
3001
- OsrResult::NotCompilable => osr_hot.1 = u32::MAX,
3002
3199
  }
3003
3200
  }
3004
3201
  }
@@ -3174,6 +3371,7 @@ impl Vm {
3174
3371
  self.stack.push(Value::Object(VmRef::new(ObjectData {
3175
3372
  strings: map,
3176
3373
  symbols: None,
3374
+ frozen: false,
3177
3375
  })));
3178
3376
  }
3179
3377
  Opcode::EnterTry => {
@@ -3316,12 +3514,13 @@ impl Vm {
3316
3514
  .unwrap_or(Value::Null);
3317
3515
  let result = match &arr {
3318
3516
  Value::NumberArray(a) => {
3319
- // All-numeric fast path: operate on raw f64, no boxing/unboxing.
3320
- let arr_borrow = a.borrow();
3321
- let mapped: Vec<Value> = arr_borrow
3322
- .iter()
3323
- .map(|&n| {
3324
- let elem = Value::Number(n);
3517
+ // Fast path over the numeric-origin array (packed or deopted): map each
3518
+ // element through the const binop.
3519
+ let mapped: Vec<Value> = a
3520
+ .borrow()
3521
+ .to_values()
3522
+ .into_iter()
3523
+ .map(|elem| {
3325
3524
  let (l, r) = if param_left {
3326
3525
  (elem, const_val.clone())
3327
3526
  } else {
@@ -3388,21 +3587,31 @@ impl Vm {
3388
3587
  .unwrap_or(Value::Null);
3389
3588
  let result = match &arr {
3390
3589
  Value::NumberArray(a) => {
3391
- let arr_borrow = a.borrow();
3392
- let filtered: Vec<f64> = arr_borrow
3393
- .iter()
3394
- .filter(|&&n| {
3395
- let elem = Value::Number(n);
3590
+ // Numeric-origin filter (packed or deopted): keep elements whose const
3591
+ // binop is truthy, preserving each element's actual Value.
3592
+ let kept: Vec<Value> = a
3593
+ .borrow()
3594
+ .to_values()
3595
+ .into_iter()
3596
+ .filter(|elem| {
3396
3597
  let (l, r) = if param_left {
3397
- (elem, const_val.clone())
3598
+ (elem.clone(), const_val.clone())
3398
3599
  } else {
3399
- (const_val.clone(), elem)
3600
+ (const_val.clone(), elem.clone())
3400
3601
  };
3401
3602
  eval_binop(binop, &l, &r).unwrap_or(Value::Null).is_truthy()
3402
3603
  })
3403
- .copied()
3404
3604
  .collect();
3405
- Value::number_array(filtered)
3605
+ // Stay packed when every kept element is a plain number.
3606
+ if let Some(nums) = kept
3607
+ .iter()
3608
+ .map(|v| v.as_number())
3609
+ .collect::<Option<Vec<f64>>>()
3610
+ {
3611
+ Value::number_array(nums)
3612
+ } else {
3613
+ Value::Array(VmRef::new(kept))
3614
+ }
3406
3615
  }
3407
3616
  Value::Array(a) => {
3408
3617
  let arr_borrow = a.borrow();
@@ -3718,6 +3927,13 @@ fn ic_set_member(
3718
3927
  use std::sync::atomic::Ordering::Relaxed;
3719
3928
  if let Value::Object(od) = obj {
3720
3929
  let mut b = od.borrow_mut();
3930
+ if b.frozen {
3931
+ // #437: writes to a frozen object throw a catchable TypeError (the SetMember handler
3932
+ // routes this Err through `catchable!` → a TypeError).
3933
+ return Err(format!(
3934
+ "Cannot assign to read only property '{key}' of a frozen object"
3935
+ ));
3936
+ }
3721
3937
  let shape = b.strings.shape();
3722
3938
  let cell = chunk.inline_caches.0.get(name_idx as usize);
3723
3939
  if shape != tishlang_core::DICT_SHAPE {
@@ -3769,11 +3985,7 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
3769
3985
  let key_s = key.as_ref();
3770
3986
  // Numeric index fast path.
3771
3987
  if let Ok(idx) = key_s.parse::<usize>() {
3772
- return Ok(a
3773
- .borrow()
3774
- .get(idx)
3775
- .map(|&n| Value::Number(n))
3776
- .unwrap_or(Value::Null));
3988
+ return Ok(a.borrow().get(idx).unwrap_or(Value::Null));
3777
3989
  }
3778
3990
  if key_s == "length" {
3779
3991
  return Ok(Value::Number(a.borrow().len() as f64));
@@ -3782,135 +3994,225 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
3782
3994
  let a_clone = a.clone();
3783
3995
  let method: ArrayMethodFn = match key_s {
3784
3996
  "push" => make_native_fn(move |args: &[Value]| {
3785
- let mut arr = a_clone.borrow_mut();
3997
+ let mut b = a_clone.borrow_mut();
3786
3998
  for v in args {
3787
- match v {
3788
- Value::Number(n) => arr.push(*n),
3789
- _ => {
3790
- arr.push(f64::NAN); // hole-marker for non-numeric
3791
- }
3999
+ match (b.as_packed_mut(), v) {
4000
+ (Some(nums), Value::Number(n)) => nums.push(*n),
4001
+ // First non-numeric (or already deopted) → upgrade in place, push Value.
4002
+ _ => b.deopt().push(v.clone()),
3792
4003
  }
3793
4004
  }
3794
- Value::Number(arr.len() as f64)
4005
+ Value::Number(b.len() as f64)
3795
4006
  }),
3796
4007
  "pop" => make_native_fn(move |_: &[Value]| {
3797
- a_clone
3798
- .borrow_mut()
3799
- .pop()
3800
- .map(|n| {
3801
- if n.is_nan() {
3802
- Value::Null
3803
- } else {
3804
- Value::Number(n)
3805
- }
3806
- })
3807
- .unwrap_or(Value::Null)
4008
+ let mut b = a_clone.borrow_mut();
4009
+ match b.as_packed_mut() {
4010
+ Some(nums) => nums
4011
+ .pop()
4012
+ .map(|n| if n.is_nan() { Value::Null } else { Value::Number(n) })
4013
+ .unwrap_or(Value::Null),
4014
+ None => b.deopt().pop().unwrap_or(Value::Null),
4015
+ }
3808
4016
  }),
3809
4017
  "shift" => make_native_fn(move |_: &[Value]| {
3810
- let mut arr = a_clone.borrow_mut();
3811
- if arr.is_empty() {
3812
- Value::Null
3813
- } else {
3814
- let n = arr.remove(0);
3815
- if n.is_nan() {
3816
- Value::Null
3817
- } else {
3818
- Value::Number(n)
4018
+ let mut b = a_clone.borrow_mut();
4019
+ if b.is_empty() {
4020
+ return Value::Null;
4021
+ }
4022
+ match b.as_packed_mut() {
4023
+ Some(nums) => {
4024
+ let n = nums.remove(0);
4025
+ if n.is_nan() { Value::Null } else { Value::Number(n) }
3819
4026
  }
4027
+ None => b.deopt().remove(0),
3820
4028
  }
3821
4029
  }),
3822
4030
  "unshift" => make_native_fn(move |args: &[Value]| {
3823
- let mut arr = a_clone.borrow_mut();
3824
- for (i, v) in args.iter().enumerate() {
3825
- let n = match v {
3826
- Value::Number(n) => *n,
3827
- _ => f64::NAN,
3828
- };
3829
- arr.insert(i, n);
4031
+ let mut b = a_clone.borrow_mut();
4032
+ let all_num = args.iter().all(|v| matches!(v, Value::Number(_)));
4033
+ if all_num && b.as_packed().is_some() {
4034
+ let nums = b.as_packed_mut().unwrap();
4035
+ for (i, v) in args.iter().enumerate() {
4036
+ if let Value::Number(n) = v {
4037
+ nums.insert(i, *n);
4038
+ }
4039
+ }
4040
+ } else {
4041
+ let boxed = b.deopt();
4042
+ for (i, v) in args.iter().enumerate() {
4043
+ boxed.insert(i, v.clone());
4044
+ }
3830
4045
  }
3831
- Value::Number(arr.len() as f64)
4046
+ Value::Number(b.len() as f64)
3832
4047
  }),
4048
+ // #502: `fill` mutates IN PLACE, but the packed block routed it through the
4049
+ // materialize-to-boxed fallback below — which fills a THROWAWAY copy, so the
4050
+ // original array was untouched (a silent no-op). A numeric fill value writes
4051
+ // straight into the Vec<f64>; a non-numeric value can't live in a packed array
4052
+ // (the documented rep limit, #199) so it deopts: materialize, fill the boxed copy,
4053
+ // and write the numeric elements back (non-numeric slots become NaN holes — the
4054
+ // same lossy edge splice/set-index already have on packed).
4055
+ "fill" => {
4056
+ let a2 = a_clone.clone();
4057
+ make_native_fn(move |args: &[Value]| {
4058
+ let len = a2.borrow().len() as i64;
4059
+ let norm = |v: Option<&Value>, dflt: i64| -> i64 {
4060
+ match v {
4061
+ Some(Value::Number(n)) => {
4062
+ let i = *n as i64;
4063
+ if i < 0 { (len + i).max(0) } else { i.min(len) }
4064
+ }
4065
+ _ => dflt,
4066
+ }
4067
+ };
4068
+ let start = norm(args.get(1), 0);
4069
+ let end = norm(args.get(2), len);
4070
+ let fill_v = args.first().cloned().unwrap_or(Value::Null);
4071
+ let mut b = a2.borrow_mut();
4072
+ match (b.as_packed_mut(), &fill_v) {
4073
+ // Numeric fill stays packed — write straight into the Vec<f64>.
4074
+ (Some(nums), Value::Number(fv)) => {
4075
+ let mut i = start;
4076
+ while i < end && (i as usize) < nums.len() {
4077
+ nums[i as usize] = *fv;
4078
+ i += 1;
4079
+ }
4080
+ }
4081
+ // Non-numeric fill (or already deopted): upgrade in place and fill the
4082
+ // boxed Vec with the actual Value — no NaN-hole loss (#199 sound).
4083
+ _ => {
4084
+ let boxed = b.deopt();
4085
+ let mut i = start;
4086
+ while i < end && (i as usize) < boxed.len() {
4087
+ boxed[i as usize] = fill_v.clone();
4088
+ i += 1;
4089
+ }
4090
+ }
4091
+ }
4092
+ drop(b);
4093
+ Value::NumberArray(a2.clone())
4094
+ })
4095
+ }
4096
+ // #560: `copyWithin` MUTATES in place, but — unlike fill/reverse/splice — it had no
4097
+ // packed arm, so it fell to the `_ =>` materialise-to-boxed fallback below and copied
4098
+ // within a THROWAWAY boxed copy, leaving the packed array untouched (a silent no-op
4099
+ // that diverged from interp). copyWithin only relocates existing elements, so the
4100
+ // array stays packed: block-copy straight on the Vec<f64> (deopt only if already
4101
+ // boxed). Index clamping mirrors `arr_builtins::copy_within` (`normalize_index`).
4102
+ "copyWithin" => {
4103
+ let a2 = a_clone.clone();
4104
+ make_native_fn(move |args: &[Value]| {
4105
+ let len = a2.borrow().len() as i64;
4106
+ let norm = |v: Option<&Value>, dflt: i64| -> i64 {
4107
+ match v {
4108
+ Some(Value::Number(n)) => {
4109
+ let i = *n as i64;
4110
+ if i < 0 { (len + i).max(0) } else { i.min(len) }
4111
+ }
4112
+ _ => dflt,
4113
+ }
4114
+ };
4115
+ let target = norm(args.first(), 0) as usize;
4116
+ let start = norm(args.get(1), 0) as usize;
4117
+ let end = norm(args.get(2), len) as usize;
4118
+ let mut b = a2.borrow_mut();
4119
+ match b.as_packed_mut() {
4120
+ Some(nums) => {
4121
+ if start < end && target < nums.len() {
4122
+ let count = (end - start).min(nums.len() - target);
4123
+ let block: Vec<f64> = nums[start..start + count].to_vec();
4124
+ for (k, val) in block.into_iter().enumerate() {
4125
+ nums[target + k] = val;
4126
+ }
4127
+ }
4128
+ }
4129
+ None => {
4130
+ let boxed = b.deopt();
4131
+ if start < end && target < boxed.len() {
4132
+ let count = (end - start).min(boxed.len() - target);
4133
+ let block: Vec<Value> = boxed[start..start + count].to_vec();
4134
+ for (k, val) in block.into_iter().enumerate() {
4135
+ boxed[target + k] = val;
4136
+ }
4137
+ }
4138
+ }
4139
+ }
4140
+ drop(b);
4141
+ Value::NumberArray(a2.clone())
4142
+ })
4143
+ }
3833
4144
  "reverse" => make_native_fn(move |_: &[Value]| {
3834
- a_clone.borrow_mut().reverse();
4145
+ let mut b = a_clone.borrow_mut();
4146
+ match b.as_packed_mut() {
4147
+ Some(nums) => nums.reverse(),
4148
+ None => b.deopt().reverse(),
4149
+ }
4150
+ drop(b);
3835
4151
  Value::NumberArray(a_clone.clone())
3836
4152
  }),
3837
4153
  "splice" => {
3838
4154
  let a2 = a_clone.clone();
3839
4155
  make_native_fn(move |args: &[Value]| {
3840
- // Check if there are non-numeric items to insert (args[2..]).
3841
- let has_non_numeric = args
3842
- .get(2..)
3843
- .unwrap_or(&[])
3844
- .iter()
3845
- .any(|v| !matches!(v, Value::Number(_)));
3846
- if has_non_numeric {
3847
- // Deopt: materialise, splice on the boxed array, then write numeric
3848
- // elements back to the original Vec<f64>. This preserves the VmRef
3849
- // identity for subsequent accesses. The array may have non-numeric
3850
- // elements after this splice — they become NaN holes in the VmRef.
3851
- let boxed = Value::materialize_number_array(&a2);
3852
- let result = arr_builtins::splice(
3853
- &boxed,
3854
- args.first().unwrap_or(&Value::Null),
3855
- args.get(1),
3856
- args.get(2..).unwrap_or(&[]),
3857
- );
3858
- // Sync the modified boxed Vec back into the original VmRef.
3859
- if let Value::Array(boxed_vmref) = &boxed {
3860
- let mut packed = a2.borrow_mut();
3861
- *packed = boxed_vmref
3862
- .borrow()
3863
- .iter()
3864
- .map(|v| match v {
3865
- Value::Number(n) => *n,
3866
- _ => f64::NAN,
3867
- })
3868
- .collect();
4156
+ let inserts = args.get(2..).unwrap_or(&[]);
4157
+ let all_num = inserts.iter().all(|v| matches!(v, Value::Number(_)));
4158
+ let mut b = a2.borrow_mut();
4159
+ let len = b.len() as i64;
4160
+ let start = match args.first() {
4161
+ Some(Value::Number(n)) => {
4162
+ let sn = *n as i64;
4163
+ if sn < 0 { (len + sn).max(0) as usize } else { (sn as usize).min(b.len()) }
3869
4164
  }
3870
- result
3871
- } else {
3872
- let mut arr = a2.borrow_mut();
3873
- let len = arr.len() as i64;
3874
- let start = match args.first() {
3875
- Some(Value::Number(n)) => {
3876
- let s = *n as i64;
3877
- if s < 0 {
3878
- (len + s).max(0) as usize
3879
- } else {
3880
- (s as usize).min(arr.len())
3881
- }
3882
- }
3883
- _ => 0,
3884
- };
3885
- let del = match args.get(1) {
3886
- Some(Value::Number(n)) => (*n as i64).max(0) as usize,
3887
- _ => arr.len().saturating_sub(start),
3888
- };
3889
- let del = del.min(arr.len().saturating_sub(start));
3890
- let new_nums: Vec<f64> = args
3891
- .get(2..)
3892
- .unwrap_or(&[])
4165
+ _ => 0,
4166
+ };
4167
+ let del = match args.get(1) {
4168
+ Some(Value::Number(n)) => (*n as i64).max(0) as usize,
4169
+ _ => b.len().saturating_sub(start),
4170
+ };
4171
+ let del = del.min(b.len().saturating_sub(start));
4172
+ if all_num && b.as_packed().is_some() {
4173
+ // Stay packed: splice the Vec<f64>, return the removed f64s.
4174
+ let nums = b.as_packed_mut().unwrap();
4175
+ let new_nums: Vec<f64> = inserts
3893
4176
  .iter()
3894
- .map(|v| match v {
3895
- Value::Number(n) => *n,
3896
- _ => f64::NAN,
3897
- })
4177
+ .map(|v| if let Value::Number(n) = v { *n } else { f64::NAN })
3898
4178
  .collect();
3899
- let removed: Vec<f64> =
3900
- arr.splice(start..start + del, new_nums).collect();
4179
+ let removed: Vec<f64> = nums.splice(start..start + del, new_nums).collect();
3901
4180
  Value::number_array(removed)
4181
+ } else {
4182
+ // #506: non-numeric inserts (or already deopted) — upgrade in place and
4183
+ // splice the boxed Vec. The array genuinely holds the inserted values and
4184
+ // the removed elements are correct (no NaN loss).
4185
+ let boxed = b.deopt();
4186
+ let removed: Vec<Value> =
4187
+ boxed.splice(start..start + del, inserts.iter().cloned()).collect();
4188
+ Value::Array(VmRef::new(removed))
3902
4189
  }
3903
4190
  })
3904
4191
  }
3905
4192
  "sort" => make_native_fn(move |args: &[Value]| {
3906
- let arr_val = Value::NumberArray(a_clone.clone());
3907
4193
  let cmp = args.first();
3908
4194
  if let Some(Value::Function(_)) = cmp {
3909
4195
  // Comparator sort: materialise first (comparator may return non-numeric).
3910
4196
  let boxed = Value::materialize_number_array(&a_clone);
3911
4197
  arr_builtins::sort_with_comparator(&boxed, cmp.unwrap())
3912
4198
  } else {
3913
- arr_builtins::sort_numeric_asc(&arr_val)
4199
+ // JS default `sort()` is LEXICOGRAPHIC — String(element), NOT numeric — so
4200
+ // `[10, 9, 100].sort()` is `[10, 100, 9]`. The boxed path already does this
4201
+ // (arr_builtins::sort_default); match it on the packed vec by comparing each
4202
+ // number's display string. (Pre-existing packed bug: it sorted numerically.)
4203
+ let mut b = a_clone.borrow_mut();
4204
+ match b.as_packed_mut() {
4205
+ Some(nums) => nums.sort_by(|x, y| {
4206
+ Value::Number(*x)
4207
+ .to_display_string()
4208
+ .cmp(&Value::Number(*y).to_display_string())
4209
+ }),
4210
+ None => b.deopt().sort_by(|x, y| {
4211
+ x.to_display_string().cmp(&y.to_display_string())
4212
+ }),
4213
+ }
4214
+ drop(b);
4215
+ Value::NumberArray(a_clone.clone())
3914
4216
  }
3915
4217
  }),
3916
4218
  _ => {
@@ -4506,6 +4808,11 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
4506
4808
  fn set_member(obj: &Value, key: &Arc<str>, val: Value) -> Result<(), String> {
4507
4809
  match obj {
4508
4810
  Value::Object(m) => {
4811
+ if m.borrow().frozen {
4812
+ return Err(format!(
4813
+ "Cannot assign to read only property '{key}' of a frozen object"
4814
+ ));
4815
+ }
4509
4816
  m.borrow_mut().strings.insert(Arc::clone(key), val);
4510
4817
  Ok(())
4511
4818
  }
@@ -4531,7 +4838,11 @@ fn set_member(obj: &Value, key: &Arc<str>, val: Value) -> Result<(), String> {
4531
4838
  if key.as_ref() == "length" {
4532
4839
  let new_len = array_length_arg(&val)?;
4533
4840
  // NaN is the packed-array hole marker (read back as Null), matching get_index.
4534
- a.borrow_mut().resize(new_len, f64::NAN);
4841
+ let mut b = a.borrow_mut();
4842
+ match b.as_packed_mut() {
4843
+ Some(nums) => nums.resize(new_len, f64::NAN),
4844
+ None => b.deopt().resize(new_len, Value::Null),
4845
+ }
4535
4846
  return Ok(());
4536
4847
  }
4537
4848
  Err(format!("Cannot set property of {}", obj.type_name()))
@@ -4566,12 +4877,9 @@ fn get_index(obj: &Value, idx: &Value) -> Result<Value, String> {
4566
4877
  // NaN is used as the hole marker (sparse-array positions); reads return Null.
4567
4878
  Ok(a.borrow()
4568
4879
  .get(i)
4569
- .map(|&n| {
4570
- if n.is_nan() {
4571
- Value::Null
4572
- } else {
4573
- Value::Number(n)
4574
- }
4880
+ .map(|v| match v {
4881
+ Value::Number(n) if n.is_nan() => Value::Null,
4882
+ other => other,
4575
4883
  })
4576
4884
  .unwrap_or(Value::Null))
4577
4885
  }
@@ -4661,6 +4969,23 @@ fn delete_index(obj: &Value, key: &Value) {
4661
4969
  }
4662
4970
  }
4663
4971
  }
4972
+ // #504: `delete arr[i]` on a packed array — the Vec<f64> can't hold Null, but NaN is the
4973
+ // rep's own hole marker (GetIndex reads a NaN element back as Null, matching a boxed hole),
4974
+ // so a delete writes NaN. Length is unchanged, exactly like the boxed arm's `= Null`.
4975
+ Value::NumberArray(a) => {
4976
+ if let Value::Number(n) = key {
4977
+ let n = *n;
4978
+ if n >= 0.0 && n.fract() == 0.0 {
4979
+ let i = n as usize;
4980
+ let mut b = a.borrow_mut();
4981
+ if i < b.len() {
4982
+ // Deopt so the hole is a real Null (matches the boxed arm exactly, and keeps
4983
+ // a genuinely-stored NaN element distinct from a deleted slot).
4984
+ b.deopt()[i] = Value::Null;
4985
+ }
4986
+ }
4987
+ }
4988
+ }
4664
4989
  _ => {}
4665
4990
  }
4666
4991
  }
@@ -4688,27 +5013,23 @@ fn set_index(obj: &Value, idx: &Value, val: Value) -> Result<(), String> {
4688
5013
  // a sentinel error — the caller (SetIndex opcode) does NOT handle deopt.
4689
5014
  // Instead we only do in-bounds-or-next-element numeric assignments here;
4690
5015
  // anything that creates holes (i > len) or sets a non-number is unsupported.
4691
- match val {
4692
- Value::Number(n) => {
4693
- let mut arr = a.borrow_mut();
4694
- // Extend with NaN "holes" if needed (NaN = sparse hole; read back as Null).
4695
- while arr.len() <= i {
4696
- arr.push(f64::NAN);
5016
+ let mut b = a.borrow_mut();
5017
+ match (b.as_packed_mut(), &val) {
5018
+ // Numeric assignment stays packed — NaN-extend to a sparse hole if past the end.
5019
+ (Some(nums), Value::Number(n)) => {
5020
+ while nums.len() <= i {
5021
+ nums.push(f64::NAN);
4697
5022
  }
4698
- arr[i] = n;
5023
+ nums[i] = *n;
4699
5024
  }
4700
- // Non-numeric set: the Vec<f64> can't represent this type. Extend with NaN holes
4701
- // up to the index, then leave the slot as NaN (the value is lost). This is a
4702
- // known limitation of NumberArray; the uncommon mixed-type path should not produce
4703
- // a NumberArray in the first place. The caller will see the correct index reads for
4704
- // numeric elements and Null for the NaN holes.
5025
+ // Non-numeric (or already deopted): upgrade in place and store the real Value —
5026
+ // the array genuinely becomes heterogeneous, no data loss (#199 sound).
4705
5027
  _ => {
4706
- let mut arr = a.borrow_mut();
4707
- while arr.len() <= i {
4708
- arr.push(f64::NAN);
5028
+ let boxed = b.deopt();
5029
+ while boxed.len() <= i {
5030
+ boxed.push(Value::Null);
4709
5031
  }
4710
- // arr[i] is already NaN (hole); we can't store the non-numeric value — acceptable
4711
- // for the experimental TISH_PACKED_ARRAYS path.
5032
+ boxed[i] = val;
4712
5033
  }
4713
5034
  }
4714
5035
  Ok(())
@@ -4735,7 +5056,15 @@ fn set_index(obj: &Value, idx: &Value, val: Value) -> Result<(), String> {
4735
5056
  arr[i] = val;
4736
5057
  Ok(())
4737
5058
  }
4738
- Value::Object(_) => object_set(obj, idx, val),
5059
+ Value::Object(m) => {
5060
+ if m.borrow().frozen {
5061
+ return Err(format!(
5062
+ "Cannot assign to read only property '{}' of a frozen object",
5063
+ idx.to_display_string()
5064
+ ));
5065
+ }
5066
+ object_set(obj, idx, val)
5067
+ }
4739
5068
  _ => Err(format!("Cannot set property of {}", obj.type_name())),
4740
5069
  }
4741
5070
  }
@@ -4837,3 +5166,84 @@ mod recursion_limit_tests_381 {
4837
5166
  );
4838
5167
  }
4839
5168
  }
5169
+
5170
+ #[cfg(test)]
5171
+ mod undeclared_var_in_fn_tests {
5172
+ use tishlang_core::Value;
5173
+
5174
+ fn run_src(src: &str) -> Result<Value, String> {
5175
+ let program = tishlang_parser::parse(src).expect("parse");
5176
+ let chunk = tishlang_bytecode::compile(&program).expect("compile");
5177
+ super::run(&chunk)
5178
+ }
5179
+
5180
+ // A free/undeclared identifier read INSIDE a called function must surface as an error, not be
5181
+ // swallowed to null (regression: closure calls used to `run_chunk(..).unwrap_or(Value::Null)`,
5182
+ // so any error in a callee body vanished — the VM returned null where the interpreter throws).
5183
+ #[test]
5184
+ fn undeclared_read_in_fn_errors_not_null() {
5185
+ let out = run_src("fn f() { return zzznotdeclared }\nf()");
5186
+ assert!(
5187
+ out.is_err(),
5188
+ "undeclared read in a function body must error, got: {out:?}"
5189
+ );
5190
+ assert!(
5191
+ out.as_ref().unwrap_err().contains("zzznotdeclared"),
5192
+ "error must name the missing variable: {out:?}"
5193
+ );
5194
+ }
5195
+
5196
+ // `undefined` is just a specific undeclared identifier — same rejection, since tish has no
5197
+ // `undefined` value.
5198
+ #[test]
5199
+ fn undefined_ident_in_fn_errors_not_null() {
5200
+ let out = run_src("fn f() { return undefined }\nf()");
5201
+ assert!(
5202
+ out.is_err() && out.as_ref().unwrap_err().contains("undefined"),
5203
+ "free `undefined` in a function body must error: {out:?}"
5204
+ );
5205
+ }
5206
+
5207
+ // The propagated error is catchable, matching the interpreter.
5208
+ // Object.freeze marks an object frozen and a subsequent property write throws a catchable
5209
+ // TypeError (#437 — tish's own stricter spec; node's sloppy mode silently ignores it).
5210
+ #[test]
5211
+ fn frozen_write_throws_catchable_typeerror() {
5212
+ // Uncaught: the frozen write propagates as an error.
5213
+ let uncaught = run_src("let o = { a: 1 }\nObject.freeze(o)\no.a = 2");
5214
+ assert!(
5215
+ uncaught.is_err(),
5216
+ "a write to a frozen object must throw, not silently succeed: {uncaught:?}"
5217
+ );
5218
+ // Caught: try/catch recovers it, and the value is unchanged.
5219
+ let caught = run_src(
5220
+ "let o = { a: 1 }\n\
5221
+ Object.freeze(o)\n\
5222
+ try { o.a = 2 } catch (e) { }\n\
5223
+ o.a",
5224
+ );
5225
+ assert!(
5226
+ caught.is_ok(),
5227
+ "the frozen write must be catchable (try/catch recovers): {caught:?}"
5228
+ );
5229
+ }
5230
+
5231
+ // The propagated error is catchable, matching the interpreter (issue #437 bucket-A).
5232
+ #[test]
5233
+ fn undeclared_read_in_fn_is_catchable() {
5234
+ // If the callee error weren't catchable it would propagate as an Err from run_src; a caught
5235
+ // throw lets the program complete normally (Ok). An uncaught baseline (below) confirms the
5236
+ // try/catch is what recovers it, not that the error silently vanished.
5237
+ let caught = run_src(
5238
+ "fn f() { return zzz }\n\
5239
+ try { f() } catch (e) { }\n\
5240
+ 1",
5241
+ );
5242
+ assert!(caught.is_ok(), "callee error must be catchable: {caught:?}");
5243
+ let uncaught = run_src("fn f() { return zzz }\nf()");
5244
+ assert!(
5245
+ uncaught.is_err(),
5246
+ "without a catch the same error must propagate, not vanish: {uncaught:?}"
5247
+ );
5248
+ }
5249
+ }