@tishlang/tish 2.12.0 → 2.16.13

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 (71) hide show
  1. package/Cargo.toml +3 -0
  2. package/bin/tish +0 -0
  3. package/crates/tish/Cargo.toml +1 -1
  4. package/crates/tish/tests/integration_test.rs +80 -0
  5. package/crates/tish_ast/src/ast.rs +10 -0
  6. package/crates/tish_builtins/src/array.rs +529 -56
  7. package/crates/tish_builtins/src/collections.rs +114 -40
  8. package/crates/tish_builtins/src/string.rs +95 -8
  9. package/crates/tish_bytecode/src/chunk.rs +7 -0
  10. package/crates/tish_bytecode/src/compiler.rs +560 -64
  11. package/crates/tish_bytecode/src/lib.rs +1 -1
  12. package/crates/tish_bytecode/src/opcode.rs +154 -2
  13. package/crates/tish_bytecode/src/serialize.rs +2 -0
  14. package/crates/tish_bytecode/tests/append_local_string_builder.rs +63 -0
  15. package/crates/tish_bytecode/tests/math_unary_intrinsic.rs +47 -0
  16. package/crates/tish_compile/src/codegen.rs +17736 -5269
  17. package/crates/tish_compile/src/infer.rs +1707 -190
  18. package/crates/tish_compile/src/lib.rs +29 -4
  19. package/crates/tish_compile/src/resolve.rs +66 -5
  20. package/crates/tish_compile/src/types.rs +224 -28
  21. package/crates/tish_compile/tests/dump_codegen.rs +21 -0
  22. package/crates/tish_compile/tests/perf_codegen_169_173.rs +8 -17
  23. package/crates/tish_compile/tests/perf_codegen_173_part3.rs +61 -0
  24. package/crates/tish_compile/tests/perf_codegen_174.rs +160 -0
  25. package/crates/tish_compile/tests/perf_codegen_175.rs +190 -0
  26. package/crates/tish_compile/tests/perf_codegen_176.rs +60 -0
  27. package/crates/tish_compile/tests/perf_codegen_177.rs +167 -0
  28. package/crates/tish_compile/tests/perf_codegen_178.rs +114 -0
  29. package/crates/tish_compile/tests/perf_codegen_178_rec.rs +124 -0
  30. package/crates/tish_compile/tests/perf_codegen_181.rs +36 -0
  31. package/crates/tish_compile/tests/perf_codegen_320.rs +211 -0
  32. package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +40 -0
  33. package/crates/tish_compile_js/src/codegen.rs +33 -0
  34. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +100 -2
  35. package/crates/tish_core/Cargo.toml +4 -0
  36. package/crates/tish_core/src/json.rs +104 -5
  37. package/crates/tish_core/src/lib.rs +174 -0
  38. package/crates/tish_core/src/shape.rs +4 -2
  39. package/crates/tish_core/src/value.rs +565 -35
  40. package/crates/tish_core/src/vmref.rs +14 -0
  41. package/crates/tish_eval/src/eval.rs +675 -76
  42. package/crates/tish_eval/src/natives.rs +19 -0
  43. package/crates/tish_eval/src/value.rs +76 -21
  44. package/crates/tish_ffi/src/lib.rs +11 -1
  45. package/crates/tish_ffi/tests/double_free.rs +35 -0
  46. package/crates/tish_fmt/src/lib.rs +75 -1
  47. package/crates/tish_lexer/src/lib.rs +76 -0
  48. package/crates/tish_lexer/src/token.rs +4 -0
  49. package/crates/tish_lint/src/lib.rs +126 -0
  50. package/crates/tish_lsp/README.md +2 -1
  51. package/crates/tish_lsp/src/main.rs +378 -28
  52. package/crates/tish_native/src/build.rs +41 -0
  53. package/crates/tish_parser/Cargo.toml +4 -0
  54. package/crates/tish_parser/src/lib.rs +27 -0
  55. package/crates/tish_parser/src/parser.rs +302 -20
  56. package/crates/tish_resolve/src/lib.rs +9 -0
  57. package/crates/tish_runtime/Cargo.toml +5 -0
  58. package/crates/tish_runtime/src/http.rs +28 -10
  59. package/crates/tish_runtime/src/http_fetch.rs +150 -1
  60. package/crates/tish_runtime/src/http_prefork.rs +72 -11
  61. package/crates/tish_runtime/src/lib.rs +523 -42
  62. package/crates/tish_runtime/src/timers.rs +49 -2
  63. package/crates/tish_ui/src/jsx.rs +3 -0
  64. package/crates/tish_vm/src/jit.rs +2514 -117
  65. package/crates/tish_vm/src/vm.rs +1227 -182
  66. package/package.json +1 -1
  67. package/platform/darwin-arm64/tish +0 -0
  68. package/platform/darwin-x64/tish +0 -0
  69. package/platform/linux-arm64/tish +0 -0
  70. package/platform/linux-x64/tish +0 -0
  71. package/platform/win32-x64/tish.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, _)
@@ -933,196 +1125,1058 @@ fn infer_object_shape(
933
1125
  Some(fields)
934
1126
  }
935
1127
 
936
- fn struct_infer_program(program: Program) -> Program {
937
- let mut reg = StructRegistry::default();
938
- let mut ctx = InferCtx::new();
939
- let mut stmts = si_block(program.statements, &mut reg, &mut ctx);
940
- // Prepend the generated struct `type` aliases so codegen synthesizes them.
941
- let mut out: Vec<Statement> = Vec::with_capacity(stmts.len() + reg.decls.len());
942
- let span = stmts.first().map(stmt_span).unwrap_or_else(zero_span);
943
- for (name, fields) in reg.decls.drain(..) {
944
- out.push(Statement::TypeAlias {
945
- name: name.as_str().into(),
946
- name_span: span,
947
- ty: TypeAnnotation::Object(fields),
948
- span,
949
- });
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
+ }
950
1180
  }
951
- out.append(&mut stmts);
952
- Program { statements: out }
1181
+ accepted
953
1182
  }
954
1183
 
955
- fn stmt_span(s: &Statement) -> tishlang_ast::Span {
956
- match s {
957
- Statement::VarDecl { span, .. }
958
- | Statement::Block { span, .. }
959
- | Statement::ExprStmt { span, .. }
960
- | Statement::If { span, .. }
961
- | Statement::For { span, .. }
962
- | Statement::ForOf { span, .. }
963
- | Statement::While { span, .. }
964
- | Statement::Return { span, .. }
965
- | Statement::FunDecl { span, .. }
966
- | Statement::TypeAlias { span, .. } => *span,
967
- _ => zero_span(),
968
- }
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)
969
1191
  }
970
1192
 
971
- fn zero_span() -> tishlang_ast::Span {
972
- tishlang_ast::Span {
973
- start: (0, 0),
974
- end: (0, 0),
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;
975
1201
  }
976
- }
977
-
978
- /// Transform a block: annotate struct-safe object `let` bindings, recursing
979
- /// into nested blocks. `ctx` provides field-type inference for initializers.
980
- fn si_block(stmts: Vec<Statement>, reg: &mut StructRegistry, ctx: &mut InferCtx) -> Vec<Statement> {
981
- ctx.push_scope();
982
- // Co-infer mutable `number[]` locals across the whole block (cross-referencing arrays) up front.
983
- // Co-infer mutable native arrays (number[] and boolean[]) across the block; an array of the
984
- // wrong element type simply fails its run and stays boxed.
985
- let native_num_arrays = block_native_arrays(&stmts, ctx, &number_ann());
986
- let native_bool_arrays = block_native_arrays(&stmts, ctx, &bool_ann());
987
- let n = stmts.len();
988
- let mut out: Vec<Statement> = Vec::with_capacity(n);
989
- for (i, stmt) in stmts.iter().enumerate() {
990
- // Candidate: `let xs = [ ...uniform native scalars... ]` with no annotation -> native `T[]`,
991
- // but only when every later use is read-only (`uses_are_array_safe`), so a `Vec<f64>`
992
- // assumption can't be violated by a later `push`/`xs[i] = …`.
993
- if let Statement::VarDecl {
994
- name,
995
- name_span,
996
- mutable,
997
- type_ann: None,
998
- init: Some(Expr::Array { elements, .. }),
999
- span,
1000
- } = stmt
1001
- {
1002
- // (a) Read-only typed array: uniform scalar literals, every later use read-only.
1003
- if let Some(elem) = infer_array_elem(elements, ctx) {
1004
- if uses_are_array_safe(name.as_ref(), &stmts[i + 1..]) {
1005
- let arr_ann = TypeAnnotation::Array(Box::new(elem));
1006
- ctx.define(name.as_ref(), arr_ann.clone());
1007
- out.push(Statement::VarDecl {
1008
- name: name.clone(),
1009
- name_span: *name_span,
1010
- mutable: *mutable,
1011
- type_ann: Some(arr_ann),
1012
- init: stmt_init_clone(stmt),
1013
- span: *span,
1014
- });
1015
- continue;
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;
1016
1207
  }
1017
1208
  }
1018
- // (b) Mutable native `number[]` / `boolean[]`: `let a = []` / `[lits]` driven by push +
1019
- // index read/write (`fannkuch`/`queens`/`nsieve`, incl. cross-referencing siblings).
1020
- // Sound via the block-level fixpoint above (every accepted array's elements are provably
1021
- // of the chosen element type). `number[]` wins over `boolean[]` if somehow in both.
1022
- let native_elem = if native_num_arrays.contains(name.as_ref()) {
1023
- Some(number_ann())
1024
- } else if native_bool_arrays.contains(name.as_ref()) {
1025
- Some(bool_ann())
1026
- } else {
1027
- None
1028
- };
1029
- if let Some(elem) = native_elem {
1030
- let arr_ann = TypeAnnotation::Array(Box::new(elem));
1031
- ctx.define(name.as_ref(), arr_ann.clone());
1032
- out.push(Statement::VarDecl {
1033
- name: name.clone(),
1034
- name_span: *name_span,
1035
- mutable: *mutable,
1036
- type_ann: Some(arr_ann),
1037
- init: stmt_init_clone(stmt),
1038
- span: *span,
1039
- });
1040
- continue;
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);
1041
1215
  }
1042
1216
  }
1043
- // Candidate: `let o = { ...object literal... }` with no annotation.
1044
- if let Statement::VarDecl {
1045
- name,
1046
- name_span,
1047
- mutable,
1048
- type_ann: None,
1049
- init: Some(Expr::Object { props, .. }),
1050
- span,
1051
- } = stmt
1052
- {
1053
- if let Some(fields) = infer_object_shape(props, ctx) {
1054
- let keys: std::collections::HashSet<&str> =
1055
- fields.iter().map(|(k, _)| k.as_ref()).collect();
1056
- // Sound: every later use in this block must be a literal-key read.
1057
- if uses_are_struct_safe(name.as_ref(), &keys, &stmts[i + 1..]) {
1058
- let alias = reg.intern(&fields);
1059
- ctx.define(name.as_ref(), TypeAnnotation::Simple(alias.as_str().into(), tishlang_ast::Span::default()));
1060
- out.push(Statement::VarDecl {
1061
- name: name.clone(),
1062
- name_span: *name_span,
1063
- mutable: *mutable,
1064
- type_ann: Some(TypeAnnotation::Simple(alias.as_str().into(), tishlang_ast::Span::default())),
1065
- init: stmt_init_clone(stmt),
1066
- span: *span,
1067
- });
1068
- continue;
1069
- }
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);
1070
1221
  }
1071
1222
  }
