@tishlang/tish-format 2.12.0 → 2.35.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 (88) hide show
  1. package/Cargo.toml +3 -0
  2. package/bin/tish-format +0 -0
  3. package/crates/js_to_tish/src/transform/expr.rs +12 -2
  4. package/crates/tish/tests/fixtures/fs_parity_callbacks.tish +22 -0
  5. package/crates/tish/tests/fixtures/fs_parity_promises.tish +21 -0
  6. package/crates/tish/tests/fixtures/fs_parity_sync.tish +20 -0
  7. package/crates/tish/tests/fs_parity.rs +103 -0
  8. package/crates/tish/tests/integration_test.rs +80 -0
  9. package/crates/tish/tests/shortcircuit.rs +3 -3
  10. package/crates/tish_ast/src/ast.rs +84 -0
  11. package/crates/tish_builtins/Cargo.toml +1 -0
  12. package/crates/tish_builtins/src/array.rs +724 -72
  13. package/crates/tish_builtins/src/collections.rs +149 -40
  14. package/crates/tish_builtins/src/globals.rs +215 -25
  15. package/crates/tish_builtins/src/math.rs +65 -7
  16. package/crates/tish_builtins/src/number.rs +173 -0
  17. package/crates/tish_builtins/src/string.rs +263 -23
  18. package/crates/tish_bytecode/src/chunk.rs +7 -0
  19. package/crates/tish_bytecode/src/compiler.rs +611 -63
  20. package/crates/tish_bytecode/src/lib.rs +1 -1
  21. package/crates/tish_bytecode/src/opcode.rs +156 -2
  22. package/crates/tish_bytecode/src/serialize.rs +2 -0
  23. package/crates/tish_bytecode/tests/append_local_string_builder.rs +63 -0
  24. package/crates/tish_bytecode/tests/math_unary_intrinsic.rs +47 -0
  25. package/crates/tish_compile/src/codegen.rs +18219 -5283
  26. package/crates/tish_compile/src/infer.rs +1562 -30
  27. package/crates/tish_compile/src/lib.rs +29 -4
  28. package/crates/tish_compile/src/resolve.rs +151 -13
  29. package/crates/tish_compile/src/types.rs +224 -28
  30. package/crates/tish_compile/tests/dump_codegen.rs +21 -0
  31. package/crates/tish_compile/tests/perf_codegen_169_173.rs +8 -17
  32. package/crates/tish_compile/tests/perf_codegen_173_part3.rs +61 -0
  33. package/crates/tish_compile/tests/perf_codegen_174.rs +160 -0
  34. package/crates/tish_compile/tests/perf_codegen_175.rs +190 -0
  35. package/crates/tish_compile/tests/perf_codegen_176.rs +60 -0
  36. package/crates/tish_compile/tests/perf_codegen_177.rs +167 -0
  37. package/crates/tish_compile/tests/perf_codegen_178.rs +114 -0
  38. package/crates/tish_compile/tests/perf_codegen_178_rec.rs +124 -0
  39. package/crates/tish_compile/tests/perf_codegen_181.rs +36 -0
  40. package/crates/tish_compile/tests/perf_codegen_320.rs +211 -0
  41. package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +40 -0
  42. package/crates/tish_compile_js/src/codegen.rs +94 -3
  43. package/crates/tish_compile_js/src/tests_jsx.rs +27 -0
  44. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +115 -2
  45. package/crates/tish_core/Cargo.toml +4 -0
  46. package/crates/tish_core/src/json.rs +109 -6
  47. package/crates/tish_core/src/lib.rs +222 -2
  48. package/crates/tish_core/src/shape.rs +4 -2
  49. package/crates/tish_core/src/uri.rs +45 -0
  50. package/crates/tish_core/src/value.rs +571 -35
  51. package/crates/tish_core/src/vmref.rs +14 -0
  52. package/crates/tish_eval/Cargo.toml +2 -1
  53. package/crates/tish_eval/src/eval.rs +1328 -101
  54. package/crates/tish_eval/src/natives.rs +283 -18
  55. package/crates/tish_eval/src/regex.rs +47 -1
  56. package/crates/tish_eval/src/value.rs +76 -21
  57. package/crates/tish_ffi/src/lib.rs +11 -1
  58. package/crates/tish_ffi/tests/double_free.rs +35 -0
  59. package/crates/tish_fmt/Cargo.toml +1 -1
  60. package/crates/tish_fmt/src/lib.rs +94 -1
  61. package/crates/tish_lexer/src/lib.rs +76 -0
  62. package/crates/tish_lexer/src/token.rs +4 -0
  63. package/crates/tish_lint/src/lib.rs +126 -0
  64. package/crates/tish_lsp/README.md +2 -1
  65. package/crates/tish_lsp/src/main.rs +378 -28
  66. package/crates/tish_native/src/build.rs +41 -0
  67. package/crates/tish_opt/src/lib.rs +20 -1
  68. package/crates/tish_parser/Cargo.toml +4 -0
  69. package/crates/tish_parser/src/lib.rs +68 -0
  70. package/crates/tish_parser/src/parser.rs +479 -28
  71. package/crates/tish_resolve/src/lib.rs +75 -5
  72. package/crates/tish_runtime/Cargo.toml +10 -1
  73. package/crates/tish_runtime/src/fs_ext.rs +359 -0
  74. package/crates/tish_runtime/src/http.rs +28 -10
  75. package/crates/tish_runtime/src/http_fetch.rs +150 -1
  76. package/crates/tish_runtime/src/http_hyper.rs +41 -18
  77. package/crates/tish_runtime/src/http_prefork.rs +72 -11
  78. package/crates/tish_runtime/src/lib.rs +651 -51
  79. package/crates/tish_runtime/src/timers.rs +49 -2
  80. package/crates/tish_ui/src/jsx.rs +21 -3
  81. package/crates/tish_vm/src/jit.rs +2514 -117
  82. package/crates/tish_vm/src/vm.rs +1479 -224
  83. package/package.json +1 -1
  84. package/platform/darwin-arm64/tish-fmt +0 -0
  85. package/platform/darwin-x64/tish-fmt +0 -0
  86. package/platform/linux-arm64/tish-fmt +0 -0
  87. package/platform/linux-x64/tish-fmt +0 -0
  88. package/platform/win32-x64/tish-fmt.exe +0 -0
@@ -10,6 +10,7 @@
10
10
  //! - Already-annotated vars are left unchanged.
11
11
 
12
12
  use std::collections::{HashMap, HashSet};
13
+ use std::sync::Arc;
13
14
  use tishlang_ast::{
14
15
  ArrowBody, BinOp, CallArg, Expr, FunParam, Literal, Program, Statement, TypeAnnotation,
15
16
  };
