@tishlang/tish-lint 3.3.0 → 3.3.3

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.
@@ -1102,6 +1102,21 @@ pub(crate) struct Codegen {
1102
1102
  /// Top-level `let name = [f64 literals…]` never reassigned — emitted as `const G_name: [f64; N]`
1103
1103
  /// for direct indexing from native fns (fasta `codes`/`probs`).
1104
1104
  module_const_f64_arrays: std::collections::HashMap<String, Vec<f64>>,
1105
+ /// #597: names passed DIRECTLY as a call argument (`fill(reqs)`). A typed array in this set must
1106
+ /// not lower to a native `Vec` — see the note at the `VarDecl` typing site.
1107
+ arrays_passed_to_calls: std::collections::HashSet<String>,
1108
+ /// The STATIC names from `module_const_f64_arrays` whose every element is an exact integer in
1109
+ /// `i32` range, so the hoist emits `[i32; N]` instead of `[f64; N]`.
1110
+ ///
1111
+ /// ARM7TDMI HAS NO FPU. These passes were written for the host, where `f64` is tish's number and
1112
+ /// the representation is free; on GBA every `f64` touch is a soft-float call and every element
1113
+ /// costs 8 bytes instead of 4. Worse, the source usually declares the element type — hoisting
1114
+ /// `let A: i32[] = [1,2,3]` to `[f64; 3]` discards a type the program already stated, then pays
1115
+ /// an `i32 -> f64 -> usize` round trip on each index to get back to where it started.
1116
+ ///
1117
+ /// Keyed by static name, not source name, so every read site (including the `_cum` aliases) can
1118
+ /// ask the question with what it already has in hand.
1119
+ module_const_int_statics: std::collections::HashSet<String>,
1105
1120
  /// Precomputed cumulative arrays for `module_const_f64_arrays` used as `cumulative(p)` input
1106
1121
  /// (`probs` → `G_probs_cum`).
1107
1122
  module_const_f64_cum: std::collections::HashMap<String, String>,
@@ -1383,6 +1398,8 @@ impl Codegen {
1383
1398
  refcell_wrapped_vars: std::collections::HashSet::new(),
1384
1399
  native_numeric_globals: std::collections::HashMap::new(),
1385
1400
  module_const_f64_arrays: std::collections::HashMap::new(),
1401
+ module_const_int_statics: std::collections::HashSet::new(),
1402
+ arrays_passed_to_calls: std::collections::HashSet::new(),
1386
1403
  module_const_f64_cum: std::collections::HashMap::new(),
1387
1404
  module_const_f64_aliases: std::collections::HashMap::new(),
1388
1405
  map_instance_locals: std::collections::HashSet::new(),
@@ -2429,6 +2446,20 @@ impl Codegen {
2429
2446
  "box", "do", "final", "macro", "override", "priv", "try", "typeof", "unsized",
2430
2447
  "virtual", "yield",
2431
2448
  ];
2449
+ // #549: FOUR KEYWORDS CANNOT BE RAW IDENTIFIERS AT ALL. `r#self`, `r#Self`, `r#super` and
2450
+ // `r#crate` are rejected by rustc outright ("`self` cannot be a raw identifier"), so the
2451
+ // blanket `r#` below emitted a program that could not compile. These are ordinary names in
2452
+ // tish and the interpreter accepts them, which made this a backend divergence that REJECTED
2453
+ // VALID SOURCE rather than merely lowering it differently.
2454
+ //
2455
+ // They are renamed instead. The suffix is deliberately not a bare `_`: `self_` is a name a
2456
+ // program could plausibly use itself, and colliding with it would trade a compile error for
2457
+ // a silent capture of the wrong binding. `__tish_kw_` cannot be written in tish source,
2458
+ // because a leading double underscore is reserved by the compiler for generated temporaries.
2459
+ const NON_RAW_KEYWORDS: &[&str] = &["self", "Self", "super", "crate"];
2460
+ if NON_RAW_KEYWORDS.contains(&name) {
2461
+ return Cow::Owned(format!("__tish_kw_{}", name));
2462
+ }
2432
2463
  if RUST_KEYWORDS.contains(&name) {
2433
2464
  Cow::Owned(format!("r#{}", name))
2434
2465
  } else {
@@ -3181,6 +3212,37 @@ impl Codegen {
3181
3212
  &program.statements,
3182
3213
  &self.module_const_f64_cum,
3183
3214
  );
3215
+ // #597: which annotated arrays escape into a call, and so must stay boxed to preserve
3216
+ // aliasing. Collected once over the whole program — an array declared here can be
3217
+ // passed to a fn declared anywhere.
3218
+ //
3219
+ // GATED ON GBA, and not only because the RESULT is GBA-only: this scan runs
3220
+ // `scan_param_use` over the whole program once per annotated array, so it is
3221
+ // O(arrays x program). Off-target that is pure waste — the set is never read — and it
3222
+ // would be a silent compile-time tax on every host build.
3223
+ if self.emit_mode == crate::NativeEmitMode::Gba {
3224
+ let mut array_names: Vec<String> = Vec::new();
3225
+ Self::collect_annotated_array_names(&program.statements, &mut array_names);
3226
+ self.arrays_passed_to_calls =
3227
+ Self::collect_escaping_array_names(&program.statements, &array_names);
3228
+ }
3229
+ // Pick the integer representation where the values allow it. GBA only: off-target the
3230
+ // `f64` layout is free and eligibility must stay byte-identical to before.
3231
+ //
3232
+ // A `_cum` array is deliberately NOT considered — the fasta ladder that consumes one
3233
+ // compares it against an `r_f64` and is specialised around f64 arms, so leaving cum
3234
+ // alone keeps that path untouched.
3235
+ if self.emit_mode == crate::NativeEmitMode::Gba {
3236
+ for (name, vals) in &self.module_const_f64_arrays {
3237
+ if vals
3238
+ .iter()
3239
+ .all(|v| v.fract() == 0.0 && *v >= i32::MIN as f64 && *v <= i32::MAX as f64)
3240
+ {
3241
+ self.module_const_int_statics
3242
+ .insert(Self::module_const_static(name));
3243
+ }
3244
+ }
3245
+ }
3184
3246
  if !self.module_const_f64_arrays.is_empty() {
3185
3247
  self.emit_module_const_f64_arrays()?;
3186
3248
  self.writeln("");
@@ -3675,7 +3737,25 @@ impl Codegen {
3675
3737
  if self.is_async {
3676
3738
  self.async_context_stack.push(true); // run() body is async Rust context
3677
3739
  }
3678
- self.emit_statements_with_folds(&program.statements)?;
3740
+ // #629: A FUNCTION MAY REFERENCE A MODULE BINDING DECLARED BELOW IT. Module bindings are
3741
+ // emitted into `run()` in source order, but a function VALUE is emitted where it appears and
3742
+ // captures by name — so `function readA() { return A[i] }` above `let A = [...]` emitted a
3743
+ // capture of `A` five lines before `let A`, and rustc rejected the program:
3744
+ //
3745
+ // let rangDone = { Value::native(move |args| { … vm_read(&rangLive) … }) }; // use
3746
+ // let rangLive = VmRef::new(vec![(0_f64) as i32]); // decl
3747
+ //
3748
+ // That is legal tish — the function is not CALLED before the binding is initialised — and
3749
+ // only the ORDER differs; moving the `let` above the functions compiles. It took hyrule's
3750
+ // whole ROM down (`rangDone` at weapons.tish:28, `rangLive` at :46).
3751
+ //
3752
+ // Reordered here rather than at the capture site because the capture is not wrong; its
3753
+ // position is. Restricted to LITERAL initialisers on purpose: hoisting `let x = f()` would
3754
+ // move a call, changing when its side effects run and possibly reading state the statements
3755
+ // above it establish. A literal cannot observe or disturb anything, so moving it is not
3756
+ // visible to the program.
3757
+ let reordered = Self::hoist_forward_referenced_module_bindings(&program.statements);
3758
+ self.emit_statements_with_folds(reordered.as_deref().unwrap_or(&program.statements))?;
3679
3759
  if self.is_async {
3680
3760
  self.async_context_stack.pop();
3681
3761
  }
@@ -4101,9 +4181,26 @@ impl Codegen {
4101
4181
  // `emit_int32_operand` (a `>>> 0` result is u32 reinterpreted to i32; signed
4102
4182
  // bitwise ops yield i32 directly) — so store the i32 with NO `f64` round-trip.
4103
4183
  if rust_type == RustType::I32 {
4184
+ // #599: A CAPTURED i32 IS A CELL, AND THE WRITE HAS TO SAY SO. Reads of a
4185
+ // wrapped scalar already go through `vm_read(&x)`; every store below used to
4186
+ // emit a bare `x = <i32>`, so the two halves disagreed and the crate failed with
4187
+ // `expected VmRef<i32>, found i32`. Net effect was that a mutable module-level
4188
+ // typed scalar could not be used at all on GBA — every counter had to move into
4189
+ // a function body or hide in a one-element `i32[]`.
4190
+ //
4191
+ // The narrow-int branch below has always done this; the i32 branch is the one
4192
+ // that was missed, across all three of its store shapes.
4193
+ let is_refcell = self.refcell_wrapped_vars.contains(name.as_ref());
4194
+ let store = |escaped: &str, val: String| {
4195
+ if is_refcell {
4196
+ format!("{{ let _t = {}; *{}.borrow_mut() = _t; }}", val, escaped)
4197
+ } else {
4198
+ format!("{} = {}", escaped, val)
4199
+ }
4200
+ };
4104
4201
  if let Some(int_code) = self.emit_int32_operand(value)? {
4105
4202
  let escaped = Self::escape_ident(name.as_ref());
4106
- return Ok(format!("{} = ({}) as i32", escaped, int_code));
4203
+ return Ok(store(&escaped, format!("({}) as i32", int_code)));
4107
4204
  }
4108
4205
  let (val_code, val_ty) = self.emit_typed_expr(value)?;
4109
4206
  // An RHS that is ALREADY an integer stores straight into the register. `d = d + 1`
@@ -4112,7 +4209,7 @@ impl Codegen {
4112
4209
  // out of it was the whole cost of incrementing an annotated counter.
4113
4210
  let escaped_int = Self::escape_ident(name.as_ref());
4114
4211
  if val_ty.is_integer_scalar() {
4115
- return Ok(format!("{} = ({}) as i32", escaped_int, val_code));
4212
+ return Ok(store(&escaped_int, format!("({}) as i32", val_code)));
4116
4213
  }
4117
4214
  // Defensive: gate guarantees `Some`, but if a future RHS shape slips through,
4118
4215
  // fall back to a sound f64-narrowed store rather than miscompiling.
@@ -4122,11 +4219,7 @@ impl Codegen {
4122
4219
  val_code
4123
4220
  };
4124
4221
  let escaped = Self::escape_ident(name.as_ref());
4125
- return Ok(format!(
4126
- "{} = {}",
4127
- escaped,
4128
- RustType::I32.from_value_expr(&v)
4129
- ));
4222
+ return Ok(store(&escaped, RustType::I32.from_value_expr(&v)));
4130
4223
  }
4131
4224
  // String self-append `s = s + rhs` -> in-place push_str (amortized O(1)). The
4132
4225
  // general path boxes via `ops::add(Value::String(s.clone()), ...)` which clones
@@ -5777,6 +5870,34 @@ impl Codegen {
5777
5870
  })
5778
5871
  .unwrap_or(RustType::Value);
5779
5872
 
5873
+ // #597: AN ARRAY THAT ESCAPES INTO A BOXED CALL STAYS BOXED. A native `Vec<T>`
5874
+ // handed to a `value_call` is marshalled by BUILDING A FRESH `Value::Array` from
5875
+ // its elements — a copy by construction — so the callee's `push`es land in the copy
5876
+ // and the caller's array silently keeps its old contents. Nothing reports it: not
5877
+ // the compiler, not the runtime. An UNTYPED array passes the same `VmRef` and
5878
+ // aliases correctly, which means adding an annotation changed program BEHAVIOUR
5879
+ // rather than just representation.
5880
+ //
5881
+ // The call site cannot fix this — boxing a `Vec<i32>` into a `Value::Array` is a
5882
+ // copy however it is spelled. So the decision has to be made here, at the
5883
+ // declaration: if the array is ever passed as a call argument, keep it boxed.
5884
+ //
5885
+ // Same rule the read-only param analysis already applies from the other side
5886
+ // (`arr_param_readonly_fns` refuses an owned copy when a param is mutated, escapes
5887
+ // or is forwarded). Conservative on purpose: an array that never escapes is
5888
+ // untouched and keeps its native lowering.
5889
+ // GBA ONLY. The reporter checked the other backends: `tish run` and a native
5890
+ // `tish build` both alias correctly and print the expected result, so this is a
5891
+ // TARGET divergence rather than a language-wide one. Narrowing off-target would
5892
+ // deopt host code to fix a bug host code does not have, and one host test
5893
+ // (`escaping_array_keeps_oob_safe_lowering`) pins the native store it would remove.
5894
+ if self.emit_mode == crate::NativeEmitMode::Gba
5895
+ && matches!(rust_type, RustType::Vec(_))
5896
+ && self.arrays_passed_to_calls.contains(name.as_ref())
5897
+ {
5898
+ rust_type = RustType::Value;
5899
+ }
5900
+
5780
5901
  // M5 native-fn body: `let r = genRandom(1)` without a `: number` annotation.
5781
5902
  if self.native_fn_body_emit && rust_type == RustType::Value {
5782
5903
  if let Some(Expr::Call { callee, .. }) = init.as_ref() {
@@ -5904,12 +6025,22 @@ impl Codegen {
5904
6025
  return Ok(());
5905
6026
  }
5906
6027
 
5907
- // Module-level const f64 arrays — no local slot in `run()`.
6028
+ // Module-level const numeric arrays — no local slot in `run()`. The element type
6029
+ // must match the emitted representation, or a consumer reading the type context
6030
+ // coerces against a layout that is not there.
5908
6031
  if self.module_const_f64_arrays.contains_key(name.as_ref())
5909
6032
  && self.outer_vars_stack.len() == 1
5910
6033
  {
6034
+ let elem = if self
6035
+ .module_const_int_statics
6036
+ .contains(&Self::module_const_static(name.as_ref()))
6037
+ {
6038
+ RustType::I32
6039
+ } else {
6040
+ RustType::F64
6041
+ };
5911
6042
  self.type_context
5912
- .define(name.as_ref(), RustType::Vec(Box::new(RustType::F64)));
6043
+ .define(name.as_ref(), RustType::Vec(Box::new(elem)));
5913
6044
  return Ok(());
5914
6045
  }
5915
6046
 
@@ -7954,7 +8085,7 @@ impl Codegen {
7954
8085
  ));
7955
8086
  }
7956
8087
  if let Some(static_name) = self.module_const_f64_array_rust_ref(name.as_ref()) {
7957
- let v = Self::module_const_f64_array_as_value(&static_name);
8088
+ let v = self.module_const_f64_array_as_value(&static_name);
7958
8089
  return Ok(if self.value_fn_depth > 0 || !self.loop_stack.is_empty() {
7959
8090
  format!("({}).clone()", v)
7960
8091
  } else {
@@ -14053,13 +14184,22 @@ impl Codegen {
14053
14184
  // so a RHS that reads the same cell (`a[i] += x`, `a[i] = a[j]`) releases its shared borrow
14054
14185
  // first (no overlapping lock → no deadlock), then store into the real element through the cell.
14055
14186
  if wrapped {
14056
- let needs_resize =
14057
- matches!(elem_type.as_ref(), RustType::F64 | RustType::Bool) && !in_bounds;
14187
+ // #611: I32 GROWS TOO. A boxed array absorbs `a[i] = v` past the end by growing; a
14188
+ // typed one used to fall through to a bare element store, so the same source line
14189
+ // behaved differently depending only on whether the annotation was present — the write
14190
+ // was lost with no error at compile time or run time. That silent divergence between
14191
+ // the typed and boxed forms is the worst of the available options, so the typed form
14192
+ // now matches the boxed one. Pad is `0`, which is what the READ path already answers
14193
+ // for an out-of-range `i32` element (`.get(i).copied().unwrap_or(0)`).
14194
+ let needs_resize = matches!(
14195
+ elem_type.as_ref(),
14196
+ RustType::F64 | RustType::Bool | RustType::I32
14197
+ ) && !in_bounds;
14058
14198
  let store = if needs_resize {
14059
- let pad = if matches!(elem_type.as_ref(), RustType::F64) {
14060
- "f64::NAN"
14061
- } else {
14062
- "false"
14199
+ let pad = match elem_type.as_ref() {
14200
+ RustType::F64 => "f64::NAN",
14201
+ RustType::I32 => "0",
14202
+ _ => "false",
14063
14203
  };
14064
14204
  format!(
14065
14205
  "let __i = {idx}; let __v = {val}; let mut __g = {esc}.borrow_mut(); if __i >= __g.len() {{ __g.resize(__i + 1, {pad}); }} __g[__i] = __v;",
@@ -14085,16 +14225,22 @@ impl Codegen {
14085
14225
  format!("{{ {}[{}] = {}; Value::Null }}", esc_obj, idx_usize, native_val)
14086
14226
  }
14087
14227
  }
14088
- RustType::F64 | RustType::Bool => {
14089
- let pad = if matches!(elem_type.as_ref(), RustType::F64) {
14090
- "f64::NAN"
14091
- } else {
14092
- "false"
14228
+ // #611: same grow-to-index rule on the unwrapped path — see the note above.
14229
+ RustType::F64 | RustType::Bool | RustType::I32 => {
14230
+ let pad = match elem_type.as_ref() {
14231
+ RustType::F64 => "f64::NAN",
14232
+ RustType::I32 => "0",
14233
+ _ => "false",
14093
14234
  };
14094
- format!(
14095
- "{{ let _idx = {}; if _idx >= {}.len() {{ {}.resize(_idx + 1, {}); }} {}[_idx] = {}; Value::Null }}",
14235
+ let store = format!(
14236
+ "{{ let _idx = {}; if _idx >= {}.len() {{ {}.resize(_idx + 1, {}); }} {}[_idx] = {}",
14096
14237
  idx_usize, esc_obj, esc_obj, pad, esc_obj, native_val
14097
- )
14238
+ );
14239
+ if side_effect_only {
14240
+ format!("{} }}", store)
14241
+ } else {
14242
+ format!("{}; Value::Null }}", store)
14243
+ }
14098
14244
  }
14099
14245
  _ if side_effect_only => {
14100
14246
  format!("{}[{}] = {}", esc_obj, idx_usize, native_val)
@@ -16798,7 +16944,15 @@ impl Codegen {
16798
16944
  {
16799
16945
  let sum_esc = Self::escape_ident(sum_name.as_ref());
16800
16946
  let arms: Vec<String> = (0..n)
16801
- .map(|i| format!("{} => {}[{}]", i, codes_static, i))
16947
+ // The fasta ladder is specialised around f64 arms, so an integer-represented
16948
+ // codes array widens here rather than perturbing that path.
16949
+ .map(|i| {
16950
+ if self.module_const_int_statics.contains(codes_static.as_str()) {
16951
+ format!("{} => {}[{}] as f64", i, codes_static, i)
16952
+ } else {
16953
+ format!("{} => {}[{}]", i, codes_static, i)
16954
+ }
16955
+ })
16802
16956
  .collect();
16803
16957
  self.writeln(&format!(
16804
16958
  "{} = (({} + match ({} as usize) {{ {}, _ => 0.0 }}) % 2147483647_f64);",
@@ -16956,6 +17110,105 @@ impl Codegen {
16956
17110
  self.native_fn_emit_name.is_some() || self.native_vec_ret.is_some()
16957
17111
  }
16958
17112
 
17113
+ /// #629: move module-level `let`s with LITERAL initialisers ahead of any function declared
17114
+ /// above them that references the name. Returns `None` when nothing needs moving, so the common
17115
+ /// program keeps its exact statement slice and this costs one scan.
17116
+ ///
17117
+ /// Only bindings that are actually forward-referenced move, and only past the point where they
17118
+ /// are first needed — the relative order of everything else is preserved.
17119
+ fn hoist_forward_referenced_module_bindings(stmts: &[Statement]) -> Option<Vec<Statement>> {
17120
+ // Names bound at module level by a literal `let`, with the index of their declaration.
17121
+ let mut literal_decls: Vec<(usize, String)> = Vec::new();
17122
+ for (i, s) in stmts.iter().enumerate() {
17123
+ if let Statement::VarDecl {
17124
+ name,
17125
+ init: Some(e),
17126
+ ..
17127
+ } = s
17128
+ {
17129
+ if Self::is_literal_initialiser(e) {
17130
+ literal_decls.push((i, name.to_string()));
17131
+ }
17132
+ }
17133
+ }
17134
+ if literal_decls.is_empty() {
17135
+ return None;
17136
+ }
17137
+ // A NAME DECLARED MORE THAN ONCE AT MODULE LEVEL IS NEVER MOVED. An earlier mention of such
17138
+ // a name refers to the EARLIER binding, not forward to this one, so hoisting this
17139
+ // declaration over it silently changes which value the earlier code sees. That is a wrong
17140
+ // ANSWER, not a build break: tests/core/js_compat_gaps.tish declares `text` twice — a string
17141
+ // at the top and a different string further down — and moving the second above the first
17142
+ // made an intervening `slice` read the wrong source, printing `hello 123 world 45` where
17143
+ // `.mdx` was expected.
17144
+ let mut decl_counts: std::collections::HashMap<&str, usize> =
17145
+ std::collections::HashMap::new();
17146
+ for s in stmts {
17147
+ if let Statement::VarDecl { name, .. } = s {
17148
+ *decl_counts.entry(name.as_ref()).or_insert(0) += 1;
17149
+ }
17150
+ }
17151
+ // A binding needs moving when a FUNCTION BODY above its declaration mentions it. Deferred
17152
+ // execution is what makes a forward reference legal — the body does not run until called.
17153
+ //
17154
+ // Straight-line code above the declaration is deliberately NOT counted: there the mention
17155
+ // either refers to a previous binding of the name or is a genuine use-before-init, and in
17156
+ // neither case may the declaration be moved over it.
17157
+ let mut needed: Vec<(usize, usize)> = Vec::new(); // (decl index, first referencing index)
17158
+ for (di, name) in &literal_decls {
17159
+ if decl_counts.get(name.as_str()).copied().unwrap_or(0) > 1 {
17160
+ continue;
17161
+ }
17162
+ let mut first = None;
17163
+ for (i, s) in stmts.iter().enumerate().take(*di) {
17164
+ if matches!(s, Statement::FunDecl { .. }) && Self::stmt_mentions_ident(s, name) {
17165
+ first = Some(i);
17166
+ break;
17167
+ }
17168
+ }
17169
+ if let Some(f) = first {
17170
+ needed.push((*di, f));
17171
+ }
17172
+ }
17173
+ if needed.is_empty() {
17174
+ return None;
17175
+ }
17176
+ // Rebuild: each moved declaration is inserted immediately before the first statement that
17177
+ // references it, keeping every other statement in place.
17178
+ let mut out: Vec<Statement> = Vec::with_capacity(stmts.len());
17179
+ let moved: std::collections::HashSet<usize> = needed.iter().map(|(d, _)| *d).collect();
17180
+ for (i, s) in stmts.iter().enumerate() {
17181
+ for (di, fi) in &needed {
17182
+ if *fi == i {
17183
+ out.push(stmts[*di].clone());
17184
+ }
17185
+ }
17186
+ if !moved.contains(&i) {
17187
+ out.push(s.clone());
17188
+ }
17189
+ }
17190
+ Some(out)
17191
+ }
17192
+
17193
+ /// An initialiser that cannot observe or disturb program state, so moving it is invisible.
17194
+ /// Deliberately narrow — array/object literals are included only when every element is itself
17195
+ /// literal, so no call or property read comes along for the ride.
17196
+ fn is_literal_initialiser(e: &Expr) -> bool {
17197
+ match e {
17198
+ Expr::Literal { .. } => true,
17199
+ Expr::Unary { operand, .. } => Self::is_literal_initialiser(operand),
17200
+ Expr::Array { elements, .. } => elements.iter().all(|el| match el {
17201
+ ArrayElement::Expr(e) => Self::is_literal_initialiser(e),
17202
+ _ => false,
17203
+ }),
17204
+ Expr::Object { props, .. } => props.iter().all(|p| match p {
17205
+ ObjectProp::KeyValue(_, v, _) => Self::is_literal_initialiser(v),
17206
+ _ => false,
17207
+ }),
17208
+ _ => false,
17209
+ }
17210
+ }
17211
+
16959
17212
  fn emit_statements_with_folds(&mut self, statements: &[Statement]) -> Result<(), CompileError> {
16960
17213
  let mut i = 0;
16961
17214
  while i < statements.len() {
@@ -17155,41 +17408,82 @@ impl Codegen {
17155
17408
  Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
17156
17409
  Self::walk_stmts_for_module_const_disqualifiers(statements, g, bad);
17157
17410
  }
17411
+ // EVERY child expression has to be visited, not just the sub-statements. These arms used
17412
+ // to walk only the bodies, so a use in a loop or branch CONDITION was invisible — which
17413
+ // is how `while (i < SB_FORCE.length)` (card-gba qb-engine.tish) kept an array eligible
17414
+ // for hoisting even though `.length` is a disqualifying member use. Same defect class as
17415
+ // the missing closure arm below: the walker's silence meant "safe", not "absent".
17158
17416
  Statement::If {
17417
+ cond,
17159
17418
  then_branch,
17160
17419
  else_branch,
17161
17420
  ..
17162
17421
  } => {
17422
+ Self::walk_expr_for_module_const_disqualifiers(cond, g, bad);
17163
17423
  Self::walk_stmt_for_module_const_disqualifiers(then_branch, g, bad);
17164
17424
  if let Some(eb) = else_branch {
17165
17425
  Self::walk_stmt_for_module_const_disqualifiers(eb, g, bad);
17166
17426
  }
17167
17427
  }
17168
- Statement::While { body, .. } | Statement::DoWhile { body, .. } => {
17428
+ Statement::While { cond, body, .. } | Statement::DoWhile { body, cond, .. } => {
17429
+ Self::walk_expr_for_module_const_disqualifiers(cond, g, bad);
17169
17430
  Self::walk_stmt_for_module_const_disqualifiers(body, g, bad);
17170
17431
  }
17171
- Statement::For { init, body, .. } => {
17432
+ Statement::For {
17433
+ init,
17434
+ cond,
17435
+ update,
17436
+ body,
17437
+ ..
17438
+ } => {
17172
17439
  if let Some(i) = init {
17173
17440
  Self::walk_stmt_for_module_const_disqualifiers(i, g, bad);
17174
17441
  }
17442
+ if let Some(c) = cond {
17443
+ Self::walk_expr_for_module_const_disqualifiers(c, g, bad);
17444
+ }
17445
+ if let Some(u) = update {
17446
+ Self::walk_expr_for_module_const_disqualifiers(u, g, bad);
17447
+ }
17175
17448
  Self::walk_stmt_for_module_const_disqualifiers(body, g, bad);
17176
17449
  }
17177
- Statement::ForOf { body, .. }
17178
- | Statement::ForIn { body, .. } => {
17450
+ Statement::ForOf { iterable, body, .. }
17451
+ | Statement::ForIn {
17452
+ object: iterable,
17453
+ body,
17454
+ ..
17455
+ } => {
17456
+ // ITERATING THE ARRAY IS A SUPPORTED USE, not a disqualifying one: `for (x of X)`
17457
+ // lowers to a native index loop over the hoisted static (`for _fof_i0 in
17458
+ // 0..G_X.len()`), so the name never survives into the output. Walking the iterable
17459
+ // unconditionally would see a bare `Ident` and refuse the hoist, which loses the
17460
+ // very optimisation the pass exists for (tests/core/typed_array_forof.tish).
17461
+ // Anything else in iterable position is walked normally.
17462
+ if !matches!(iterable, Expr::Ident { name, .. } if name.as_ref() == g) {
17463
+ Self::walk_expr_for_module_const_disqualifiers(iterable, g, bad);
17464
+ }
17179
17465
  Self::walk_stmt_for_module_const_disqualifiers(body, g, bad);
17180
17466
  }
17181
17467
  Statement::Switch {
17468
+ expr,
17182
17469
  cases,
17183
17470
  default_body,
17184
17471
  ..
17185
17472
  } => {
17186
- for (_, stmts) in cases {
17473
+ Self::walk_expr_for_module_const_disqualifiers(expr, g, bad);
17474
+ for (case_expr, stmts) in cases {
17475
+ if let Some(e) = case_expr {
17476
+ Self::walk_expr_for_module_const_disqualifiers(e, g, bad);
17477
+ }
17187
17478
  Self::walk_stmts_for_module_const_disqualifiers(stmts, g, bad);
17188
17479
  }
17189
17480
  if let Some(stmts) = default_body {
17190
17481
  Self::walk_stmts_for_module_const_disqualifiers(stmts, g, bad);
17191
17482
  }
17192
17483
  }
17484
+ Statement::Throw { value, .. } => {
17485
+ Self::walk_expr_for_module_const_disqualifiers(value, g, bad);
17486
+ }
17193
17487
  Statement::Try {
17194
17488
  body,
17195
17489
  catch_body,
@@ -17220,10 +17514,32 @@ impl Codegen {
17220
17514
  Self::walk_expr_for_module_const_disqualifiers(e, g, bad);
17221
17515
  }
17222
17516
  }
17223
- _ => {}
17517
+ // CONSERVATIVE DEFAULT. This walker decides whether it is safe to move an array into a
17518
+ // `const`, so "I do not model this statement" must mean "refuse", never "permit" — the
17519
+ // old `_ => {}` meant the latter, and a wrong permit is a program that does not compile.
17520
+ // Over-rejecting only costs the hoist; under-rejecting cost four ROMs their build.
17521
+ s => {
17522
+ if Self::stmt_mentions_ident(s, g) {
17523
+ *bad = true;
17524
+ }
17525
+ }
17224
17526
  }
17225
17527
  }
17226
17528
 
17529
+ /// Complete-traversal membership test, used as the safe fallback by both walkers above.
17530
+ fn stmt_mentions_ident(s: &Statement, g: &str) -> bool {
17531
+ let mut idents = HashSet::new();
17532
+ Self::collect_stmt_idents(s, &mut idents);
17533
+ idents.contains(g)
17534
+ }
17535
+
17536
+ /// As `stmt_mentions_ident`, for an expression subtree.
17537
+ fn expr_mentions_ident(e: &Expr, g: &str) -> bool {
17538
+ let mut idents = HashSet::new();
17539
+ Self::collect_expr_idents(e, &mut idents);
17540
+ idents.contains(g)
17541
+ }
17542
+
17227
17543
  fn walk_expr_for_module_const_disqualifiers(e: &Expr, g: &str, bad: &mut bool) {
17228
17544
  if *bad {
17229
17545
  return;
@@ -17281,9 +17597,51 @@ impl Codegen {
17281
17597
  callee.as_ref(),
17282
17598
  Expr::Ident { name, .. } if name.as_ref() == "cumulative"
17283
17599
  );
17600
+ // A METHOD CALL ON THE ARRAY IS SUPPORTED — but only the ones that go through the
17601
+ // `Value` bridge. `X.join(", ")` emits the object as
17602
+ // `NumArrayBacking::Packed(G_X…)`, so the local name does not survive and the hoist
17603
+ // is fine (tests/core/template_literals.tish).
17604
+ //
17605
+ // THE FUSEABLE HOFs ARE NOT. `X.map(...)`, `X.reduce(...)` and their siblings lower
17606
+ // to a NATIVE iterator chain — `xs.iter().copied().map(...)` — which names the local
17607
+ // directly and is NOT rewritten to the static. Exempting them hoisted an array whose
17608
+ // every use then referenced a binding that no longer existed:
17609
+ //
17610
+ // let mut sum: f64 = { … for b in xs.iter().copied() { … } … };
17611
+ // error[E0425]: cannot find value `xs` in this scope
17612
+ //
17613
+ // Caught by the perf gauntlet's `typed_array_hof` fixture on the NATIVE target —
17614
+ // this walker is not GBA-gated, so a too-permissive exemption breaks the host.
17615
+ //
17616
+ // Nor does the exemption extend to a member READ such as `X.length`, which lowers to
17617
+ // a bare `X.len()` — one of the two shapes that broke the ROMs.
17618
+ // ALLOWLIST, NOT DENYLIST. This started as "any method call except the fuseable
17619
+ // HOFs", which is allow-by-default — and every MUTATING method walked straight
17620
+ // through it. `arr2.unshift(1, 2)` hoisted its receiver into a `const` and then
17621
+ // tried to mutate it, so the local vanished and rustc reported
17622
+ // `cannot find value arr2` (tests/core/array_methods.tish, under
17623
+ // TISH_PACKED_ARRAYS=1). `push`/`pop`/`splice`/`sort`/`reverse`/`fill` are the same
17624
+ // shape.
17625
+ //
17626
+ // So only methods PROVEN to route the receiver through the `Value` bridge are
17627
+ // exempt. `join` is the one this pass actually needs
17628
+ // (tests/core/template_literals.tish emits
17629
+ // `array_join(&Value::NumberArray(…Packed(G_NUMS…)))`, where the local name does not
17630
+ // survive). Anything else — mutators, the HOFs that lower to a native iterator chain
17631
+ // over the bare local, and any method added later — disqualifies the hoist, which is
17632
+ // the safe direction: it costs an optimisation, not a build.
17633
+ let member_call_on_g = matches!(
17634
+ callee.as_ref(),
17635
+ Expr::Member { object, prop, .. }
17636
+ if matches!(object.as_ref(), Expr::Ident { name, .. } if name.as_ref() == g)
17637
+ && matches!(
17638
+ prop,
17639
+ MemberProp::Name { name: m, .. } if m.as_ref() == "join"
17640
+ )
17641
+ );
17284
17642
  if matches!(callee.as_ref(), Expr::Ident { name, .. } if name.as_ref() == g) {
17285
17643
  *bad = true;
17286
- } else {
17644
+ } else if !member_call_on_g {
17287
17645
  Self::walk_expr_for_module_const_disqualifiers(callee, g, bad);
17288
17646
  }
17289
17647
  for (i, a) in args.iter().enumerate() {
@@ -17334,7 +17692,75 @@ impl Codegen {
17334
17692
  }
17335
17693
  }
17336
17694
  }
17337
- _ => {}
17695
+ // Everything below was missing, and the omission is what made this pass unsafe: an
17696
+ // admission check whose walker reports "no disqualifying use" for a shape it does not
17697
+ // recognise is silently permissive. `ArrowFunction` was the expensive one — every use
17698
+ // inside a CLOSURE landed in `_ => {}`, so an array a closure writes got hoisted to a
17699
+ // `const` anyway. Reads then renamed to `G_X` while the write kept the boxed local, and
17700
+ // that local is declared after the closure: rustc reports `cannot find value` on a name
17701
+ // the game never misspelled (card-gba queensblood/triad/gwent; tish-gba hyrule's
17702
+ // `rangLive`). Renaming the write would not have been a fix either — a `const` cannot be
17703
+ // assigned through, so a written array must not be hoisted at all. The sibling scalar
17704
+ // walker has always had these arms; this one is a copy that lost them.
17705
+ //
17706
+ // Indexing stays deliberately absent from the disqualifier list: `X[i]` is the whole
17707
+ // point of the hoist, so descending here must not become "any closure mention kills it",
17708
+ // which would give back the #594 reachability this pass exists to provide.
17709
+ Expr::ArrowFunction { body, .. } => match body {
17710
+ ArrowBody::Expr(e) => Self::walk_expr_for_module_const_disqualifiers(e, g, bad),
17711
+ ArrowBody::Block(s) => Self::walk_stmt_for_module_const_disqualifiers(s, g, bad),
17712
+ },
17713
+ // A write to a member of the const, by the same rule the `Member` arm above uses.
17714
+ Expr::MemberAssign { object, value, .. } => {
17715
+ if matches!(object.as_ref(), Expr::Ident { name, .. } if name.as_ref() == g) {
17716
+ *bad = true;
17717
+ } else {
17718
+ Self::walk_expr_for_module_const_disqualifiers(object, g, bad);
17719
+ }
17720
+ Self::walk_expr_for_module_const_disqualifiers(value, g, bad);
17721
+ }
17722
+ Expr::Delete { target, .. }
17723
+ if matches!(target.as_ref(), Expr::Ident { name, .. } if name.as_ref() == g) =>
17724
+ {
17725
+ *bad = true;
17726
+ }
17727
+ // The remaining shapes need no special rule: a bare mention of the name is already
17728
+ // disqualifying via the `Ident` arm, so plain descent is sufficient.
17729
+ Expr::New { callee, args, .. } => {
17730
+ Self::walk_expr_for_module_const_disqualifiers(callee, g, bad);
17731
+ for a in args {
17732
+ if let CallArg::Expr(e) | CallArg::Spread(e) = a {
17733
+ Self::walk_expr_for_module_const_disqualifiers(e, g, bad);
17734
+ }
17735
+ }
17736
+ }
17737
+ Expr::NullishCoalesce { left, right, .. } => {
17738
+ Self::walk_expr_for_module_const_disqualifiers(left, g, bad);
17739
+ Self::walk_expr_for_module_const_disqualifiers(right, g, bad);
17740
+ }
17741
+ Expr::TypeOf { operand, .. } => {
17742
+ Self::walk_expr_for_module_const_disqualifiers(operand, g, bad);
17743
+ }
17744
+ Expr::TemplateLiteral { exprs, .. } => {
17745
+ for e in exprs {
17746
+ Self::walk_expr_for_module_const_disqualifiers(e, g, bad);
17747
+ }
17748
+ }
17749
+ // Leaves, listed explicitly so they do NOT reach the fallback below. A literal cannot
17750
+ // mention a binding, and there is one in nearly every expression in a real program —
17751
+ // routing those through a whole-subtree ident scan turned this walk superlinear and hung
17752
+ // the compiler outright (a `tish build` of `scii` spun at 100% CPU for 40+ minutes in the
17753
+ // frontend, no rustc involved). The fallback is for shapes that might CONTAIN a
17754
+ // reference, not for every constant in the file.
17755
+ Expr::Literal { .. } => {}
17756
+ // CONSERVATIVE DEFAULT — see the statement walker. An expression shape this match does
17757
+ // not model (today: `Await`, `JsxElement`, and anything the AST gains later) disqualifies
17758
+ // if the name appears anywhere beneath it, rather than being waved through.
17759
+ other => {
17760
+ if Self::expr_mentions_ident(other, g) {
17761
+ *bad = true;
17762
+ }
17763
+ }
17338
17764
  }
17339
17765
  }
17340
17766
 
@@ -17518,15 +17944,41 @@ impl Codegen {
17518
17944
  )
17519
17945
  }
17520
17946
 
17521
- fn module_const_f64_array_as_value(static_name: &str) -> String {
17947
+ /// The boxed bridge. `NumArrayBacking::Packed` is a `Vec<f64>`, so an `[i32; N]` hoist widens on
17948
+ /// the way out — this is the escape hatch where the array is handed to code that wants a
17949
+ /// `Value`, and it is a whole-array copy either way.
17950
+ fn module_const_f64_array_as_value(&self, static_name: &str) -> String {
17951
+ let elems = if self.module_const_int_statics.contains(static_name) {
17952
+ format!("{}.iter().map(|v| *v as f64)", static_name)
17953
+ } else {
17954
+ format!("{}.iter().copied()", static_name)
17955
+ };
17522
17956
  format!(
17523
- "Value::NumberArray(VmRef::new(tishlang_runtime::NumArrayBacking::Packed({}.iter().copied().collect::<Vec<f64>>())))",
17524
- static_name
17957
+ "Value::NumberArray(VmRef::new(tishlang_runtime::NumArrayBacking::Packed({}.collect::<Vec<f64>>())))",
17958
+ elems
17525
17959
  )
17526
17960
  }
17527
17961
 
17528
17962
  fn emit_module_const_f64_arrays(&mut self) -> Result<(), CompileError> {
17529
17963
  for (name, vals) in self.module_const_f64_arrays.clone() {
17964
+ let static_name = Self::module_const_static(name.as_str());
17965
+ // `[i32; N]` where the values permit it — half the ROM, and reads that stay in the
17966
+ // integer domain instead of round-tripping through soft float. See the field comment on
17967
+ // `module_const_int_statics`.
17968
+ if self.module_const_int_statics.contains(&static_name) {
17969
+ let lit = vals
17970
+ .iter()
17971
+ .map(|v| format!("{}", *v as i32))
17972
+ .collect::<Vec<_>>()
17973
+ .join(", ");
17974
+ self.writeln(&format!(
17975
+ "const {}: [i32; {}] = [{}];",
17976
+ static_name,
17977
+ vals.len(),
17978
+ lit
17979
+ ));
17980
+ continue;
17981
+ }
17530
17982
  let lit = vals
17531
17983
  .iter()
17532
17984
  .map(|v| Self::f64_lit(*v))
@@ -17534,7 +17986,7 @@ impl Codegen {
17534
17986
  .join(", ");
17535
17987
  self.writeln(&format!(
17536
17988
  "const {}: [f64; {}] = [{}];",
17537
- Self::module_const_static(name.as_str()),
17989
+ static_name,
17538
17990
  vals.len(),
17539
17991
  lit
17540
17992
  ));
@@ -17938,35 +18390,74 @@ impl Codegen {
17938
18390
  Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
17939
18391
  Self::walk_stmts_for_global_disqualifiers(statements, g, bad);
17940
18392
  }
18393
+ // Conditions are visited, not just bodies. This walker had the same blind spot the
18394
+ // module-const one did: `while (i < X.length)` put a disqualifying use somewhere the
18395
+ // walk never reached, so the global was hoisted anyway and the emitted program
18396
+ // referenced a name that no longer existed. Latent here only because a scalar is rarely
18397
+ // spelled that way — the defect is identical.
17941
18398
  Statement::If {
18399
+ cond,
17942
18400
  then_branch,
17943
18401
  else_branch,
17944
18402
  ..
17945
18403
  } => {
18404
+ Self::walk_expr_for_global_disqualifiers(cond, g, bad);
17946
18405
  Self::walk_stmt_for_global_disqualifiers(then_branch, g, bad);
17947
18406
  if let Some(eb) = else_branch {
17948
18407
  Self::walk_stmt_for_global_disqualifiers(eb, g, bad);
17949
18408
  }
17950
18409
  }
17951
- Statement::While { body, .. }
17952
- | Statement::DoWhile { body, .. }
17953
- | Statement::For { body, .. }
17954
- | Statement::ForOf { body, .. }
17955
- | Statement::ForIn { body, .. } => {
18410
+ Statement::While { cond, body, .. } | Statement::DoWhile { body, cond, .. } => {
18411
+ Self::walk_expr_for_global_disqualifiers(cond, g, bad);
18412
+ Self::walk_stmt_for_global_disqualifiers(body, g, bad);
18413
+ }
18414
+ Statement::For {
18415
+ init,
18416
+ cond,
18417
+ update,
18418
+ body,
18419
+ ..
18420
+ } => {
18421
+ if let Some(i) = init {
18422
+ Self::walk_stmt_for_global_disqualifiers(i, g, bad);
18423
+ }
18424
+ if let Some(c) = cond {
18425
+ Self::walk_expr_for_global_disqualifiers(c, g, bad);
18426
+ }
18427
+ if let Some(u) = update {
18428
+ Self::walk_expr_for_global_disqualifiers(u, g, bad);
18429
+ }
18430
+ Self::walk_stmt_for_global_disqualifiers(body, g, bad);
18431
+ }
18432
+ Statement::ForOf { iterable, body, .. }
18433
+ | Statement::ForIn {
18434
+ object: iterable,
18435
+ body,
18436
+ ..
18437
+ } => {
18438
+ Self::walk_expr_for_global_disqualifiers(iterable, g, bad);
17956
18439
  Self::walk_stmt_for_global_disqualifiers(body, g, bad);
17957
18440
  }
17958
18441
  Statement::Switch {
18442
+ expr,
17959
18443
  cases,
17960
18444
  default_body,
17961
18445
  ..
17962
18446
  } => {
17963
- for (_, stmts) in cases {
18447
+ Self::walk_expr_for_global_disqualifiers(expr, g, bad);
18448
+ for (case_expr, stmts) in cases {
18449
+ if let Some(e) = case_expr {
18450
+ Self::walk_expr_for_global_disqualifiers(e, g, bad);
18451
+ }
17964
18452
  Self::walk_stmts_for_global_disqualifiers(stmts, g, bad);
17965
18453
  }
17966
18454
  if let Some(d) = default_body {
17967
18455
  Self::walk_stmts_for_global_disqualifiers(d, g, bad);
17968
18456
  }
17969
18457
  }
18458
+ Statement::Throw { value, .. } => {
18459
+ Self::walk_expr_for_global_disqualifiers(value, g, bad);
18460
+ }
17970
18461
  Statement::Try {
17971
18462
  body,
17972
18463
  catch_body,
@@ -18165,6 +18656,151 @@ impl Codegen {
18165
18656
  ok
18166
18657
  }
18167
18658
 
18659
+ /// #597: every `let x: T[]` name in the program, at any nesting depth. These are the candidates
18660
+ /// whose escape has to be checked before they may lower to a native `Vec`.
18661
+ fn collect_annotated_array_names(stmts: &[Statement], out: &mut Vec<String>) {
18662
+ for s in stmts {
18663
+ Self::collect_annotated_array_names_stmt(s, out);
18664
+ }
18665
+ }
18666
+
18667
+ fn collect_annotated_array_names_stmt(s: &Statement, out: &mut Vec<String>) {
18668
+ match s {
18669
+ Statement::VarDecl {
18670
+ name,
18671
+ type_ann: Some(t),
18672
+ ..
18673
+ } => {
18674
+ if matches!(crate::types::RustType::from_annotation(t), RustType::Vec(_)) {
18675
+ out.push(name.to_string());
18676
+ }
18677
+ }
18678
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
18679
+ for s in statements {
18680
+ Self::collect_annotated_array_names_stmt(s, out);
18681
+ }
18682
+ }
18683
+ Statement::If {
18684
+ then_branch,
18685
+ else_branch,
18686
+ ..
18687
+ } => {
18688
+ Self::collect_annotated_array_names_stmt(then_branch, out);
18689
+ if let Some(e) = else_branch {
18690
+ Self::collect_annotated_array_names_stmt(e, out);
18691
+ }
18692
+ }
18693
+ Statement::While { body, .. }
18694
+ | Statement::DoWhile { body, .. }
18695
+ | Statement::ForOf { body, .. }
18696
+ | Statement::ForIn { body, .. }
18697
+ | Statement::FunDecl { body, .. } => {
18698
+ Self::collect_annotated_array_names_stmt(body, out);
18699
+ }
18700
+ Statement::For { init, body, .. } => {
18701
+ if let Some(i) = init {
18702
+ Self::collect_annotated_array_names_stmt(i, out);
18703
+ }
18704
+ Self::collect_annotated_array_names_stmt(body, out);
18705
+ }
18706
+ Statement::Switch {
18707
+ cases,
18708
+ default_body,
18709
+ ..
18710
+ } => {
18711
+ for (_, stmts) in cases {
18712
+ for s in stmts {
18713
+ Self::collect_annotated_array_names_stmt(s, out);
18714
+ }
18715
+ }
18716
+ if let Some(stmts) = default_body {
18717
+ for s in stmts {
18718
+ Self::collect_annotated_array_names_stmt(s, out);
18719
+ }
18720
+ }
18721
+ }
18722
+ Statement::Try {
18723
+ body,
18724
+ catch_body,
18725
+ finally_body,
18726
+ ..
18727
+ } => {
18728
+ Self::collect_annotated_array_names_stmt(body, out);
18729
+ if let Some(c) = catch_body {
18730
+ Self::collect_annotated_array_names_stmt(c, out);
18731
+ }
18732
+ if let Some(f) = finally_body {
18733
+ Self::collect_annotated_array_names_stmt(f, out);
18734
+ }
18735
+ }
18736
+ _ => {}
18737
+ }
18738
+ }
18739
+
18740
+ /// #597: array names that ESCAPE — handed to a call as a bare argument, or otherwise used as a
18741
+ /// whole value rather than indexed.
18742
+ ///
18743
+ /// Reuses `scan_param_use`, the same analysis the read-only param path already trusts for this
18744
+ /// exact hazard: its `forwarded` flag is documented as "the callee could mutate the array, and a
18745
+ /// write to the caller's array would be lost on the owned copy". That is #597 from the other
18746
+ /// side, so the answer should come from one analysis rather than a second, subtly-different one.
18747
+ ///
18748
+ /// Over-approximating only costs an array its native lowering. Under-approximating brings back a
18749
+ /// silently wrong result, so the bias is deliberate.
18750
+ fn collect_escaping_array_names(stmts: &[Statement], names: &[String]) -> std::collections::HashSet<String> {
18751
+ // `for_each_stmt_expr` deliberately does NOT descend into `FunDecl` bodies, so the forwarding
18752
+ // call is normally invisible to it — the array is declared inside a function and passed
18753
+ // inside that same function, which is exactly the reported shape. Gather nested bodies as
18754
+ // additional roots first.
18755
+ fn add_roots<'a>(s: &'a Statement, roots: &mut Vec<&'a Statement>) {
18756
+ roots.push(s);
18757
+ match s {
18758
+ Statement::FunDecl { body, .. }
18759
+ | Statement::While { body, .. }
18760
+ | Statement::DoWhile { body, .. }
18761
+ | Statement::ForOf { body, .. }
18762
+ | Statement::ForIn { body, .. }
18763
+ | Statement::For { body, .. } => add_roots(body, roots),
18764
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
18765
+ for s in statements {
18766
+ add_roots(s, roots);
18767
+ }
18768
+ }
18769
+ Statement::If {
18770
+ then_branch,
18771
+ else_branch,
18772
+ ..
18773
+ } => {
18774
+ add_roots(then_branch, roots);
18775
+ if let Some(e) = else_branch {
18776
+ add_roots(e, roots);
18777
+ }
18778
+ }
18779
+ _ => {}
18780
+ }
18781
+ }
18782
+ let mut roots: Vec<&Statement> = Vec::new();
18783
+ for s in stmts {
18784
+ add_roots(s, &mut roots);
18785
+ }
18786
+ let mut out = std::collections::HashSet::new();
18787
+ for n in names {
18788
+ let mut f = ParamUse::default();
18789
+ for s in &roots {
18790
+ Self::for_each_stmt_expr(s, &mut |e| Self::scan_param_use(e, n, false, &mut f));
18791
+ }
18792
+ // `forwarded` ONLY, not `escaped`. `escaped` covers any whole-value use — including
18793
+ // `return arr`, which hands ownership out and does not create the aliasing hazard this
18794
+ // is about. Using it deopted arrays that are legitimately native
18795
+ // (`numeric_returning_fn_keeps_pushed_array_native`). #597 is specifically "passed as a
18796
+ // call argument", which is what `forwarded` means.
18797
+ if f.forwarded {
18798
+ out.insert(n.clone());
18799
+ }
18800
+ }
18801
+ out
18802
+ }
18803
+
18168
18804
  fn for_each_expr_root(stmts: &[Statement], f: &mut dyn FnMut(&Expr)) {
18169
18805
  for s in stmts {
18170
18806
  Self::for_each_expr_root_stmt(s, f);
@@ -20084,6 +20720,14 @@ impl Codegen {
20084
20720
  }
20085
20721
  }
20086
20722
  }
20723
+ // #630: A NAME REMOVED HERE IS REJECTED FOR GOOD. `collect_bad_vec_callsites` is a
20724
+ // SOUNDNESS verdict — the fn is called somewhere in a way the native-vec ABI cannot
20725
+ // represent — and that fact is a property of the program, which does not change during this
20726
+ // analysis. Re-admitting it later can only produce the same wrong answer.
20727
+ //
20728
+ // Recording it is also what makes the forwarding fixpoint below terminate; see the note
20729
+ // there.
20730
+ let mut rejected: std::collections::HashSet<String> = std::collections::HashSet::new();
20087
20731
  loop {
20088
20732
  let mut remove: Vec<String> = Vec::new();
20089
20733
  for s in &program.statements {
@@ -20095,6 +20739,7 @@ impl Codegen {
20095
20739
  break;
20096
20740
  }
20097
20741
  for n in remove {
20742
+ rejected.insert(n.clone());
20098
20743
  sigs.remove(&n);
20099
20744
  }
20100
20745
  if sigs.is_empty() {
@@ -20130,6 +20775,22 @@ impl Codegen {
20130
20775
  if Self::ann_is_simple(return_type.as_ref(), "fixed") {
20131
20776
  continue;
20132
20777
  }
20778
+ // #630: AND IT MUST NOT RE-ADD A NAME THE REMOVAL PASS ALREADY REJECTED. This is
20779
+ // what makes the analysis terminate. Without it the two fixpoints fight: the
20780
+ // forwarding pass admits `F`, the removal pass below proves `F` unsound and
20781
+ // drops it, `added` is true so the outer loop runs again, and `F` — re-evaluated
20782
+ // against a program that has not changed — is admitted again. Forever.
20783
+ //
20784
+ // The only exits were `!added` and `sigs.is_empty()`, and an oscillating pair
20785
+ // satisfies neither: measured on the reporter's project it sat at a steady
20786
+ // `sigs=3` for 200+ iterations at 100% CPU, with no cargo child and nothing
20787
+ // printed, which is why it read as a dead build rather than a slow one.
20788
+ //
20789
+ // With this guard every outer iteration must admit a name never admitted before,
20790
+ // so the loop is bounded by the number of top-level fns.
20791
+ if rejected.contains(name.as_ref()) {
20792
+ continue;
20793
+ }
20133
20794
  if let Some(sig) = Self::infer_vec_fn_sig_forwarding(params, body, &sigs) {
20134
20795
  let has_array_param = sig
20135
20796
  .params
@@ -20156,6 +20817,7 @@ impl Codegen {
20156
20817
  break;
20157
20818
  }
20158
20819
  for n in remove {
20820
+ rejected.insert(n.clone());
20159
20821
  sigs.remove(&n);
20160
20822
  }
20161
20823
  if sigs.is_empty() {
@@ -24862,33 +25524,47 @@ impl Codegen {
24862
25524
  iv
24863
25525
  )
24864
25526
  };
24865
- let access = if in_bounds {
24866
- format!("{}[{}]", static_name, idx_usize)
25527
+ let is_int = self.module_const_int_statics.contains(&static_name);
25528
+ // The proven-in-bounds read on an integer array is the whole point: a
25529
+ // plain word load, reported as I32 so the consumer stays in the integer
25530
+ // domain instead of converting back and forth through f64.
25531
+ if in_bounds {
25532
+ let access = format!("{}[{}]", static_name, idx_usize);
25533
+ return Ok((
25534
+ access,
25535
+ if is_int { RustType::I32 } else { RustType::F64 },
25536
+ ));
25537
+ }
25538
+ let const_len = self
25539
+ .cum_static_and_len(name.as_ref())
25540
+ .map(|(_, n)| n)
25541
+ .or_else(|| {
25542
+ self.module_const_f64_arrays
25543
+ .get(name.as_ref())
25544
+ .map(|v| v.len())
25545
+ })
25546
+ .unwrap_or(0);
25547
+ // OUT-OF-RANGE STAYS f64. An unproven index still has to be able to
25548
+ // answer `NaN`, and there is no `i32` that means "absent" — picking a
25549
+ // sentinel here would silently turn a missing element into a real value.
25550
+ // So the bounds-checked form keeps today's exact semantics and only the
25551
+ // STORAGE narrows; the integer win is claimed above, where the index is
25552
+ // proven and no sentinel is reachable.
25553
+ let elem = if is_int {
25554
+ format!("{}[_i] as f64", static_name)
24867
25555
  } else {
24868
- let const_len = self
24869
- .cum_static_and_len(name.as_ref())
24870
- .map(|(_, n)| n)
24871
- .or_else(|| {
24872
- self.module_const_f64_arrays
24873
- .get(name.as_ref())
24874
- .map(|v| v.len())
24875
- })
24876
- .unwrap_or(0);
24877
- if const_len > 0 {
24878
- format!(
24879
- "{{ let _i = {}; if _i < {} {{ {}[_i] }} else {{ f64::NAN }} }}",
24880
- idx_usize,
24881
- const_len,
24882
- static_name
24883
- )
24884
- } else {
24885
- format!(
24886
- "{{ let _i = {}; if _i < {}.len() {{ {}[_i] }} else {{ f64::NAN }} }}",
24887
- idx_usize,
24888
- static_name,
24889
- static_name
24890
- )
24891
- }
25556
+ format!("{}[_i]", static_name)
25557
+ };
25558
+ let access = if const_len > 0 {
25559
+ format!(
25560
+ "{{ let _i = {}; if _i < {} {{ {} }} else {{ f64::NAN }} }}",
25561
+ idx_usize, const_len, elem
25562
+ )
25563
+ } else {
25564
+ format!(
25565
+ "{{ let _i = {}; if _i < {}.len() {{ {} }} else {{ f64::NAN }} }}",
25566
+ idx_usize, static_name, elem
25567
+ )
24892
25568
  };
24893
25569
  return Ok((access, RustType::F64));
24894
25570
  }
@@ -24960,6 +25636,19 @@ impl Codegen {
24960
25636
  // `borrow()` guard a non-Copy element must be cloned OUT (it can't be
24961
25637
  // moved from the borrow); a plain local keeps the direct place.
24962
25638
  _ if idx_wrapped => format!("{}[{}].clone()", esc_obj, idx_ref),
25639
+ // #600: a STRING element is cloned even unwrapped. `let s: string =
25640
+ // NAMES[i]` binds the element, and binding a non-Copy value out of a
25641
+ // `Vec` is a move — `error[E0507]: cannot move out of index`. The
25642
+ // obvious spelling was the one that did not compile, with indexing
25643
+ // straight into a call as the only working form.
25644
+ //
25645
+ // Scoped to String deliberately. The struct case (#571) looks
25646
+ // identical but must NOT clone here: `states[id].timer` projects a
25647
+ // field out of the place, and cloning would copy the whole struct on
25648
+ // every field read — the O(n)-per-read footgun #558 exists about.
25649
+ // A string is already copied at every boxed boundary, so the clone
25650
+ // costs nothing that was not already being paid.
25651
+ RustType::String => format!("{}[{}].clone()", esc_obj, idx_ref),
24963
25652
  _ => format!("{}[{}]", esc_obj, idx_ref),
24964
25653
  };
24965
25654
  // #567: wrap the wrapped-Vec read in a block that pre-binds the index