1072
- // Record a typed plain local (e.g. `let i = 0`) so a LATER object literal can type its
1073
- // fields from it (`{ x: i }` → struct). The first inference pass may already have annotated
1074
- // it (`let i: number`), so read the annotation OR infer from the init. Object-literal lets
1075
- // defined their struct alias above and `continue`d; this runs for the rest. Without it,
1076
- // `{ x: i }` can't resolve `i`'s type and the object stays a boxed `PropMap` (object_sum gap).
1077
- if let Statement::VarDecl {
1078
- name,
1079
- name_span,
1080
- mutable,
1081
- type_ann,
1082
- init,
1083
- span,
1084
- } = stmt
1085
- {
1086
- let inferred = type_ann
1087
- .clone()
1088
- .or_else(|| init.as_ref().and_then(|e| infer_expr_type(e, ctx)));
1089
- if let Some(t) = &inferred {
1090
- ctx.define(name.as_ref(), t.clone());
1223
+ While { body, .. } | DoWhile { body, .. } | ForOf { 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);
1091
1229
  }
1092
- // #170: the base inference pass ran before array types were known, so an unannotated
1093
- // local whose init only types now that `perm: number[]` is known (`let k = perm[0]`,
1094
- // `let temp = perm[i]`) was left `type_ann: None` — and codegen reads the NODE, not
1095
- // `ctx`, so it stayed a boxed `Value` despite being provably `f64`. Persist a proven
1096
- // `number` type back onto the node here (the second, type-aware pass). The codegen
1097
- // demote-gate (`collect_demoted_numeric_locals`) re-boxes any such local whose later
1098
- // reassignment can escape `number`, so writing the annotation can never miscompile.
1099
- if type_ann.is_none() {
1100
- if let Some(t @ TypeAnnotation::Simple(s, _)) = &inferred {
1101
- if s.as_ref() == "number" {
1102
- out.push(Statement::VarDecl {
1103
- name: name.clone(),
1104
- name_span: *name_span,
1105
- mutable: *mutable,
1106
- type_ann: Some(t.clone()),
1107
- init: stmt_init_clone(stmt),
1108
- span: *span,
1109
- });
1110
- continue;
1111
- }
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);
1112
1241
  }
1113
1242
  }
1114
1243
  }
1115
- out.push(si_recurse(stmt, reg, ctx));
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
+ _ => {}
1116
1254
  }
1117
- ctx.pop_scope();
1118
- out
1119
1255
  }
1120
1256
 