@@ -18,15 +19,42 @@ use tishlang_ast::{
18
19
  #[derive(Default, Clone)]
19
20
  pub struct InferCtx {
20
21
  scopes: Vec<HashMap<String, TypeAnnotation>>,
22
+ /// #175: fns de-virtualized to native-vec free fns → per-param "is an array param" flags. Lets
23
+ /// the mutable-array co-inference treat `f(arr)` as a native use (the callee takes `&/&mut Vec`),
24
+ /// not a boxing escape, so the caller's array stays an unboxed `Vec`.
25
+ native_vec_array_params: HashMap<String, Vec<bool>>,
26
+ /// #320: fns PROVEN to always return a `number` (every return is numeric and the body can't fall
27
+ /// through to an implicit `undefined`). Lets `infer_expr_type(f(...))` be `number`, so
28
+ /// `a.push(f(...))` infers `a: number[]` (e.g. k_nucleotide's `seq.push(nextBase())`).
29
+ number_returning_fns: std::collections::HashSet<String>,
30
+ /// #373: registered struct-alias shapes (alias → ordered fields), so `infer_expr_type` can
31
+ /// resolve `<expr: Simple(alias)>.field` → the field's type — needed to type a CROSS-ARRAY
32
+ /// struct-field read like `base[i].key` when building another record array (`rows`).
33
+ struct_shapes: HashMap<String, Vec<(std::sync::Arc<str>, TypeAnnotation)>>,
21
34
  }
22
35
 
23
36
  impl InferCtx {
24
37
  pub fn new() -> Self {
25
38
  Self {
26
39
  scopes: vec![HashMap::new()],
40
+ native_vec_array_params: HashMap::new(),
41
+ number_returning_fns: std::collections::HashSet::new(),
42
+ struct_shapes: HashMap::new(),
27
43
  }
28
44
  }
29
45
 
46
+ fn define_struct(&mut self, alias: &str, fields: Vec<(std::sync::Arc<str>, TypeAnnotation)>) {
47
+ self.struct_shapes.insert(alias.to_string(), fields);
48
+ }
49
+
50
+ fn struct_field(&self, alias: &str, key: &str) -> Option<&TypeAnnotation> {
51
+ self.struct_shapes
52
+ .get(alias)?
53
+ .iter()
54
+ .find(|(k, _)| k.as_ref() == key)
55
+ .map(|(_, t)| t)
56
+ }
57
+
30
58
  fn push_scope(&mut self) {
31
59
  self.scopes.push(HashMap::new());
32
60
  }
@@ -115,6 +143,13 @@ pub fn infer_expr_type(expr: &Expr, ctx: &InferCtx) -> Option<TypeAnnotation> {
115
143
  Expr::Binary {
116
144
  left, op, right, ..
117
145
  } => {
146
+ // Always-numeric ops coerce BOTH operands to Number (JS ToNumber) → result is Number
147
+ // regardless of operand types, even when an operand type is unknown (e.g. a call result).
148
+ // Excludes `+` (string concat) and comparisons. Lets `{ key: r % 65536 }` type as number.
149
+ if matches!(op, BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Mod | BinOp::Pow
150
+ | BinOp::BitAnd | BinOp::BitOr | BinOp::BitXor | BinOp::Shl | BinOp::Shr | BinOp::UShr) {
151
+ return Some(number_ann());
152
+ }
118
153
  let lt = infer_expr_type(left, ctx)?;
119
154
  let rt = infer_expr_type(right, ctx)?;
120
155
  if is_number(&lt) && is_number(&rt) {
@@ -164,10 +199,29 @@ pub fn infer_expr_type(expr: &Expr, ctx: &InferCtx) -> Option<TypeAnnotation> {
164
199
  }
165
200
  }
166
201
  // Index of a typed array yields its element type (`a[i]` where `a: T[]` → `T`).
202
+ // `<expr: Simple(alias)>.field` — struct field read → the field's type (alias shape must be
203
+ // registered in ctx). Enables cross-array reads like `base[i].key` (#373). Non-struct
204
+ // receivers (or `Simple("number")` etc.) resolve to None, unchanged.
205
+ Expr::Member {
206
+ object,
207
+ prop: tishlang_ast::MemberProp::Name { name: k, .. },
208
+ ..
209
+ } => match infer_expr_type(object, ctx)? {
210
+ TypeAnnotation::Simple(alias, _) => ctx.struct_field(alias.as_ref(), k.as_ref()).cloned(),
211
+ _ => None,
212
+ },
167
213
  Expr::Index { object, .. } => match infer_expr_type(object, ctx) {
168
214
  Some(TypeAnnotation::Array(elem)) => Some(*elem),
169
215
  _ => None,
170
216
  },
217
+ // #320: a call to a fn PROVEN to always return a number is itself a number — so
218
+ // `a.push(f(...))` can infer `a: number[]` (e.g. `seq.push(nextBase())`).
219
+ Expr::Call { callee, .. } => match callee.as_ref() {
220
+ Expr::Ident { name, .. } if ctx.number_returning_fns.contains(name.as_ref()) => {
221
+ Some(number_ann())
222
+ }
223
+ _ => None,
224
+ },
171
225
  _ => None,
172
226
  }
173
227
  }
@@ -181,7 +235,7 @@ pub fn infer_program(program: &Program) -> Program {
181
235
  // param types — otherwise they fall back to boxed `Value` and the whole hot loop boxes with
182
236
  // them (the difference between an idiomatic numeric fn going native vs staying boxed).
183
237
  // Conservative — any non-numeric / write / escape use bails (param stays boxed Value).
184
- let p = if std::env::var("TISH_PARAM_INFER").map(|v| v != "0").unwrap_or(false) {
238
+ let p = if crate::native_opts_enabled() {
185
239
  param_infer_program(program.clone())
186
240
  } else {
187
241
  program.clone()
@@ -196,7 +250,7 @@ pub fn infer_program(program: &Program) -> Program {
196
250
  // backend emits unboxed structs with direct field access. Conservative —
197
251
  // only applies when every use of the binding is a literal-key field read,
198
252
  // so it can never miscompile (any uncertainty falls back to boxed Value).
199
- let p = if std::env::var("TISH_STRUCT_INFER").map(|v| v != "0").unwrap_or(false) {
253
+ let p = if crate::native_opts_enabled() {
200
254
  struct_infer_program(p)
201
255
  } else {
202
256
  p
@@ -212,7 +266,7 @@ pub fn infer_program(program: &Program) -> Program {
212
266
  // until those land this pass only emits the annotations the existing codegen backs SOUNDLY
213
267
  // (the S-0 scalar `: number` params, identical to the M4 mechanism) plus the inert struct
214
268
  // alias decls. See `aggregate_infer_program`.
215
- if std::env::var("TISH_AGGREGATE_INFER").map(|v| v != "0").unwrap_or(false) {
269
+ if crate::native_opts_enabled() {
216
270
  aggregate_infer_program(p)
217
271
  } else {
218
272
  p
@@ -224,12 +278,22 @@ pub fn infer_program(program: &Program) -> Program {
224
278
  // ---------------------------------------------------------------------------
225
279
 
226
280
  fn param_infer_program(program: Program) -> Program {
281
+ // Soundness: a fn called ANYWHERE with a provably-non-numeric arg (a string/bool/null literal,
282
+ // template, array, or object) must NOT get a `: number` param shadow — the body may "look
283
+ // numeric" (`return SHARED`) yet the caller passes e.g. `"arg"`, which the f64 coercion would
284
+ // panic on / turn to NaN where JS keeps the value. Skip those fns (they stay fully boxed). Same
285
+ // set `collect_native_fns` uses to refuse the M5 native form.
286
+ let nonnumeric = crate::codegen::Codegen::fns_called_with_nonnumeric_arg(&program.statements);
227
287
  Program {
228
- statements: program.statements.into_iter().map(pi_stmt).collect(),
288
+ statements: program
289
+ .statements
290
+ .into_iter()
291
+ .map(|s| pi_stmt(s, &nonnumeric))
292
+ .collect(),
229
293
  }
230
294
  }
231
295
 
232
- fn pi_stmt(s: Statement) -> Statement {
296
+ fn pi_stmt(s: Statement, nonnumeric: &HashSet<String>) -> Statement {
233
297
  if let Statement::FunDecl {
234
298
  async_,
235
299
  name,
@@ -241,6 +305,19 @@ fn pi_stmt(s: Statement) -> Statement {
241
305
  span,
242
306
  } = s
243
307
  {
308
+ // A fn reached with a non-numeric arg somewhere: leave its params dynamic (boxed).
309
+ if nonnumeric.contains(name.as_ref()) {
310
+ return Statement::FunDecl {
311
+ async_,
312
+ name,
313
+ name_span,
314
+ params,
315
+ rest_param,
316
+ return_type,
317
+ body,
318
+ span,
319
+ };
320
+ }
244
321
  // Locals provably numeric in this body (annotated, incl. base-inferred `let i = 0`), so a
245
322
  // bare loop counter `i` counts as a numeric operand and `i < n` can prove the param `n`.
246
323
  // Fixpoint-closed so copy-chains of numeric literals (`let a = 0; let b = a`) propagate.
@@ -871,6 +948,11 @@ struct StructRegistry {
871
948
  by_shape: HashMap<String, String>,
872
949
  /// alias name → field list (for emitting the `type` decls)
873
950
  decls: Vec<StructDecl>,
951
+ /// #179 Stage B: canonical union key ("shape0|shape1|…") → union alias name.
952
+ by_union: HashMap<String, String>,
953
+ /// #179 Stage B: union alias → its variant shapes. Emitted as `type TishAnonUnion_N =
954
+ /// Union([Object(s0), Object(s1), …])`; codegen synthesizes the enum + per-variant structs.
955
+ union_decls: Vec<(String, Vec<Vec<(std::sync::Arc<str>, TypeAnnotation)>>)>,
874
956
  }
875
957
 
876
958
  impl StructRegistry {
@@ -888,6 +970,37 @@ impl StructRegistry {
888
970
  self.decls.push((name.clone(), fields.to_vec()));
889
971
  name
890
972
  }
973
+
974
+ /// #179 Stage B: register a closed set of distinct record shapes as a shape-union alias
975
+ /// (`TishAnonUnion_N`). Identical sets (order-insensitive per the caller's dedup) share one alias.
976
+ fn intern_union(&mut self, shapes: &[Vec<(std::sync::Arc<str>, TypeAnnotation)>]) -> String {
977
+ let canon = shapes
978
+ .iter()
979
+ .map(|s| {
980
+ s.iter()
981
+ .map(|(k, t)| format!("{}:{}", k, type_canon(t)))
982
+ .collect::<Vec<_>>()
983
+ .join(";")
984
+ })
985
+ .collect::<Vec<_>>()
986
+ .join("|");
987
+ if let Some(name) = self.by_union.get(&canon) {
988
+ return name.clone();
989
+ }
990
+ let name = format!("TishAnonUnion_{}", self.union_decls.len());
991
+ self.by_union.insert(canon, name.clone());
992
+ self.union_decls.push((name.clone(), shapes.to_vec()));
993
+ name
994
+ }
995
+
996
+ /// The field list registered under an alias (`TishAnon_N` or a user `type`), for inlining a
997
+ /// struct-typed spread's fields into a merged object shape.
998
+ fn fields_of(&self, alias: &str) -> Option<&[(std::sync::Arc<str>, TypeAnnotation)]> {
999
+ self.decls
1000
+ .iter()
1001
+ .find(|(n, _)| n == alias)
1002
+ .map(|(_, f)| f.as_slice())
1003
+ }
891
1004
  }
892
1005
 
893
1006
  fn type_canon(t: &TypeAnnotation) -> String {
@@ -905,6 +1018,79 @@ fn type_canon(t: &TypeAnnotation) -> String {
905
1018
  }
906
1019
  }
907
1020
 
1021
+ /// If `props` is `{ ...base, <explicit KeyValue props> }` where `base` is a struct-typed local and
1022
+ /// the explicit keys don't collide with base's fields, return (merged struct field list, spread-free
1023
+ /// rewritten props: `base`'s fields as `base.field` reads, then the explicit props). This lets a
1024
+ /// `{ ...base, k: v }` immutable-update / config-merge lower to an unboxed struct via the normal
1025
+ /// struct-from-object codegen — no codegen change. Conservative — `None` (stays a boxed PropMap)
1026
+ /// unless: exactly one spread, it is the FIRST prop, over a plain struct-typed ident; every explicit
1027
+ /// prop is a typeable `KeyValue`; and no explicit key collides with a base field (keeps spread
1028
+ /// override-order semantics out of scope). `base` is already a struct ⇒ proven read-only, so
1029
+ /// inlining its field reads is sound.
1030
+ fn inline_struct_spread(
1031
+ props: &[tishlang_ast::ObjectProp],
1032
+ ctx: &InferCtx,
1033
+ reg: &StructRegistry,
1034
+ ) -> Option<(
1035
+ Vec<(std::sync::Arc<str>, TypeAnnotation)>,
1036
+ Vec<tishlang_ast::ObjectProp>,
1037
+ )> {
1038
+ use tishlang_ast::{Expr, MemberProp, ObjectProp};
1039
+ if props
1040
+ .iter()
1041
+ .filter(|p| matches!(p, ObjectProp::Spread(_)))
1042
+ .count()
1043
+ != 1
1044
+ {
1045
+ return None;
1046
+ }
1047
+ let (base_name, base_span) = match props.first() {
1048
+ Some(ObjectProp::Spread(Expr::Ident { name, span })) => (name.clone(), *span),
1049
+ _ => return None, // spread not first, or not a plain ident
1050
+ };
1051
+ let alias = match ctx.lookup(base_name.as_ref()) {
1052
+ Some(TypeAnnotation::Simple(a, _)) => a.clone(),
1053
+ _ => return None, // base is not a struct-typed local
1054
+ };
1055
+ let base_fields = reg.fields_of(&alias)?.to_vec();
1056
+ let explicit = &props[1..];
1057
+ // Explicit props must be spread-free + all typeable (infer_object_shape bails otherwise).
1058
+ let explicit_fields = infer_object_shape(explicit, ctx)?;
1059
+ let base_keys: std::collections::HashSet<&str> =
1060
+ base_fields.iter().map(|(k, _)| k.as_ref()).collect();
1061
+ if explicit_fields
1062
+ .iter()
1063
+ .any(|(k, _)| base_keys.contains(k.as_ref()))
1064
+ {
1065
+ return None;
1066
+ }
1067
+ let mut merged = base_fields.clone();
1068
+ merged.extend(explicit_fields.iter().cloned());
1069
+ let mut rewritten: Vec<ObjectProp> = base_fields
1070
+ .iter()
1071
+ .map(|(k, _)| {
1072
+ ObjectProp::KeyValue(
1073
+ k.clone(),
1074
+ Expr::Member {
1075
+ object: Box::new(Expr::Ident {
1076
+ name: base_name.clone(),
1077
+ span: base_span,
1078
+ }),
1079
+ prop: MemberProp::Name {
1080
+ name: k.clone(),
1081
+ span: base_span,
1082
+ },
1083
+ optional: false,
1084
+ span: base_span,
1085
+ },
1086
+ base_span,
1087
+ )
1088
+ })
1089
+ .collect();
1090
+ rewritten.extend_from_slice(explicit);
1091
+ Some((merged, rewritten))
1092
+ }
1093
+
908
1094
  /// Infer a concrete object shape from an object literal, or `None` if any field
909
1095
  /// can't be typed concretely / there's a spread.
910
1096
  fn infer_object_shape(
@@ -915,6 +1101,12 @@ fn infer_object_shape(
915
1101
  for p in props {
916
1102
  match p {
917
1103
  tishlang_ast::ObjectProp::KeyValue(k, v, _) => {
1104
+ // Security #379: an object key that is not a valid Rust identifier must not become a
1105
+ // native struct field (it would be interpolated verbatim into generated Rust — code
1106
+ // injection). Decline the shape → the object stays on the boxed `Value` path.
1107
+ if !crate::types::is_struct_field_safe(k) {
1108
+ return None;
1109
+ }
918
1110
  let ty = infer_expr_type(v, ctx)?;
919
1111
  // Only primitive field types in this conservative version.
920
1112
  if !matches!(&ty, TypeAnnotation::Simple(s, _)
@@ -930,12 +1122,760 @@ fn infer_object_shape(
930
1122
  if fields.is_empty() {
931
1123
  return None;
932
1124
  }
933
- Some(fields)
1125
+ Some(fields)
1126
+ }
1127
+
1128
+ /// #320: the set of top-level fns PROVEN to ALWAYS return a `number` — every `return` carries a
1129
+ /// numeric value and the body can't fall through to an implicit `undefined`. Lets `infer_expr_type`
1130
+ /// type a call `f(...)` as `number`, so `seq.push(nextBase())` infers `seq: number[]` (native key
1131
+ /// arithmetic over a `Vec<f64>` instead of a boxed `Value[]`). Conservative + sound: any fn we
1132
+ /// can't prove is left out, and the Call arm then declines to type its calls. Small fixpoint so a
1133
+ /// number-fn may call another already-accepted number-fn.
1134
+ fn collect_number_returning_fns(
1135
+ stmts: &[Statement],
1136
+ base: &InferCtx,
1137
+ ) -> std::collections::HashSet<String> {
1138
+ use std::collections::HashSet;
1139
+ let mut accepted: HashSet<String> = HashSet::new();
1140
+ loop {
1141
+ let mut changed = false;
1142
+ for s in stmts {
1143
+ if let Statement::FunDecl {
1144
+ async_: false,
1145
+ name,
1146
+ params,
1147
+ rest_param: None,
1148
+ body,
1149
+ ..
1150
+ } = s
1151
+ {
1152
+ if accepted.contains(name.as_ref()) {
1153
+ continue;
1154
+ }
1155
+ // Fn-local ctx: numeric params (param-infer already annotated them), numeric locals,
1156
+ // and the number-fns accepted so far (so a numeric call inside resolves).
1157
+ let mut fctx = base.clone();
1158
+ fctx.number_returning_fns = accepted.clone();
1159
+ fctx.push_scope();
1160
+ for p in params {
1161
+ if let FunParam::Simple(tp) = p {
1162
+ if tp.type_ann.as_ref().is_some_and(is_number) {
1163
+ fctx.define(&tp.name, number_ann());
1164
+ }
1165
+ }
1166
+ }
1167
+ // A few passes so chained numeric locals settle (`let a = 0; let b = a * 2`).
1168
+ for _ in 0..4 {
1169
+ seed_numeric_locals(body, &mut fctx);
1170
+ }
1171
+ if fn_always_returns_number(body, &fctx) {
1172
+ accepted.insert(name.to_string());
1173
+ changed = true;
1174
+ }
1175
+ }
1176
+ }
1177
+ if !changed {
1178
+ break;
1179
+ }
1180
+ }
1181
+ accepted
1182
+ }
1183
+
1184
+ /// A fn always returns a number iff EVERY `return` in its body carries a value that infers to
1185
+ /// `number` AND the body can't fall through to an implicit `undefined` (conservatively: its last
1186
+ /// statement is an unconditional `return <value>`).
1187
+ fn fn_always_returns_number(body: &Statement, ctx: &InferCtx) -> bool {
1188
+ let mut ok = true;
1189
+ check_returns_numeric(body, ctx, &mut ok);
1190
+ ok && body_ends_in_return(body)
1191
+ }
1192
+
1193
+ /// Visit EVERY `return` reachable in THIS fn (descends all statement-nesting constructs but NOT
1194
+ /// nested fn bodies, whose returns belong to the closure). Sets `ok = false` on a bare `return;` or
1195
+ /// a `return <e>` whose value doesn't provably infer to `number`. Missing a construct here would be
1196
+ /// unsound, so every statement-nesting variant is enumerated explicitly.
1197
+ fn check_returns_numeric(s: &Statement, ctx: &InferCtx, ok: &mut bool) {
1198
+ use Statement::*;
1199
+ if !*ok {
1200
+ return;
1201
+ }
1202
+ match s {
1203
+ Return { value, .. } => match value {
1204
+ Some(e) => {
1205
+ if infer_expr_type(e, ctx).as_ref().map_or(true, |t| !is_number(t)) {
1206
+ *ok = false;
1207
+ }
1208
+ }
1209
+ None => *ok = false,
1210
+ },
1211
+ FunDecl { .. } => {} // nested fn: its returns are not this fn's
1212
+ Block { statements, .. } | Multi { statements, .. } => {
1213
+ for c in statements {
1214
+ check_returns_numeric(c, ctx, ok);
1215
+ }
1216
+ }
1217
+ If { then_branch, else_branch, .. } => {
1218
+ check_returns_numeric(then_branch, ctx, ok);
1219
+ if let Some(e) = else_branch {
1220
+ check_returns_numeric(e, ctx, ok);
1221
+ }
1222
+ }
1223
+ While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } | ForIn { body, .. } => {
1224
+ check_returns_numeric(body, ctx, ok);
1225
+ }
1226
+ For { init, body, .. } => {
1227
+ if let Some(i) = init {
1228
+ check_returns_numeric(i, ctx, ok);
1229
+ }
1230
+ check_returns_numeric(body, ctx, ok);
1231
+ }
1232
+ Switch { cases, default_body, .. } => {
1233
+ for (_, body) in cases {
1234
+ for c in body {
1235
+ check_returns_numeric(c, ctx, ok);
1236
+ }
1237
+ }
1238
+ if let Some(d) = default_body {
1239
+ for c in d {
1240
+ check_returns_numeric(c, ctx, ok);
1241
+ }
1242
+ }
1243
+ }
1244
+ Try { body, catch_body, finally_body, .. } => {
1245
+ check_returns_numeric(body, ctx, ok);
1246
+ if let Some(c) = catch_body {
1247
+ check_returns_numeric(c, ctx, ok);
1248
+ }
1249
+ if let Some(f) = finally_body {
1250
+ check_returns_numeric(f, ctx, ok);
1251
+ }
1252
+ }
1253
+ _ => {}
1254
+ }
1255
+ }
1256
+
1257
+ /// Conservative "the body always reaches a return": the last statement is an unconditional
1258
+ /// `return <value>` (or a nested block that itself ends in one). Anything else (trailing `if`,
1259
+ /// loop, fall-off) is treated as a possible implicit-`undefined` exit and rejects the fn.
1260
+ fn body_ends_in_return(s: &Statement) -> bool {
1261
+ match s {
1262
+ Statement::Return { value: Some(_), .. } => true,
1263
+ Statement::Block { statements, .. } => statements.last().is_some_and(body_ends_in_return),
1264
+ _ => false,
1265
+ }
1266
+ }
1267
+
1268
+ // ---------------------------------------------------------------------------
1269
+ // #179 Stage A: single-call nullary-factory inlining (fail-closed)
1270
+ // ---------------------------------------------------------------------------
1271
+
1272
+ /// Count uses of function name `f` across `stmts`. Returns `(direct_calls, other_uses)`:
1273
+ /// a `direct_call` is `f(...)` (an `Ident(f)` in callee position); `other_uses` is EVERY other
1274
+ /// occurrence of the name — as a value, an assignment/inc-dec target, etc. Exhaustive over the
1275
+ /// whole AST (walks fn bodies, switch, try — which the shared `walk_exprs_stmt` skips) so it can
1276
+ /// back a SOUND escape check: if `other_uses > 0` the function escapes and must not be inlined.
1277
+ fn fn_name_uses(stmts: &[Statement], f: &str) -> (usize, usize) {
1278
+ let mut calls = 0usize;
1279
+ let mut others = 0usize;
1280
+ for s in stmts {
1281
+ count_uses_stmt(s, f, &mut calls, &mut others);
1282
+ }
1283
+ (calls, others)
1284
+ }
1285
+
1286
+ fn count_uses_stmt(s: &Statement, f: &str, calls: &mut usize, others: &mut usize) {
1287
+ use Statement::*;
1288
+ match s {
1289
+ VarDecl { init, .. } => {
1290
+ if let Some(e) = init {
1291
+ count_uses_expr(e, f, calls, others);
1292
+ }
1293
+ }
1294
+ VarDeclDestructure { init, .. } => count_uses_expr(init, f, calls, others),
1295
+ Multi { statements, .. } | Block { statements, .. } => {
1296
+ statements.iter().for_each(|x| count_uses_stmt(x, f, calls, others))
1297
+ }
1298
+ ExprStmt { expr, .. } => count_uses_expr(expr, f, calls, others),
1299
+ If { cond, then_branch, else_branch, .. } => {
1300
+ count_uses_expr(cond, f, calls, others);
1301
+ count_uses_stmt(then_branch, f, calls, others);
1302
+ if let Some(e) = else_branch {
1303
+ count_uses_stmt(e, f, calls, others);
1304
+ }
1305
+ }
1306
+ While { cond, body, .. } | DoWhile { cond, body, .. } => {
1307
+ count_uses_expr(cond, f, calls, others);
1308
+ count_uses_stmt(body, f, calls, others);
1309
+ }
1310
+ For { init, cond, update, body, .. } => {
1311
+ if let Some(i) = init {
1312
+ count_uses_stmt(i, f, calls, others);
1313
+ }
1314
+ if let Some(c) = cond {
1315
+ count_uses_expr(c, f, calls, others);
1316
+ }
1317
+ if let Some(u) = update {
1318
+ count_uses_expr(u, f, calls, others);
1319
+ }
1320
+ count_uses_stmt(body, f, calls, others);
1321
+ }
1322
+ ForOf { iterable, body, .. }
1323
+ | ForIn {
1324
+ object: iterable,
1325
+ body,
1326
+ ..
1327
+ } => {
1328
+ count_uses_expr(iterable, f, calls, others);
1329
+ count_uses_stmt(body, f, calls, others);
1330
+ }
1331
+ Return { value: Some(e), .. } => count_uses_expr(e, f, calls, others),
1332
+ Throw { value, .. } => count_uses_expr(value, f, calls, others),
1333
+ FunDecl { body, .. } => count_uses_stmt(body, f, calls, others),
1334
+ Switch { expr, cases, default_body, .. } => {
1335
+ count_uses_expr(expr, f, calls, others);
1336
+ for (test, body) in cases {
1337
+ if let Some(t) = test {
1338
+ count_uses_expr(t, f, calls, others);
1339
+ }
1340
+ body.iter().for_each(|x| count_uses_stmt(x, f, calls, others));
1341
+ }
1342
+ if let Some(body) = default_body {
1343
+ body.iter().for_each(|x| count_uses_stmt(x, f, calls, others));
1344
+ }
1345
+ }
1346
+ Try { body, catch_body, finally_body, .. } => {
1347
+ count_uses_stmt(body, f, calls, others);
1348
+ if let Some(cb) = catch_body {
1349
+ count_uses_stmt(cb, f, calls, others);
1350
+ }
1351
+ if let Some(fb) = finally_body {
1352
+ count_uses_stmt(fb, f, calls, others);
1353
+ }
1354
+ }
1355
+ Export { declaration, .. } => match declaration.as_ref() {
1356
+ tishlang_ast::ExportDeclaration::Named(inner) => {
1357
+ count_uses_stmt(inner, f, calls, others)
1358
+ }
1359
+ tishlang_ast::ExportDeclaration::Default(e) => count_uses_expr(e, f, calls, others),
1360
+ tishlang_ast::ExportDeclaration::ReExport { .. } => {}
1361
+ },
1362
+ Return { value: None, .. }
1363
+ | Break { .. }
1364
+ | Continue { .. }
1365
+ | Import { .. }
1366
+ | TypeAlias { .. }
1367
+ | DeclareVar { .. }
1368
+ | DeclareFun { .. } => {}
1369
+ }
1370
+ }
1371
+
1372
+ fn count_uses_expr(e: &Expr, f: &str, calls: &mut usize, others: &mut usize) {
1373
+ use Expr::*;
1374
+ match e {
1375
+ Ident { name, .. } => {
1376
+ if name.as_ref() == f {
1377
+ *others += 1;
1378
+ }
1379
+ }
1380
+ Call { callee, args, .. } => {
1381
+ // `f(...)` in callee position is a direct call — do NOT count the callee ident as an
1382
+ // "other use". Any other callee shape (member call, computed) recurses normally.
1383
+ match callee.as_ref() {
1384
+ Ident { name, .. } if name.as_ref() == f => *calls += 1,
1385
+ _ => count_uses_expr(callee, f, calls, others),
1386
+ }
1387
+ for a in args {
1388
+ match a {
1389
+ CallArg::Expr(x) | CallArg::Spread(x) => count_uses_expr(x, f, calls, others),
1390
+ }
1391
+ }
1392
+ }
1393
+ New { callee, args, .. } => {
1394
+ count_uses_expr(callee, f, calls, others);
1395
+ for a in args {
1396
+ match a {
1397
+ CallArg::Expr(x) | CallArg::Spread(x) => count_uses_expr(x, f, calls, others),
1398
+ }
1399
+ }
1400
+ }
1401
+ Assign { name, value, .. }
1402
+ | CompoundAssign { name, value, .. }
1403
+ | LogicalAssign { name, value, .. } => {
1404
+ if name.as_ref() == f {
1405
+ *others += 1; // reassigning the function name — an escape
1406
+ }
1407
+ count_uses_expr(value, f, calls, others);
1408
+ }
1409
+ PostfixInc { name, .. } | PostfixDec { name, .. } | PrefixInc { name, .. }
1410
+ | PrefixDec { name, .. } => {
1411
+ if name.as_ref() == f {
1412
+ *others += 1;
1413
+ }
1414
+ }
1415
+ Binary { left, right, .. } | NullishCoalesce { left, right, .. } => {
1416
+ count_uses_expr(left, f, calls, others);
1417
+ count_uses_expr(right, f, calls, others);
1418
+ }
1419
+ Unary { operand, .. } | TypeOf { operand, .. } | Await { operand, .. }
1420
+ | Delete { target: operand, .. } => count_uses_expr(operand, f, calls, others),
1421
+ Member { object, prop, .. } => {
1422
+ count_uses_expr(object, f, calls, others);
1423
+ if let tishlang_ast::MemberProp::Expr(p) = prop {
1424
+ count_uses_expr(p, f, calls, others);
1425
+ }
1426
+ }
1427
+ Index { object, index, .. } => {
1428
+ count_uses_expr(object, f, calls, others);
1429
+ count_uses_expr(index, f, calls, others);
1430
+ }
1431
+ Conditional { cond, then_branch, else_branch, .. } => {
1432
+ count_uses_expr(cond, f, calls, others);
1433
+ count_uses_expr(then_branch, f, calls, others);
1434
+ count_uses_expr(else_branch, f, calls, others);
1435
+ }
1436
+ Array { elements, .. } => {
1437
+ for el in elements {
1438
+ match el {
1439
+ tishlang_ast::ArrayElement::Expr(x)
1440
+ | tishlang_ast::ArrayElement::Spread(x) => {
1441
+ count_uses_expr(x, f, calls, others)
1442
+ }
1443
+ }
1444
+ }
1445
+ }
1446
+ Object { props, .. } => {
1447
+ for p in props {
1448
+ match p {
1449
+ tishlang_ast::ObjectProp::KeyValue(_, v, _) => {
1450
+ count_uses_expr(v, f, calls, others)
1451
+ }
1452
+ tishlang_ast::ObjectProp::Spread(x) => count_uses_expr(x, f, calls, others),
1453
+ }
1454
+ }
1455
+ }
1456
+ MemberAssign { object, value, .. } => {
1457
+ count_uses_expr(object, f, calls, others);
1458
+ count_uses_expr(value, f, calls, others);
1459
+ }
1460
+ IndexAssign { object, index, value, .. } => {
1461
+ count_uses_expr(object, f, calls, others);
1462
+ count_uses_expr(index, f, calls, others);
1463
+ count_uses_expr(value, f, calls, others);
1464
+ }
1465
+ ArrowFunction { body, .. } => match body {
1466
+ tishlang_ast::ArrowBody::Expr(x) => count_uses_expr(x, f, calls, others),
1467
+ tishlang_ast::ArrowBody::Block(s) => count_uses_stmt(s, f, calls, others),
1468
+ },
1469
+ TemplateLiteral { exprs, .. } => {
1470
+ exprs.iter().for_each(|x| count_uses_expr(x, f, calls, others))
1471
+ }
1472
+ JsxElement { props, children, .. } => {
1473
+ for prop in props {
1474
+ match prop {
1475
+ tishlang_ast::JsxProp::Attr { value, .. } => {
1476
+ if let tishlang_ast::JsxAttrValue::Expr(x) = value {
1477
+ count_uses_expr(x, f, calls, others);
1478
+ }
1479
+ }
1480
+ tishlang_ast::JsxProp::Spread(x) => count_uses_expr(x, f, calls, others),
1481
+ }
1482
+ }
1483
+ for c in children {
1484
+ if let tishlang_ast::JsxChild::Expr(x) = c {
1485
+ count_uses_expr(x, f, calls, others);
1486
+ }
1487
+ }
1488
+ }
1489
+ JsxFragment { children, .. } => {
1490
+ for c in children {
1491
+ if let tishlang_ast::JsxChild::Expr(x) = c {
1492
+ count_uses_expr(x, f, calls, others);
1493
+ }
1494
+ }
1495
+ }
1496
+ Literal { .. } | NativeModuleLoad { .. } => {}
1497
+ }
1498
+ }
1499
+
1500
+ /// Does statement `s` (or anything nested) contain a closure (`FunDecl`/`ArrowFunction`) or a
1501
+ /// `this`/`arguments`/`super` reference? Such a body is NOT safely alpha-renameable by the flat
1502
+ /// splice below (a closure could capture a renamed local), so we refuse to inline it.
1503
+ fn body_has_closure_or_dynamic(s: &Statement) -> bool {
1504
+ let mut bad = false;
1505
+ walk_exprs_stmt_full(s, &mut |e| {
1506
+ match e {
1507
+ Expr::ArrowFunction { .. } => bad = true,
1508
+ Expr::Ident { name, .. }
1509
+ if matches!(name.as_ref(), "this" | "arguments" | "super") =>
1510
+ {
1511
+ bad = true
1512
+ }
1513
+ _ => {}
1514
+ }
1515
+ });
1516
+ bad || stmt_contains_fundecl(s)
1517
+ }
1518
+
1519
+ fn stmt_contains_fundecl(s: &Statement) -> bool {
1520
+ use Statement::*;
1521
+ match s {
1522
+ FunDecl { .. } => true,
1523
+ Block { statements, .. } | Multi { statements, .. } => {
1524
+ statements.iter().any(stmt_contains_fundecl)
1525
+ }
1526
+ If { then_branch, else_branch, .. } => {
1527
+ stmt_contains_fundecl(then_branch)
1528
+ || else_branch.as_ref().is_some_and(|e| stmt_contains_fundecl(e))
1529
+ }
1530
+ While { body, .. } | DoWhile { body, .. } | For { body, .. } | ForOf { body, .. } | ForIn { body, .. } => {
1531
+ stmt_contains_fundecl(body)
1532
+ }
1533
+ Try { body, catch_body, finally_body, .. } => {
1534
+ stmt_contains_fundecl(body)
1535
+ || catch_body.as_ref().is_some_and(|b| stmt_contains_fundecl(b))
1536
+ || finally_body.as_ref().is_some_and(|b| stmt_contains_fundecl(b))
1537
+ }
1538
+ Switch { cases, default_body, .. } => {
1539
+ cases.iter().any(|(_, b)| b.iter().any(stmt_contains_fundecl))
1540
+ || default_body
1541
+ .as_ref()
1542
+ .is_some_and(|b| b.iter().any(stmt_contains_fundecl))
1543
+ }
1544
+ _ => false,
1545
+ }
1546
+ }
1547
+
1548
+ /// Exhaustive expr-visitor over a statement (unlike the shared `walk_exprs_stmt`, this also
1549
+ /// descends fn bodies, switch, and try). Visits every `Expr` node via `f`.
1550
+ fn walk_exprs_stmt_full(s: &Statement, f: &mut impl FnMut(&Expr)) {
1551
+ use Statement::*;
1552
+ match s {
1553
+ VarDecl { init: Some(e), .. } => walk_exprs_expr(e, f),
1554
+ VarDeclDestructure { init, .. } => walk_exprs_expr(init, f),
1555
+ Multi { statements, .. } | Block { statements, .. } => {
1556
+ statements.iter().for_each(|x| walk_exprs_stmt_full(x, f))
1557
+ }
1558
+ ExprStmt { expr, .. } => walk_exprs_expr(expr, f),
1559
+ If { cond, then_branch, else_branch, .. } => {
1560
+ walk_exprs_expr(cond, f);
1561
+ walk_exprs_stmt_full(then_branch, f);
1562
+ if let Some(e) = else_branch {
1563
+ walk_exprs_stmt_full(e, f);
1564
+ }
1565
+ }
1566
+ While { cond, body, .. } | DoWhile { cond, body, .. } => {
1567
+ walk_exprs_expr(cond, f);
1568
+ walk_exprs_stmt_full(body, f);
1569
+ }
1570
+ For { init, cond, update, body, .. } => {
1571
+ if let Some(i) = init {
1572
+ walk_exprs_stmt_full(i, f);
1573
+ }
1574
+ if let Some(c) = cond {
1575
+ walk_exprs_expr(c, f);
1576
+ }
1577
+ if let Some(u) = update {
1578
+ walk_exprs_expr(u, f);
1579
+ }
1580
+ walk_exprs_stmt_full(body, f);
1581
+ }
1582
+ ForOf { iterable, body, .. }
1583
+ | ForIn {
1584
+ object: iterable,
1585
+ body,
1586
+ ..
1587
+ } => {
1588
+ walk_exprs_expr(iterable, f);
1589
+ walk_exprs_stmt_full(body, f);
1590
+ }
1591
+ Return { value: Some(e), .. } => walk_exprs_expr(e, f),
1592
+ Throw { value, .. } => walk_exprs_expr(value, f),
1593
+ FunDecl { body, .. } => walk_exprs_stmt_full(body, f),
1594
+ Switch { expr, cases, default_body, .. } => {
1595
+ walk_exprs_expr(expr, f);
1596
+ for (test, body) in cases {
1597
+ if let Some(t) = test {
1598
+ walk_exprs_expr(t, f);
1599
+ }
1600
+ body.iter().for_each(|x| walk_exprs_stmt_full(x, f));
1601
+ }
1602
+ if let Some(body) = default_body {
1603
+ body.iter().for_each(|x| walk_exprs_stmt_full(x, f));
1604
+ }
1605
+ }
1606
+ Try { body, catch_body, finally_body, .. } => {
1607
+ walk_exprs_stmt_full(body, f);
1608
+ if let Some(cb) = catch_body {
1609
+ walk_exprs_stmt_full(cb, f);
1610
+ }
1611
+ if let Some(fb) = finally_body {
1612
+ walk_exprs_stmt_full(fb, f);
1613
+ }
1614
+ }
1615
+ Export { declaration, .. } => match declaration.as_ref() {
1616
+ tishlang_ast::ExportDeclaration::Named(inner) => walk_exprs_stmt_full(inner, f),
1617
+ tishlang_ast::ExportDeclaration::Default(e) => walk_exprs_expr(e, f),
1618
+ tishlang_ast::ExportDeclaration::ReExport { .. } => {}
1619
+ },
1620
+ _ => {}
1621
+ }
1622
+ }
1623
+
1624
+ /// Collect the names declared by DIRECT (top-level-of-body) `let`/`const` VarDecls in `body_stmts`.
1625
+ /// These are the locals that would leak into the caller's scope when the body is spliced there, so
1626
+ /// they must be alpha-renamed. Nested-scope declarations (inside loops/blocks) stay properly scoped
1627
+ /// after splicing and are left alone. Destructuring decls make the factory ineligible (returns None).
1628
+ fn direct_body_locals(body_stmts: &[Statement]) -> Option<Vec<Arc<str>>> {
1629
+ let mut out = Vec::new();
1630
+ for s in body_stmts {
1631
+ match s {
1632
+ Statement::VarDecl { name, .. } => out.push(name.clone()),
1633
+ Statement::VarDeclDestructure { .. } => return None,
1634
+ _ => {}
1635
+ }
1636
+ }
1637
+ Some(out)
1638
+ }
1639
+
1640
+ /// Count `return` statements anywhere in `s`.
1641
+ fn count_returns(s: &Statement) -> usize {
1642
+ use Statement::*;
1643
+ match s {
1644
+ Return { .. } => 1,
1645
+ Block { statements, .. } | Multi { statements, .. } => {
1646
+ statements.iter().map(count_returns).sum()
1647
+ }
1648
+ If { then_branch, else_branch, .. } => {
1649
+ count_returns(then_branch)
1650
+ + else_branch.as_ref().map_or(0, |e| count_returns(e))
1651
+ }
1652
+ While { body, .. } | DoWhile { body, .. } | For { body, .. } | ForOf { body, .. } | ForIn { body, .. } => {
1653
+ count_returns(body)
1654
+ }
1655
+ Try { body, catch_body, finally_body, .. } => {
1656
+ count_returns(body)
1657
+ + catch_body.as_ref().map_or(0, |b| count_returns(b))
1658
+ + finally_body.as_ref().map_or(0, |b| count_returns(b))
1659
+ }
1660
+ Switch { cases, default_body, .. } => {
1661
+ cases.iter().map(|(_, b)| b.iter().map(count_returns).sum::<usize>()).sum::<usize>()
1662
+ + default_body
1663
+ .as_ref()
1664
+ .map_or(0, |b| b.iter().map(count_returns).sum())
1665
+ }
1666
+ _ => 0,
1667
+ }
1668
+ }
1669
+
1670
+ /// #179 Stage A driver: for each eligible nullary factory, splice its body at its single top-level
1671
+ /// call site (alpha-renamed) and drop the FunDecl. Fail-closed: any shape not proven safe is left
1672
+ /// untouched.
1673
+ fn inline_single_call_factories(stmts: Vec<Statement>) -> Vec<Statement> {
1674
+ // Snapshot eligible (name → FunDecl body, is_mutable-array) candidates. We do at most one
1675
+ // inlining pass over top-level statements; a factory that itself calls another eligible factory
1676
+ // is handled on the next compile-invariant since we only splice direct top-level call sites.
1677
+ let mut result = stmts;
1678
+ // Collect candidate factory names first (immutable borrow), then transform.
1679
+ let candidates: Vec<Arc<str>> = result
1680
+ .iter()
1681
+ .filter_map(|s| match s {
1682
+ Statement::FunDecl {
1683
+ async_: false,
1684
+ name,
1685
+ params,
1686
+ rest_param: None,
1687
+ body,
1688
+ ..
1689
+ } if params.is_empty() => {
1690
+ // body must be a Block/Multi ending in a single tail `return E`.
1691
+ let body_stmts = match body.as_ref() {
1692
+ Statement::Block { statements, .. }
1693
+ | Statement::Multi { statements, .. } => statements.as_slice(),
1694
+ _ => return None,
1695
+ };
1696
+ if count_returns(body) != 1 {
1697
+ return None;
1698
+ }
1699
+ match body_stmts.last() {
1700
+ Some(Statement::Return { value: Some(_), .. }) => {}
1701
+ _ => return None,
1702
+ }
1703
+ if body_has_closure_or_dynamic(body) {
1704
+ return None;
1705
+ }
1706
+ if direct_body_locals(body_stmts).is_none() {
1707
+ return None;
1708
+ }
1709
+ Some(name.clone())
1710
+ }
1711
+ _ => None,
1712
+ })
1713
+ .collect();
1714
+
1715
+ for fname in candidates {
1716
+ // Re-check use counts against the CURRENT program (a prior inline may have changed them).
1717
+ let (calls, others) = fn_name_uses(&result, fname.as_ref());
1718
+ if others != 0 || calls != 1 {
1719
+ continue; // escapes, unused, or called more than once → not inlinable
1720
+ }
1721
+ // Find the single top-level call site index + form.
1722
+ let mut site: Option<(usize, Option<Arc<str>>)> = None; // (idx, Some(target)=VarDecl / None=ExprStmt)
1723
+ let mut ambiguous = false;
1724
+ for (i, s) in result.iter().enumerate() {
1725
+ let hit = match s {
1726
+ Statement::VarDecl {
1727
+ name,
1728
+ type_ann: None,
1729
+ init: Some(Expr::Call { callee, args, .. }),
1730
+ ..
1731
+ } if args.is_empty()
1732
+ && matches!(callee.as_ref(), Expr::Ident { name: c, .. } if c.as_ref() == fname.as_ref()) =>
1733
+ {
1734
+ Some(Some(name.clone()))
1735
+ }
1736
+ Statement::ExprStmt { expr: Expr::Call { callee, args, .. }, .. }
1737
+ if args.is_empty()
1738
+ && matches!(callee.as_ref(), Expr::Ident { name: c, .. } if c.as_ref() == fname.as_ref()) =>
1739
+ {
1740
+ Some(None)
1741
+ }
1742
+ _ => None,
1743
+ };
1744
+ if let Some(target) = hit {
1745
+ if site.is_some() {
1746
+ ambiguous = true;
1747
+ break;
1748
+ }
1749
+ site = Some((i, target));
1750
+ }
1751
+ }
1752
+ // The single call must be a TOP-LEVEL statement (calls==1 guarantees no other call exists;
1753
+ // if we didn't find it at top level it is nested — skip, fail-closed).
1754
+ let (site_idx, target) = match site {
1755
+ Some(s) if !ambiguous => s,
1756
+ _ => continue,
1757
+ };
1758
+
1759
+ // Pull the factory body out of its FunDecl.
1760
+ let fdecl_idx = result.iter().position(|s| {
1761
+ matches!(s, Statement::FunDecl { name, .. } if name.as_ref() == fname.as_ref())
1762
+ });
1763
+ let Some(fdecl_idx) = fdecl_idx else { continue };
1764
+ let body_stmts: Vec<Statement> = match &result[fdecl_idx] {
1765
+ Statement::FunDecl { body, .. } => match body.as_ref() {
1766
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
1767
+ statements.clone()
1768
+ }
1769
+ _ => continue,
1770
+ },
1771
+ _ => continue,
1772
+ };
1773
+
1774
+ // Split off the trailing `return E`.
1775
+ let (prefix, ret_expr) = match body_stmts.split_last() {
1776
+ Some((Statement::Return { value: Some(e), .. }, head)) => (head.to_vec(), e.clone()),
1777
+ _ => continue,
1778
+ };
1779
+
1780
+ // Build the alpha-rename map for the leaked (direct) body locals.
1781
+ let Some(leaked) = direct_body_locals(&prefix) else { continue };
1782
+ // NARROW to the push-built factory shape ONLY: `return R` where R is a leaked (body-top-level)
1783
+ // local built via `let R = []` + pushes, consumed as `let X = f()`. A literal-array return
1784
+ // (`return [a, b]`), a call return, or a discarded `f()` is left intact — those are the #177
1785
+ // aggregate machinery's territory (nbody's `makeBodies` returns `[sun, jupiter, …]`, and
1786
+ // inlining it removes the function the aggregate ABI expects → nbody BUILD-FAIL under
1787
+ // TISH_AGGREGATE_INFER). This keeps the inliner to exactly the Stage A/B bridge target.
1788
+ let return_ident = match &ret_expr {
1789
+ Expr::Ident { name, .. } if leaked.iter().any(|l| l.as_ref() == name.as_ref()) => {
1790
+ name.clone()
1791
+ }
1792
+ _ => continue,
1793
+ };
1794
+ let Some(tgt) = target.clone() else { continue }; // only `let X = f()`, not a discarded call
1795
+ let mut active: std::collections::HashMap<String, Arc<str>> =
1796
+ std::collections::HashMap::new();
1797
+ // Map the returned array var R → the call-site target X, so the spliced `let R = []` becomes
1798
+ // `let X = []` and the record-array/ShapeUnion machinery types X directly. Every other leaked
1799
+ // local gets a fresh, collision-proof name.
1800
+ for (n, l) in leaked.iter().enumerate() {
1801
+ if l.as_ref() == return_ident.as_ref() {
1802
+ active.insert(l.to_string(), tgt.clone());
1803
+ } else {
1804
+ let fresh: Arc<str> = format!("__inl_{}_{}_{}", fname, n, l).into();
1805
+ active.insert(l.to_string(), fresh);
1806
+ }
1807
+ }
1808
+
1809
+ // Alpha-rename the prefix (declarations + references) using the resolution-tested renamer.
1810
+ // The trailing `return R` is dropped: R was renamed to X, so X is already the array binding.
1811
+ let mut renamed_prefix = prefix;
1812
+ for s in &mut renamed_prefix {
1813
+ let mut a = active.clone();
1814
+ crate::resolve::rewrite_stmt_scope(s, &mut a, true);
1815
+ }
1816
+ let spliced: Vec<Statement> = renamed_prefix;
1817
+
1818
+ // Rebuild the program: replace the call site with the spliced body, drop the FunDecl.
1819
+ let mut out: Vec<Statement> = Vec::with_capacity(result.len() + spliced.len());
1820
+ for (i, s) in result.into_iter().enumerate() {
1821
+ if i == fdecl_idx {
1822
+ continue; // drop the now-inlined factory
1823
+ }
1824
+ if i == site_idx {
1825
+ out.extend(spliced.iter().cloned());
1826
+ continue;
1827
+ }
1828
+ out.push(s);
1829
+ }
1830
+ result = out;
1831
+ }
1832
+ result
934
1833
  }
935
1834
 
936
1835
  fn struct_infer_program(program: Program) -> Program {
1836
+ // #179 Stage A: inline a nullary function that is called EXACTLY ONCE (as a top-level
1837
+ // statement) and never used as a value, splicing its body at the call site. This bridges a
1838
+ // build-then-return record-array factory (`function mk(){ let a=[]; a.push({..}); return a }`;
1839
+ // `let xs = mk()`) into a TOP-LEVEL push-built array, which the record-array / ShapeUnion
1840
+ // machinery can then type. Sound by construction: a single-call inline substitutes the whole
1841
+ // call, so evaluation order and side effects are preserved exactly (a captured-global write in
1842
+ // the body becomes an ordinary top-level statement; a factory used as a value is left alone).
1843
+ let program = if crate::native_opts_enabled() {
1844
+ Program { statements: inline_single_call_factories(program.statements) }
1845
+ } else {
1846
+ program
1847
+ };
937
1848
  let mut reg = StructRegistry::default();
938
1849
  let mut ctx = InferCtx::new();
1850
+ // #320: fns proven to always return a number, so `infer_expr_type(f(...))` is `number` and a
1851
+ // `seq.push(nextBase())` keeps `seq` a native `number[]`. Computed off the param-inferred
1852
+ // program so numeric params are already typed.
1853
+ ctx.number_returning_fns = collect_number_returning_fns(&program.statements, &ctx);
1854
+ // #175: tell the mutable-array co-inference which fns take native `&/&mut Vec` array params, so
1855
+ // forwarding an array into one isn't treated as a boxing escape (lets the caller's array stay
1856
+ // unboxed). Same AST-only detection codegen uses, so the two never disagree.
1857
+ if crate::native_opts_enabled() {
1858
+ for (fname, sig) in crate::codegen::Codegen::detect_native_vec_fns(&program) {
1859
+ let flags: Vec<bool> = sig
1860
+ .params
1861
+ .iter()
1862
+ .map(|(_, k)| matches!(k, crate::codegen::VecParamKind::Array { .. }))
1863
+ .collect();
1864
+ ctx.native_vec_array_params.insert(fname, flags);
1865
+ }
1866
+ }
1867
+ // #320: mark a read-only `number[]`-param fn's array args as non-escapes so the caller's array
1868
+ // stays `number[]` (e.g. `kNucleotide(seq, k)`). This uses the READ-ONLY criterion only — sound
1869
+ // regardless of the arg type, since passing a native `Vec` to a boxed closure copies it (a
1870
+ // read-only callee can't mutate the caller's array through the copy). Codegen separately proves,
1871
+ // via the call sites, which of these it may actually UNBOX (`native_arr_param_fns`); a fn it
1872
+ // can't prove simply stays a fully boxed closure. Runs pre-`si_block`, so it must not rely on
1873
+ // `number[]` stamps — `arr_param_readonly_fns` only inspects each fn's own body.
1874
+ if crate::native_opts_enabled() {
1875
+ for (fname, flags) in crate::codegen::Codegen::arr_param_readonly_fns(&program) {
1876
+ ctx.native_vec_array_params.insert(fname, flags);
1877
+ }
1878
+ }
939
1879
  let mut stmts = si_block(program.statements, &mut reg, &mut ctx);
940
1880
  // Prepend the generated struct `type` aliases so codegen synthesizes them.
941
1881
  let mut out: Vec<Statement> = Vec::with_capacity(stmts.len() + reg.decls.len());
@@ -948,6 +1888,20 @@ fn struct_infer_program(program: Program) -> Program {
948
1888
  span,
949
1889
  });
950
1890
  }
1891
+ // #179 Stage B: emit each shape-union alias as `type TishAnonUnion_N = Union([Object(s0), …])`;
1892
+ // codegen recognizes the reserved-prefix alias and synthesizes the enum + per-variant structs.
1893
+ for (name, shapes) in reg.union_decls.drain(..) {
1894
+ let members: Vec<TypeAnnotation> = shapes
1895
+ .into_iter()
1896
+ .map(TypeAnnotation::Object)
1897
+ .collect();
1898
+ out.push(Statement::TypeAlias {
1899
+ name: name.as_str().into(),
1900
+ name_span: span,
1901
+ ty: TypeAnnotation::Union(members),
1902
+ span,
1903
+ });
1904
+ }
951
1905
  out.append(&mut stmts);
952
1906
  Program { statements: out }
953
1907
  }
@@ -1001,7 +1955,11 @@ fn si_block(stmts: Vec<Statement>, reg: &mut StructRegistry, ctx: &mut InferCtx)
1001
1955
  {
1002
1956
  // (a) Read-only typed array: uniform scalar literals, every later use read-only.
1003
1957
  if let Some(elem) = infer_array_elem(elements, ctx) {
1004
- if uses_are_array_safe(name.as_ref(), &stmts[i + 1..]) {
1958
+ // A constant OOB read (`let a=[1,2,3]; a[5]`) yields NaN under Vec<f64> but `null`
1959
+ // boxed, so disqualify it even though the use is "read-only".
1960
+ if uses_are_array_safe(name.as_ref(), &stmts[i + 1..])
1961
+ && !array_oob_const_access(&stmts[i + 1..], name.as_ref(), elements.len())
1962
+ {
1005
1963
  let arr_ann = TypeAnnotation::Array(Box::new(elem));
1006
1964
  ctx.define(name.as_ref(), arr_ann.clone());
1007
1965
  out.push(Statement::VarDecl {
@@ -1039,6 +1997,78 @@ fn si_block(stmts: Vec<Statement>, reg: &mut StructRegistry, ctx: &mut InferCtx)
1039
1997
  });
1040
1998
  continue;
1041
1999
  }
2000
+ // (d) Array of uniform records: `let rows = []` (or `[{lits}]`) built by
2001
+ // `rows.push({uniform primitive object literal})` and used only as `rows[i].field`
2002
+ // reads / `rows.length`. Lower to a native `Vec<struct>` — the struct path emits direct
2003
+ // field access and (post-#350) allocation-free construction. Conservative: any other use
2004
+ // of the binding (escape, element/field write, for-of, bare ref) leaves it boxed.
2005
+ if let Some(fields) = record_array_fields(name.as_ref(), elements, &stmts[i + 1..], ctx) {
2006
+ let keys: std::collections::HashSet<&str> =
2007
+ fields.iter().map(|(k, _)| k.as_ref()).collect();
2008
+ if rec_arr_uses_safe(name.as_ref(), &keys, &stmts[i + 1..]) {
2009
+ let alias = reg.intern(&fields);
2010
+ let arr_ann = TypeAnnotation::Array(Box::new(TypeAnnotation::Simple(
2011
+ alias.as_str().into(),
2012
+ tishlang_ast::Span::default(),
2013
+ )));
2014
+ ctx.define(name.as_ref(), arr_ann.clone());
2015
+ ctx.define_struct(alias.as_str(), fields.clone()); // #373: enable cross-array field reads
2016
+ out.push(Statement::VarDecl {
2017
+ name: name.clone(),
2018
+ name_span: *name_span,
2019
+ mutable: *mutable,
2020
+ type_ann: Some(arr_ann),
2021
+ init: stmt_init_clone(stmt),
2022
+ span: *span,
2023
+ });
2024
+ continue;
2025
+ }
2026
+ }
2027
+ // (e) #179 Stage B — array of a CLOSED SET of heterogeneous record shapes ("megamorphic":
2028
+ // `objs.push({value,a})`, `objs.push({b,value,c})`, … all read as `objs[i].value`). Lower
2029
+ // to a native `Vec<enum>` (ShapeUnion): a `.field` present-and-same-type in EVERY variant
2030
+ // becomes a direct `match` load. Opt-in (TISH_SHAPE_UNION) until Stage C's integer modulo
2031
+ // makes it a net win. The safety walk uses ONLY the common keys, so a read of a
2032
+ // not-in-every-variant field disqualifies (stays boxed → undefined/null preserved).
2033
+ if shape_union_enabled() {
2034
+ if let Some(shapes) =
2035
+ record_array_shape_set(name.as_ref(), elements, &stmts[i + 1..], ctx)
2036
+ {
2037
+ let common = shape_set_common_keys(&shapes);
2038
+ let common_refs: std::collections::HashSet<&str> =
2039
+ common.iter().map(|k| k.as_ref()).collect();
2040
+ if !common_refs.is_empty()
2041
+ && rec_arr_uses_safe(name.as_ref(), &common_refs, &stmts[i + 1..])
2042
+ {
2043
+ let union_alias = reg.intern_union(&shapes);
2044
+ let arr_ann = TypeAnnotation::Array(Box::new(TypeAnnotation::Simple(
2045
+ union_alias.as_str().into(),
2046
+ tishlang_ast::Span::default(),
2047
+ )));
2048
+ ctx.define(name.as_ref(), arr_ann.clone());
2049
+ // Register the union's COMMON fields as a struct shape so `infer_expr_type`
2050
+ // types `objs[i].field` as its (number) type — otherwise a numeric
2051
+ // accumulator fed by the read (`sum = sum + objs[i].value`) stays boxed.
2052
+ // All common fields share one type across variants (guaranteed above), so the
2053
+ // first shape's types are correct.
2054
+ let common_fields: Vec<(std::sync::Arc<str>, TypeAnnotation)> = shapes[0]
2055
+ .iter()
2056
+ .filter(|(k, _)| common.contains(k))
2057
+ .cloned()
2058
+ .collect();
2059
+ ctx.define_struct(union_alias.as_str(), common_fields);
2060
+ out.push(Statement::VarDecl {
2061
+ name: name.clone(),
2062
+ name_span: *name_span,
2063
+ mutable: *mutable,
2064
+ type_ann: Some(arr_ann),
2065
+ init: stmt_init_clone(stmt),
2066
+ span: *span,
2067
+ });
2068
+ continue;
2069
+ }
2070
+ }
2071
+ }
1042
2072
  }
1043
2073
  // Candidate: `let o = { ...object literal... }` with no annotation.
1044
2074
  if let Statement::VarDecl {
@@ -1046,23 +2076,37 @@ fn si_block(stmts: Vec<Statement>, reg: &mut StructRegistry, ctx: &mut InferCtx)
1046
2076
  name_span,
1047
2077
  mutable,
1048
2078
  type_ann: None,
1049
- init: Some(Expr::Object { props, .. }),
2079
+ init: Some(Expr::Object { props, span: obj_span }),
1050
2080
  span,
1051
2081
  } = stmt
1052
2082
  {
1053
- if let Some(fields) = infer_object_shape(props, ctx) {
2083
+ // A struct-typed leading spread (`{ ...base, k: v }`) is inlined to a plain
2084
+ // `{ a: base.a, …, k: v }` literal so it lowers to an unboxed struct; otherwise the plain
2085
+ // literal path applies (a spread it can't inline leaves `infer_object_shape` → None → boxed).
2086
+ let (shape, rewritten_props) = match inline_struct_spread(props, ctx, reg) {
2087
+ Some((f, rp)) => (Some(f), Some(rp)),
2088
+ None => (infer_object_shape(props, ctx), None),
2089
+ };
2090
+ if let Some(fields) = shape {
1054
2091
  let keys: std::collections::HashSet<&str> =
1055
2092
  fields.iter().map(|(k, _)| k.as_ref()).collect();
1056
2093
  // Sound: every later use in this block must be a literal-key read.
1057
2094
  if uses_are_struct_safe(name.as_ref(), &keys, &stmts[i + 1..]) {
1058
2095
  let alias = reg.intern(&fields);
1059
2096
  ctx.define(name.as_ref(), TypeAnnotation::Simple(alias.as_str().into(), tishlang_ast::Span::default()));
2097
+ let new_init = match rewritten_props {
2098
+ Some(rp) => Some(Expr::Object {
2099
+ props: rp,
2100
+ span: *obj_span,
2101
+ }),
2102
+ None => stmt_init_clone(stmt),
2103
+ };
1060
2104
  out.push(Statement::VarDecl {
1061
2105
  name: name.clone(),
1062
2106
  name_span: *name_span,
1063
2107
  mutable: *mutable,
1064
2108
  type_ann: Some(TypeAnnotation::Simple(alias.as_str().into(), tishlang_ast::Span::default())),
1065
- init: stmt_init_clone(stmt),
2109
+ init: new_init,
1066
2110
  span: *span,
1067
2111
  });
1068
2112
  continue;
@@ -1126,6 +2170,26 @@ fn stmt_init_clone(stmt: &Statement) -> Option<Expr> {
1126
2170
  }
1127
2171
  }
1128
2172
 
2173
+ /// Define a `let`/`const` binding's name+type in `ctx` from its annotation or inferred init type, so
2174
+ /// inference in an inner scope can resolve references to it. Used to bind `for`-init loop variables
2175
+ /// before recursing into the loop body (enables struct inference of loop-local objects that read the
2176
+ /// counter). Only the simple `let x = <expr>` shape; anything else is skipped (sound — the body just
2177
+ /// keeps today's behavior).
2178
+ fn si_bind_decl(stmt: &Statement, ctx: &mut InferCtx) {
2179
+ if let Statement::VarDecl {
2180
+ name,
2181
+ type_ann,
2182
+ init: Some(e),
2183
+ ..
2184
+ } = stmt
2185
+ {
2186
+ let t = type_ann.clone().or_else(|| infer_expr_type(e, ctx));
2187
+ if let Some(t) = t {
2188
+ ctx.define(name.as_ref(), t);
2189
+ }
2190
+ }
2191
+ }
2192
+
1129
2193
  /// Recurse struct inference into a statement's nested blocks (function bodies,
1130
2194
  /// loop/if bodies). Non-block statements pass through unchanged.
1131
2195
  fn si_recurse(stmt: &Statement, reg: &mut StructRegistry, ctx: &mut InferCtx) -> Statement {
@@ -1140,13 +2204,26 @@ fn si_recurse(stmt: &Statement, reg: &mut StructRegistry, ctx: &mut InferCtx) ->
1140
2204
  update,
1141
2205
  body,
1142
2206
  span,
1143
- } => Statement::For {
1144
- init: init.clone(),
1145
- cond: cond.clone(),
1146
- update: update.clone(),
1147
- body: Box::new(si_recurse(body, reg, ctx)),
1148
- span: *span,
1149
- },
2207
+ } => {
2208
+ // Bind the loop variable(s) from `init` into a fresh scope so the body's struct inference
2209
+ // can type object-literal fields that reference the counter — e.g. `{ id: i, v: i * 2 }`
2210
+ // inside `for (let i = 0; …)`. Without this, `infer_object_shape` can't type those fields
2211
+ // and a loop-local object stays a boxed `PropMap`. (A loop-local object that reads only
2212
+ // outer/literal values already lowers to an unboxed struct; this closes the loop-var gap.)
2213
+ ctx.push_scope();
2214
+ if let Some(init_stmt) = init.as_deref() {
2215
+ si_bind_decl(init_stmt, ctx);
2216
+ }
2217
+ let new_body = Box::new(si_recurse(body, reg, ctx));
2218
+ ctx.pop_scope();
2219
+ Statement::For {
2220
+ init: init.clone(),
2221
+ cond: cond.clone(),
2222
+ update: update.clone(),
2223
+ body: new_body,
2224
+ span: *span,
2225
+ }
2226
+ }
1150
2227
  Statement::ForOf {
1151
2228
  name,
1152
2229
  name_span,
@@ -1227,6 +2304,373 @@ fn uses_are_struct_safe(name: &str, keys: &std::collections::HashSet<&str>, tail
1227
2304
  tail.iter().all(|s| stmt_name_safe(s, name, keys))
1228
2305
  }
1229
2306
 
2307
+ // ── array-of-records inference (`let rows = []; rows.push({…}); rows[i].field`) ───────────────────
2308
+ // Lowers an untyped local array of uniform primitive records to a native `Vec<struct>`, so the
2309
+ // struct codegen emits direct field access + (post-#350) allocation-free construction. Conservative:
2310
+ // only fires when the binding is used solely as `push({uniform literal})`, `name[i].<key>` reads, and
2311
+ // `name.length` — any other use leaves it a boxed `Value[]`.
2312
+
2313
+ /// Compare two inferred record shapes for equality, order-independently (same keys + field types).
2314
+ fn fields_eq(
2315
+ a: &[(std::sync::Arc<str>, TypeAnnotation)],
2316
+ b: &[(std::sync::Arc<str>, TypeAnnotation)],
2317
+ ) -> bool {
2318
+ if a.len() != b.len() {
2319
+ return false;
2320
+ }
2321
+ let mut av: Vec<(String, String)> =
2322
+ a.iter().map(|(k, t)| (k.to_string(), type_canon(t))).collect();
2323
+ let mut bv: Vec<(String, String)> =
2324
+ b.iter().map(|(k, t)| (k.to_string(), type_canon(t))).collect();
2325
+ av.sort();
2326
+ bv.sort();
2327
+ av == bv
2328
+ }
2329
+
2330
+ /// Fold one object literal's shape into the running record shape, flagging `*ok=false` on any
2331
+ /// non-uniform or non-primitive literal.
2332
+ fn record_shape_consider(
2333
+ shape: &mut Option<Vec<(std::sync::Arc<str>, TypeAnnotation)>>,
2334
+ ok: &mut bool,
2335
+ props: &[tishlang_ast::ObjectProp],
2336
+ ctx: &InferCtx,
2337
+ ) {
2338
+ match infer_object_shape(props, ctx) {
2339
+ Some(f) => match shape {
2340
+ None => *shape = Some(f),
2341
+ Some(prev) => {
2342
+ if !fields_eq(prev, &f) {
2343
+ *ok = false;
2344
+ }
2345
+ }
2346
+ },
2347
+ None => *ok = false,
2348
+ }
2349
+ }
2350
+
2351
+ /// Infer the common record shape of candidate array-of-records `name`: unify the shapes of its
2352
+ /// initial object-literal elements and every `name.push({…})` in `tail`. Returns the field list, or
2353
+ /// None if there is no object shape, the literals disagree, or a push arg is not a uniform primitive
2354
+ /// object literal. Loop vars in the literals (`{ x: i }`) are typed by seeding numeric locals first.
2355
+ fn record_array_fields(
2356
+ name: &str,
2357
+ init_elements: &[tishlang_ast::ArrayElement],
2358
+ tail: &[Statement],
2359
+ ctx: &InferCtx,
2360
+ ) -> Option<Vec<(std::sync::Arc<str>, TypeAnnotation)>> {
2361
+ // Seed numeric block-locals (incl. enclosing loop vars) so `{ x: i, y: i * 2 }` field values type.
2362
+ let mut sctx = ctx.clone();
2363
+ for _ in 0..4 {
2364
+ for s in tail {
2365
+ seed_numeric_locals(s, &mut sctx);
2366
+ }
2367
+ }
2368
+ let mut shape: Option<Vec<(std::sync::Arc<str>, TypeAnnotation)>> = None;
2369
+ let mut ok = true;
2370
+ for el in init_elements {
2371
+ match el {
2372
+ tishlang_ast::ArrayElement::Expr(Expr::Object { props, .. }) => {
2373
+ record_shape_consider(&mut shape, &mut ok, props, &sctx);
2374
+ }
2375
+ _ => return None,
2376
+ }
2377
+ if !ok {
2378
+ return None;
2379
+ }
2380
+ }
2381
+ for s in tail {
2382
+ walk_exprs_stmt(s, &mut |e| {
2383
+ if let Expr::Call { callee, args, .. } = e {
2384
+ if let Expr::Member {
2385
+ object,
2386
+ prop: tishlang_ast::MemberProp::Name { name: m, .. },
2387
+ ..
2388
+ } = callee.as_ref()
2389
+ {
2390
+ if m.as_ref() == "push"
2391
+ && matches!(object.as_ref(), Expr::Ident { name: n, .. } if n.as_ref() == name)
2392
+ {
2393
+ if let [CallArg::Expr(Expr::Object { props, .. })] = args.as_slice() {
2394
+ record_shape_consider(&mut shape, &mut ok, props, &sctx);
2395
+ } else {
2396
+ ok = false;
2397
+ }
2398
+ }
2399
+ }
2400
+ }
2401
+ });
2402
+ if !ok {
2403
+ return None;
2404
+ }
2405
+ }
2406
+ shape
2407
+ }
2408
+
2409
+ /// True if every use of array-of-records `name` in `tail` is `name.push({uniform literal})`,
2410
+ /// `name[i].<key>` (field read, `key` in the shape), or `name.length`. Mirrors the #177 S-D safety
2411
+ /// walk; any other use of the binding (bare ref, element/field write, escape into a call / return /
2412
+ /// spread, for-of, method other than push) returns false → the array stays boxed.
2413
+ fn rec_arr_uses_safe(name: &str, keys: &std::collections::HashSet<&str>, tail: &[Statement]) -> bool {
2414
+ tail.iter().all(|s| ra_stmt(s, name, keys))
2415
+ }
2416
+
2417
+ /// #179 Stage B/C: shape-union (megamorphic) inference — a heterogeneous closed-shape-set array
2418
+ /// lowers to a native `Vec<enum>` with direct enum-match field reads. DEFAULT-ON as of Stage C
2419
+ /// (the native-length fix made megamorphic beat node: 485→~16ms vs node ~58ms). Part of the native
2420
+ /// stack, so `TISH_NATIVE_OPT=0` disables it (the boxed baseline); `TISH_SHAPE_UNION=0` is a
2421
+ /// dedicated escape hatch.
2422
+ fn shape_union_enabled() -> bool {
2423
+ crate::native_opts_enabled() && std::env::var("TISH_SHAPE_UNION").as_deref() != Ok("0")
2424
+ }
2425
+
2426
+ /// #179 Stage B: like [`record_array_fields`] but for a HETEROGENEOUS array — accumulate the set of
2427
+ /// DISTINCT primitive-record shapes across the initial elements + every `name.push({…})`. Returns
2428
+ /// the deduped shapes when there are 2..=8 of them (a genuine union; 1 shape is the existing
2429
+ /// single-struct path), or None if a push arg isn't a uniform primitive object literal, a non-object
2430
+ /// element appears, or the set exceeds 8 shapes.
2431
+ fn record_array_shape_set(
2432
+ name: &str,
2433
+ init_elements: &[tishlang_ast::ArrayElement],
2434
+ tail: &[Statement],
2435
+ ctx: &InferCtx,
2436
+ ) -> Option<Vec<Vec<(std::sync::Arc<str>, TypeAnnotation)>>> {
2437
+ let mut sctx = ctx.clone();
2438
+ for _ in 0..4 {
2439
+ for s in tail {
2440
+ seed_numeric_locals(s, &mut sctx);
2441
+ }
2442
+ }
2443
+ let mut shapes: Vec<Vec<(std::sync::Arc<str>, TypeAnnotation)>> = Vec::new();
2444
+ let mut ok = true;
2445
+ for el in init_elements {
2446
+ match el {
2447
+ tishlang_ast::ArrayElement::Expr(Expr::Object { props, .. }) => {
2448
+ shape_set_consider(&mut shapes, &mut ok, props, &sctx);
2449
+ }
2450
+ _ => return None,
2451
+ }
2452
+ if !ok {
2453
+ return None;
2454
+ }
2455
+ }
2456
+ for s in tail {
2457
+ walk_exprs_stmt(s, &mut |e| {
2458
+ if let Expr::Call { callee, args, .. } = e {
2459
+ if let Expr::Member {
2460
+ object,
2461
+ prop: tishlang_ast::MemberProp::Name { name: m, .. },
2462
+ ..
2463
+ } = callee.as_ref()
2464
+ {
2465
+ if m.as_ref() == "push"
2466
+ && matches!(object.as_ref(), Expr::Ident { name: n, .. } if n.as_ref() == name)
2467
+ {
2468
+ if let [CallArg::Expr(Expr::Object { props, .. })] = args.as_slice() {
2469
+ shape_set_consider(&mut shapes, &mut ok, props, &sctx);
2470
+ } else {
2471
+ ok = false;
2472
+ }
2473
+ }
2474
+ }
2475
+ }
2476
+ });
2477
+ if !ok {
2478
+ return None;
2479
+ }
2480
+ }
2481
+ if !(2..=8).contains(&shapes.len()) {
2482
+ return None;
2483
+ }
2484
+ // Every variant must have a DISTINCT key set — codegen selects the variant for an object literal
2485
+ // by its key set, so a same-keys/different-types collision would be ambiguous → stay boxed.
2486
+ let mut keysets: Vec<Vec<&str>> = shapes
2487
+ .iter()
2488
+ .map(|s| {
2489
+ let mut k: Vec<&str> = s.iter().map(|(k, _)| k.as_ref()).collect();
2490
+ k.sort_unstable();
2491
+ k
2492
+ })
2493
+ .collect();
2494
+ let n = keysets.len();
2495
+ keysets.sort();
2496
+ keysets.dedup();
2497
+ if keysets.len() != n {
2498
+ return None;
2499
+ }
2500
+ Some(shapes)
2501
+ }
2502
+
2503
+ /// Accumulate a distinct primitive-record shape into the set (dedup by `fields_eq`). Sets `ok=false`
2504
+ /// if the literal is not a primitive record or the set would exceed 8 shapes.
2505
+ fn shape_set_consider(
2506
+ shapes: &mut Vec<Vec<(std::sync::Arc<str>, TypeAnnotation)>>,
2507
+ ok: &mut bool,
2508
+ props: &[tishlang_ast::ObjectProp],
2509
+ ctx: &InferCtx,
2510
+ ) {
2511
+ match infer_object_shape(props, ctx) {
2512
+ Some(f) => {
2513
+ if !shapes.iter().any(|s| fields_eq(s, &f)) {
2514
+ if shapes.len() >= 8 {
2515
+ *ok = false;
2516
+ return;
2517
+ }
2518
+ shapes.push(f);
2519
+ }
2520
+ }
2521
+ None => *ok = false,
2522
+ }
2523
+ }
2524
+
2525
+ /// #179 Stage B: the fields present with an IDENTICAL type in EVERY variant of a shape set — the only
2526
+ /// fields a `union[i].field` read may soundly lower to a native `match` (a field missing from, or
2527
+ /// type-divergent across, some variant must stay boxed → undefined/null semantics preserved).
2528
+ fn shape_set_common_keys(
2529
+ shapes: &[Vec<(std::sync::Arc<str>, TypeAnnotation)>],
2530
+ ) -> std::collections::HashSet<std::sync::Arc<str>> {
2531
+ let mut common = std::collections::HashSet::new();
2532
+ let Some(first) = shapes.first() else {
2533
+ return common;
2534
+ };
2535
+ 'field: for (k, ty) in first {
2536
+ for other in &shapes[1..] {
2537
+ match other.iter().find(|(ok, _)| ok.as_ref() == k.as_ref()) {
2538
+ Some((_, oty)) if type_canon(oty) == type_canon(ty) => {}
2539
+ _ => continue 'field, // absent or type-divergent in some variant
2540
+ }
2541
+ }
2542
+ common.insert(k.clone());
2543
+ }
2544
+ common
2545
+ }
2546
+
2547
+ fn ra_stmt(s: &Statement, name: &str, keys: &std::collections::HashSet<&str>) -> bool {
2548
+ use Statement::*;
2549
+ match s {
2550
+ Block { statements, .. } | Multi { statements, .. } => {
2551
+ statements.iter().all(|x| ra_stmt(x, name, keys))
2552
+ }
2553
+ VarDecl { name: n, init, .. } => {
2554
+ n.as_ref() != name && init.as_ref().is_none_or(|e| ra_expr(e, name, keys))
2555
+ }
2556
+ ExprStmt { expr, .. } => ra_expr(expr, name, keys),
2557
+ Return { value, .. } => value.as_ref().is_none_or(|e| ra_expr(e, name, keys)),
2558
+ If { cond, then_branch, else_branch, .. } => {
2559
+ ra_expr(cond, name, keys)
2560
+ && ra_stmt(then_branch, name, keys)
2561
+ && else_branch.as_ref().is_none_or(|e| ra_stmt(e, name, keys))
2562
+ }
2563
+ While { cond, body, .. } => ra_expr(cond, name, keys) && ra_stmt(body, name, keys),
2564
+ DoWhile { cond, body, .. } => ra_expr(cond, name, keys) && ra_stmt(body, name, keys),
2565
+ For { init, cond, update, body, .. } => {
2566
+ init.as_ref().is_none_or(|x| ra_stmt(x, name, keys))
2567
+ && cond.as_ref().is_none_or(|e| ra_expr(e, name, keys))
2568
+ && update.as_ref().is_none_or(|e| ra_expr(e, name, keys))
2569
+ && ra_stmt(body, name, keys)
2570
+ }
2571
+ Break { .. } | Continue { .. } => true,
2572
+ _ => false,
2573
+ }
2574
+ }
2575
+
2576
+ fn ra_expr(e: &Expr, name: &str, keys: &std::collections::HashSet<&str>) -> bool {
2577
+ use Expr::*;
2578
+ match e {
2579
+ Literal { .. } => true,
2580
+ Ident { name: n, .. } => n.as_ref() != name,
2581
+ Member { object, prop, optional: false, .. } => {
2582
+ let mname = match prop {
2583
+ tishlang_ast::MemberProp::Name { name: m, .. } => m.as_ref(),
2584
+ tishlang_ast::MemberProp::Expr(_) => return false,
2585
+ };
2586
+ match object.as_ref() {
2587
+ Ident { name: n, .. } if n.as_ref() == name => mname == "length",
2588
+ // `name[i].<key>` field read of THIS array. Guard on `io == name` so a DIFFERENT
2589
+ // record array's `other[i].field` falls through to the safe `_` recursion (bug: the
2590
+ // old unguarded arm returned false for it, failing any 2+-record-array program).
2591
+ Index { object: io, index, .. }
2592
+ if matches!(io.as_ref(), Ident { name: n, .. } if n.as_ref() == name) =>
2593
+ {
2594
+ keys.contains(mname) && ra_expr(index, name, keys)
2595
+ }
2596
+ _ => ra_expr(object, name, keys),
2597
+ }
2598
+ }
2599
+ Member { .. } => false,
2600
+ Index { object, index, .. } => {
2601
+ if matches!(object.as_ref(), Ident { name: n, .. } if n.as_ref() == name) {
2602
+ return false;
2603
+ }
2604
+ ra_expr(object, name, keys) && ra_expr(index, name, keys)
2605
+ }
2606
+ Binary { left, right, .. } => ra_expr(left, name, keys) && ra_expr(right, name, keys),
2607
+ Unary { operand, .. } => ra_expr(operand, name, keys),
2608
+ Conditional { cond, then_branch, else_branch, .. } => {
2609
+ ra_expr(cond, name, keys)
2610
+ && ra_expr(then_branch, name, keys)
2611
+ && ra_expr(else_branch, name, keys)
2612
+ }
2613
+ Assign { name: n, value, .. } => n.as_ref() != name && ra_expr(value, name, keys),
2614
+ CompoundAssign { name: n, value, .. } => n.as_ref() != name && ra_expr(value, name, keys),
2615
+ LogicalAssign { name: n, value, .. } => n.as_ref() != name && ra_expr(value, name, keys),
2616
+ MemberAssign { object, value, .. } => {
2617
+ ra_expr(object, name, keys) && ra_expr(value, name, keys)
2618
+ }
2619
+ IndexAssign { object, index, value, .. } => {
2620
+ !matches!(object.as_ref(), Ident { name: n, .. } if n.as_ref() == name)
2621
+ && ra_expr(object, name, keys)
2622
+ && ra_expr(index, name, keys)
2623
+ && ra_expr(value, name, keys)
2624
+ }
2625
+ Call { callee, args, .. } => {
2626
+ if let Member { object, prop, optional: false, .. } = callee.as_ref() {
2627
+ if matches!(object.as_ref(), Ident { name: n, .. } if n.as_ref() == name) {
2628
+ if let tishlang_ast::MemberProp::Name { name: m, .. } = prop {
2629
+ if m.as_ref() == "push" {
2630
+ return matches!(
2631
+ args.as_slice(),
2632
+ [CallArg::Expr(Object { props, .. })]
2633
+ if props.iter().all(|pr| matches!(pr,
2634
+ tishlang_ast::ObjectProp::KeyValue(_, v, _) if ra_expr(v, name, keys)))
2635
+ );
2636
+ }
2637
+ // `.sort(cmp)` is an in-place PERMUTATION, not an escape (shape + element
2638
+ // values unchanged). Safe iff the comparator does not escape `name`.
2639
+ if m.as_ref() == "sort" {
2640
+ return args
2641
+ .iter()
2642
+ .all(|a| matches!(a, CallArg::Expr(x) if ra_expr(x, name, keys)));
2643
+ }
2644
+ }
2645
+ return false;
2646
+ }
2647
+ }
2648
+ ra_expr(callee, name, keys)
2649
+ && args
2650
+ .iter()
2651
+ .all(|a| matches!(a, CallArg::Expr(x) if ra_expr(x, name, keys)))
2652
+ }
2653
+ TemplateLiteral { exprs, .. } => exprs.iter().all(|x| ra_expr(x, name, keys)),
2654
+ PostfixInc { name: n, .. }
2655
+ | PostfixDec { name: n, .. }
2656
+ | PrefixInc { name: n, .. }
2657
+ | PrefixDec { name: n, .. } => n.as_ref() != name,
2658
+ Array { elements, .. } => elements
2659
+ .iter()
2660
+ .all(|el| matches!(el, tishlang_ast::ArrayElement::Expr(x) if ra_expr(x, name, keys))),
2661
+ Object { props, .. } => props
2662
+ .iter()
2663
+ .all(|pr| matches!(pr, tishlang_ast::ObjectProp::KeyValue(_, v, _) if ra_expr(v, name, keys))),
2664
+ // HOF callbacks (e.g. the `.sort` comparator): recurse into the body precisely — a comparator
2665
+ // over `a.key`/`b.key` never touches `name` (pi_mentions is too conservative on closures).
2666
+ ArrowFunction { body, .. } => match body {
2667
+ tishlang_ast::ArrowBody::Expr(e) => ra_expr(e, name, keys),
2668
+ tishlang_ast::ArrowBody::Block(st) => ra_stmt(st, name, keys),
2669
+ },
2670
+ _ => false,
2671
+ }
2672
+ }
2673
+
1230
2674
  /// Define every provably-numeric block-local into `hyp` (flat across nested scopes): annotated
1231
2675
  /// `: number`, a number-literal init, or any init `infer_expr_type` proves `number` *under the
1232
2676
  /// current hypothesis* — so `let temp = perm[i]` becomes `number` once `perm` is hypothesized
@@ -1260,7 +2704,7 @@ fn seed_numeric_locals(s: &Statement, hyp: &mut InferCtx) {
1260
2704
  }
1261
2705
  seed_numeric_locals(body, hyp);
1262
2706
  }
1263
- While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } => {
2707
+ While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } | ForIn { body, .. } => {
1264
2708
  seed_numeric_locals(body, hyp)
1265
2709
  }
1266
2710
  _ => {}
@@ -1292,7 +2736,12 @@ fn block_native_arrays(
1292
2736
  // Empty `[]` is a candidate for either element type; literal elements must match `elem`.
1293
2737
  let lit_ok = elements.is_empty()
1294
2738
  || matches!(infer_array_elem(elements, outer_ctx), Some(t) if &t == elem);
1295
- if lit_ok {
2739
+ // Soundness: a non-empty literal array accessed at a constant index past its initial
2740
+ // length is sparse (`let arr=[1,2,3]; arr[5]=100`) — the native Vec pads holes with NaN
2741
+ // and OOB reads yield NaN, diverging from boxed `null`. Keep it boxed.
2742
+ let sparse = !elements.is_empty()
2743
+ && array_oob_const_access(&stmts[i + 1..], name.as_ref(), elements.len());
2744
+ if lit_ok && !sparse {
1296
2745
  cands.push((i, name.to_string()));
1297
2746
  }
1298
2747
  }
@@ -1330,6 +2779,40 @@ fn block_native_arrays(
1330
2779
  accepted
1331
2780
  }
1332
2781
 
2782
+ /// True if `stmts` access `name` at a **constant** non-negative integer index `>= init_len`
2783
+ /// (`name[K]` read or `name[K] = v`). For a non-empty literal-initialized array, such an index is
2784
+ /// out of the initial bounds: the native `Vec<elem>` representation pads sparse writes with
2785
+ /// `NaN`/`0`/`false` and yields those on OOB reads, whereas the boxed array grows with `null` holes
2786
+ /// and reads `null`. So an array with such an access must stay boxed to preserve semantics. Only
2787
+ /// meaningful for non-empty literal inits — an empty `[]` grows dynamically (length unknown here),
2788
+ /// and its holes are typically only truth-tested (`NaN`/`null` are both falsy), so it is left alone.
2789
+ fn array_oob_const_access(stmts: &[Statement], name: &str, init_len: usize) -> bool {
2790
+ let mut found = false;
2791
+ for s in stmts {
2792
+ walk_exprs_stmt(s, &mut |e| {
2793
+ let idx = match e {
2794
+ Expr::Index { object, index, .. } | Expr::IndexAssign { object, index, .. } => {
2795
+ if matches!(object.as_ref(), Expr::Ident { name: n, .. } if n.as_ref() == name) {
2796
+ index
2797
+ } else {
2798
+ return;
2799
+ }
2800
+ }
2801
+ _ => return,
2802
+ };
2803
+ if let Expr::Literal { value: Literal::Number(k), .. } = idx.as_ref() {
2804
+ if *k >= 0.0 && k.fract() == 0.0 && (*k as usize) >= init_len {
2805
+ found = true;
2806
+ }
2807
+ }
2808
+ });
2809
+ if found {
2810
+ return true;
2811
+ }
2812
+ }
2813
+ found
2814
+ }
2815
+
1333
2816
  /// A value written into / pushed onto `name` must have the array's element type `elem`: a `name[_]`
1334
2817
  /// self-read (already `elem` by hypothesis) or any expression `infer_expr_type` proves is `elem`.
1335
2818
  fn mut_arr_value_ok(v: &Expr, name: &str, elem: &TypeAnnotation, hyp: &InferCtx) -> bool {
@@ -1379,7 +2862,8 @@ fn mut_arr_stmt_ok(s: &Statement, name: &str, elem: &TypeAnnotation, hyp: &Infer
1379
2862
  Return { value, .. } => value.as_ref().is_none_or(|e| mut_arr_expr_ok(e, name, elem, hyp)),
1380
2863
  Throw { value, .. } => mut_arr_expr_ok(value, name, elem, hyp),
1381
2864
  Break { .. } | Continue { .. } | TypeAlias { .. } => true,
1382
- _ => false, // switch / try / nested fn / etc: bail
2865
+ FunDecl { body, .. } => mut_arr_stmt_ok(body, name, elem, hyp),
2866
+ _ => false, // switch / try / etc: bail
1383
2867
  }
1384
2868
  }
1385
2869
 
@@ -1420,6 +2904,20 @@ fn mut_arr_expr_ok(e: &Expr, name: &str, elem: &TypeAnnotation, hyp: &InferCtx)
1420
2904
  });
1421
2905
  }
1422
2906
  }
2907
+ // #175: forwarding `name` into a native-vec fn's ARRAY param is a native use, not a
2908
+ // boxing escape — the callee takes it by `&/&mut Vec<elem>`, so the array stays unboxed.
2909
+ if let Ident { name: fname, .. } = callee.as_ref() {
2910
+ if let Some(is_arr) = hyp.native_vec_array_params.get(fname.as_ref()) {
2911
+ if args.len() == is_arr.len() {
2912
+ return args.iter().enumerate().all(|(i, a)| match a {
2913
+ tishlang_ast::CallArg::Expr(v) => {
2914
+ (is_name(v) && is_arr[i]) || mut_arr_expr_ok(v, name, elem, hyp)
2915
+ }
2916
+ tishlang_ast::CallArg::Spread(_) => false,
2917
+ });
2918
+ }
2919
+ }
2920
+ }
1423
2921
  mut_arr_expr_ok(callee, name, elem, hyp)
1424
2922
  && args.iter().all(|a| match a {
1425
2923
  tishlang_ast::CallArg::Expr(v) => mut_arr_expr_ok(v, name, elem, hyp),
@@ -1514,6 +3012,7 @@ fn arr_stmt_safe(s: &Statement, name: &str) -> bool {
1514
3012
  Return { value, .. } => arr_opt_expr_safe(value, name),
1515
3013
  Throw { value, .. } => arr_expr_safe(value, name),
1516
3014
  Break { .. } | Continue { .. } | TypeAlias { .. } => true,
3015
+ FunDecl { body, .. } => arr_stmt_safe(body, name),
1517
3016
  // Nested fn could capture+mutate; switch/try and anything else: be safe, bail.
1518
3017
  _ => false,
1519
3018
  }
@@ -1726,10 +3225,33 @@ fn expr_name_safe(e: &Expr, name: &str, keys: &std::collections::HashSet<&str>)
1726
3225
  expr_name_safe(e, name, keys)
1727
3226
  }
1728
3227
  }),
1729
- Object { props, .. } => props.iter().all(|p| match p {
1730
- tishlang_ast::ObjectProp::KeyValue(_, v, _) => expr_name_safe(v, name, keys),
1731
- tishlang_ast::ObjectProp::Spread(e) => expr_name_safe(e, name, keys),
1732
- }),
3228
+ Object { props, .. } => {
3229
+ // A `{ ...name, k: v, … }` immutable-update spreads `name` — a read-only bulk field read,
3230
+ // not an escape — and `inline_struct_spread` will lower it to a plain struct literal
3231
+ // (`{ a: name.a, …, k: v }`). Treat a leading self-spread as SAFE iff it matches that
3232
+ // inlining's eligibility EXACTLY: first prop, the only spread, and every other prop is a
3233
+ // `KeyValue` whose key doesn't collide with `name`'s keys (and whose value is name-safe).
3234
+ // Consistency: `name` then structifies iff the spread is inlined, so there's never a
3235
+ // struct spread into a boxed object (which would miscompile).
3236
+ let leading_self_spread = matches!(
3237
+ props.first(),
3238
+ Some(tishlang_ast::ObjectProp::Spread(Expr::Ident { name: n, .. })) if n.as_ref() == name
3239
+ );
3240
+ if leading_self_spread
3241
+ && props[1..].iter().all(|p| match p {
3242
+ tishlang_ast::ObjectProp::KeyValue(k, v, _) => {
3243
+ !keys.contains(k.as_ref()) && expr_name_safe(v, name, keys)
3244
+ }
3245
+ tishlang_ast::ObjectProp::Spread(_) => false,
3246
+ })
3247
+ {
3248
+ return true;
3249
+ }
3250
+ props.iter().all(|p| match p {
3251
+ tishlang_ast::ObjectProp::KeyValue(_, v, _) => expr_name_safe(v, name, keys),
3252
+ tishlang_ast::ObjectProp::Spread(e) => expr_name_safe(e, name, keys),
3253
+ })
3254
+ }
1733
3255
  TemplateLiteral { exprs, .. } => exprs.iter().all(|e| expr_name_safe(e, name, keys)),
1734
3256
  // Reassignment / mutation referencing `name` by identifier → unsafe.
1735
3257
  Assign { name: n, value, .. }
@@ -2137,7 +3659,7 @@ fn collect_return_shapes(
2137
3659
  }
2138
3660
  }
2139
3661
  }
2140
- For { body, .. } | While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } => {
3662
+ For { body, .. } | While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } | ForIn { body, .. } => {
2141
3663
  if !collect_return_shapes(body, pctx, found, ok) {
2142
3664
  return false;
2143
3665
  }
@@ -2153,6 +3675,11 @@ fn collect_return_shapes(
2153
3675
  pub fn analyze_aggregate(program: &Program) -> AggregateAnalysis {
2154
3676
  let mut analysis = AggregateAnalysis::default();
2155
3677
  let mut reg = StructRegistry::default();
3678
+ // Soundness: a fn called ANYWHERE with a provably-non-numeric arg must not get S-0 `: number`
3679
+ // params either — `fn makeRecord(id, name){ return {id, name} }` called `makeRecord(1, "Alice")`
3680
+ // would otherwise emit a `match … panic!("expected number")` coercion on the String. Same set the
3681
+ // M4 `param_infer` path uses (infer.rs:273).
3682
+ let nonnumeric = crate::codegen::Codegen::fns_called_with_nonnumeric_arg(&program.statements);
2156
3683
 
2157
3684
  // ---- S-0 + S-A: per top-level function, find numeric params and a return shape.
2158
3685
  for s in &program.statements {
@@ -2204,7 +3731,7 @@ pub fn analyze_aggregate(program: &Program) -> AggregateAnalysis {
2204
3731
  }
2205
3732
  }
2206
3733
  }
2207
- if !numeric_params.is_empty() {
3734
+ if !numeric_params.is_empty() && !nonnumeric.contains(name.as_ref()) {
2208
3735
  analysis.fn_numeric_params.insert(name.to_string(), numeric_params);
2209
3736
  }
2210
3737
  // S-A: a single all-f64 object-literal return shape ⇒ a struct alias.
@@ -2399,7 +3926,7 @@ fn collect_element_aliases(s: &Statement, p: &str, out: &mut HashSet<String>) {
2399
3926
  }
2400
3927
  collect_element_aliases(body, p, out);
2401
3928
  }
2402
- While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } => {
3929
+ While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } | ForIn { body, .. } => {
2403
3930
  collect_element_aliases(body, p, out)
2404
3931
  }
2405
3932
  _ => {}
@@ -2724,7 +4251,12 @@ fn walk_exprs_stmt(s: &Statement, f: &mut impl FnMut(&Expr)) {
2724
4251
  }
2725
4252
  walk_exprs_stmt(body, f);
2726
4253
  }
2727
- ForOf { iterable, body, .. } => {
4254
+ ForOf { iterable, body, .. }
4255
+ | ForIn {
4256
+ object: iterable,
4257
+ body,
4258
+ ..
4259
+ } => {
2728
4260
  walk_exprs_expr(iterable, f);
2729
4261
  walk_exprs_stmt(body, f);
2730
4262
  }
@@ -2837,7 +4369,7 @@ fn collect_body_struct_locals(
2837
4369
  collect_body_struct_locals(e, analysis, out);
2838
4370
  }
2839
4371
  }
2840
- For { body, .. } | While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } => {
4372
+ For { body, .. } | While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } | ForIn { body, .. } => {
2841
4373
  collect_body_struct_locals(body, analysis, out)
2842
4374
  }
2843
4375
  _ => {}
@@ -2871,7 +4403,7 @@ fn find_array_return(s: &Statement, locals: &HashMap<String, String>) -> Option<
2871
4403
  }
2872
4404
  If { then_branch, else_branch, .. } => find_array_return(then_branch, locals)
2873
4405
  .or_else(|| else_branch.as_ref().and_then(|e| find_array_return(e, locals))),
2874
- For { body, .. } | While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } => {
4406
+ For { body, .. } | While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } | ForIn { body, .. } => {
2875
4407
  find_array_return(body, locals)
2876
4408
  }
2877
4409
  _ => None,