1121
- fn stmt_init_clone(stmt: &Statement) -> Option<Expr> {
1122
- if let Statement::VarDecl { init, .. } = stmt {
1123
- init.clone()
1124
- } else {
1125
- None
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
+ count_uses_expr(iterable, f, calls, others);
1324
+ count_uses_stmt(body, f, calls, others);
1325
+ }
1326
+ Return { value: Some(e), .. } => count_uses_expr(e, f, calls, others),
1327
+ Throw { value, .. } => count_uses_expr(value, f, calls, others),
1328
+ FunDecl { body, .. } => count_uses_stmt(body, f, calls, others),
1329
+ Switch { expr, cases, default_body, .. } => {
1330
+ count_uses_expr(expr, f, calls, others);
1331
+ for (test, body) in cases {
1332
+ if let Some(t) = test {
1333
+ count_uses_expr(t, f, calls, others);
1334
+ }
1335
+ body.iter().for_each(|x| count_uses_stmt(x, f, calls, others));
1336
+ }
1337
+ if let Some(body) = default_body {
1338
+ body.iter().for_each(|x| count_uses_stmt(x, f, calls, others));
1339
+ }
1340
+ }
1341
+ Try { body, catch_body, finally_body, .. } => {
1342
+ count_uses_stmt(body, f, calls, others);
1343
+ if let Some(cb) = catch_body {
1344
+ count_uses_stmt(cb, f, calls, others);
1345
+ }
1346
+ if let Some(fb) = finally_body {
1347
+ count_uses_stmt(fb, f, calls, others);
1348
+ }
1349
+ }
1350
+ Export { declaration, .. } => match declaration.as_ref() {
1351
+ tishlang_ast::ExportDeclaration::Named(inner) => {
1352
+ count_uses_stmt(inner, f, calls, others)
1353
+ }
1354
+ tishlang_ast::ExportDeclaration::Default(e) => count_uses_expr(e, f, calls, others),
1355
+ tishlang_ast::ExportDeclaration::ReExport { .. } => {}
1356
+ },
1357
+ Return { value: None, .. }
1358
+ | Break { .. }
1359
+ | Continue { .. }
1360
+ | Import { .. }
1361
+ | TypeAlias { .. }
1362
+ | DeclareVar { .. }
1363
+ | DeclareFun { .. } => {}
1364
+ }
1365
+ }
1366
+
1367
+ fn count_uses_expr(e: &Expr, f: &str, calls: &mut usize, others: &mut usize) {
1368
+ use Expr::*;
1369
+ match e {
1370
+ Ident { name, .. } => {
1371
+ if name.as_ref() == f {
1372
+ *others += 1;
1373
+ }
1374
+ }
1375
+ Call { callee, args, .. } => {
1376
+ // `f(...)` in callee position is a direct call — do NOT count the callee ident as an
1377
+ // "other use". Any other callee shape (member call, computed) recurses normally.
1378
+ match callee.as_ref() {
1379
+ Ident { name, .. } if name.as_ref() == f => *calls += 1,
1380
+ _ => count_uses_expr(callee, f, calls, others),
1381
+ }
1382
+ for a in args {
1383
+ match a {
1384
+ CallArg::Expr(x) | CallArg::Spread(x) => count_uses_expr(x, f, calls, others),
1385
+ }
1386
+ }
1387
+ }
1388
+ New { callee, args, .. } => {
1389
+ count_uses_expr(callee, f, calls, others);
1390
+ for a in args {
1391
+ match a {
1392
+ CallArg::Expr(x) | CallArg::Spread(x) => count_uses_expr(x, f, calls, others),
1393
+ }
1394
+ }
1395
+ }
1396
+ Assign { name, value, .. }
1397
+ | CompoundAssign { name, value, .. }
1398
+ | LogicalAssign { name, value, .. } => {
1399
+ if name.as_ref() == f {
1400
+ *others += 1; // reassigning the function name — an escape
1401
+ }
1402
+ count_uses_expr(value, f, calls, others);
1403
+ }
1404
+ PostfixInc { name, .. } | PostfixDec { name, .. } | PrefixInc { name, .. }
1405
+ | PrefixDec { name, .. } => {
1406
+ if name.as_ref() == f {
1407
+ *others += 1;
1408
+ }
1409
+ }
1410
+ Binary { left, right, .. } | NullishCoalesce { left, right, .. } => {
1411
+ count_uses_expr(left, f, calls, others);
1412
+ count_uses_expr(right, f, calls, others);
1413
+ }
1414
+ Unary { operand, .. } | TypeOf { operand, .. } | Await { operand, .. }
1415
+ | Delete { target: operand, .. } => count_uses_expr(operand, f, calls, others),
1416
+ Member { object, prop, .. } => {
1417
+ count_uses_expr(object, f, calls, others);
1418
+ if let tishlang_ast::MemberProp::Expr(p) = prop {
1419
+ count_uses_expr(p, f, calls, others);
1420
+ }
1421
+ }
1422
+ Index { object, index, .. } => {
1423
+ count_uses_expr(object, f, calls, others);
1424
+ count_uses_expr(index, f, calls, others);
1425
+ }
1426
+ Conditional { cond, then_branch, else_branch, .. } => {
1427
+ count_uses_expr(cond, f, calls, others);
1428
+ count_uses_expr(then_branch, f, calls, others);
1429
+ count_uses_expr(else_branch, f, calls, others);
1430
+ }
1431
+ Array { elements, .. } => {
1432
+ for el in elements {
1433
+ match el {
1434
+ tishlang_ast::ArrayElement::Expr(x)
1435
+ | tishlang_ast::ArrayElement::Spread(x) => {
1436
+ count_uses_expr(x, f, calls, others)
1437
+ }
1438
+ }
1439
+ }
1440
+ }
1441
+ Object { props, .. } => {
1442
+ for p in props {
1443
+ match p {
1444
+ tishlang_ast::ObjectProp::KeyValue(_, v, _) => {
1445
+ count_uses_expr(v, f, calls, others)
1446
+ }
1447
+ tishlang_ast::ObjectProp::Spread(x) => count_uses_expr(x, f, calls, others),
1448
+ }
1449
+ }
1450
+ }
1451
+ MemberAssign { object, value, .. } => {
1452
+ count_uses_expr(object, f, calls, others);
1453
+ count_uses_expr(value, f, calls, others);
1454
+ }
1455
+ IndexAssign { object, index, value, .. } => {
1456
+ count_uses_expr(object, f, calls, others);
1457
+ count_uses_expr(index, f, calls, others);
1458
+ count_uses_expr(value, f, calls, others);
1459
+ }
1460
+ ArrowFunction { body, .. } => match body {
1461
+ tishlang_ast::ArrowBody::Expr(x) => count_uses_expr(x, f, calls, others),
1462
+ tishlang_ast::ArrowBody::Block(s) => count_uses_stmt(s, f, calls, others),
1463
+ },
1464
+ TemplateLiteral { exprs, .. } => {
1465
+ exprs.iter().for_each(|x| count_uses_expr(x, f, calls, others))
1466
+ }
1467
+ JsxElement { props, children, .. } => {
1468
+ for prop in props {
1469
+ match prop {
1470
+ tishlang_ast::JsxProp::Attr { value, .. } => {
1471
+ if let tishlang_ast::JsxAttrValue::Expr(x) = value {
1472
+ count_uses_expr(x, f, calls, others);
1473
+ }
1474
+ }
1475
+ tishlang_ast::JsxProp::Spread(x) => count_uses_expr(x, f, calls, others),
1476
+ }
1477
+ }
1478
+ for c in children {
1479
+ if let tishlang_ast::JsxChild::Expr(x) = c {
1480
+ count_uses_expr(x, f, calls, others);
1481
+ }
1482
+ }
1483
+ }
1484
+ JsxFragment { children, .. } => {
1485
+ for c in children {
1486
+ if let tishlang_ast::JsxChild::Expr(x) = c {
1487
+ count_uses_expr(x, f, calls, others);
1488
+ }
1489
+ }
1490
+ }
1491
+ Literal { .. } | NativeModuleLoad { .. } => {}
1492
+ }
1493
+ }
1494
+
1495
+ /// Does statement `s` (or anything nested) contain a closure (`FunDecl`/`ArrowFunction`) or a
1496
+ /// `this`/`arguments`/`super` reference? Such a body is NOT safely alpha-renameable by the flat
1497
+ /// splice below (a closure could capture a renamed local), so we refuse to inline it.
1498
+ fn body_has_closure_or_dynamic(s: &Statement) -> bool {
1499
+ let mut bad = false;
1500
+ walk_exprs_stmt_full(s, &mut |e| {
1501
+ match e {
1502
+ Expr::ArrowFunction { .. } => bad = true,
1503
+ Expr::Ident { name, .. }
1504
+ if matches!(name.as_ref(), "this" | "arguments" | "super") =>
1505
+ {
1506
+ bad = true
1507
+ }
1508
+ _ => {}
1509
+ }
1510
+ });
1511
+ bad || stmt_contains_fundecl(s)
1512
+ }
1513
+
1514
+ fn stmt_contains_fundecl(s: &Statement) -> bool {
1515
+ use Statement::*;
1516
+ match s {
1517
+ FunDecl { .. } => true,
1518
+ Block { statements, .. } | Multi { statements, .. } => {
1519
+ statements.iter().any(stmt_contains_fundecl)
1520
+ }
1521
+ If { then_branch, else_branch, .. } => {
1522
+ stmt_contains_fundecl(then_branch)
1523
+ || else_branch.as_ref().is_some_and(|e| stmt_contains_fundecl(e))
1524
+ }
1525
+ While { body, .. } | DoWhile { body, .. } | For { body, .. } | ForOf { body, .. } => {
1526
+ stmt_contains_fundecl(body)
1527
+ }
1528
+ Try { body, catch_body, finally_body, .. } => {
1529
+ stmt_contains_fundecl(body)
1530
+ || catch_body.as_ref().is_some_and(|b| stmt_contains_fundecl(b))
1531
+ || finally_body.as_ref().is_some_and(|b| stmt_contains_fundecl(b))
1532
+ }
1533
+ Switch { cases, default_body, .. } => {
1534
+ cases.iter().any(|(_, b)| b.iter().any(stmt_contains_fundecl))
1535
+ || default_body
1536
+ .as_ref()
1537
+ .is_some_and(|b| b.iter().any(stmt_contains_fundecl))
1538
+ }
1539
+ _ => false,
1540
+ }
1541
+ }
1542
+
1543
+ /// Exhaustive expr-visitor over a statement (unlike the shared `walk_exprs_stmt`, this also
1544
+ /// descends fn bodies, switch, and try). Visits every `Expr` node via `f`.
1545
+ fn walk_exprs_stmt_full(s: &Statement, f: &mut impl FnMut(&Expr)) {
1546
+ use Statement::*;
1547
+ match s {
1548
+ VarDecl { init: Some(e), .. } => walk_exprs_expr(e, f),
1549
+ VarDeclDestructure { init, .. } => walk_exprs_expr(init, f),
1550
+ Multi { statements, .. } | Block { statements, .. } => {
1551
+ statements.iter().for_each(|x| walk_exprs_stmt_full(x, f))
1552
+ }
1553
+ ExprStmt { expr, .. } => walk_exprs_expr(expr, f),
1554
+ If { cond, then_branch, else_branch, .. } => {
1555
+ walk_exprs_expr(cond, f);
1556
+ walk_exprs_stmt_full(then_branch, f);
1557
+ if let Some(e) = else_branch {
1558
+ walk_exprs_stmt_full(e, f);
1559
+ }
1560
+ }
1561
+ While { cond, body, .. } | DoWhile { cond, body, .. } => {
1562
+ walk_exprs_expr(cond, f);
1563
+ walk_exprs_stmt_full(body, f);
1564
+ }
1565
+ For { init, cond, update, body, .. } => {
1566
+ if let Some(i) = init {
1567
+ walk_exprs_stmt_full(i, f);
1568
+ }
1569
+ if let Some(c) = cond {
1570
+ walk_exprs_expr(c, f);
1571
+ }
1572
+ if let Some(u) = update {
1573
+ walk_exprs_expr(u, f);
1574
+ }
1575
+ walk_exprs_stmt_full(body, f);
1576
+ }
1577
+ ForOf { iterable, body, .. } => {
1578
+ walk_exprs_expr(iterable, f);
1579
+ walk_exprs_stmt_full(body, f);
1580
+ }
1581
+ Return { value: Some(e), .. } => walk_exprs_expr(e, f),
1582
+ Throw { value, .. } => walk_exprs_expr(value, f),
1583
+ FunDecl { body, .. } => walk_exprs_stmt_full(body, f),
1584
+ Switch { expr, cases, default_body, .. } => {
1585
+ walk_exprs_expr(expr, f);
1586
+ for (test, body) in cases {
1587
+ if let Some(t) = test {
1588
+ walk_exprs_expr(t, f);
1589
+ }
1590
+ body.iter().for_each(|x| walk_exprs_stmt_full(x, f));
1591
+ }
1592
+ if let Some(body) = default_body {
1593
+ body.iter().for_each(|x| walk_exprs_stmt_full(x, f));
1594
+ }
1595
+ }
1596
+ Try { body, catch_body, finally_body, .. } => {
1597
+ walk_exprs_stmt_full(body, f);
1598
+ if let Some(cb) = catch_body {
1599
+ walk_exprs_stmt_full(cb, f);
1600
+ }
1601
+ if let Some(fb) = finally_body {
1602
+ walk_exprs_stmt_full(fb, f);
1603
+ }
1604
+ }
1605
+ Export { declaration, .. } => match declaration.as_ref() {
1606
+ tishlang_ast::ExportDeclaration::Named(inner) => walk_exprs_stmt_full(inner, f),
1607
+ tishlang_ast::ExportDeclaration::Default(e) => walk_exprs_expr(e, f),
1608
+ tishlang_ast::ExportDeclaration::ReExport { .. } => {}
1609
+ },
1610
+ _ => {}
1611
+ }
1612
+ }
1613
+
1614
+ /// Collect the names declared by DIRECT (top-level-of-body) `let`/`const` VarDecls in `body_stmts`.
1615
+ /// These are the locals that would leak into the caller's scope when the body is spliced there, so
1616
+ /// they must be alpha-renamed. Nested-scope declarations (inside loops/blocks) stay properly scoped
1617
+ /// after splicing and are left alone. Destructuring decls make the factory ineligible (returns None).
1618
+ fn direct_body_locals(body_stmts: &[Statement]) -> Option<Vec<Arc<str>>> {
1619
+ let mut out = Vec::new();
1620
+ for s in body_stmts {
1621
+ match s {
1622
+ Statement::VarDecl { name, .. } => out.push(name.clone()),
1623
+ Statement::VarDeclDestructure { .. } => return None,
1624
+ _ => {}
1625
+ }
1626
+ }
1627
+ Some(out)
1628
+ }
1629
+
1630
+ /// Count `return` statements anywhere in `s`.
1631
+ fn count_returns(s: &Statement) -> usize {
1632
+ use Statement::*;
1633
+ match s {
1634
+ Return { .. } => 1,
1635
+ Block { statements, .. } | Multi { statements, .. } => {
1636
+ statements.iter().map(count_returns).sum()
1637
+ }
1638
+ If { then_branch, else_branch, .. } => {
1639
+ count_returns(then_branch)
1640
+ + else_branch.as_ref().map_or(0, |e| count_returns(e))
1641
+ }
1642
+ While { body, .. } | DoWhile { body, .. } | For { body, .. } | ForOf { body, .. } => {
1643
+ count_returns(body)
1644
+ }
1645
+ Try { body, catch_body, finally_body, .. } => {
1646
+ count_returns(body)
1647
+ + catch_body.as_ref().map_or(0, |b| count_returns(b))
1648
+ + finally_body.as_ref().map_or(0, |b| count_returns(b))
1649
+ }
1650
+ Switch { cases, default_body, .. } => {
1651
+ cases.iter().map(|(_, b)| b.iter().map(count_returns).sum::<usize>()).sum::<usize>()
1652
+ + default_body
1653
+ .as_ref()
1654
+ .map_or(0, |b| b.iter().map(count_returns).sum())
1655
+ }
1656
+ _ => 0,
1657
+ }
1658
+ }
1659
+
1660
+ /// #179 Stage A driver: for each eligible nullary factory, splice its body at its single top-level
1661
+ /// call site (alpha-renamed) and drop the FunDecl. Fail-closed: any shape not proven safe is left
1662
+ /// untouched.
1663
+ fn inline_single_call_factories(stmts: Vec<Statement>) -> Vec<Statement> {
1664
+ // Snapshot eligible (name → FunDecl body, is_mutable-array) candidates. We do at most one
1665
+ // inlining pass over top-level statements; a factory that itself calls another eligible factory
1666
+ // is handled on the next compile-invariant since we only splice direct top-level call sites.
1667
+ let mut result = stmts;
1668
+ // Collect candidate factory names first (immutable borrow), then transform.
1669
+ let candidates: Vec<Arc<str>> = result
1670
+ .iter()
1671
+ .filter_map(|s| match s {
1672
+ Statement::FunDecl {
1673
+ async_: false,
1674
+ name,
1675
+ params,
1676
+ rest_param: None,
1677
+ body,
1678
+ ..
1679
+ } if params.is_empty() => {
1680
+ // body must be a Block/Multi ending in a single tail `return E`.
1681
+ let body_stmts = match body.as_ref() {
1682
+ Statement::Block { statements, .. }
1683
+ | Statement::Multi { statements, .. } => statements.as_slice(),
1684
+ _ => return None,
1685
+ };
1686
+ if count_returns(body) != 1 {
1687
+ return None;
1688
+ }
1689
+ match body_stmts.last() {
1690
+ Some(Statement::Return { value: Some(_), .. }) => {}
1691
+ _ => return None,
1692
+ }
1693
+ if body_has_closure_or_dynamic(body) {
1694
+ return None;
1695
+ }
1696
+ if direct_body_locals(body_stmts).is_none() {
1697
+ return None;
1698
+ }
1699
+ Some(name.clone())
1700
+ }
1701
+ _ => None,
1702
+ })
1703
+ .collect();
1704
+
1705
+ for fname in candidates {
1706
+ // Re-check use counts against the CURRENT program (a prior inline may have changed them).
1707
+ let (calls, others) = fn_name_uses(&result, fname.as_ref());
1708
+ if others != 0 || calls != 1 {
1709
+ continue; // escapes, unused, or called more than once → not inlinable
1710
+ }
1711
+ // Find the single top-level call site index + form.
1712
+ let mut site: Option<(usize, Option<Arc<str>>)> = None; // (idx, Some(target)=VarDecl / None=ExprStmt)
1713
+ let mut ambiguous = false;
1714
+ for (i, s) in result.iter().enumerate() {
1715
+ let hit = match s {
1716
+ Statement::VarDecl {
1717
+ name,
1718
+ type_ann: None,
1719
+ init: Some(Expr::Call { callee, args, .. }),
1720
+ ..
1721
+ } if args.is_empty()
1722
+ && matches!(callee.as_ref(), Expr::Ident { name: c, .. } if c.as_ref() == fname.as_ref()) =>
1723
+ {
1724
+ Some(Some(name.clone()))
1725
+ }
1726
+ Statement::ExprStmt { expr: Expr::Call { callee, args, .. }, .. }
1727
+ if args.is_empty()
1728
+ && matches!(callee.as_ref(), Expr::Ident { name: c, .. } if c.as_ref() == fname.as_ref()) =>
1729
+ {
1730
+ Some(None)
1731
+ }
1732
+ _ => None,
1733
+ };
1734
+ if let Some(target) = hit {
1735
+ if site.is_some() {
1736
+ ambiguous = true;
1737
+ break;
1738
+ }
1739
+ site = Some((i, target));
1740
+ }
1741
+ }
1742
+ // The single call must be a TOP-LEVEL statement (calls==1 guarantees no other call exists;
1743
+ // if we didn't find it at top level it is nested — skip, fail-closed).
1744
+ let (site_idx, target) = match site {
1745
+ Some(s) if !ambiguous => s,
1746
+ _ => continue,
1747
+ };
1748
+
1749
+ // Pull the factory body out of its FunDecl.
1750
+ let fdecl_idx = result.iter().position(|s| {
1751
+ matches!(s, Statement::FunDecl { name, .. } if name.as_ref() == fname.as_ref())
1752
+ });
1753
+ let Some(fdecl_idx) = fdecl_idx else { continue };
1754
+ let body_stmts: Vec<Statement> = match &result[fdecl_idx] {
1755
+ Statement::FunDecl { body, .. } => match body.as_ref() {
1756
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
1757
+ statements.clone()
1758
+ }
1759
+ _ => continue,
1760
+ },
1761
+ _ => continue,
1762
+ };
1763
+
1764
+ // Split off the trailing `return E`.
1765
+ let (prefix, ret_expr) = match body_stmts.split_last() {
1766
+ Some((Statement::Return { value: Some(e), .. }, head)) => (head.to_vec(), e.clone()),
1767
+ _ => continue,
1768
+ };
1769
+
1770
+ // Build the alpha-rename map for the leaked (direct) body locals.
1771
+ let Some(leaked) = direct_body_locals(&prefix) else { continue };
1772
+ // NARROW to the push-built factory shape ONLY: `return R` where R is a leaked (body-top-level)
1773
+ // local built via `let R = []` + pushes, consumed as `let X = f()`. A literal-array return
1774
+ // (`return [a, b]`), a call return, or a discarded `f()` is left intact — those are the #177
1775
+ // aggregate machinery's territory (nbody's `makeBodies` returns `[sun, jupiter, …]`, and
1776
+ // inlining it removes the function the aggregate ABI expects → nbody BUILD-FAIL under
1777
+ // TISH_AGGREGATE_INFER). This keeps the inliner to exactly the Stage A/B bridge target.
1778
+ let return_ident = match &ret_expr {
1779
+ Expr::Ident { name, .. } if leaked.iter().any(|l| l.as_ref() == name.as_ref()) => {
1780
+ name.clone()
1781
+ }
1782
+ _ => continue,
1783
+ };
1784
+ let Some(tgt) = target.clone() else { continue }; // only `let X = f()`, not a discarded call
1785
+ let mut active: std::collections::HashMap<String, Arc<str>> =
1786
+ std::collections::HashMap::new();
1787
+ // Map the returned array var R → the call-site target X, so the spliced `let R = []` becomes
1788
+ // `let X = []` and the record-array/ShapeUnion machinery types X directly. Every other leaked
1789
+ // local gets a fresh, collision-proof name.
1790
+ for (n, l) in leaked.iter().enumerate() {
1791
+ if l.as_ref() == return_ident.as_ref() {
1792
+ active.insert(l.to_string(), tgt.clone());
1793
+ } else {
1794
+ let fresh: Arc<str> = format!("__inl_{}_{}_{}", fname, n, l).into();
1795
+ active.insert(l.to_string(), fresh);
1796
+ }
1797
+ }
1798
+
1799
+ // Alpha-rename the prefix (declarations + references) using the resolution-tested renamer.
1800
+ // The trailing `return R` is dropped: R was renamed to X, so X is already the array binding.
1801
+ let mut renamed_prefix = prefix;
1802
+ for s in &mut renamed_prefix {
1803
+ let mut a = active.clone();
1804
+ crate::resolve::rewrite_stmt_scope(s, &mut a, true);
1805
+ }
1806
+ let spliced: Vec<Statement> = renamed_prefix;
1807
+
1808
+ // Rebuild the program: replace the call site with the spliced body, drop the FunDecl.
1809
+ let mut out: Vec<Statement> = Vec::with_capacity(result.len() + spliced.len());
1810
+ for (i, s) in result.into_iter().enumerate() {
1811
+ if i == fdecl_idx {
1812
+ continue; // drop the now-inlined factory
1813
+ }
1814
+ if i == site_idx {
1815
+ out.extend(spliced.iter().cloned());
1816
+ continue;
1817
+ }
1818
+ out.push(s);
1819
+ }
1820
+ result = out;
1821
+ }
1822
+ result
1823
+ }
1824
+
1825
+ fn struct_infer_program(program: Program) -> Program {
1826
+ // #179 Stage A: inline a nullary function that is called EXACTLY ONCE (as a top-level
1827
+ // statement) and never used as a value, splicing its body at the call site. This bridges a
1828
+ // build-then-return record-array factory (`function mk(){ let a=[]; a.push({..}); return a }`;
1829
+ // `let xs = mk()`) into a TOP-LEVEL push-built array, which the record-array / ShapeUnion
1830
+ // machinery can then type. Sound by construction: a single-call inline substitutes the whole
1831
+ // call, so evaluation order and side effects are preserved exactly (a captured-global write in
1832
+ // the body becomes an ordinary top-level statement; a factory used as a value is left alone).
1833
+ let program = if crate::native_opts_enabled() {
1834
+ Program { statements: inline_single_call_factories(program.statements) }
1835
+ } else {
1836
+ program
1837
+ };
1838
+ let mut reg = StructRegistry::default();
1839
+ let mut ctx = InferCtx::new();
1840
+ // #320: fns proven to always return a number, so `infer_expr_type(f(...))` is `number` and a
1841
+ // `seq.push(nextBase())` keeps `seq` a native `number[]`. Computed off the param-inferred
1842
+ // program so numeric params are already typed.
1843
+ ctx.number_returning_fns = collect_number_returning_fns(&program.statements, &ctx);
1844
+ // #175: tell the mutable-array co-inference which fns take native `&/&mut Vec` array params, so
1845
+ // forwarding an array into one isn't treated as a boxing escape (lets the caller's array stay
1846
+ // unboxed). Same AST-only detection codegen uses, so the two never disagree.
1847
+ if crate::native_opts_enabled() {
1848
+ for (fname, sig) in crate::codegen::Codegen::detect_native_vec_fns(&program) {
1849
+ let flags: Vec<bool> = sig
1850
+ .params
1851
+ .iter()
1852
+ .map(|(_, k)| matches!(k, crate::codegen::VecParamKind::Array { .. }))
1853
+ .collect();
1854
+ ctx.native_vec_array_params.insert(fname, flags);
1855
+ }
1856
+ }
1857
+ // #320: mark a read-only `number[]`-param fn's array args as non-escapes so the caller's array
1858
+ // stays `number[]` (e.g. `kNucleotide(seq, k)`). This uses the READ-ONLY criterion only — sound
1859
+ // regardless of the arg type, since passing a native `Vec` to a boxed closure copies it (a
1860
+ // read-only callee can't mutate the caller's array through the copy). Codegen separately proves,
1861
+ // via the call sites, which of these it may actually UNBOX (`native_arr_param_fns`); a fn it
1862
+ // can't prove simply stays a fully boxed closure. Runs pre-`si_block`, so it must not rely on
1863
+ // `number[]` stamps — `arr_param_readonly_fns` only inspects each fn's own body.
1864
+ if crate::native_opts_enabled() {
1865
+ for (fname, flags) in crate::codegen::Codegen::arr_param_readonly_fns(&program) {
1866
+ ctx.native_vec_array_params.insert(fname, flags);
1867
+ }
1868
+ }
1869
+ let mut stmts = si_block(program.statements, &mut reg, &mut ctx);
1870
+ // Prepend the generated struct `type` aliases so codegen synthesizes them.
1871
+ let mut out: Vec<Statement> = Vec::with_capacity(stmts.len() + reg.decls.len());
1872
+ let span = stmts.first().map(stmt_span).unwrap_or_else(zero_span);
1873
+ for (name, fields) in reg.decls.drain(..) {
1874
+ out.push(Statement::TypeAlias {
1875
+ name: name.as_str().into(),
1876
+ name_span: span,
1877
+ ty: TypeAnnotation::Object(fields),
1878
+ span,
1879
+ });
1880
+ }
1881
+ // #179 Stage B: emit each shape-union alias as `type TishAnonUnion_N = Union([Object(s0), …])`;
1882
+ // codegen recognizes the reserved-prefix alias and synthesizes the enum + per-variant structs.
1883
+ for (name, shapes) in reg.union_decls.drain(..) {
1884
+ let members: Vec<TypeAnnotation> = shapes
1885
+ .into_iter()
1886
+ .map(TypeAnnotation::Object)
1887
+ .collect();
1888
+ out.push(Statement::TypeAlias {
1889
+ name: name.as_str().into(),
1890
+ name_span: span,
1891
+ ty: TypeAnnotation::Union(members),
1892
+ span,
1893
+ });
1894
+ }
1895
+ out.append(&mut stmts);
1896
+ Program { statements: out }
1897
+ }
1898
+
1899
+ fn stmt_span(s: &Statement) -> tishlang_ast::Span {
1900
+ match s {
1901
+ Statement::VarDecl { span, .. }
1902
+ | Statement::Block { span, .. }
1903
+ | Statement::ExprStmt { span, .. }
1904
+ | Statement::If { span, .. }
1905
+ | Statement::For { span, .. }
1906
+ | Statement::ForOf { span, .. }
1907
+ | Statement::While { span, .. }
1908
+ | Statement::Return { span, .. }
1909
+ | Statement::FunDecl { span, .. }
1910
+ | Statement::TypeAlias { span, .. } => *span,
1911
+ _ => zero_span(),
1912
+ }
1913
+ }
1914
+
1915
+ fn zero_span() -> tishlang_ast::Span {
1916
+ tishlang_ast::Span {
1917
+ start: (0, 0),
1918
+ end: (0, 0),
1919
+ }
1920
+ }
1921
+
1922
+ /// Transform a block: annotate struct-safe object `let` bindings, recursing
1923
+ /// into nested blocks. `ctx` provides field-type inference for initializers.
1924
+ fn si_block(stmts: Vec<Statement>, reg: &mut StructRegistry, ctx: &mut InferCtx) -> Vec<Statement> {
1925
+ ctx.push_scope();
1926
+ // Co-infer mutable `number[]` locals across the whole block (cross-referencing arrays) up front.
1927
+ // Co-infer mutable native arrays (number[] and boolean[]) across the block; an array of the
1928
+ // wrong element type simply fails its run and stays boxed.
1929
+ let native_num_arrays = block_native_arrays(&stmts, ctx, &number_ann());
1930
+ let native_bool_arrays = block_native_arrays(&stmts, ctx, &bool_ann());
1931
+ let n = stmts.len();
1932
+ let mut out: Vec<Statement> = Vec::with_capacity(n);
1933
+ for (i, stmt) in stmts.iter().enumerate() {
1934
+ // Candidate: `let xs = [ ...uniform native scalars... ]` with no annotation -> native `T[]`,
1935
+ // but only when every later use is read-only (`uses_are_array_safe`), so a `Vec<f64>`
1936
+ // assumption can't be violated by a later `push`/`xs[i] = …`.
1937
+ if let Statement::VarDecl {
1938
+ name,
1939
+ name_span,
1940
+ mutable,
1941
+ type_ann: None,
1942
+ init: Some(Expr::Array { elements, .. }),
1943
+ span,
1944
+ } = stmt
1945
+ {
1946
+ // (a) Read-only typed array: uniform scalar literals, every later use read-only.
1947
+ if let Some(elem) = infer_array_elem(elements, ctx) {
1948
+ // A constant OOB read (`let a=[1,2,3]; a[5]`) yields NaN under Vec<f64> but `null`
1949
+ // boxed, so disqualify it even though the use is "read-only".
1950
+ if uses_are_array_safe(name.as_ref(), &stmts[i + 1..])
1951
+ && !array_oob_const_access(&stmts[i + 1..], name.as_ref(), elements.len())
1952
+ {
1953
+ let arr_ann = TypeAnnotation::Array(Box::new(elem));
1954
+ ctx.define(name.as_ref(), arr_ann.clone());
1955
+ out.push(Statement::VarDecl {
1956
+ name: name.clone(),
1957
+ name_span: *name_span,
1958
+ mutable: *mutable,
1959
+ type_ann: Some(arr_ann),
1960
+ init: stmt_init_clone(stmt),
1961
+ span: *span,
1962
+ });
1963
+ continue;
1964
+ }
1965
+ }
1966
+ // (b) Mutable native `number[]` / `boolean[]`: `let a = []` / `[lits]` driven by push +
1967
+ // index read/write (`fannkuch`/`queens`/`nsieve`, incl. cross-referencing siblings).
1968
+ // Sound via the block-level fixpoint above (every accepted array's elements are provably
1969
+ // of the chosen element type). `number[]` wins over `boolean[]` if somehow in both.
1970
+ let native_elem = if native_num_arrays.contains(name.as_ref()) {
1971
+ Some(number_ann())
1972
+ } else if native_bool_arrays.contains(name.as_ref()) {
1973
+ Some(bool_ann())
1974
+ } else {
1975
+ None
1976
+ };
1977
+ if let Some(elem) = native_elem {
1978
+ let arr_ann = TypeAnnotation::Array(Box::new(elem));
1979
+ ctx.define(name.as_ref(), arr_ann.clone());
1980
+ out.push(Statement::VarDecl {
1981
+ name: name.clone(),
1982
+ name_span: *name_span,
1983
+ mutable: *mutable,
1984
+ type_ann: Some(arr_ann),
1985
+ init: stmt_init_clone(stmt),
1986
+ span: *span,
1987
+ });
1988
+ continue;
1989
+ }
1990
+ // (d) Array of uniform records: `let rows = []` (or `[{lits}]`) built by
1991
+ // `rows.push({uniform primitive object literal})` and used only as `rows[i].field`
1992
+ // reads / `rows.length`. Lower to a native `Vec<struct>` — the struct path emits direct
1993
+ // field access and (post-#350) allocation-free construction. Conservative: any other use
1994
+ // of the binding (escape, element/field write, for-of, bare ref) leaves it boxed.
1995
+ if let Some(fields) = record_array_fields(name.as_ref(), elements, &stmts[i + 1..], ctx) {
1996
+ let keys: std::collections::HashSet<&str> =
1997
+ fields.iter().map(|(k, _)| k.as_ref()).collect();
1998
+ if rec_arr_uses_safe(name.as_ref(), &keys, &stmts[i + 1..]) {
1999
+ let alias = reg.intern(&fields);
2000
+ let arr_ann = TypeAnnotation::Array(Box::new(TypeAnnotation::Simple(
2001
+ alias.as_str().into(),
2002
+ tishlang_ast::Span::default(),
2003
+ )));
2004
+ ctx.define(name.as_ref(), arr_ann.clone());
2005
+ ctx.define_struct(alias.as_str(), fields.clone()); // #373: enable cross-array field reads
2006
+ out.push(Statement::VarDecl {
2007
+ name: name.clone(),
2008
+ name_span: *name_span,
2009
+ mutable: *mutable,
2010
+ type_ann: Some(arr_ann),
2011
+ init: stmt_init_clone(stmt),
2012
+ span: *span,
2013
+ });
2014
+ continue;
2015
+ }
2016
+ }
2017
+ // (e) #179 Stage B — array of a CLOSED SET of heterogeneous record shapes ("megamorphic":
2018
+ // `objs.push({value,a})`, `objs.push({b,value,c})`, … all read as `objs[i].value`). Lower
2019
+ // to a native `Vec<enum>` (ShapeUnion): a `.field` present-and-same-type in EVERY variant
2020
+ // becomes a direct `match` load. Opt-in (TISH_SHAPE_UNION) until Stage C's integer modulo
2021
+ // makes it a net win. The safety walk uses ONLY the common keys, so a read of a
2022
+ // not-in-every-variant field disqualifies (stays boxed → undefined/null preserved).
2023
+ if shape_union_enabled() {
2024
+ if let Some(shapes) =
2025
+ record_array_shape_set(name.as_ref(), elements, &stmts[i + 1..], ctx)
2026
+ {
2027
+ let common = shape_set_common_keys(&shapes);
2028
+ let common_refs: std::collections::HashSet<&str> =
2029
+ common.iter().map(|k| k.as_ref()).collect();
2030
+ if !common_refs.is_empty()
2031
+ && rec_arr_uses_safe(name.as_ref(), &common_refs, &stmts[i + 1..])
2032
+ {
2033
+ let union_alias = reg.intern_union(&shapes);
2034
+ let arr_ann = TypeAnnotation::Array(Box::new(TypeAnnotation::Simple(
2035
+ union_alias.as_str().into(),
2036
+ tishlang_ast::Span::default(),
2037
+ )));
2038
+ ctx.define(name.as_ref(), arr_ann.clone());
2039
+ // Register the union's COMMON fields as a struct shape so `infer_expr_type`
2040
+ // types `objs[i].field` as its (number) type — otherwise a numeric
2041
+ // accumulator fed by the read (`sum = sum + objs[i].value`) stays boxed.
2042
+ // All common fields share one type across variants (guaranteed above), so the
2043
+ // first shape's types are correct.
2044
+ let common_fields: Vec<(std::sync::Arc<str>, TypeAnnotation)> = shapes[0]
2045
+ .iter()
2046
+ .filter(|(k, _)| common.contains(k))
2047
+ .cloned()
2048
+ .collect();
2049
+ ctx.define_struct(union_alias.as_str(), common_fields);
2050
+ out.push(Statement::VarDecl {
2051
+ name: name.clone(),
2052
+ name_span: *name_span,
2053
+ mutable: *mutable,
2054
+ type_ann: Some(arr_ann),
2055
+ init: stmt_init_clone(stmt),
2056
+ span: *span,
2057
+ });
2058
+ continue;
2059
+ }
2060
+ }
2061
+ }
2062
+ }
2063
+ // Candidate: `let o = { ...object literal... }` with no annotation.
2064
+ if let Statement::VarDecl {
2065
+ name,
2066
+ name_span,
2067
+ mutable,
2068
+ type_ann: None,
2069
+ init: Some(Expr::Object { props, span: obj_span }),
2070
+ span,
2071
+ } = stmt
2072
+ {
2073
+ // A struct-typed leading spread (`{ ...base, k: v }`) is inlined to a plain
2074
+ // `{ a: base.a, …, k: v }` literal so it lowers to an unboxed struct; otherwise the plain
2075
+ // literal path applies (a spread it can't inline leaves `infer_object_shape` → None → boxed).
2076
+ let (shape, rewritten_props) = match inline_struct_spread(props, ctx, reg) {
2077
+ Some((f, rp)) => (Some(f), Some(rp)),
2078
+ None => (infer_object_shape(props, ctx), None),
2079
+ };
2080
+ if let Some(fields) = shape {
2081
+ let keys: std::collections::HashSet<&str> =
2082
+ fields.iter().map(|(k, _)| k.as_ref()).collect();
2083
+ // Sound: every later use in this block must be a literal-key read.
2084
+ if uses_are_struct_safe(name.as_ref(), &keys, &stmts[i + 1..]) {
2085
+ let alias = reg.intern(&fields);
2086
+ ctx.define(name.as_ref(), TypeAnnotation::Simple(alias.as_str().into(), tishlang_ast::Span::default()));
2087
+ let new_init = match rewritten_props {
2088
+ Some(rp) => Some(Expr::Object {
2089
+ props: rp,
2090
+ span: *obj_span,
2091
+ }),
2092
+ None => stmt_init_clone(stmt),
2093
+ };
2094
+ out.push(Statement::VarDecl {
2095
+ name: name.clone(),
2096
+ name_span: *name_span,
2097
+ mutable: *mutable,
2098
+ type_ann: Some(TypeAnnotation::Simple(alias.as_str().into(), tishlang_ast::Span::default())),
2099
+ init: new_init,
2100
+ span: *span,
2101
+ });
2102
+ continue;
2103
+ }
2104
+ }
2105
+ }
2106
+ // Record a typed plain local (e.g. `let i = 0`) so a LATER object literal can type its
2107
+ // fields from it (`{ x: i }` → struct). The first inference pass may already have annotated
2108
+ // it (`let i: number`), so read the annotation OR infer from the init. Object-literal lets
2109
+ // defined their struct alias above and `continue`d; this runs for the rest. Without it,
2110
+ // `{ x: i }` can't resolve `i`'s type and the object stays a boxed `PropMap` (object_sum gap).
2111
+ if let Statement::VarDecl {
2112
+ name,
2113
+ name_span,
2114
+ mutable,
2115
+ type_ann,
2116
+ init,
2117
+ span,
2118
+ } = stmt
2119
+ {
2120
+ let inferred = type_ann
2121
+ .clone()
2122
+ .or_else(|| init.as_ref().and_then(|e| infer_expr_type(e, ctx)));
2123
+ if let Some(t) = &inferred {
2124
+ ctx.define(name.as_ref(), t.clone());
2125
+ }
2126
+ // #170: the base inference pass ran before array types were known, so an unannotated
2127
+ // local whose init only types now that `perm: number[]` is known (`let k = perm[0]`,
2128
+ // `let temp = perm[i]`) was left `type_ann: None` — and codegen reads the NODE, not
2129
+ // `ctx`, so it stayed a boxed `Value` despite being provably `f64`. Persist a proven
2130
+ // `number` type back onto the node here (the second, type-aware pass). The codegen
2131
+ // demote-gate (`collect_demoted_numeric_locals`) re-boxes any such local whose later
2132
+ // reassignment can escape `number`, so writing the annotation can never miscompile.
2133
+ if type_ann.is_none() {
2134
+ if let Some(t @ TypeAnnotation::Simple(s, _)) = &inferred {
2135
+ if s.as_ref() == "number" {
2136
+ out.push(Statement::VarDecl {
2137
+ name: name.clone(),
2138
+ name_span: *name_span,
2139
+ mutable: *mutable,
2140
+ type_ann: Some(t.clone()),
2141
+ init: stmt_init_clone(stmt),
2142
+ span: *span,
2143
+ });
2144
+ continue;
2145
+ }
2146
+ }
2147
+ }
2148
+ }
2149
+ out.push(si_recurse(stmt, reg, ctx));
2150
+ }
2151
+ ctx.pop_scope();
2152
+ out
2153
+ }
2154
+
2155
+ fn stmt_init_clone(stmt: &Statement) -> Option<Expr> {
2156
+ if let Statement::VarDecl { init, .. } = stmt {
2157
+ init.clone()
2158
+ } else {
2159
+ None
2160
+ }
2161
+ }
2162
+
2163
+ /// Define a `let`/`const` binding's name+type in `ctx` from its annotation or inferred init type, so
2164
+ /// inference in an inner scope can resolve references to it. Used to bind `for`-init loop variables
2165
+ /// before recursing into the loop body (enables struct inference of loop-local objects that read the
2166
+ /// counter). Only the simple `let x = <expr>` shape; anything else is skipped (sound — the body just
2167
+ /// keeps today's behavior).
2168
+ fn si_bind_decl(stmt: &Statement, ctx: &mut InferCtx) {
2169
+ if let Statement::VarDecl {
2170
+ name,
2171
+ type_ann,
2172
+ init: Some(e),
2173
+ ..
2174
+ } = stmt
2175
+ {
2176
+ let t = type_ann.clone().or_else(|| infer_expr_type(e, ctx));
2177
+ if let Some(t) = t {
2178
+ ctx.define(name.as_ref(), t);
2179
+ }
1126
2180
  }
1127
2181
  }
1128
2182
 
@@ -1140,13 +2194,26 @@ fn si_recurse(stmt: &Statement, reg: &mut StructRegistry, ctx: &mut InferCtx) ->
1140
2194
  update,
1141
2195
  body,
1142
2196
  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
- },
2197
+ } => {
2198
+ // Bind the loop variable(s) from `init` into a fresh scope so the body's struct inference
2199
+ // can type object-literal fields that reference the counter — e.g. `{ id: i, v: i * 2 }`
2200
+ // inside `for (let i = 0; …)`. Without this, `infer_object_shape` can't type those fields
2201
+ // and a loop-local object stays a boxed `PropMap`. (A loop-local object that reads only
2202
+ // outer/literal values already lowers to an unboxed struct; this closes the loop-var gap.)
2203
+ ctx.push_scope();
2204
+ if let Some(init_stmt) = init.as_deref() {
2205
+ si_bind_decl(init_stmt, ctx);
2206
+ }
2207
+ let new_body = Box::new(si_recurse(body, reg, ctx));
2208
+ ctx.pop_scope();
2209
+ Statement::For {
2210
+ init: init.clone(),
2211
+ cond: cond.clone(),
2212
+ update: update.clone(),
2213
+ body: new_body,
2214
+ span: *span,
2215
+ }
2216
+ }
1150
2217
  Statement::ForOf {
1151
2218
  name,
1152
2219
  name_span,
@@ -1227,6 +2294,373 @@ fn uses_are_struct_safe(name: &str, keys: &std::collections::HashSet<&str>, tail
1227
2294
  tail.iter().all(|s| stmt_name_safe(s, name, keys))
1228
2295
  }
1229
2296
 
2297
+ // ── array-of-records inference (`let rows = []; rows.push({…}); rows[i].field`) ───────────────────
2298
+ // Lowers an untyped local array of uniform primitive records to a native `Vec<struct>`, so the
2299
+ // struct codegen emits direct field access + (post-#350) allocation-free construction. Conservative:
2300
+ // only fires when the binding is used solely as `push({uniform literal})`, `name[i].<key>` reads, and
2301
+ // `name.length` — any other use leaves it a boxed `Value[]`.
2302
+
2303
+ /// Compare two inferred record shapes for equality, order-independently (same keys + field types).
2304
+ fn fields_eq(
2305
+ a: &[(std::sync::Arc<str>, TypeAnnotation)],
2306
+ b: &[(std::sync::Arc<str>, TypeAnnotation)],
2307
+ ) -> bool {
2308
+ if a.len() != b.len() {
2309
+ return false;
2310
+ }
2311
+ let mut av: Vec<(String, String)> =
2312
+ a.iter().map(|(k, t)| (k.to_string(), type_canon(t))).collect();
2313
+ let mut bv: Vec<(String, String)> =
2314
+ b.iter().map(|(k, t)| (k.to_string(), type_canon(t))).collect();
2315
+ av.sort();
2316
+ bv.sort();
2317
+ av == bv
2318
+ }
2319
+
2320
+ /// Fold one object literal's shape into the running record shape, flagging `*ok=false` on any
2321
+ /// non-uniform or non-primitive literal.
2322
+ fn record_shape_consider(
2323
+ shape: &mut Option<Vec<(std::sync::Arc<str>, TypeAnnotation)>>,
2324
+ ok: &mut bool,
2325
+ props: &[tishlang_ast::ObjectProp],
2326
+ ctx: &InferCtx,
2327
+ ) {
2328
+ match infer_object_shape(props, ctx) {
2329
+ Some(f) => match shape {
2330
+ None => *shape = Some(f),
2331
+ Some(prev) => {
2332
+ if !fields_eq(prev, &f) {
2333
+ *ok = false;
2334
+ }
2335
+ }
2336
+ },
2337
+ None => *ok = false,
2338
+ }
2339
+ }
2340
+
2341
+ /// Infer the common record shape of candidate array-of-records `name`: unify the shapes of its
2342
+ /// initial object-literal elements and every `name.push({…})` in `tail`. Returns the field list, or
2343
+ /// None if there is no object shape, the literals disagree, or a push arg is not a uniform primitive
2344
+ /// object literal. Loop vars in the literals (`{ x: i }`) are typed by seeding numeric locals first.
2345
+ fn record_array_fields(
2346
+ name: &str,
2347
+ init_elements: &[tishlang_ast::ArrayElement],
2348
+ tail: &[Statement],
2349
+ ctx: &InferCtx,
2350
+ ) -> Option<Vec<(std::sync::Arc<str>, TypeAnnotation)>> {
2351
+ // Seed numeric block-locals (incl. enclosing loop vars) so `{ x: i, y: i * 2 }` field values type.
2352
+ let mut sctx = ctx.clone();
2353
+ for _ in 0..4 {
2354
+ for s in tail {
2355
+ seed_numeric_locals(s, &mut sctx);
2356
+ }
2357
+ }
2358
+ let mut shape: Option<Vec<(std::sync::Arc<str>, TypeAnnotation)>> = None;
2359
+ let mut ok = true;
2360
+ for el in init_elements {
2361
+ match el {
2362
+ tishlang_ast::ArrayElement::Expr(Expr::Object { props, .. }) => {
2363
+ record_shape_consider(&mut shape, &mut ok, props, &sctx);
2364
+ }
2365
+ _ => return None,
2366
+ }
2367
+ if !ok {
2368
+ return None;
2369
+ }
2370
+ }
2371
+ for s in tail {
2372
+ walk_exprs_stmt(s, &mut |e| {
2373
+ if let Expr::Call { callee, args, .. } = e {
2374
+ if let Expr::Member {
2375
+ object,
2376
+ prop: tishlang_ast::MemberProp::Name { name: m, .. },
2377
+ ..
2378
+ } = callee.as_ref()
2379
+ {
2380
+ if m.as_ref() == "push"
2381
+ && matches!(object.as_ref(), Expr::Ident { name: n, .. } if n.as_ref() == name)
2382
+ {
2383
+ if let [CallArg::Expr(Expr::Object { props, .. })] = args.as_slice() {
2384
+ record_shape_consider(&mut shape, &mut ok, props, &sctx);
2385
+ } else {
2386
+ ok = false;
2387
+ }
2388
+ }
2389
+ }
2390
+ }
2391
+ });
2392
+ if !ok {
2393
+ return None;
2394
+ }
2395
+ }
2396
+ shape
2397
+ }
2398
+
2399
+ /// True if every use of array-of-records `name` in `tail` is `name.push({uniform literal})`,
2400
+ /// `name[i].<key>` (field read, `key` in the shape), or `name.length`. Mirrors the #177 S-D safety
2401
+ /// walk; any other use of the binding (bare ref, element/field write, escape into a call / return /
2402
+ /// spread, for-of, method other than push) returns false → the array stays boxed.
2403
+ fn rec_arr_uses_safe(name: &str, keys: &std::collections::HashSet<&str>, tail: &[Statement]) -> bool {
2404
+ tail.iter().all(|s| ra_stmt(s, name, keys))
2405
+ }
2406
+
2407
+ /// #179 Stage B/C: shape-union (megamorphic) inference — a heterogeneous closed-shape-set array
2408
+ /// lowers to a native `Vec<enum>` with direct enum-match field reads. DEFAULT-ON as of Stage C
2409
+ /// (the native-length fix made megamorphic beat node: 485→~16ms vs node ~58ms). Part of the native
2410
+ /// stack, so `TISH_NATIVE_OPT=0` disables it (the boxed baseline); `TISH_SHAPE_UNION=0` is a
2411
+ /// dedicated escape hatch.
2412
+ fn shape_union_enabled() -> bool {
2413
+ crate::native_opts_enabled() && std::env::var("TISH_SHAPE_UNION").as_deref() != Ok("0")
2414
+ }
2415
+
2416
+ /// #179 Stage B: like [`record_array_fields`] but for a HETEROGENEOUS array — accumulate the set of
2417
+ /// DISTINCT primitive-record shapes across the initial elements + every `name.push({…})`. Returns
2418
+ /// the deduped shapes when there are 2..=8 of them (a genuine union; 1 shape is the existing
2419
+ /// single-struct path), or None if a push arg isn't a uniform primitive object literal, a non-object
2420
+ /// element appears, or the set exceeds 8 shapes.
2421
+ fn record_array_shape_set(
2422
+ name: &str,
2423
+ init_elements: &[tishlang_ast::ArrayElement],
2424
+ tail: &[Statement],
2425
+ ctx: &InferCtx,
2426
+ ) -> Option<Vec<Vec<(std::sync::Arc<str>, TypeAnnotation)>>> {
2427
+ let mut sctx = ctx.clone();
2428
+ for _ in 0..4 {
2429
+ for s in tail {
2430
+ seed_numeric_locals(s, &mut sctx);
2431
+ }
2432
+ }
2433
+ let mut shapes: Vec<Vec<(std::sync::Arc<str>, TypeAnnotation)>> = Vec::new();
2434
+ let mut ok = true;
2435
+ for el in init_elements {
2436
+ match el {
2437
+ tishlang_ast::ArrayElement::Expr(Expr::Object { props, .. }) => {
2438
+ shape_set_consider(&mut shapes, &mut ok, props, &sctx);
2439
+ }
2440
+ _ => return None,
2441
+ }
2442
+ if !ok {
2443
+ return None;
2444
+ }
2445
+ }
2446
+ for s in tail {
2447
+ walk_exprs_stmt(s, &mut |e| {
2448
+ if let Expr::Call { callee, args, .. } = e {
2449
+ if let Expr::Member {
2450
+ object,
2451
+ prop: tishlang_ast::MemberProp::Name { name: m, .. },
2452
+ ..
2453
+ } = callee.as_ref()
2454
+ {
2455
+ if m.as_ref() == "push"
2456
+ && matches!(object.as_ref(), Expr::Ident { name: n, .. } if n.as_ref() == name)
2457
+ {
2458
+ if let [CallArg::Expr(Expr::Object { props, .. })] = args.as_slice() {
2459
+ shape_set_consider(&mut shapes, &mut ok, props, &sctx);
2460
+ } else {
2461
+ ok = false;
2462
+ }
2463
+ }
2464
+ }
2465
+ }
2466
+ });
2467
+ if !ok {
2468
+ return None;
2469
+ }
2470
+ }
2471
+ if !(2..=8).contains(&shapes.len()) {
2472
+ return None;
2473
+ }
2474
+ // Every variant must have a DISTINCT key set — codegen selects the variant for an object literal
2475
+ // by its key set, so a same-keys/different-types collision would be ambiguous → stay boxed.
2476
+ let mut keysets: Vec<Vec<&str>> = shapes
2477
+ .iter()
2478
+ .map(|s| {
2479
+ let mut k: Vec<&str> = s.iter().map(|(k, _)| k.as_ref()).collect();
2480
+ k.sort_unstable();
2481
+ k
2482
+ })
2483
+ .collect();
2484
+ let n = keysets.len();
2485
+ keysets.sort();
2486
+ keysets.dedup();
2487
+ if keysets.len() != n {
2488
+ return None;
2489
+ }
2490
+ Some(shapes)
2491
+ }
2492
+
2493
+ /// Accumulate a distinct primitive-record shape into the set (dedup by `fields_eq`). Sets `ok=false`
2494
+ /// if the literal is not a primitive record or the set would exceed 8 shapes.
2495
+ fn shape_set_consider(
2496
+ shapes: &mut Vec<Vec<(std::sync::Arc<str>, TypeAnnotation)>>,
2497
+ ok: &mut bool,
2498
+ props: &[tishlang_ast::ObjectProp],
2499
+ ctx: &InferCtx,
2500
+ ) {
2501
+ match infer_object_shape(props, ctx) {
2502
+ Some(f) => {
2503
+ if !shapes.iter().any(|s| fields_eq(s, &f)) {
2504
+ if shapes.len() >= 8 {
2505
+ *ok = false;
2506
+ return;
2507
+ }
2508
+ shapes.push(f);
2509
+ }
2510
+ }
2511
+ None => *ok = false,
2512
+ }
2513
+ }
2514
+
2515
+ /// #179 Stage B: the fields present with an IDENTICAL type in EVERY variant of a shape set — the only
2516
+ /// fields a `union[i].field` read may soundly lower to a native `match` (a field missing from, or
2517
+ /// type-divergent across, some variant must stay boxed → undefined/null semantics preserved).
2518
+ fn shape_set_common_keys(
2519
+ shapes: &[Vec<(std::sync::Arc<str>, TypeAnnotation)>],
2520
+ ) -> std::collections::HashSet<std::sync::Arc<str>> {
2521
+ let mut common = std::collections::HashSet::new();
2522
+ let Some(first) = shapes.first() else {
2523
+ return common;
2524
+ };
2525
+ 'field: for (k, ty) in first {
2526
+ for other in &shapes[1..] {
2527
+ match other.iter().find(|(ok, _)| ok.as_ref() == k.as_ref()) {
2528
+ Some((_, oty)) if type_canon(oty) == type_canon(ty) => {}
2529
+ _ => continue 'field, // absent or type-divergent in some variant
2530
+ }
2531
+ }
2532
+ common.insert(k.clone());
2533
+ }
2534
+ common
2535
+ }
2536
+
2537
+ fn ra_stmt(s: &Statement, name: &str, keys: &std::collections::HashSet<&str>) -> bool {
2538
+ use Statement::*;
2539
+ match s {
2540
+ Block { statements, .. } | Multi { statements, .. } => {
2541
+ statements.iter().all(|x| ra_stmt(x, name, keys))
2542
+ }
2543
+ VarDecl { name: n, init, .. } => {
2544
+ n.as_ref() != name && init.as_ref().is_none_or(|e| ra_expr(e, name, keys))
2545
+ }
2546
+ ExprStmt { expr, .. } => ra_expr(expr, name, keys),
2547
+ Return { value, .. } => value.as_ref().is_none_or(|e| ra_expr(e, name, keys)),
2548
+ If { cond, then_branch, else_branch, .. } => {
2549
+ ra_expr(cond, name, keys)
2550
+ && ra_stmt(then_branch, name, keys)
2551
+ && else_branch.as_ref().is_none_or(|e| ra_stmt(e, name, keys))
2552
+ }
2553
+ While { cond, body, .. } => ra_expr(cond, name, keys) && ra_stmt(body, name, keys),
2554
+ DoWhile { cond, body, .. } => ra_expr(cond, name, keys) && ra_stmt(body, name, keys),
2555
+ For { init, cond, update, body, .. } => {
2556
+ init.as_ref().is_none_or(|x| ra_stmt(x, name, keys))
2557
+ && cond.as_ref().is_none_or(|e| ra_expr(e, name, keys))
2558
+ && update.as_ref().is_none_or(|e| ra_expr(e, name, keys))
2559
+ && ra_stmt(body, name, keys)
2560
+ }
2561
+ Break { .. } | Continue { .. } => true,
2562
+ _ => false,
2563
+ }
2564
+ }
2565
+
2566
+ fn ra_expr(e: &Expr, name: &str, keys: &std::collections::HashSet<&str>) -> bool {
2567
+ use Expr::*;
2568
+ match e {
2569
+ Literal { .. } => true,
2570
+ Ident { name: n, .. } => n.as_ref() != name,
2571
+ Member { object, prop, optional: false, .. } => {
2572
+ let mname = match prop {
2573
+ tishlang_ast::MemberProp::Name { name: m, .. } => m.as_ref(),
2574
+ tishlang_ast::MemberProp::Expr(_) => return false,
2575
+ };
2576
+ match object.as_ref() {
2577
+ Ident { name: n, .. } if n.as_ref() == name => mname == "length",
2578
+ // `name[i].<key>` field read of THIS array. Guard on `io == name` so a DIFFERENT
2579
+ // record array's `other[i].field` falls through to the safe `_` recursion (bug: the
2580
+ // old unguarded arm returned false for it, failing any 2+-record-array program).
2581
+ Index { object: io, index, .. }
2582
+ if matches!(io.as_ref(), Ident { name: n, .. } if n.as_ref() == name) =>
2583
+ {
2584
+ keys.contains(mname) && ra_expr(index, name, keys)
2585
+ }
2586
+ _ => ra_expr(object, name, keys),
2587
+ }
2588
+ }
2589
+ Member { .. } => false,
2590
+ Index { object, index, .. } => {
2591
+ if matches!(object.as_ref(), Ident { name: n, .. } if n.as_ref() == name) {
2592
+ return false;
2593
+ }
2594
+ ra_expr(object, name, keys) && ra_expr(index, name, keys)
2595
+ }
2596
+ Binary { left, right, .. } => ra_expr(left, name, keys) && ra_expr(right, name, keys),
2597
+ Unary { operand, .. } => ra_expr(operand, name, keys),
2598
+ Conditional { cond, then_branch, else_branch, .. } => {
2599
+ ra_expr(cond, name, keys)
2600
+ && ra_expr(then_branch, name, keys)
2601
+ && ra_expr(else_branch, name, keys)
2602
+ }
2603
+ Assign { name: n, value, .. } => n.as_ref() != name && ra_expr(value, name, keys),
2604
+ CompoundAssign { name: n, value, .. } => n.as_ref() != name && ra_expr(value, name, keys),
2605
+ LogicalAssign { name: n, value, .. } => n.as_ref() != name && ra_expr(value, name, keys),
2606
+ MemberAssign { object, value, .. } => {
2607
+ ra_expr(object, name, keys) && ra_expr(value, name, keys)
2608
+ }
2609
+ IndexAssign { object, index, value, .. } => {
2610
+ !matches!(object.as_ref(), Ident { name: n, .. } if n.as_ref() == name)
2611
+ && ra_expr(object, name, keys)
2612
+ && ra_expr(index, name, keys)
2613
+ && ra_expr(value, name, keys)
2614
+ }
2615
+ Call { callee, args, .. } => {
2616
+ if let Member { object, prop, optional: false, .. } = callee.as_ref() {
2617
+ if matches!(object.as_ref(), Ident { name: n, .. } if n.as_ref() == name) {
2618
+ if let tishlang_ast::MemberProp::Name { name: m, .. } = prop {
2619
+ if m.as_ref() == "push" {
2620
+ return matches!(
2621
+ args.as_slice(),
2622
+ [CallArg::Expr(Object { props, .. })]
2623
+ if props.iter().all(|pr| matches!(pr,
2624
+ tishlang_ast::ObjectProp::KeyValue(_, v, _) if ra_expr(v, name, keys)))
2625
+ );
2626
+ }
2627
+ // `.sort(cmp)` is an in-place PERMUTATION, not an escape (shape + element
2628
+ // values unchanged). Safe iff the comparator does not escape `name`.
2629
+ if m.as_ref() == "sort" {
2630
+ return args
2631
+ .iter()
2632
+ .all(|a| matches!(a, CallArg::Expr(x) if ra_expr(x, name, keys)));
2633
+ }
2634
+ }
2635
+ return false;
2636
+ }
2637
+ }
2638
+ ra_expr(callee, name, keys)
2639
+ && args
2640
+ .iter()
2641
+ .all(|a| matches!(a, CallArg::Expr(x) if ra_expr(x, name, keys)))
2642
+ }
2643
+ TemplateLiteral { exprs, .. } => exprs.iter().all(|x| ra_expr(x, name, keys)),
2644
+ PostfixInc { name: n, .. }
2645
+ | PostfixDec { name: n, .. }
2646
+ | PrefixInc { name: n, .. }
2647
+ | PrefixDec { name: n, .. } => n.as_ref() != name,
2648
+ Array { elements, .. } => elements
2649
+ .iter()
2650
+ .all(|el| matches!(el, tishlang_ast::ArrayElement::Expr(x) if ra_expr(x, name, keys))),
2651
+ Object { props, .. } => props
2652
+ .iter()
2653
+ .all(|pr| matches!(pr, tishlang_ast::ObjectProp::KeyValue(_, v, _) if ra_expr(v, name, keys))),
2654
+ // HOF callbacks (e.g. the `.sort` comparator): recurse into the body precisely — a comparator
2655
+ // over `a.key`/`b.key` never touches `name` (pi_mentions is too conservative on closures).
2656
+ ArrowFunction { body, .. } => match body {
2657
+ tishlang_ast::ArrowBody::Expr(e) => ra_expr(e, name, keys),
2658
+ tishlang_ast::ArrowBody::Block(st) => ra_stmt(st, name, keys),
2659
+ },
2660
+ _ => false,
2661
+ }
2662
+ }
2663
+
1230
2664
  /// Define every provably-numeric block-local into `hyp` (flat across nested scopes): annotated
1231
2665
  /// `: number`, a number-literal init, or any init `infer_expr_type` proves `number` *under the
1232
2666
  /// current hypothesis* — so `let temp = perm[i]` becomes `number` once `perm` is hypothesized
@@ -1292,7 +2726,12 @@ fn block_native_arrays(
1292
2726
  // Empty `[]` is a candidate for either element type; literal elements must match `elem`.
1293
2727
  let lit_ok = elements.is_empty()
1294
2728
  || matches!(infer_array_elem(elements, outer_ctx), Some(t) if &t == elem);
1295
- if lit_ok {
2729
+ // Soundness: a non-empty literal array accessed at a constant index past its initial
2730
+ // length is sparse (`let arr=[1,2,3]; arr[5]=100`) — the native Vec pads holes with NaN
2731
+ // and OOB reads yield NaN, diverging from boxed `null`. Keep it boxed.
2732
+ let sparse = !elements.is_empty()
2733
+ && array_oob_const_access(&stmts[i + 1..], name.as_ref(), elements.len());
2734
+ if lit_ok && !sparse {
1296
2735
  cands.push((i, name.to_string()));
1297
2736
  }
1298
2737
  }
@@ -1330,6 +2769,40 @@ fn block_native_arrays(
1330
2769
  accepted
1331
2770
  }
1332
2771
 
2772
+ /// True if `stmts` access `name` at a **constant** non-negative integer index `>= init_len`
2773
+ /// (`name[K]` read or `name[K] = v`). For a non-empty literal-initialized array, such an index is
2774
+ /// out of the initial bounds: the native `Vec<elem>` representation pads sparse writes with
2775
+ /// `NaN`/`0`/`false` and yields those on OOB reads, whereas the boxed array grows with `null` holes
2776
+ /// and reads `null`. So an array with such an access must stay boxed to preserve semantics. Only
2777
+ /// meaningful for non-empty literal inits — an empty `[]` grows dynamically (length unknown here),
2778
+ /// and its holes are typically only truth-tested (`NaN`/`null` are both falsy), so it is left alone.
2779
+ fn array_oob_const_access(stmts: &[Statement], name: &str, init_len: usize) -> bool {
2780
+ let mut found = false;
2781
+ for s in stmts {
2782
+ walk_exprs_stmt(s, &mut |e| {
2783
+ let idx = match e {
2784
+ Expr::Index { object, index, .. } | Expr::IndexAssign { object, index, .. } => {
2785
+ if matches!(object.as_ref(), Expr::Ident { name: n, .. } if n.as_ref() == name) {
2786
+ index
2787
+ } else {
2788
+ return;
2789
+ }
2790
+ }
2791
+ _ => return,
2792
+ };
2793
+ if let Expr::Literal { value: Literal::Number(k), .. } = idx.as_ref() {
2794
+ if *k >= 0.0 && k.fract() == 0.0 && (*k as usize) >= init_len {
2795
+ found = true;
2796
+ }
2797
+ }
2798
+ });
2799
+ if found {
2800
+ return true;
2801
+ }
2802
+ }
2803
+ found
2804
+ }
2805
+
1333
2806
  /// A value written into / pushed onto `name` must have the array's element type `elem`: a `name[_]`
1334
2807
  /// self-read (already `elem` by hypothesis) or any expression `infer_expr_type` proves is `elem`.
1335
2808
  fn mut_arr_value_ok(v: &Expr, name: &str, elem: &TypeAnnotation, hyp: &InferCtx) -> bool {
@@ -1379,7 +2852,8 @@ fn mut_arr_stmt_ok(s: &Statement, name: &str, elem: &TypeAnnotation, hyp: &Infer
1379
2852
  Return { value, .. } => value.as_ref().is_none_or(|e| mut_arr_expr_ok(e, name, elem, hyp)),
1380
2853
  Throw { value, .. } => mut_arr_expr_ok(value, name, elem, hyp),
1381
2854
  Break { .. } | Continue { .. } | TypeAlias { .. } => true,
1382
- _ => false, // switch / try / nested fn / etc: bail
2855
+ FunDecl { body, .. } => mut_arr_stmt_ok(body, name, elem, hyp),
2856
+ _ => false, // switch / try / etc: bail
1383
2857
  }
1384
2858
  }
1385
2859
 
@@ -1420,6 +2894,20 @@ fn mut_arr_expr_ok(e: &Expr, name: &str, elem: &TypeAnnotation, hyp: &InferCtx)
1420
2894
  });
1421
2895
  }
1422
2896
  }
2897
+ // #175: forwarding `name` into a native-vec fn's ARRAY param is a native use, not a
2898
+ // boxing escape — the callee takes it by `&/&mut Vec<elem>`, so the array stays unboxed.
2899
+ if let Ident { name: fname, .. } = callee.as_ref() {
2900
+ if let Some(is_arr) = hyp.native_vec_array_params.get(fname.as_ref()) {
2901
+ if args.len() == is_arr.len() {
2902
+ return args.iter().enumerate().all(|(i, a)| match a {
2903
+ tishlang_ast::CallArg::Expr(v) => {
2904
+ (is_name(v) && is_arr[i]) || mut_arr_expr_ok(v, name, elem, hyp)
2905
+ }
2906
+ tishlang_ast::CallArg::Spread(_) => false,
2907
+ });
2908
+ }
2909
+ }
2910
+ }
1423
2911
  mut_arr_expr_ok(callee, name, elem, hyp)
1424
2912
  && args.iter().all(|a| match a {
1425
2913
  tishlang_ast::CallArg::Expr(v) => mut_arr_expr_ok(v, name, elem, hyp),
@@ -1514,6 +3002,7 @@ fn arr_stmt_safe(s: &Statement, name: &str) -> bool {
1514
3002
  Return { value, .. } => arr_opt_expr_safe(value, name),
1515
3003
  Throw { value, .. } => arr_expr_safe(value, name),
1516
3004
  Break { .. } | Continue { .. } | TypeAlias { .. } => true,
3005
+ FunDecl { body, .. } => arr_stmt_safe(body, name),
1517
3006
  // Nested fn could capture+mutate; switch/try and anything else: be safe, bail.
1518
3007
  _ => false,
1519
3008
  }
@@ -1726,10 +3215,33 @@ fn expr_name_safe(e: &Expr, name: &str, keys: &std::collections::HashSet<&str>)
1726
3215
  expr_name_safe(e, name, keys)
1727
3216
  }
1728
3217
  }),
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
- }),
3218
+ Object { props, .. } => {
3219
+ // A `{ ...name, k: v, }` immutable-update spreads `name` — a read-only bulk field read,
3220
+ // not an escape — and `inline_struct_spread` will lower it to a plain struct literal
3221
+ // (`{ a: name.a, …, k: v }`). Treat a leading self-spread as SAFE iff it matches that
3222
+ // inlining's eligibility EXACTLY: first prop, the only spread, and every other prop is a
3223
+ // `KeyValue` whose key doesn't collide with `name`'s keys (and whose value is name-safe).
3224
+ // Consistency: `name` then structifies iff the spread is inlined, so there's never a
3225
+ // struct spread into a boxed object (which would miscompile).
3226
+ let leading_self_spread = matches!(
3227
+ props.first(),
3228
+ Some(tishlang_ast::ObjectProp::Spread(Expr::Ident { name: n, .. })) if n.as_ref() == name
3229
+ );
3230
+ if leading_self_spread
3231
+ && props[1..].iter().all(|p| match p {
3232
+ tishlang_ast::ObjectProp::KeyValue(k, v, _) => {
3233
+ !keys.contains(k.as_ref()) && expr_name_safe(v, name, keys)
3234
+ }
3235
+ tishlang_ast::ObjectProp::Spread(_) => false,
3236
+ })
3237
+ {
3238
+ return true;
3239
+ }
3240
+ props.iter().all(|p| match p {
3241
+ tishlang_ast::ObjectProp::KeyValue(_, v, _) => expr_name_safe(v, name, keys),
3242
+ tishlang_ast::ObjectProp::Spread(e) => expr_name_safe(e, name, keys),
3243
+ })
3244
+ }
1733
3245
  TemplateLiteral { exprs, .. } => exprs.iter().all(|e| expr_name_safe(e, name, keys)),
1734
3246
  // Reassignment / mutation referencing `name` by identifier → unsafe.
1735
3247
  Assign { name: n, value, .. }
@@ -2153,6 +3665,11 @@ fn collect_return_shapes(
2153
3665
  pub fn analyze_aggregate(program: &Program) -> AggregateAnalysis {
2154
3666
  let mut analysis = AggregateAnalysis::default();
2155
3667
  let mut reg = StructRegistry::default();
3668
+ // Soundness: a fn called ANYWHERE with a provably-non-numeric arg must not get S-0 `: number`
3669
+ // params either — `fn makeRecord(id, name){ return {id, name} }` called `makeRecord(1, "Alice")`
3670
+ // would otherwise emit a `match … panic!("expected number")` coercion on the String. Same set the
3671
+ // M4 `param_infer` path uses (infer.rs:273).
3672
+ let nonnumeric = crate::codegen::Codegen::fns_called_with_nonnumeric_arg(&program.statements);
2156
3673
 
2157
3674
  // ---- S-0 + S-A: per top-level function, find numeric params and a return shape.
2158
3675
  for s in &program.statements {
@@ -2204,7 +3721,7 @@ pub fn analyze_aggregate(program: &Program) -> AggregateAnalysis {
2204
3721
  }
2205
3722
  }
2206
3723
  }
2207
- if !numeric_params.is_empty() {
3724
+ if !numeric_params.is_empty() && !nonnumeric.contains(name.as_ref()) {
2208
3725
  analysis.fn_numeric_params.insert(name.to_string(), numeric_params);
2209
3726
  }
2210
3727
  // S-A: a single all-f64 object-literal return shape ⇒ a struct alias.