@tishlang/tish 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -196,10 +196,26 @@ pub fn infer_program(program: &Program) -> Program {
196
196
  // backend emits unboxed structs with direct field access. Conservative —
197
197
  // only applies when every use of the binding is a literal-key field read,
198
198
  // so it can never miscompile (any uncertainty falls back to boxed Value).
199
- if std::env::var("TISH_STRUCT_INFER").map(|v| v != "0").unwrap_or(false) {
199
+ let p = if std::env::var("TISH_STRUCT_INFER").map(|v| v != "0").unwrap_or(false) {
200
200
  struct_infer_program(p)
201
201
  } else {
202
202
  p
203
+ };
204
+ // S-0..S-C aggregate (interprocedural monomorphic struct) inference — issue #177, opt-in via
205
+ // TISH_AGGREGATE_INFER, OFF by default. Front-end of the nbody unboxing lever: it
206
+ // S-0: types params used ONLY as object-literal field values (`body(x,…)` → `: number`),
207
+ // S-A: registers the return-shape struct alias for an all-f64 object-literal-returning fn,
208
+ // S-B: propagates a call's return type to `let p = body(…)` / `let bs = makeBodies()`,
209
+ // S-C: types `[ident,…]` array literals from those struct-typed locals.
210
+ // The full lever also needs S-D (write-permitting param-shape) + S-E/S-F (the typed-fn ABI
211
+ // tier in codegen) before the annotations can be *consumed* without a boxed-edge miscompile;
212
+ // until those land this pass only emits the annotations the existing codegen backs SOUNDLY
213
+ // (the S-0 scalar `: number` params, identical to the M4 mechanism) plus the inert struct
214
+ // alias decls. See `aggregate_infer_program`.
215
+ if std::env::var("TISH_AGGREGATE_INFER").map(|v| v != "0").unwrap_or(false) {
216
+ aggregate_infer_program(p)
217
+ } else {
218
+ p
203
219
  }
204
220
  }
205
221
 
@@ -227,17 +243,44 @@ fn pi_stmt(s: Statement) -> Statement {
227
243
  {
228
244
  // Locals provably numeric in this body (annotated, incl. base-inferred `let i = 0`), so a
229
245
  // bare loop counter `i` counts as a numeric operand and `i < n` can prove the param `n`.
230
- let mut nums = HashSet::new();
231
- collect_numeric_locals(&body, &mut nums);
246
+ // Fixpoint-closed so copy-chains of numeric literals (`let a = 0; let b = a`) propagate.
247
+ let mut base_nums = HashSet::new();
248
+ collect_numeric_locals_fixpoint(&body, &mut base_nums);
232
249
  let new_params = params
233
250
  .into_iter()
234
251
  .map(|p| match p {
235
252
  FunParam::Simple(mut tp) => {
253
+ // OPTIMISTIC PER-PARAM FIXPOINT (#172): assume the candidate param numeric, then
254
+ // propagate its copies — `let r = n` makes `r` numeric — to a fixpoint, so the
255
+ // chicken-and-egg `n` needs `r` (via `r === n`); `r` (`let r = n`) needs `n`
256
+ // closes. We then VERIFY every use of `n` is numeric-safe (`nus_stmt`) under this
257
+ // augmented set; a genuinely non-numeric use (string concat, object store, bare
258
+ // escape) still bails, so `fn label(x){return "v="+x}` keeps `x` dynamic.
259
+ //
260
+ // SOUNDNESS of the copy laundering: a copy `let r = n` only stays sound if `r`
261
+ // ITSELF is used numeric-safely everywhere — else `let r = n; r + "!"` would
262
+ // type `n` numeric yet do string concat (a divergence). So we also require every
263
+ // local the fixpoint added *because of this candidate* (in `nums` but not in
264
+ // `base_nums` — i.e. a copy-descendant of `n`) to pass `lns_stmt` (the
265
+ // local-numeric verifier). A poisoned copy-target bails the whole param.
266
+ // Monotone, locals/param-candidate-only.
236
267
  if tp.type_ann.is_none()
237
268
  && tp.default.is_none()
238
- && nus_stmt(&body, tp.name.as_ref(), &nums)
239
269
  {
240
- tp.type_ann = Some(TypeAnnotation::Simple(std::sync::Arc::from("number"), tishlang_ast::Span::default()));
270
+ let mut nums = base_nums.clone();
271
+ nums.insert(tp.name.to_string());
272
+ collect_numeric_locals_fixpoint(&body, &mut nums);
273
+ let copy_descendants: Vec<&String> = nums
274
+ .iter()
275
+ .filter(|x| x.as_str() != tp.name.as_ref() && !base_nums.contains(*x))
276
+ .collect();
277
+ if nus_stmt(&body, tp.name.as_ref(), &nums)
278
+ && copy_descendants
279
+ .iter()
280
+ .all(|x| lns_stmt(&body, x.as_str(), &nums))
281
+ {
282
+ tp.type_ann = Some(TypeAnnotation::Simple(std::sync::Arc::from("number"), tishlang_ast::Span::default()));
283
+ }
241
284
  }
242
285
  FunParam::Simple(tp)
243
286
  }
@@ -264,6 +307,13 @@ fn pi_stmt(s: Statement) -> Statement {
264
307
  /// letting `i < n` / `i * n + k` prove the *param* `n` numeric. Flat across nested scopes; at worst
265
308
  /// it over-includes a shadowed name, which only widens inference (still bounded by the same
266
309
  /// caller-passes-a-number assumption M4 already makes).
310
+ ///
311
+ /// MONOTONE FIXPOINT (#172): a single pass seeds number-LITERAL / annotated inits AND propagates
312
+ /// bare-ident copies — `let x = y` where `y` is ALREADY in `out` (a copy of an already-numeric
313
+ /// local/param). Run to a fixpoint via `collect_numeric_locals_fixpoint` so a chain
314
+ /// `let r = n; let s = r` closes. Soundness is identical to the literal seeding: a copy of a value
315
+ /// known numeric is itself numeric; over-inclusion only widens, bounded by M4's
316
+ /// caller-passes-a-number / NaN-coercion contract and the gauntlet/corpus differential oracles.
267
317
  fn collect_numeric_locals(s: &Statement, out: &mut HashSet<String>) {
268
318
  use Statement::*;
269
319
  match s {
@@ -286,6 +336,12 @@ fn collect_numeric_locals(s: &Statement, out: &mut HashSet<String>) {
286
336
  value: tishlang_ast::Literal::Number(_),
287
337
  ..
288
338
  })
339
+ )
340
+ // FIXPOINT: a bare-ident copy of an already-numeric local/param — `let r = n` where
341
+ // `n` is already known numeric. The copied value carries the source's numeric type.
342
+ || matches!(
343
+ init,
344
+ Some(tishlang_ast::Expr::Ident { name: src, .. }) if out.contains(src.as_ref())
289
345
  );
290
346
  if numeric {
291
347
  out.insert(name.to_string());
@@ -315,6 +371,21 @@ fn collect_numeric_locals(s: &Statement, out: &mut HashSet<String>) {
315
371
  }
316
372
  }
317
373
 
374
+ /// Run `collect_numeric_locals` to a monotone fixpoint over `body`, seeding `out` with any
375
+ /// pre-known-numeric names (e.g. the param-candidate assumed numeric). Each pass can only ADD
376
+ /// names (a copy of an already-numeric source), so it converges in at most one pass per copy-chain
377
+ /// link; the `out.len()` watermark detects quiescence. Soundness: every added name is a copy of a
378
+ /// value already proven/assumed numeric, the same basis as the literal seeding.
379
+ fn collect_numeric_locals_fixpoint(body: &Statement, out: &mut HashSet<String>) {
380
+ loop {
381
+ let before = out.len();
382
+ collect_numeric_locals(body, out);
383
+ if out.len() == before {
384
+ break;
385
+ }
386
+ }
387
+ }
388
+
318
389
  /// One side of an OVERLOADED binop (`+`, comparisons). If `operand` is bare `name`, the `other`
319
390
  /// side must be PROVABLY numeric (else `name + x` / `name < x` could be string ops, and `name`
320
391
  /// a string). If `operand` is a sub-expr, recurse (its own context decides).
@@ -394,7 +465,19 @@ fn nus_stmt(s: &Statement, name: &str, nums: &HashSet<String>) -> bool {
394
465
  }
395
466
  VarDecl {
396
467
  name: vn, init, ..
397
- } => vn.as_ref() != name && init.as_ref().is_none_or(|e| nus_expr(e, name, nums)),
468
+ } => {
469
+ // Writing the candidate to a name that shadows it bails (its type could change).
470
+ // Otherwise a bare-ident COPY of the candidate — `let r = n` — is a numeric-safe use:
471
+ // the param's value flows into a local that the per-param fixpoint already proved
472
+ // numeric (`r ∈ nums`), so it lowers to `f64`. This is what gates the fannkuch cascade
473
+ // (#172). Any other init form is checked by `nus_expr` as before (so `let s = n + ":"`
474
+ // still bails via the overloaded-`+` rule, keeping string-concat params dynamic).
475
+ vn.as_ref() != name
476
+ && init.as_ref().is_none_or(|e| {
477
+ matches!(e, Expr::Ident { name: src, .. } if src.as_ref() == name)
478
+ || nus_expr(e, name, nums)
479
+ })
480
+ }
398
481
  Break { .. } | Continue { .. } => true,
399
482
  // Any other statement (switch/throw/try/nested fn/...) -> bail (don't infer this param).
400
483
  _ => false,
@@ -502,6 +585,203 @@ fn nus_num_operand(e: &Expr, name: &str, nums: &HashSet<String>) -> bool {
502
585
  nus_expr(e, name, nums)
503
586
  }
504
587
 
588
+ // ---------------------------------------------------------------------------
589
+ // LOCAL-numeric-safe (#172): verify a LOCAL `loc` that the per-param copy-fixpoint marked numeric
590
+ // (a copy-descendant of the candidate param, e.g. `r` in `let r = n`) is used consistently as a
591
+ // number throughout the body — so the param→local laundering can't mask a non-numeric use. This is
592
+ // the dual of `nus_*` (which is for params), differing in exactly the local-only safe forms:
593
+ // * a bare read `loc` is a numeric VALUE (it IS a number) → safe in any value position;
594
+ // * the DEFINING `let loc = <numeric/copy>` and self-reassign `loc = <numeric>` keep it numeric;
595
+ // * the SAME bail set as params — `loc + <non-numeric>` (string concat), member/object store,
596
+ // bare call-arg escape, index-OBJECT use — still bails, so `let r = n; r + "!"` poisons it.
597
+ // `nums` already contains every numeric local/param-candidate, so the overloaded-`+`/comparison
598
+ // "other side provably numeric" rule reuses `numeric_provable` unchanged.
599
+ // ---------------------------------------------------------------------------
600
+
601
+ /// Every use of the known-numeric local `loc` in `s` is consistent with `loc` being a number.
602
+ fn lns_stmt(s: &Statement, loc: &str, nums: &HashSet<String>) -> bool {
603
+ use Statement::*;
604
+ match s {
605
+ Block { statements, .. } | Multi { statements, .. } => {
606
+ statements.iter().all(|x| lns_stmt(x, loc, nums))
607
+ }
608
+ Return { value, .. } => value.as_ref().is_none_or(|e| lns_num_operand(e, loc, nums)),
609
+ If {
610
+ cond,
611
+ then_branch,
612
+ else_branch,
613
+ ..
614
+ } => {
615
+ lns_expr(cond, loc, nums)
616
+ && lns_stmt(then_branch, loc, nums)
617
+ && else_branch.as_ref().is_none_or(|e| lns_stmt(e, loc, nums))
618
+ }
619
+ ExprStmt { expr, .. } => lns_expr(expr, loc, nums),
620
+ While { cond, body, .. } => lns_expr(cond, loc, nums) && lns_stmt(body, loc, nums),
621
+ For {
622
+ init,
623
+ cond,
624
+ update,
625
+ body,
626
+ ..
627
+ } => {
628
+ init.as_ref().is_none_or(|x| lns_stmt(x, loc, nums))
629
+ && cond.as_ref().is_none_or(|e| lns_expr(e, loc, nums))
630
+ && update.as_ref().is_none_or(|e| lns_expr(e, loc, nums))
631
+ && lns_stmt(body, loc, nums)
632
+ }
633
+ VarDecl {
634
+ name: vn, init, ..
635
+ } => {
636
+ if vn.as_ref() == loc {
637
+ // The DEFINING decl of `loc`: a numeric / copy-of-numeric init keeps it numeric.
638
+ // (Re-`let` to a non-numeric value would poison it — require a provable init.)
639
+ init.as_ref().is_none_or(|e| lns_def_init_ok(e, nums))
640
+ } else {
641
+ init.as_ref().is_none_or(|e| lns_expr(e, loc, nums))
642
+ }
643
+ }
644
+ Break { .. } | Continue { .. } => true,
645
+ _ => false,
646
+ }
647
+ }
648
+
649
+ /// The init/RHS of a numeric local's defining decl or self-assign: must be PROVABLY numeric (a
650
+ /// number literal / arithmetic / Math / a bare numeric local incl. the param-candidate). This is
651
+ /// `numeric_provable` over `nums` — bare `loc` itself counts (it is numeric).
652
+ fn lns_def_init_ok(e: &Expr, nums: &HashSet<String>) -> bool {
653
+ use Expr::*;
654
+ match e {
655
+ // Bare ident: a copy of a numeric local/param-candidate (`let r = n`, `r = i`).
656
+ Ident { name: n, .. } => nums.contains(n.as_ref()),
657
+ // `r = r - 1`, `r = r + 1` etc.: overloaded ops need at least one provably-numeric operand,
658
+ // which `numeric_provable` already enforces for the non-`Add` family; for `+` we require
659
+ // BOTH sides numeric-provable (so `r + "!"` is rejected — string concat poisons `r`).
660
+ Binary {
661
+ left, op, right, ..
662
+ } => {
663
+ use tishlang_ast::BinOp::*;
664
+ match op {
665
+ Sub | Mul | Div | Mod | Pow | BitAnd | BitOr | BitXor | Shl | Shr | UShr | Add => {
666
+ numeric_provable(left, nums) && numeric_provable(right, nums)
667
+ }
668
+ _ => false,
669
+ }
670
+ }
671
+ _ => numeric_provable(e, nums),
672
+ }
673
+ }
674
+
675
+ /// `e` with the known-numeric local `loc` used only where a number is valid.
676
+ fn lns_expr(e: &Expr, loc: &str, nums: &HashSet<String>) -> bool {
677
+ use Expr::*;
678
+ match e {
679
+ Literal { .. } => true,
680
+ // Bare read of `loc` (a number) — or of any other ident — is a numeric VALUE, always safe.
681
+ Ident { .. } => true,
682
+ Binary {
683
+ left, op, right, ..
684
+ } => {
685
+ use tishlang_ast::BinOp::*;
686
+ match op {
687
+ Sub | Mul | Div | Mod | Pow | BitAnd | BitOr | BitXor | Shl | Shr | UShr => {
688
+ lns_num_operand(left, loc, nums) && lns_num_operand(right, loc, nums)
689
+ }
690
+ // OVERLOADED: if `loc` is a direct operand, the OTHER side must be provably numeric
691
+ // — so `loc + "!"` (string concat) bails, poisoning the candidate.
692
+ Add | Lt | Le | Gt | Ge | StrictEq | StrictNe => {
693
+ lns_overloaded(left, right, loc, nums) && lns_overloaded(right, left, loc, nums)
694
+ }
695
+ And | Or => lns_expr(left, loc, nums) && lns_expr(right, loc, nums),
696
+ _ => !pi_mentions(left, loc) && !pi_mentions(right, loc),
697
+ }
698
+ }
699
+ Unary { op, operand, .. } => {
700
+ if matches!(
701
+ op,
702
+ tishlang_ast::UnaryOp::Neg | tishlang_ast::UnaryOp::Pos | tishlang_ast::UnaryOp::BitNot
703
+ ) {
704
+ lns_num_operand(operand, loc, nums)
705
+ } else {
706
+ !pi_mentions(operand, loc)
707
+ }
708
+ }
709
+ // `a[loc]`: index is a numeric operand; `loc` must NOT be the array object (member-ish use).
710
+ Index { object, index, .. } => {
711
+ !pi_mentions(object, loc) && lns_num_operand(index, loc, nums)
712
+ }
713
+ Call { callee, args, .. } => {
714
+ !pi_mentions(callee, loc)
715
+ && args.iter().all(|a| match a {
716
+ tishlang_ast::CallArg::Expr(x) => lns_arg(x, loc, nums),
717
+ tishlang_ast::CallArg::Spread(_) => false,
718
+ })
719
+ }
720
+ Conditional {
721
+ cond,
722
+ then_branch,
723
+ else_branch,
724
+ ..
725
+ } => {
726
+ lns_expr(cond, loc, nums)
727
+ && lns_num_operand(then_branch, loc, nums)
728
+ && lns_num_operand(else_branch, loc, nums)
729
+ }
730
+ // Self-assign `loc = <numeric>` keeps it numeric; assign to another var may read `loc`.
731
+ Assign { name: an, value, .. }
732
+ | CompoundAssign { name: an, value, .. }
733
+ | LogicalAssign { name: an, value, .. } => {
734
+ if an.as_ref() == loc {
735
+ lns_def_init_ok(value, nums)
736
+ } else {
737
+ lns_expr(value, loc, nums)
738
+ }
739
+ }
740
+ // `loc++` / `--loc`: numeric in/decrement keeps `loc` numeric.
741
+ PostfixInc { .. } | PostfixDec { .. } | PrefixInc { .. } | PrefixDec { .. } => true,
742
+ // `c[idx] = <val>`: index & val are numeric-operand positions (bare `loc` value is a number).
743
+ IndexAssign {
744
+ object,
745
+ index,
746
+ value,
747
+ ..
748
+ } => {
749
+ !pi_mentions(object, loc)
750
+ && lns_num_operand(index, loc, nums)
751
+ && lns_num_operand(value, loc, nums)
752
+ }
753
+ MemberAssign { object, value, .. } => {
754
+ !pi_mentions(object, loc) && lns_expr(value, loc, nums)
755
+ }
756
+ _ => !pi_mentions(e, loc),
757
+ }
758
+ }
759
+
760
+ /// One side of an overloaded binop for a numeric local: if it is bare `loc`, the OTHER side must be
761
+ /// provably numeric; otherwise recurse.
762
+ fn lns_overloaded(operand: &Expr, other: &Expr, loc: &str, nums: &HashSet<String>) -> bool {
763
+ if matches!(operand, Expr::Ident { name: n, .. } if n.as_ref() == loc) {
764
+ return numeric_provable(other, nums);
765
+ }
766
+ lns_expr(operand, loc, nums)
767
+ }
768
+
769
+ /// A numeric-operand position for a numeric local: bare `loc` is a number; else a numeric sub-expr.
770
+ fn lns_num_operand(e: &Expr, loc: &str, nums: &HashSet<String>) -> bool {
771
+ if matches!(e, Expr::Ident { name: n, .. } if n.as_ref() == loc) {
772
+ return true;
773
+ }
774
+ lns_expr(e, loc, nums)
775
+ }
776
+
777
+ /// A call argument for a numeric local: passing `loc` BARE bails (callee param type unknown).
778
+ fn lns_arg(e: &Expr, loc: &str, nums: &HashSet<String>) -> bool {
779
+ if matches!(e, Expr::Ident { name: n, .. } if n.as_ref() == loc) {
780
+ return false;
781
+ }
782
+ lns_expr(e, loc, nums)
783
+ }
784
+
505
785
  /// A call argument: passing `name` BARE bails (callee param type unknown); a numeric sub-expr ok.
506
786
  fn nus_arg(e: &Expr, name: &str, nums: &HashSet<String>) -> bool {
507
787
  if matches!(e, Expr::Ident { name: n, .. } if n.as_ref() == name) {
@@ -796,16 +1076,40 @@ fn si_block(stmts: Vec<Statement>, reg: &mut StructRegistry, ctx: &mut InferCtx)
796
1076
  // `{ x: i }` can't resolve `i`'s type and the object stays a boxed `PropMap` (object_sum gap).
797
1077
  if let Statement::VarDecl {
798
1078
  name,
1079
+ name_span,
1080
+ mutable,
799
1081
  type_ann,
800
1082
  init,
801
- ..
1083
+ span,
802
1084
  } = stmt
803
1085
  {
804
- let t = type_ann
1086
+ let inferred = type_ann
805
1087
  .clone()
806
1088
  .or_else(|| init.as_ref().and_then(|e| infer_expr_type(e, ctx)));
807
- if let Some(t) = t {
808
- ctx.define(name.as_ref(), t);
1089
+ if let Some(t) = &inferred {
1090
+ ctx.define(name.as_ref(), t.clone());
1091
+ }
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
+ }
1112
+ }
809
1113
  }
810
1114
  }
811
1115
  out.push(si_recurse(stmt, reg, ctx));
@@ -1622,60 +1926,1650 @@ fn _uses_call_arg(_: &CallArg) {}
1622
1926
  #[allow(dead_code)]
1623
1927
  fn _uses_arrow_body(_: &ArrowBody) {}
1624
1928
 
1625
- #[cfg(test)]
1626
- mod param_infer_tests {
1627
- use super::*;
1628
- use tishlang_parser::parse;
1929
+ // ===========================================================================
1930
+ // S-0..S-C: aggregate (interprocedural monomorphic struct) inference — issue #177
1931
+ // ===========================================================================
1932
+ //
1933
+ // This is the front-end of the nbody-unboxing lever. It runs ONLY under
1934
+ // `TISH_AGGREGATE_INFER` (OFF by default). The four sub-passes are pure analysis
1935
+ // over the (already base/param/struct-inferred) `Program`:
1936
+ //
1937
+ // S-0 param-numeric-as-field-value: a param used ONLY as an object-literal
1938
+ // field value (and otherwise numeric-safely) is `: number`. This MUST run
1939
+ // before S-A so `infer_object_shape(body)` can see the param types.
1940
+ // S-A return-shape: a fn whose EVERY `return` is the same all-f64 object
1941
+ // literal gets a registered struct alias as its inferred return shape.
1942
+ // S-B call-return propagation: `let p = body(…)` → struct; a fn that returns
1943
+ // an array of struct-typed idents → `Array(struct)`, so `let bs =
1944
+ // makeBodies()` is `Array(struct)`.
1945
+ // S-C array-of-ident element typing: `[a, b, c]` where each ident is the same
1946
+ // struct alias → `Array(struct)`.
1947
+ //
1948
+ // SOUNDNESS / WHAT IS ACTUALLY WRITTEN BACK
1949
+ // -----------------------------------------
1950
+ // The struct types S-A/S-B/S-C compute cannot yet be *consumed* by codegen
1951
+ // without the S-D write-permitting param predicate and the S-E/S-F typed-fn ABI
1952
+ // tier (a de-virtualized `fn advance(bodies: &VmRef<Vec<TishStruct_Body>>, …)`).
1953
+ // Until those land, writing a `Named`/`Array(Named)` annotation onto a fn param
1954
+ // or a call-initialised local would MISCOMPILE: `collect_annotated_types`
1955
+ // (codegen.rs) records the param/local as a native struct while the actual
1956
+ // binding is still a boxed `Value` (the call returns boxed `Value` — there is no
1957
+ // FnSigTable / struct-returning emission), so a `p.x` field read or `bodies[i].vx
1958
+ // = …` write would be lowered against a boxed value. The boxed-edge `===`/escape
1959
+ // hazards in the design's candidacy predicate are the same class of problem.
1960
+ //
1961
+ // Therefore `aggregate_infer_program` writes back ONLY the annotations the
1962
+ // EXISTING codegen backs soundly:
1963
+ // * S-0's scalar `: number` params (identical to the M4 param-infer mechanism,
1964
+ // already consumed soundly), and
1965
+ // * the inert struct `type` alias decls (unreferenced aliases are dropped by
1966
+ // codegen — they change nothing on their own).
1967
+ // The S-A/S-B/S-C struct *shapes* are computed and exposed via
1968
+ // `analyze_aggregate` for unit tests and for the future S-D..S-F consumers, but
1969
+ // are NOT yet stamped onto params / call-locals. This keeps the ON path free of
1970
+ // any checksum divergence while the inference logic is validated independently.
1629
1971
 
1630
- /// Run base inference, then M4 param inference, and return the inferred annotation name (if
1631
- /// any) for parameter `param` of `fn <fn_name>`.
1632
- fn inferred_param(src: &str, fn_name: &str, param: &str) -> Option<String> {
1633
- let parsed = parse(src).unwrap();
1634
- let base = Program {
1635
- statements: infer_statements(&parsed.statements, &mut InferCtx::new()),
1636
- };
1637
- let prog = param_infer_program(base);
1638
- for s in &prog.statements {
1639
- if let Statement::FunDecl { name, params, .. } = s {
1640
- if name.as_ref() == fn_name {
1641
- for p in params {
1642
- if let FunParam::Simple(tp) = p {
1643
- if tp.name.as_ref() == param {
1644
- return tp.type_ann.as_ref().map(|a| match a {
1645
- TypeAnnotation::Simple(s, _) => s.to_string(),
1646
- _ => "<complex>".to_string(),
1647
- });
1972
+ /// The result of the aggregate analysis: the registered struct shapes plus, per
1973
+ /// function, the inferred return shape and the set of params S-0 typed numeric.
1974
+ #[derive(Default, Debug, Clone)]
1975
+ pub struct AggregateAnalysis {
1976
+ /// alias name ordered field list (all `number`), one per distinct shape.
1977
+ pub struct_decls: Vec<StructDecl>,
1978
+ /// fn name → its return shape: `Struct(alias)` or `ArrayOfStruct(alias)`.
1979
+ pub fn_return: HashMap<String, AggReturn>,
1980
+ /// fn name → set of param names S-0 proved numeric (object-field-value-only).
1981
+ pub fn_numeric_params: HashMap<String, HashSet<String>>,
1982
+ /// top-level `let` name → inferred aggregate type (S-B/S-C), for tests.
1983
+ pub local_agg: HashMap<String, AggReturn>,
1984
+ /// S-D: the whole-program unboxing verdict. `Some(alias)` the struct alias
1985
+ /// `alias` is fully unboxable: its factory, array-factory, the top-level
1986
+ /// `Array(alias)` local(s), and every fn taking that array by param pass the
1987
+ /// all-or-nothing candidacy predicate, so codegen may emit the typed free-fn
1988
+ /// tier. `None` ⇒ bail the whole group to boxed (S-0 scalars still stamp).
1989
+ pub unbox_alias: Option<String>,
1990
+ /// S-D: fn name → (param-index, param-name) of the `Array(alias)` param, for
1991
+ /// the fns in the unbox group. Only populated when `unbox_alias` is `Some`.
1992
+ pub array_param_fns: HashMap<String, (usize, String)>,
1993
+ /// S-D: top-level `let` names whose inferred type is `Array(unbox_alias)`.
1994
+ pub array_locals: Vec<String>,
1995
+ }
1996
+
1997
+ /// An inferred aggregate (struct-ish) type produced by the S-A..S-C passes.
1998
+ #[derive(Debug, Clone, PartialEq, Eq)]
1999
+ pub enum AggReturn {
2000
+ /// A monomorphic all-f64 struct shape, identified by its registered alias.
2001
+ Struct(String),
2002
+ /// An array whose elements are all the same `Struct(alias)`.
2003
+ ArrayOfStruct(String),
2004
+ }
2005
+
2006
+ /// S-0 predicate: every use of `name` in `s` is EITHER a numeric-operand use
2007
+ /// (per the existing `nus_*` rules) OR an object-literal field value `{ k: name }`.
2008
+ /// Any other use — write to `name`, escape as a bare call-arg, member/index of
2009
+ /// `name`, `===` on `name`, etc. — bails (returns false). `nums` carries the
2010
+ /// numeric-local set so overloaded `+`/comparisons resolve as in `nus_*`.
2011
+ fn pus_stmt(s: &Statement, name: &str, nums: &HashSet<String>) -> bool {
2012
+ use Statement::*;
2013
+ match s {
2014
+ Block { statements, .. } | Multi { statements, .. } => {
2015
+ statements.iter().all(|x| pus_stmt(x, name, nums))
2016
+ }
2017
+ Return { value, .. } => value.as_ref().is_none_or(|e| pus_value(e, name, nums)),
2018
+ ExprStmt { expr, .. } => pus_value(expr, name, nums),
2019
+ VarDecl { name: vn, init, .. } => {
2020
+ vn.as_ref() != name && init.as_ref().is_none_or(|e| pus_value(e, name, nums))
2021
+ }
2022
+ If { cond, then_branch, else_branch, .. } => {
2023
+ pus_value(cond, name, nums)
2024
+ && pus_stmt(then_branch, name, nums)
2025
+ && else_branch.as_ref().is_none_or(|e| pus_stmt(e, name, nums))
2026
+ }
2027
+ While { cond, body, .. } => pus_value(cond, name, nums) && pus_stmt(body, name, nums),
2028
+ For { init, cond, update, body, .. } => {
2029
+ init.as_ref().is_none_or(|x| pus_stmt(x, name, nums))
2030
+ && cond.as_ref().is_none_or(|e| pus_value(e, name, nums))
2031
+ && update.as_ref().is_none_or(|e| pus_value(e, name, nums))
2032
+ && pus_stmt(body, name, nums)
2033
+ }
2034
+ Break { .. } | Continue { .. } => true,
2035
+ _ => false,
2036
+ }
2037
+ }
2038
+
2039
+ /// An expression position for S-0: `name` may be an object-literal field value, a
2040
+ /// numeric operand, or absent. Bare `name` at this level (an escape) bails.
2041
+ fn pus_value(e: &Expr, name: &str, nums: &HashSet<String>) -> bool {
2042
+ use Expr::*;
2043
+ match e {
2044
+ // `{ k: name, … }` — the field-value use S-0 exists to permit. Each prop value
2045
+ // must itself be S-0-safe (so a nested `{ k: name + "!" }` still bails via nus).
2046
+ Object { props, .. } => props.iter().all(|p| match p {
2047
+ tishlang_ast::ObjectProp::KeyValue(_, v, _) => {
2048
+ // A bare `name` as a field value is OK; otherwise it must be numeric-safe.
2049
+ matches!(v, Expr::Ident { name: n, .. } if n.as_ref() == name)
2050
+ || pus_value(v, name, nums)
2051
+ }
2052
+ tishlang_ast::ObjectProp::Spread(_) => false,
2053
+ }),
2054
+ // Anywhere else, defer to the numeric-operand rules (a bare `name` here is not a
2055
+ // numeric operand and `nus_expr` returns false for it → escape bails, as intended).
2056
+ _ => nus_expr(e, name, nums),
2057
+ }
2058
+ }
2059
+
2060
+ /// Is `s` a fn body whose EVERY `return` returns the SAME object literal whose
2061
+ /// fields are all `number` (under `pctx`, the params-as-number context)? Returns
2062
+ /// the field list of that shape, or None.
2063
+ fn return_object_shape(
2064
+ s: &Statement,
2065
+ pctx: &InferCtx,
2066
+ ) -> Option<Vec<(std::sync::Arc<str>, TypeAnnotation)>> {
2067
+ let mut found: Option<Vec<(std::sync::Arc<str>, TypeAnnotation)>> = None;
2068
+ if !collect_return_shapes(s, pctx, &mut found, &mut true) {
2069
+ return None;
2070
+ }
2071
+ found
2072
+ }
2073
+
2074
+ /// Walk `s`; for each `return <obj>` confirm it's an all-f64 object literal with a
2075
+ /// key set/order identical to any previously seen one. `ok` flips false on any
2076
+ /// non-conforming return (a non-object return, a shape mismatch, an untypeable
2077
+ /// field). Returns `ok`.
2078
+ fn collect_return_shapes(
2079
+ s: &Statement,
2080
+ pctx: &InferCtx,
2081
+ found: &mut Option<Vec<(std::sync::Arc<str>, TypeAnnotation)>>,
2082
+ ok: &mut bool,
2083
+ ) -> bool {
2084
+ use Statement::*;
2085
+ if !*ok {
2086
+ return false;
2087
+ }
2088
+ match s {
2089
+ Return { value: Some(Expr::Object { props, .. }), .. } => {
2090
+ match infer_object_shape(props, pctx) {
2091
+ Some(fields) => {
2092
+ // HARD all-f64 gate (string/bool field ⇒ bail to boxed).
2093
+ if !fields.iter().all(|(_, t)| is_number(t)) {
2094
+ *ok = false;
2095
+ return false;
2096
+ }
2097
+ match found {
2098
+ None => *found = Some(fields),
2099
+ Some(prev) => {
2100
+ // Identical ordered key set AND identical field types.
2101
+ if prev.len() != fields.len()
2102
+ || prev.iter().zip(&fields).any(|((pk, pt), (fk, ft))| {
2103
+ pk != fk || type_canon(pt) != type_canon(ft)
2104
+ })
2105
+ {
2106
+ *ok = false;
2107
+ return false;
1648
2108
  }
1649
2109
  }
1650
2110
  }
1651
2111
  }
2112
+ None => {
2113
+ *ok = false;
2114
+ return false;
2115
+ }
1652
2116
  }
1653
2117
  }
1654
- None
2118
+ // A `return` of anything other than an object literal ⇒ not a struct factory.
2119
+ Return { .. } => {
2120
+ *ok = false;
2121
+ return false;
2122
+ }
2123
+ Block { statements, .. } | Multi { statements, .. } => {
2124
+ for x in statements {
2125
+ if !collect_return_shapes(x, pctx, found, ok) {
2126
+ return false;
2127
+ }
2128
+ }
2129
+ }
2130
+ If { then_branch, else_branch, .. } => {
2131
+ if !collect_return_shapes(then_branch, pctx, found, ok) {
2132
+ return false;
2133
+ }
2134
+ if let Some(e) = else_branch {
2135
+ if !collect_return_shapes(e, pctx, found, ok) {
2136
+ return false;
2137
+ }
2138
+ }
2139
+ }
2140
+ For { body, .. } | While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } => {
2141
+ if !collect_return_shapes(body, pctx, found, ok) {
2142
+ return false;
2143
+ }
2144
+ }
2145
+ // No return here.
2146
+ _ => {}
1655
2147
  }
2148
+ *ok
2149
+ }
1656
2150
 
1657
- #[test]
1658
- fn infers_loop_bound_param_via_numeric_local() {
1659
- // `n` is the bare operand of `i < n`; `i` (`let i = 0`, base-inferred numeric) makes the
1660
- // OTHER operand provably numeric, so `n` is inferred `number` (the numeric-locals fix).
1661
- let src = "fn countUp(n) { let total = 0; for (let i = 0; i < n; i = i + 1) { total = total + i } return total }";
1662
- assert_eq!(inferred_param(src, "countUp", "n").as_deref(), Some("number"));
2151
+ /// Run the S-0..S-C analysis over a program (read-only; no mutation). Exposed for
2152
+ /// unit tests and the future S-D..S-F consumers.
2153
+ pub fn analyze_aggregate(program: &Program) -> AggregateAnalysis {
2154
+ let mut analysis = AggregateAnalysis::default();
2155
+ let mut reg = StructRegistry::default();
2156
+
2157
+ // ---- S-0 + S-A: per top-level function, find numeric params and a return shape.
2158
+ for s in &program.statements {
2159
+ if let Statement::FunDecl { name, params, body, return_type: None, async_: false, rest_param: None, .. } = s {
2160
+ // Locals provably numeric in the body (so overloaded `+`/comparison resolve).
2161
+ let mut nums = HashSet::new();
2162
+ collect_numeric_locals_fixpoint(body, &mut nums);
2163
+ // S-0: a simple, unannotated, default-less param used only as a field value /
2164
+ // numerically is numeric. OPTIMISTIC FIXPOINT (mirrors M4 #172): assume ALL candidate
2165
+ // params numeric, verify each under that hypothesis, drop the failures, repeat until
2166
+ // stable. The surviving set is self-consistent — every overloaded `+`/comparison's
2167
+ // OTHER operand is itself a surviving numeric param — so a param that only resolves by
2168
+ // relying on a sibling that later bails is itself dropped (no `f64 + Value` miscompile).
2169
+ let candidates: HashSet<String> = params
2170
+ .iter()
2171
+ .filter_map(|p| match p {
2172
+ FunParam::Simple(tp) if tp.type_ann.is_none() && tp.default.is_none() => {
2173
+ Some(tp.name.to_string())
2174
+ }
2175
+ _ => None,
2176
+ })
2177
+ .collect();
2178
+ let mut numeric_params = candidates.clone();
2179
+ loop {
2180
+ let mut local_nums = nums.clone();
2181
+ for n in &numeric_params {
2182
+ local_nums.insert(n.clone());
2183
+ }
2184
+ let drop: Vec<String> = numeric_params
2185
+ .iter()
2186
+ .filter(|n| !pus_stmt(body, n.as_str(), &local_nums))
2187
+ .cloned()
2188
+ .collect();
2189
+ if drop.is_empty() {
2190
+ break;
2191
+ }
2192
+ for n in drop {
2193
+ numeric_params.remove(&n);
2194
+ }
2195
+ }
2196
+ // Build a param-context: every S-0 numeric param is `: number`.
2197
+ let mut pctx = InferCtx::new();
2198
+ for p in params {
2199
+ if let FunParam::Simple(tp) = p {
2200
+ if let Some(ann) = &tp.type_ann {
2201
+ pctx.define(tp.name.as_ref(), ann.clone());
2202
+ } else if numeric_params.contains(tp.name.as_ref()) {
2203
+ pctx.define(tp.name.as_ref(), number_ann());
2204
+ }
2205
+ }
2206
+ }
2207
+ if !numeric_params.is_empty() {
2208
+ analysis.fn_numeric_params.insert(name.to_string(), numeric_params);
2209
+ }
2210
+ // S-A: a single all-f64 object-literal return shape ⇒ a struct alias.
2211
+ if let Some(fields) = return_object_shape(body, &pctx) {
2212
+ let alias = reg.intern(&fields);
2213
+ analysis.fn_return.insert(name.to_string(), AggReturn::Struct(alias));
2214
+ }
2215
+ }
1663
2216
  }
1664
2217
 
1665
- #[test]
1666
- fn does_not_infer_string_concat_param() {
1667
- // `x` is the bare operand of `+` against a string literal — NOT provably numeric, so `x`
1668
- // must stay dynamic (else `label("hi")` would mistype to f64 and panic at runtime).
1669
- let src = "fn label(x) { return \"v=\" + x }";
1670
- assert_eq!(inferred_param(src, "label", "x"), None);
2218
+ // ---- S-B (array return): a fn whose return is `[ident, …]` all of one struct type.
2219
+ // Needs the per-fn struct returns from S-A, plus the locals' struct types inside the body.
2220
+ for s in &program.statements {
2221
+ if let Statement::FunDecl { name, body, .. } = s {
2222
+ if analysis.fn_return.contains_key(name.as_ref()) {
2223
+ continue; // already a struct factory
2224
+ }
2225
+ if let Some(alias) = fn_returns_array_of_struct(body, &analysis) {
2226
+ analysis
2227
+ .fn_return
2228
+ .insert(name.to_string(), AggReturn::ArrayOfStruct(alias));
2229
+ }
2230
+ }
1671
2231
  }
1672
2232
 
1673
- #[test]
1674
- fn does_not_treat_other_param_as_numeric_local() {
1675
- // `a < b`: neither operand is a known numeric *local* (both are params), so neither is
1676
- // provable and neither param is inferred the relaxation is locals-only.
1677
- let src = "fn cmp(a, b) { if (a < b) { return 1 } return 0 }";
1678
- assert_eq!(inferred_param(src, "cmp", "a"), None);
1679
- assert_eq!(inferred_param(src, "cmp", "b"), None);
2233
+ // ---- S-B/S-C (top level): propagate call-return + array-of-ident to top-level locals.
2234
+ let mut local_types: HashMap<String, AggReturn> = HashMap::new();
2235
+ for s in &program.statements {
2236
+ if let Statement::VarDecl { name, type_ann: None, init: Some(init), .. } = s {
2237
+ if let Some(t) = infer_aggregate_expr(init, &analysis, &local_types) {
2238
+ local_types.insert(name.to_string(), t);
2239
+ }
2240
+ }
2241
+ }
2242
+ analysis.local_agg = local_types;
2243
+
2244
+ analysis.struct_decls = reg.decls;
2245
+
2246
+ // ---- S-D: whole-program unboxing candidacy. All-or-nothing per struct alias.
2247
+ compute_unbox_candidacy(program, &mut analysis);
2248
+
2249
+ analysis
2250
+ }
2251
+
2252
+ /// S-D candidacy: decide whether a single struct alias may be FULLY unboxed into a
2253
+ /// native `VmRef<Vec<TishStruct_alias>>` threaded through de-virtualized typed free
2254
+ /// fns. Sets `analysis.unbox_alias`/`array_param_fns`/`array_locals` iff EVERY use of
2255
+ /// the struct group is safe (else leaves them empty → boxed). Conservative and
2256
+ /// all-or-nothing: a single unsafe use anywhere bails the whole alias.
2257
+ fn compute_unbox_candidacy(program: &Program, analysis: &mut AggregateAnalysis) {
2258
+ // The factory: a fn whose return shape is `Struct(alias)`.
2259
+ // Require EXACTLY one struct alias overall (nbody shape) — keeps it monomorphic
2260
+ // and avoids ambiguity about which array elements are which struct.
2261
+ if analysis.struct_decls.len() != 1 {
2262
+ return;
2263
+ }
2264
+ let alias = analysis.struct_decls[0].0.clone();
2265
+
2266
+ // There must be an array factory returning `ArrayOfStruct(alias)` (makeBodies),
2267
+ // and at least one top-level `let` of `ArrayOfStruct(alias)` (bodies).
2268
+ let has_array_factory = analysis
2269
+ .fn_return
2270
+ .values()
2271
+ .any(|r| matches!(r, AggReturn::ArrayOfStruct(a) if *a == alias));
2272
+ if !has_array_factory {
2273
+ return;
2274
+ }
2275
+ let array_locals: Vec<String> = analysis
2276
+ .local_agg
2277
+ .iter()
2278
+ .filter_map(|(n, r)| match r {
2279
+ AggReturn::ArrayOfStruct(a) if *a == alias => Some(n.clone()),
2280
+ _ => None,
2281
+ })
2282
+ .collect();
2283
+ if array_locals.is_empty() {
2284
+ return;
2285
+ }
2286
+
2287
+ // Identify every fn whose first param is THE bodies array (used as `p[i]`,
2288
+ // `p.length`, `p[i].field` read/write). Each such fn must pass the per-fn
2289
+ // struct-array body safety predicate; a single failure bails the whole alias.
2290
+ let mut array_param_fns: HashMap<String, (usize, String)> = HashMap::new();
2291
+ for s in &program.statements {
2292
+ if let Statement::FunDecl {
2293
+ name, params, body, async_: false, rest_param: None, ..
2294
+ } = s
2295
+ {
2296
+ // Find a param used array-of-struct-ish.
2297
+ for (pi, p) in params.iter().enumerate() {
2298
+ let pname = match p {
2299
+ FunParam::Simple(tp) if tp.default.is_none() => tp.name.as_ref(),
2300
+ _ => continue,
2301
+ };
2302
+ if !param_used_as_struct_array(body, pname) {
2303
+ continue;
2304
+ }
2305
+ // This param is the bodies array. The whole fn body must be unbox-safe
2306
+ // for that array (every element use is a literal-key field op; no escape,
2307
+ // no reshape, no `===`, no computed-key). Bail the whole alias otherwise.
2308
+ if !struct_array_fn_safe(body, pname) {
2309
+ return;
2310
+ }
2311
+ array_param_fns.insert(name.to_string(), (pi, pname.to_string()));
2312
+ break; // one array param per fn (nbody shape)
2313
+ }
2314
+ }
2315
+ }
2316
+ if array_param_fns.is_empty() {
2317
+ return;
2318
+ }
2319
+
2320
+ // Every CALL of an array-param fn must pass the array as a bare top-level array
2321
+ // local (no boxed reshaping). And the array local must not escape elsewhere
2322
+ // (passed to a non-group fn, stored, console.log'd, indexed-assigned wholesale).
2323
+ // The call-site routing + escape check is enforced in codegen too, but we gate
2324
+ // here so the annotations are only stamped when the program globally conforms.
2325
+ if !array_use_sites_safe(program, &array_locals, &array_param_fns) {
2326
+ return;
2327
+ }
2328
+
2329
+ analysis.unbox_alias = Some(alias);
2330
+ analysis.array_param_fns = array_param_fns;
2331
+ analysis.array_locals = array_locals;
2332
+ }
2333
+
2334
+ /// Does `body` use `p` in a struct-array shape — at least one `p[i]` index, `p.length`,
2335
+ /// or `p[i].field` access? (Used to *identify* the array param, before safety-checking.)
2336
+ fn param_used_as_struct_array(body: &Statement, p: &str) -> bool {
2337
+ let mut found = false;
2338
+ walk_exprs_stmt(body, &mut |e| {
2339
+ match e {
2340
+ Expr::Index { object, .. } => {
2341
+ if matches!(object.as_ref(), Expr::Ident { name, .. } if name.as_ref() == p) {
2342
+ found = true;
2343
+ }
2344
+ }
2345
+ Expr::Member { object, prop: tishlang_ast::MemberProp::Name { name: m, .. }, .. } => {
2346
+ if m.as_ref() == "length"
2347
+ && matches!(object.as_ref(), Expr::Ident { name, .. } if name.as_ref() == p)
2348
+ {
2349
+ found = true;
2350
+ }
2351
+ }
2352
+ _ => {}
2353
+ }
2354
+ });
2355
+ found
2356
+ }
2357
+
2358
+ /// S-D per-fn struct-array safety. Every use of array param `p` inside `body` must be one of:
2359
+ /// * `p.length` (read)
2360
+ /// * `p[i]` as a `let x = p[i]` element-alias OR directly `p[i].field` read/write
2361
+ /// * `p[i].field` read / `p[i].field = <scalar>` write
2362
+ /// * `x.field` read / `x.field = <scalar>` write where `x` is an alias `let x = p[i]`
2363
+ /// Anything else — bare `p`, `p` as a call arg, `p[i]` stored/escaped, computed `.field`,
2364
+ /// `===`/`!==` on a body, `p.push`/`p.splice`/`p.length = …` reshape — bails (false).
2365
+ fn struct_array_fn_safe(body: &Statement, p: &str) -> bool {
2366
+ // Collect element-alias locals: `let x = p[i]` where `i` is a simple ident.
2367
+ // Track alias name → index-var name. Require the index var is never reassigned
2368
+ // in the alias's scope and the alias never escapes (checked via uses).
2369
+ let mut aliases: HashSet<String> = HashSet::new();
2370
+ collect_element_aliases(body, p, &mut aliases);
2371
+ saf_stmt(body, p, &aliases)
2372
+ }
2373
+
2374
+ /// Collect `let x = p[idx]` element-alias binding names (idx a bare ident).
2375
+ fn collect_element_aliases(s: &Statement, p: &str, out: &mut HashSet<String>) {
2376
+ use Statement::*;
2377
+ match s {
2378
+ VarDecl { name, init: Some(init), .. } => {
2379
+ if let Expr::Index { object, index, .. } = init {
2380
+ if matches!(object.as_ref(), Expr::Ident { name: o, .. } if o.as_ref() == p)
2381
+ && matches!(index.as_ref(), Expr::Ident { .. })
2382
+ {
2383
+ out.insert(name.to_string());
2384
+ }
2385
+ }
2386
+ }
2387
+ Block { statements, .. } | Multi { statements, .. } => {
2388
+ statements.iter().for_each(|x| collect_element_aliases(x, p, out))
2389
+ }
2390
+ If { then_branch, else_branch, .. } => {
2391
+ collect_element_aliases(then_branch, p, out);
2392
+ if let Some(e) = else_branch {
2393
+ collect_element_aliases(e, p, out);
2394
+ }
2395
+ }
2396
+ For { init, body, .. } => {
2397
+ if let Some(i) = init {
2398
+ collect_element_aliases(i, p, out);
2399
+ }
2400
+ collect_element_aliases(body, p, out);
2401
+ }
2402
+ While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } => {
2403
+ collect_element_aliases(body, p, out)
2404
+ }
2405
+ _ => {}
2406
+ }
2407
+ }
2408
+
2409
+ /// Statement-level S-D safety walk for array param `p` with element aliases `aliases`.
2410
+ fn saf_stmt(s: &Statement, p: &str, aliases: &HashSet<String>) -> bool {
2411
+ use Statement::*;
2412
+ match s {
2413
+ Block { statements, .. } | Multi { statements, .. } => {
2414
+ statements.iter().all(|x| saf_stmt(x, p, aliases))
2415
+ }
2416
+ VarDecl { name, init, .. } => {
2417
+ // `let x = p[i]` element-alias decl is OK (the alias is registered).
2418
+ // Any other decl: the init must be p-safe AND must not bind `p` itself.
2419
+ if name.as_ref() == p {
2420
+ return false;
2421
+ }
2422
+ if let Some(Expr::Index { object, index, .. }) = init {
2423
+ if matches!(object.as_ref(), Expr::Ident { name: o, .. } if o.as_ref() == p) {
2424
+ // element alias: index must be a simple ident, and the binding must be
2425
+ // in `aliases` (it is, by construction). OK.
2426
+ return matches!(index.as_ref(), Expr::Ident { .. })
2427
+ && aliases.contains(name.as_ref());
2428
+ }
2429
+ }
2430
+ init.as_ref().is_none_or(|e| saf_expr(e, p, aliases))
2431
+ }
2432
+ ExprStmt { expr, .. } => saf_expr(expr, p, aliases),
2433
+ Return { value, .. } => {
2434
+ // A bare `return p` escapes the array; a `return <scalar>` is fine.
2435
+ value.as_ref().is_none_or(|e| saf_expr(e, p, aliases))
2436
+ }
2437
+ If { cond, then_branch, else_branch, .. } => {
2438
+ saf_expr(cond, p, aliases)
2439
+ && saf_stmt(then_branch, p, aliases)
2440
+ && else_branch.as_ref().is_none_or(|e| saf_stmt(e, p, aliases))
2441
+ }
2442
+ While { cond, body, .. } => saf_expr(cond, p, aliases) && saf_stmt(body, p, aliases),
2443
+ DoWhile { cond, body, .. } => saf_expr(cond, p, aliases) && saf_stmt(body, p, aliases),
2444
+ For { init, cond, update, body, .. } => {
2445
+ init.as_ref().is_none_or(|x| saf_stmt(x, p, aliases))
2446
+ && cond.as_ref().is_none_or(|e| saf_expr(e, p, aliases))
2447
+ && update.as_ref().is_none_or(|e| saf_expr(e, p, aliases))
2448
+ && saf_stmt(body, p, aliases)
2449
+ }
2450
+ Break { .. } | Continue { .. } => true,
2451
+ _ => false,
2452
+ }
2453
+ }
2454
+
2455
+ /// Expression-level S-D safety. `p` is the array param; `aliases` are `let x = p[i]`
2456
+ /// element aliases. Returns false on any unsafe use of `p` or an alias.
2457
+ fn saf_expr(e: &Expr, p: &str, aliases: &HashSet<String>) -> bool {
2458
+ use Expr::*;
2459
+ match e {
2460
+ Literal { .. } => true,
2461
+ Ident { name, .. } => {
2462
+ // A BARE reference to the array `p` (not behind `[]`/`.length`) escapes — bail.
2463
+ // A bare reference to an element alias `x` ALSO escapes (the alias must only
2464
+ // appear as `x.field`); bail so the alias never leaks as a value.
2465
+ name.as_ref() != p && !aliases.contains(name.as_ref())
2466
+ }
2467
+ // `p.length` (read) — OK. `p.field` for any other field is not a thing on the array;
2468
+ // bail. `x.field` where `x` is an element alias — OK (Copy f64 read).
2469
+ Member { object, prop, optional: false, .. } => {
2470
+ let mname = match prop {
2471
+ tishlang_ast::MemberProp::Name { name, .. } => name.as_ref(),
2472
+ tishlang_ast::MemberProp::Expr(_) => return false, // computed key bails
2473
+ };
2474
+ match object.as_ref() {
2475
+ Ident { name, .. } if name.as_ref() == p => mname == "length",
2476
+ Ident { name, .. } if aliases.contains(name.as_ref()) => true,
2477
+ // `p[i].field` read.
2478
+ Index { object: io, index, .. } => {
2479
+ matches!(io.as_ref(), Ident { name, .. } if name.as_ref() == p)
2480
+ && saf_expr(index, p, aliases)
2481
+ }
2482
+ _ => saf_expr(object, p, aliases),
2483
+ }
2484
+ }
2485
+ Member { .. } => false, // optional member on the array/alias bails
2486
+ // `p[i]` index: only valid as part of `p[i].field` (handled above) or `let x = p[i]`
2487
+ // (handled in saf_stmt). A bare `p[i]` value here escapes the element — bail.
2488
+ Index { object, .. } => {
2489
+ if matches!(object.as_ref(), Ident { name, .. } if name.as_ref() == p) {
2490
+ return false;
2491
+ }
2492
+ // index into something else (not the array) — safe if its parts are safe.
2493
+ saf_expr(object, p, aliases)
2494
+ }
2495
+ Binary { left, op, right, .. } => {
2496
+ // `===`/`!==` directly on a body element / alias would compare references — bail.
2497
+ if matches!(op, BinOp::StrictEq | BinOp::StrictNe | BinOp::Eq | BinOp::Ne) {
2498
+ if expr_is_body_ref(left, p, aliases) || expr_is_body_ref(right, p, aliases) {
2499
+ return false;
2500
+ }
2501
+ }
2502
+ saf_expr(left, p, aliases) && saf_expr(right, p, aliases)
2503
+ }
2504
+ Unary { operand, .. } => saf_expr(operand, p, aliases),
2505
+ Conditional { cond, then_branch, else_branch, .. } => {
2506
+ saf_expr(cond, p, aliases)
2507
+ && saf_expr(then_branch, p, aliases)
2508
+ && saf_expr(else_branch, p, aliases)
2509
+ }
2510
+ Assign { name, value, .. } => {
2511
+ name.as_ref() != p && !aliases.contains(name.as_ref()) && saf_expr(value, p, aliases)
2512
+ }
2513
+ CompoundAssign { name, value, .. } => {
2514
+ name.as_ref() != p && !aliases.contains(name.as_ref()) && saf_expr(value, p, aliases)
2515
+ }
2516
+ // Field write: `x.field = <scalar>` (alias) or `p[i].field = <scalar>` — OK iff RHS p-safe
2517
+ // (scalar). Computed-key write or write onto the bare array bails.
2518
+ MemberAssign { object, prop: _, value, .. } => {
2519
+ let target_ok = match object.as_ref() {
2520
+ Ident { name, .. } if aliases.contains(name.as_ref()) => true,
2521
+ Index { object: io, index, .. } => {
2522
+ matches!(io.as_ref(), Ident { name, .. } if name.as_ref() == p)
2523
+ && saf_expr(index, p, aliases)
2524
+ }
2525
+ _ => false,
2526
+ };
2527
+ target_ok && saf_expr(value, p, aliases)
2528
+ }
2529
+ // `p[i] = …` / `p.length = …` reshape, or any index-assign onto the array, bails.
2530
+ IndexAssign { object, .. } => {
2531
+ !matches!(object.as_ref(), Ident { name, .. } if name.as_ref() == p)
2532
+ }
2533
+ Call { callee, args, .. } => {
2534
+ // Method calls on the array (push/splice/etc.) or on an alias bail: `p.push(...)`,
2535
+ // `x.something()`. Math.<fn>(...) and other free calls are fine if args are p-safe
2536
+ // (and no body ref escapes as an arg — checked by saf_expr on each arg).
2537
+ if let Member { object, .. } = callee.as_ref() {
2538
+ if expr_is_body_ref(object, p, aliases) {
2539
+ return false; // method call on the array/element
2540
+ }
2541
+ }
2542
+ saf_expr(callee, p, aliases)
2543
+ && args.iter().all(|a| match a {
2544
+ CallArg::Expr(x) => saf_expr(x, p, aliases),
2545
+ CallArg::Spread(_) => false,
2546
+ })
2547
+ }
2548
+ TemplateLiteral { exprs, .. } => exprs.iter().all(|x| saf_expr(x, p, aliases)),
2549
+ // Any other expr form that could touch `p` is not modelled — be conservative.
2550
+ PostfixInc { name, .. } | PostfixDec { name, .. } | PrefixInc { name, .. }
2551
+ | PrefixDec { name, .. } => name.as_ref() != p && !aliases.contains(name.as_ref()),
2552
+ _ => false,
2553
+ }
2554
+ }
2555
+
2556
+ /// Is `e` a direct reference to the array `p` or an element alias (`x` or `p[i]`)?
2557
+ fn expr_is_body_ref(e: &Expr, p: &str, aliases: &HashSet<String>) -> bool {
2558
+ match e {
2559
+ Expr::Ident { name, .. } => name.as_ref() == p || aliases.contains(name.as_ref()),
2560
+ Expr::Index { object, .. } => {
2561
+ matches!(object.as_ref(), Expr::Ident { name, .. } if name.as_ref() == p)
2562
+ }
2563
+ _ => false,
2564
+ }
2565
+ }
2566
+
2567
+ /// The top-level program's uses of the array locals must be safe: each is constructed
2568
+ /// once by an array factory call, only passed to group fns (or read scalar-only), and
2569
+ /// never escapes (no console.log of the array, no store, no bare pass to a non-group fn).
2570
+ fn array_use_sites_safe(
2571
+ program: &Program,
2572
+ array_locals: &[String],
2573
+ group_fns: &HashMap<String, (usize, String)>,
2574
+ ) -> bool {
2575
+ let set: HashSet<&str> = array_locals.iter().map(|s| s.as_str()).collect();
2576
+ let mut ok = true;
2577
+ for s in &program.statements {
2578
+ // Skip the decl of the array local itself.
2579
+ if let Statement::VarDecl { name, .. } = s {
2580
+ if set.contains(name.as_ref()) {
2581
+ continue;
2582
+ }
2583
+ }
2584
+ if !top_use_safe(s, &set, group_fns) {
2585
+ ok = false;
2586
+ break;
2587
+ }
2588
+ }
2589
+ ok
2590
+ }
2591
+
2592
+ fn top_use_safe(
2593
+ s: &Statement,
2594
+ set: &HashSet<&str>,
2595
+ group_fns: &HashMap<String, (usize, String)>,
2596
+ ) -> bool {
2597
+ use Statement::*;
2598
+ match s {
2599
+ FunDecl { .. } | TypeAlias { .. } | DeclareVar { .. } | DeclareFun { .. } => true,
2600
+ VarDecl { init, .. } => init.as_ref().is_none_or(|e| top_use_expr(e, set, group_fns)),
2601
+ ExprStmt { expr, .. } => top_use_expr(expr, set, group_fns),
2602
+ For { init, cond, update, body, .. } => {
2603
+ init.as_ref().is_none_or(|x| top_use_safe(x, set, group_fns))
2604
+ && cond.as_ref().is_none_or(|e| top_use_expr(e, set, group_fns))
2605
+ && update.as_ref().is_none_or(|e| top_use_expr(e, set, group_fns))
2606
+ && top_use_safe(body, set, group_fns)
2607
+ }
2608
+ While { cond, body, .. } | DoWhile { cond, body, .. } => {
2609
+ top_use_expr(cond, set, group_fns) && top_use_safe(body, set, group_fns)
2610
+ }
2611
+ If { cond, then_branch, else_branch, .. } => {
2612
+ top_use_expr(cond, set, group_fns)
2613
+ && top_use_safe(then_branch, set, group_fns)
2614
+ && else_branch.as_ref().is_none_or(|e| top_use_safe(e, set, group_fns))
2615
+ }
2616
+ Block { statements, .. } | Multi { statements, .. } => {
2617
+ statements.iter().all(|x| top_use_safe(x, set, group_fns))
2618
+ }
2619
+ Return { value, .. } => value.as_ref().is_none_or(|e| top_use_expr(e, set, group_fns)),
2620
+ _ => true,
2621
+ }
2622
+ }
2623
+
2624
+ fn top_use_expr(
2625
+ e: &Expr,
2626
+ set: &HashSet<&str>,
2627
+ group_fns: &HashMap<String, (usize, String)>,
2628
+ ) -> bool {
2629
+ use Expr::*;
2630
+ match e {
2631
+ // A bare reference to an array local escapes UNLESS it's a call arg to a group fn
2632
+ // at the expected array-param position (handled in Call below).
2633
+ Ident { name, .. } => !set.contains(name.as_ref()),
2634
+ Call { callee, args, .. } => {
2635
+ // A call to a group fn may pass an array local at its array-param slot.
2636
+ if let Ident { name: fname, .. } = callee.as_ref() {
2637
+ if let Some((api, _)) = group_fns.get(fname.as_ref()) {
2638
+ return args.iter().enumerate().all(|(ai, a)| match a {
2639
+ CallArg::Expr(Ident { name, .. }) if ai == *api => {
2640
+ // array local at the array slot — OK; or a non-array ident.
2641
+ set.contains(name.as_ref()) || true
2642
+ }
2643
+ CallArg::Expr(x) => top_use_expr(x, set, group_fns),
2644
+ CallArg::Spread(_) => false,
2645
+ });
2646
+ }
2647
+ }
2648
+ top_use_expr(callee, set, group_fns)
2649
+ && args.iter().all(|a| match a {
2650
+ CallArg::Expr(x) => top_use_expr(x, set, group_fns),
2651
+ CallArg::Spread(_) => false,
2652
+ })
2653
+ }
2654
+ // `arr[i]` / `arr.length` at top level reads scalars — that's a boxed read; but the
2655
+ // array local should only be consumed via group fns. Reading `arr.length` etc. at top
2656
+ // level is rare and would require the boxed array; bail to keep semantics simple.
2657
+ Index { object, .. } => !expr_refs_set(object, set) && top_use_expr(object, set, group_fns),
2658
+ Member { object, .. } => !expr_refs_set(object, set) && top_use_expr(object, set, group_fns),
2659
+ Binary { left, right, .. } => {
2660
+ top_use_expr(left, set, group_fns) && top_use_expr(right, set, group_fns)
2661
+ }
2662
+ Unary { operand, .. } => top_use_expr(operand, set, group_fns),
2663
+ Conditional { cond, then_branch, else_branch, .. } => {
2664
+ top_use_expr(cond, set, group_fns)
2665
+ && top_use_expr(then_branch, set, group_fns)
2666
+ && top_use_expr(else_branch, set, group_fns)
2667
+ }
2668
+ Assign { value, .. } => top_use_expr(value, set, group_fns),
2669
+ CompoundAssign { value, .. } => top_use_expr(value, set, group_fns),
2670
+ TemplateLiteral { exprs, .. } => exprs.iter().all(|x| top_use_expr(x, set, group_fns)),
2671
+ Literal { .. } => true,
2672
+ Array { elements, .. } => elements.iter().all(|el| match el {
2673
+ tishlang_ast::ArrayElement::Expr(x) => top_use_expr(x, set, group_fns),
2674
+ tishlang_ast::ArrayElement::Spread(_) => false,
2675
+ }),
2676
+ PostfixInc { .. } | PostfixDec { .. } | PrefixInc { .. } | PrefixDec { .. } => true,
2677
+ _ => true,
2678
+ }
2679
+ }
2680
+
2681
+ fn expr_refs_set(e: &Expr, set: &HashSet<&str>) -> bool {
2682
+ matches!(e, Expr::Ident { name, .. } if set.contains(name.as_ref()))
2683
+ }
2684
+
2685
+ /// Walk every sub-expression of a statement, invoking `f` on each.
2686
+ fn walk_exprs_stmt(s: &Statement, f: &mut impl FnMut(&Expr)) {
2687
+ use Statement::*;
2688
+ match s {
2689
+ Block { statements, .. } | Multi { statements, .. } => {
2690
+ statements.iter().for_each(|x| walk_exprs_stmt(x, f))
2691
+ }
2692
+ VarDecl { init, .. } => {
2693
+ if let Some(e) = init {
2694
+ walk_exprs_expr(e, f);
2695
+ }
2696
+ }
2697
+ VarDeclDestructure { init, .. } => walk_exprs_expr(init, f),
2698
+ ExprStmt { expr, .. } => walk_exprs_expr(expr, f),
2699
+ Return { value, .. } => {
2700
+ if let Some(e) = value {
2701
+ walk_exprs_expr(e, f);
2702
+ }
2703
+ }
2704
+ If { cond, then_branch, else_branch, .. } => {
2705
+ walk_exprs_expr(cond, f);
2706
+ walk_exprs_stmt(then_branch, f);
2707
+ if let Some(e) = else_branch {
2708
+ walk_exprs_stmt(e, f);
2709
+ }
2710
+ }
2711
+ While { cond, body, .. } | DoWhile { cond, body, .. } => {
2712
+ walk_exprs_expr(cond, f);
2713
+ walk_exprs_stmt(body, f);
2714
+ }
2715
+ For { init, cond, update, body, .. } => {
2716
+ if let Some(i) = init {
2717
+ walk_exprs_stmt(i, f);
2718
+ }
2719
+ if let Some(c) = cond {
2720
+ walk_exprs_expr(c, f);
2721
+ }
2722
+ if let Some(u) = update {
2723
+ walk_exprs_expr(u, f);
2724
+ }
2725
+ walk_exprs_stmt(body, f);
2726
+ }
2727
+ ForOf { iterable, body, .. } => {
2728
+ walk_exprs_expr(iterable, f);
2729
+ walk_exprs_stmt(body, f);
2730
+ }
2731
+ Throw { value, .. } => walk_exprs_expr(value, f),
2732
+ _ => {}
2733
+ }
2734
+ }
2735
+
2736
+ fn walk_exprs_expr(e: &Expr, f: &mut impl FnMut(&Expr)) {
2737
+ use Expr::*;
2738
+ f(e);
2739
+ match e {
2740
+ Binary { left, right, .. } => {
2741
+ walk_exprs_expr(left, f);
2742
+ walk_exprs_expr(right, f);
2743
+ }
2744
+ Unary { operand, .. } | TypeOf { operand, .. } | Await { operand, .. }
2745
+ | Delete { target: operand, .. } => walk_exprs_expr(operand, f),
2746
+ Call { callee, args, .. } | New { callee, args, .. } => {
2747
+ walk_exprs_expr(callee, f);
2748
+ for a in args {
2749
+ match a {
2750
+ CallArg::Expr(x) | CallArg::Spread(x) => walk_exprs_expr(x, f),
2751
+ }
2752
+ }
2753
+ }
2754
+ Member { object, prop, .. } => {
2755
+ walk_exprs_expr(object, f);
2756
+ if let tishlang_ast::MemberProp::Expr(p) = prop {
2757
+ walk_exprs_expr(p, f);
2758
+ }
2759
+ }
2760
+ Index { object, index, .. } => {
2761
+ walk_exprs_expr(object, f);
2762
+ walk_exprs_expr(index, f);
2763
+ }
2764
+ Conditional { cond, then_branch, else_branch, .. } => {
2765
+ walk_exprs_expr(cond, f);
2766
+ walk_exprs_expr(then_branch, f);
2767
+ walk_exprs_expr(else_branch, f);
2768
+ }
2769
+ NullishCoalesce { left, right, .. } => {
2770
+ walk_exprs_expr(left, f);
2771
+ walk_exprs_expr(right, f);
2772
+ }
2773
+ Array { elements, .. } => {
2774
+ for el in elements {
2775
+ match el {
2776
+ tishlang_ast::ArrayElement::Expr(x)
2777
+ | tishlang_ast::ArrayElement::Spread(x) => walk_exprs_expr(x, f),
2778
+ }
2779
+ }
2780
+ }
2781
+ Object { props, .. } => {
2782
+ for p in props {
2783
+ match p {
2784
+ tishlang_ast::ObjectProp::KeyValue(_, v, _) => walk_exprs_expr(v, f),
2785
+ tishlang_ast::ObjectProp::Spread(x) => walk_exprs_expr(x, f),
2786
+ }
2787
+ }
2788
+ }
2789
+ Assign { value, .. } | CompoundAssign { value, .. } | LogicalAssign { value, .. } => {
2790
+ walk_exprs_expr(value, f)
2791
+ }
2792
+ MemberAssign { object, value, .. } => {
2793
+ walk_exprs_expr(object, f);
2794
+ walk_exprs_expr(value, f);
2795
+ }
2796
+ IndexAssign { object, index, value, .. } => {
2797
+ walk_exprs_expr(object, f);
2798
+ walk_exprs_expr(index, f);
2799
+ walk_exprs_expr(value, f);
2800
+ }
2801
+ TemplateLiteral { exprs, .. } => exprs.iter().for_each(|x| walk_exprs_expr(x, f)),
2802
+ _ => {}
2803
+ }
2804
+ }
2805
+
2806
+ /// S-B array-return: `return [a, b, …]` where each element is a bare ident of the
2807
+ /// SAME `Struct(alias)` (resolved from the locals defined in this body via call-
2808
+ /// return). Returns the alias if so.
2809
+ fn fn_returns_array_of_struct(body: &Statement, analysis: &AggregateAnalysis) -> Option<String> {
2810
+ // Map this body's locals to struct aliases (only `let x = factory(…)` shapes).
2811
+ let mut locals: HashMap<String, String> = HashMap::new();
2812
+ collect_body_struct_locals(body, analysis, &mut locals);
2813
+ // Find the (single) `return [idents]`.
2814
+ find_array_return(body, &locals)
2815
+ }
2816
+
2817
+ fn collect_body_struct_locals(
2818
+ s: &Statement,
2819
+ analysis: &AggregateAnalysis,
2820
+ out: &mut HashMap<String, String>,
2821
+ ) {
2822
+ use Statement::*;
2823
+ match s {
2824
+ VarDecl { name, type_ann: None, init: Some(init), .. } => {
2825
+ if let Some(AggReturn::Struct(alias)) =
2826
+ infer_aggregate_expr(init, analysis, &HashMap::new())
2827
+ {
2828
+ out.insert(name.to_string(), alias);
2829
+ }
2830
+ }
2831
+ Block { statements, .. } | Multi { statements, .. } => {
2832
+ statements.iter().for_each(|x| collect_body_struct_locals(x, analysis, out))
2833
+ }
2834
+ If { then_branch, else_branch, .. } => {
2835
+ collect_body_struct_locals(then_branch, analysis, out);
2836
+ if let Some(e) = else_branch {
2837
+ collect_body_struct_locals(e, analysis, out);
2838
+ }
2839
+ }
2840
+ For { body, .. } | While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } => {
2841
+ collect_body_struct_locals(body, analysis, out)
2842
+ }
2843
+ _ => {}
2844
+ }
2845
+ }
2846
+
2847
+ fn find_array_return(s: &Statement, locals: &HashMap<String, String>) -> Option<String> {
2848
+ use Statement::*;
2849
+ match s {
2850
+ Return { value: Some(Expr::Array { elements, .. }), .. } => {
2851
+ let mut alias: Option<String> = None;
2852
+ for el in elements {
2853
+ let e = match el {
2854
+ tishlang_ast::ArrayElement::Expr(e) => e,
2855
+ tishlang_ast::ArrayElement::Spread(_) => return None,
2856
+ };
2857
+ let a = match e {
2858
+ Expr::Ident { name, .. } => locals.get(name.as_ref())?,
2859
+ _ => return None,
2860
+ };
2861
+ match &alias {
2862
+ None => alias = Some(a.clone()),
2863
+ Some(prev) if prev != a => return None,
2864
+ _ => {}
2865
+ }
2866
+ }
2867
+ alias
2868
+ }
2869
+ Block { statements, .. } | Multi { statements, .. } => {
2870
+ statements.iter().find_map(|x| find_array_return(x, locals))
2871
+ }
2872
+ If { then_branch, else_branch, .. } => find_array_return(then_branch, locals)
2873
+ .or_else(|| else_branch.as_ref().and_then(|e| find_array_return(e, locals))),
2874
+ For { body, .. } | While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } => {
2875
+ find_array_return(body, locals)
2876
+ }
2877
+ _ => None,
2878
+ }
2879
+ }
2880
+
2881
+ /// S-B/S-C: the aggregate type of an init expression.
2882
+ /// * `factory(…)` where `factory` has an S-A/S-B return shape → that shape.
2883
+ /// * `[a, b, …]` where every element is a struct-typed local → `ArrayOfStruct`.
2884
+ fn infer_aggregate_expr(
2885
+ e: &Expr,
2886
+ analysis: &AggregateAnalysis,
2887
+ locals: &HashMap<String, AggReturn>,
2888
+ ) -> Option<AggReturn> {
2889
+ match e {
2890
+ // S-B: call-return propagation.
2891
+ Expr::Call { callee, .. } => {
2892
+ if let Expr::Ident { name, .. } = callee.as_ref() {
2893
+ return analysis.fn_return.get(name.as_ref()).cloned();
2894
+ }
2895
+ None
2896
+ }
2897
+ // S-C: array-of-ident element typing.
2898
+ Expr::Array { elements, .. } => {
2899
+ let mut alias: Option<String> = None;
2900
+ for el in elements {
2901
+ let x = match el {
2902
+ tishlang_ast::ArrayElement::Expr(x) => x,
2903
+ tishlang_ast::ArrayElement::Spread(_) => return None,
2904
+ };
2905
+ let a = match x {
2906
+ Expr::Ident { name, .. } => match locals.get(name.as_ref()) {
2907
+ Some(AggReturn::Struct(a)) => a.clone(),
2908
+ _ => return None,
2909
+ },
2910
+ Expr::Call { .. } => match infer_aggregate_expr(x, analysis, locals) {
2911
+ Some(AggReturn::Struct(a)) => a,
2912
+ _ => return None,
2913
+ },
2914
+ _ => return None,
2915
+ };
2916
+ match &alias {
2917
+ None => alias = Some(a),
2918
+ Some(prev) if *prev != a => return None,
2919
+ _ => {}
2920
+ }
2921
+ }
2922
+ alias.map(AggReturn::ArrayOfStruct)
2923
+ }
2924
+ // A bare ident copy of a struct-typed local.
2925
+ Expr::Ident { name, .. } => locals.get(name.as_ref()).cloned(),
2926
+ _ => None,
2927
+ }
2928
+ }
2929
+
2930
+ /// Aggregate inference writeback. Runs the S-0..S-C analysis, then stamps onto the
2931
+ /// program ONLY the annotations the existing codegen backs soundly (see the module
2932
+ /// note above): the S-0 numeric `: number` params, plus the inert struct alias
2933
+ /// `type` decls. The S-A/S-B/S-C struct shapes are computed and available via
2934
+ /// `analyze_aggregate` but are NOT written onto params / call-locals until the
2935
+ /// S-D..S-F typed-fn ABI tier exists to consume them without a boxed-edge miscompile.
2936
+ fn aggregate_infer_program(program: Program) -> Program {
2937
+ let analysis = analyze_aggregate(&program);
2938
+
2939
+ // Stamp S-0 numeric params onto the matching FunDecls. When S-D candidacy holds
2940
+ // (`unbox_alias` is `Some`), ALSO stamp the struct/array annotations: the factory
2941
+ // return `: alias`, the array-factory return `: alias[]`, the group fns' array
2942
+ // params `: alias[]`, and the top-level array local(s) `: alias[]`. Codegen's S-F
2943
+ // tier consumes these (and bypasses the boxed closure path) so the writes persist.
2944
+ let mut statements: Vec<Statement> = program
2945
+ .statements
2946
+ .into_iter()
2947
+ .map(|s| stamp_aggregate(s, &analysis))
2948
+ .collect();
2949
+
2950
+ // Prepend the struct alias decls. Under S-D candidacy codegen canonicalises the
2951
+ // matching alias into `RustType::Named` and emits a `Copy` struct; otherwise the
2952
+ // alias is inert (unreferenced aliases are dropped downstream).
2953
+ if !analysis.struct_decls.is_empty() {
2954
+ let span = statements.first().map(stmt_span).unwrap_or_else(zero_span);
2955
+ let mut out: Vec<Statement> = Vec::with_capacity(statements.len() + analysis.struct_decls.len());
2956
+ for (name, fields) in &analysis.struct_decls {
2957
+ out.push(Statement::TypeAlias {
2958
+ name: name.as_str().into(),
2959
+ name_span: span,
2960
+ ty: TypeAnnotation::Object(fields.clone()),
2961
+ span,
2962
+ });
2963
+ }
2964
+ out.append(&mut statements);
2965
+ statements = out;
2966
+ }
2967
+
2968
+ Program { statements }
2969
+ }
2970
+
2971
+ /// `alias[]` annotation (an `Array(Simple(alias))`), the S-D array-param/local type.
2972
+ fn array_of_alias_ann(alias: &str) -> TypeAnnotation {
2973
+ TypeAnnotation::Array(Box::new(TypeAnnotation::Simple(alias.into(), zero_span())))
2974
+ }
2975
+
2976
+ /// Stamp both the S-0 numeric scalar params AND, under S-D candidacy, the struct/array
2977
+ /// annotations onto a top-level statement.
2978
+ fn stamp_aggregate(s: Statement, analysis: &AggregateAnalysis) -> Statement {
2979
+ // First apply the always-safe S-0 numeric-param stamping.
2980
+ let s = stamp_numeric_params(s, analysis);
2981
+ let Some(alias) = analysis.unbox_alias.as_deref() else {
2982
+ return s; // no S-D candidacy → only S-0 params stamped
2983
+ };
2984
+ match s {
2985
+ // Stamp fn returns / array params for the unbox group.
2986
+ Statement::FunDecl {
2987
+ async_, name, name_span, params, rest_param, return_type, body, span,
2988
+ } => {
2989
+ // Return type: factory → `: alias`; array-factory → `: alias[]`.
2990
+ let new_return = match analysis.fn_return.get(name.as_ref()) {
2991
+ Some(AggReturn::Struct(a)) if a == alias => {
2992
+ Some(TypeAnnotation::Simple(alias.into(), zero_span()))
2993
+ }
2994
+ Some(AggReturn::ArrayOfStruct(a)) if a == alias => Some(array_of_alias_ann(alias)),
2995
+ _ => return_type,
2996
+ };
2997
+ // Array param: the group fn's bodies param → `: alias[]`.
2998
+ let array_pi = analysis.array_param_fns.get(name.as_ref()).map(|(pi, _)| *pi);
2999
+ let new_params: Vec<FunParam> = params
3000
+ .into_iter()
3001
+ .enumerate()
3002
+ .map(|(pi, p)| match p {
3003
+ FunParam::Simple(mut tp) if Some(pi) == array_pi => {
3004
+ tp.type_ann = Some(array_of_alias_ann(alias));
3005
+ FunParam::Simple(tp)
3006
+ }
3007
+ other => other,
3008
+ })
3009
+ .collect();
3010
+ Statement::FunDecl {
3011
+ async_, name, name_span, params: new_params, rest_param,
3012
+ return_type: new_return, body, span,
3013
+ }
3014
+ }
3015
+ // Stamp the top-level array local(s) `: alias[]`.
3016
+ Statement::VarDecl { name, name_span, mutable, type_ann, init, span }
3017
+ if analysis.array_locals.iter().any(|n| n == name.as_ref()) =>
3018
+ {
3019
+ Statement::VarDecl {
3020
+ name, name_span, mutable,
3021
+ type_ann: type_ann.or_else(|| Some(array_of_alias_ann(alias))),
3022
+ init, span,
3023
+ }
3024
+ }
3025
+ other => other,
3026
+ }
3027
+ }
3028
+
3029
+ fn stamp_numeric_params(s: Statement, analysis: &AggregateAnalysis) -> Statement {
3030
+ if let Statement::FunDecl {
3031
+ async_,
3032
+ name,
3033
+ name_span,
3034
+ params,
3035
+ rest_param,
3036
+ return_type,
3037
+ body,
3038
+ span,
3039
+ } = s
3040
+ {
3041
+ let numeric = analysis.fn_numeric_params.get(name.as_ref());
3042
+ let new_params = params
3043
+ .into_iter()
3044
+ .map(|p| match p {
3045
+ FunParam::Simple(mut tp)
3046
+ if tp.type_ann.is_none()
3047
+ && tp.default.is_none()
3048
+ && numeric.is_some_and(|set| set.contains(tp.name.as_ref())) =>
3049
+ {
3050
+ tp.type_ann = Some(number_ann());
3051
+ FunParam::Simple(tp)
3052
+ }
3053
+ other => other,
3054
+ })
3055
+ .collect();
3056
+ Statement::FunDecl {
3057
+ async_,
3058
+ name,
3059
+ name_span,
3060
+ params: new_params,
3061
+ rest_param,
3062
+ return_type,
3063
+ body,
3064
+ span,
3065
+ }
3066
+ } else {
3067
+ s
3068
+ }
3069
+ }
3070
+
3071
+ #[cfg(test)]
3072
+ mod param_infer_tests {
3073
+ use super::*;
3074
+ use tishlang_parser::parse;
3075
+
3076
+ /// Run base inference, then M4 param inference, and return the inferred annotation name (if
3077
+ /// any) for parameter `param` of `fn <fn_name>`.
3078
+ fn inferred_param(src: &str, fn_name: &str, param: &str) -> Option<String> {
3079
+ let parsed = parse(src).unwrap();
3080
+ let base = Program {
3081
+ statements: infer_statements(&parsed.statements, &mut InferCtx::new()),
3082
+ };
3083
+ let prog = param_infer_program(base);
3084
+ for s in &prog.statements {
3085
+ if let Statement::FunDecl { name, params, .. } = s {
3086
+ if name.as_ref() == fn_name {
3087
+ for p in params {
3088
+ if let FunParam::Simple(tp) = p {
3089
+ if tp.name.as_ref() == param {
3090
+ return tp.type_ann.as_ref().map(|a| match a {
3091
+ TypeAnnotation::Simple(s, _) => s.to_string(),
3092
+ _ => "<complex>".to_string(),
3093
+ });
3094
+ }
3095
+ }
3096
+ }
3097
+ }
3098
+ }
3099
+ }
3100
+ None
3101
+ }
3102
+
3103
+ #[test]
3104
+ fn infers_loop_bound_param_via_numeric_local() {
3105
+ // `n` is the bare operand of `i < n`; `i` (`let i = 0`, base-inferred numeric) makes the
3106
+ // OTHER operand provably numeric, so `n` is inferred `number` (the numeric-locals fix).
3107
+ let src = "fn countUp(n) { let total = 0; for (let i = 0; i < n; i = i + 1) { total = total + i } return total }";
3108
+ assert_eq!(inferred_param(src, "countUp", "n").as_deref(), Some("number"));
3109
+ }
3110
+
3111
+ #[test]
3112
+ fn does_not_infer_string_concat_param() {
3113
+ // `x` is the bare operand of `+` against a string literal — NOT provably numeric, so `x`
3114
+ // must stay dynamic (else `label("hi")` would mistype to f64 and panic at runtime).
3115
+ let src = "fn label(x) { return \"v=\" + x }";
3116
+ assert_eq!(inferred_param(src, "label", "x"), None);
3117
+ }
3118
+
3119
+ #[test]
3120
+ fn does_not_treat_other_param_as_numeric_local() {
3121
+ // `a < b`: neither operand is a known numeric *local* (both are params), so neither is
3122
+ // provable and neither param is inferred — the relaxation is locals-only.
3123
+ let src = "fn cmp(a, b) { if (a < b) { return 1 } return 0 }";
3124
+ assert_eq!(inferred_param(src, "cmp", "a"), None);
3125
+ assert_eq!(inferred_param(src, "cmp", "b"), None);
3126
+ }
3127
+
3128
+ #[test]
3129
+ fn infers_param_copied_to_local_then_compared() {
3130
+ // #172 fannkuch keystone: the param `n` is copied into `r` (`let r = n`) and later compared
3131
+ // against it (`r === n`). The optimistic per-param fixpoint assumes `n` numeric, propagates
3132
+ // the copy so `r` is numeric, and `r === n` then proves `n` numeric (the other operand `r`
3133
+ // is provable). Closes the chicken-and-egg that previously left `n`/`r`/`count` boxed.
3134
+ let src =
3135
+ "fn f(n) { let r = n; while (r !== 1) { r = r - 1 }; return r === n }";
3136
+ assert_eq!(inferred_param(src, "f", "n").as_deref(), Some("number"));
3137
+ }
3138
+
3139
+ #[test]
3140
+ fn does_not_infer_param_copied_then_string_concatenated() {
3141
+ // NEGATIVE (soundness): the copy `let r = x` flows into `+` against a string literal. The
3142
+ // overloaded-`+` rule still bails on `r + "!"` for the candidate... but more directly, `x`
3143
+ // itself is used in a non-numeric `+`, so the param MUST stay dynamic even though it is
3144
+ // copied to a local — the copy relaxation must not mask a genuinely non-numeric use.
3145
+ let src = "fn g(x) { let r = x; return r + \"!\" }";
3146
+ assert_eq!(inferred_param(src, "g", "x"), None);
3147
+ }
3148
+ }
3149
+
3150
+ #[cfg(test)]
3151
+ mod struct_infer_writeback_tests {
3152
+ use super::*;
3153
+ use tishlang_parser::parse;
3154
+
3155
+ /// Annotation name written onto top-level `let <var>` after base inference + struct inference.
3156
+ fn local_ann(src: &str, var: &str) -> Option<String> {
3157
+ let parsed = parse(src).unwrap();
3158
+ let base = Program {
3159
+ statements: infer_statements(&parsed.statements, &mut InferCtx::new()),
3160
+ };
3161
+ let prog = struct_infer_program(base);
3162
+ for s in &prog.statements {
3163
+ if let Statement::VarDecl { name, type_ann, .. } = s {
3164
+ if name.as_ref() == var {
3165
+ return type_ann.as_ref().map(|a| match a {
3166
+ TypeAnnotation::Simple(s, _) => s.to_string(),
3167
+ _ => "<complex>".to_string(),
3168
+ });
3169
+ }
3170
+ }
3171
+ }
3172
+ None
3173
+ }
3174
+
3175
+ #[test]
3176
+ fn writes_native_scalar_back_from_typed_array_index() {
3177
+ // #170: once `perm: number[]` is known, `let k = perm[0]` must carry `type_ann: number` on
3178
+ // the NODE (codegen reads the node, not the inference ctx) so it lowers to a native f64.
3179
+ let src = "let perm = [3, 2, 1, 0]\nlet k = perm[0]\nconsole.log(k)";
3180
+ assert_eq!(local_ann(src, "k").as_deref(), Some("number"));
3181
+ }
3182
+
3183
+ #[test]
3184
+ fn does_not_annotate_non_native_local() {
3185
+ // The write-back must NEVER turn a non-number local into `number` (the base pass already
3186
+ // types this as `string`; the invariant is that the #170 write-back can't clobber it).
3187
+ let src = "let s = \"hi\"\nconsole.log(s)";
3188
+ assert_ne!(local_ann(src, "s").as_deref(), Some("number"));
3189
+ }
3190
+ }
3191
+
3192
+ #[cfg(test)]
3193
+ mod aggregate_infer_tests {
3194
+ use super::*;
3195
+ use tishlang_parser::parse;
3196
+
3197
+ /// Parse + run base inference (so locals get their numeric types), then the aggregate analysis.
3198
+ fn analyze(src: &str) -> AggregateAnalysis {
3199
+ let parsed = parse(src).unwrap();
3200
+ let base = Program {
3201
+ statements: infer_statements(&parsed.statements, &mut InferCtx::new()),
3202
+ };
3203
+ analyze_aggregate(&base)
3204
+ }
3205
+
3206
+ /// The inferred `: number` param annotations of `fn` after the full aggregate WRITEBACK.
3207
+ fn numeric_params_after_writeback(src: &str, fn_name: &str) -> Vec<String> {
3208
+ let parsed = parse(src).unwrap();
3209
+ let base = Program {
3210
+ statements: infer_statements(&parsed.statements, &mut InferCtx::new()),
3211
+ };
3212
+ let prog = aggregate_infer_program(base);
3213
+ let mut out = Vec::new();
3214
+ for s in &prog.statements {
3215
+ if let Statement::FunDecl { name, params, .. } = s {
3216
+ if name.as_ref() == fn_name {
3217
+ for p in params {
3218
+ if let FunParam::Simple(tp) = p {
3219
+ if tp.type_ann.as_ref().is_some_and(is_number) {
3220
+ out.push(tp.name.to_string());
3221
+ }
3222
+ }
3223
+ }
3224
+ }
3225
+ }
3226
+ }
3227
+ out.sort();
3228
+ out
3229
+ }
3230
+
3231
+ // ---- S-0 -------------------------------------------------------------
3232
+
3233
+ #[test]
3234
+ fn s0_infers_field_value_only_params_numeric() {
3235
+ // `body`'s params are used ONLY as object-literal field values — the case M4 cannot
3236
+ // handle (object store is not numeric-safe in `nus_*`). S-0 must type all seven numeric.
3237
+ let src = "function body(x, y, z, vx, vy, vz, mass) {\n return { x: x, y: y, z: z, vx: vx, vy: vy, vz: vz, mass: mass }\n}";
3238
+ let a = analyze(src);
3239
+ let set = a.fn_numeric_params.get("body").cloned().unwrap_or_default();
3240
+ let mut got: Vec<_> = set.into_iter().collect();
3241
+ got.sort();
3242
+ assert_eq!(got, vec!["mass", "vx", "vy", "vz", "x", "y", "z"]);
3243
+ }
3244
+
3245
+ #[test]
3246
+ fn s0_mixed_field_value_and_arithmetic_param() {
3247
+ // A param used as a field value AND in arithmetic is still numeric.
3248
+ let src = "function f(a, b) { return { sum: a + b, a: a } }";
3249
+ let a = analyze(src);
3250
+ let set = a.fn_numeric_params.get("f").cloned().unwrap_or_default();
3251
+ assert!(set.contains("a") && set.contains("b"));
3252
+ }
3253
+
3254
+ #[test]
3255
+ fn s0_bails_on_string_concat_field_value() {
3256
+ // `{ label: "v=" + p }` is string concat — NOT numeric; the param must stay dynamic.
3257
+ let src = "function f(p) { return { label: \"v=\" + p } }";
3258
+ let a = analyze(src);
3259
+ assert!(!a.fn_numeric_params.get("f").map(|s| s.contains("p")).unwrap_or(false));
3260
+ }
3261
+
3262
+ #[test]
3263
+ fn s0_optimistic_fixpoint_drops_string_sibling() {
3264
+ // S-0's ALL-params optimistic fixpoint must still drop a genuinely non-numeric param even
3265
+ // when a SIBLING is numeric: `f(a, b)` with `a` a numeric loop bound but `b` string-
3266
+ // concatenated. `a` is typed; `b` is NOT (else `"r="+b` would mistype `b` to f64). This is
3267
+ // the joint-dependency soundness guard (the fixpoint drops `b`, re-verifies, `a` survives).
3268
+ let src = "function f(a, b) {\n let total = 0\n for (let i = 0; i < a; i = i + 1) { total = total + i }\n return \"r=\" + b\n}";
3269
+ let a = analyze(src);
3270
+ let set = a.fn_numeric_params.get("f").cloned().unwrap_or_default();
3271
+ assert!(set.contains("a"), "numeric loop-bound param must be typed");
3272
+ assert!(!set.contains("b"), "string-concatenated param must NOT be typed");
3273
+ }
3274
+
3275
+ #[test]
3276
+ fn s0_bails_on_escaping_param() {
3277
+ // A param passed BARE to another call escapes (callee type unknown) — bail.
3278
+ let src = "function f(p) { return { boxed: g(p) } }\nfunction g(q) { return q }";
3279
+ let a = analyze(src);
3280
+ assert!(!a.fn_numeric_params.get("f").map(|s| s.contains("p")).unwrap_or(false));
3281
+ }
3282
+
3283
+ // ---- S-A -------------------------------------------------------------
3284
+
3285
+ #[test]
3286
+ fn sa_registers_return_struct_shape() {
3287
+ // `body()` returns one all-f64 object literal ⇒ a `Struct(alias)` return shape.
3288
+ let src = "function body(x, y, mass) { return { x: x, y: y, mass: mass } }";
3289
+ let a = analyze(src);
3290
+ assert!(matches!(a.fn_return.get("body"), Some(AggReturn::Struct(_))));
3291
+ // exactly one struct alias registered, with the three f64 fields in order.
3292
+ assert_eq!(a.struct_decls.len(), 1);
3293
+ let (_, fields) = &a.struct_decls[0];
3294
+ let keys: Vec<&str> = fields.iter().map(|(k, _)| k.as_ref()).collect();
3295
+ assert_eq!(keys, vec!["x", "y", "mass"]);
3296
+ assert!(fields.iter().all(|(_, t)| is_number(t)));
3297
+ }
3298
+
3299
+ #[test]
3300
+ fn sa_bails_on_string_field_return_shape() {
3301
+ // HARD all-f64 gate: a string field ⇒ NOT a struct factory (S-D/S-F write lowering
3302
+ // would Copy-assume a non-Copy field). Must produce no return shape.
3303
+ let src = "function mk(n) { return { id: n, name: \"x\" } }";
3304
+ let a = analyze(src);
3305
+ assert!(a.fn_return.get("mk").is_none());
3306
+ }
3307
+
3308
+ #[test]
3309
+ fn sa_bails_on_divergent_return_shapes() {
3310
+ // Two different shapes (different key sets) ⇒ not monomorphic ⇒ no return shape.
3311
+ let src = "function mk(c, a) { if (c > 0) { return { a: a } } return { b: a } }";
3312
+ let a = analyze(src);
3313
+ assert!(a.fn_return.get("mk").is_none());
3314
+ }
3315
+
3316
+ // ---- S-B / S-C -------------------------------------------------------
3317
+
3318
+ #[test]
3319
+ fn sb_propagates_call_return_to_local() {
3320
+ // `let p = body(…)` ⇒ the local has the `body` return struct shape.
3321
+ let src = "function body(x, y) { return { x: x, y: y } }\nlet p = body(1, 2)";
3322
+ let a = analyze(src);
3323
+ assert!(matches!(a.local_agg.get("p"), Some(AggReturn::Struct(_))));
3324
+ }
3325
+
3326
+ #[test]
3327
+ fn sb_array_return_is_array_of_struct() {
3328
+ // `makeBodies()` returns `[sun, jupiter]` (both `body()` locals) ⇒ ArrayOfStruct, and a
3329
+ // top-level `let bodies = makeBodies()` carries that array-of-struct type.
3330
+ let src = "function body(x) { return { x: x } }\n\
3331
+ function makeBodies() { let sun = body(1); let jup = body(2); return [sun, jup] }\n\
3332
+ let bodies = makeBodies()";
3333
+ let a = analyze(src);
3334
+ assert!(matches!(a.fn_return.get("makeBodies"), Some(AggReturn::ArrayOfStruct(_))));
3335
+ assert!(matches!(a.local_agg.get("bodies"), Some(AggReturn::ArrayOfStruct(_))));
3336
+ }
3337
+
3338
+ #[test]
3339
+ fn sc_array_of_struct_idents() {
3340
+ // A direct top-level `[a, b]` of struct-typed locals ⇒ ArrayOfStruct.
3341
+ let src = "function body(x) { return { x: x } }\n\
3342
+ let a = body(1)\nlet b = body(2)\nlet arr = [a, b]";
3343
+ let a = analyze(src);
3344
+ assert!(matches!(a.local_agg.get("arr"), Some(AggReturn::ArrayOfStruct(_))));
3345
+ }
3346
+
3347
+ #[test]
3348
+ fn sc_bails_on_heterogeneous_array() {
3349
+ // `[a, b]` where the two structs differ (different shapes) ⇒ no array-of-struct type.
3350
+ let src = "function p(x) { return { x: x } }\n\
3351
+ function q(y) { return { y: y } }\n\
3352
+ let a = p(1)\nlet b = q(2)\nlet arr = [a, b]";
3353
+ let a = analyze(src);
3354
+ assert!(a.local_agg.get("arr").is_none());
3355
+ }
3356
+
3357
+ // ---- end-to-end: the nbody factory chain ----------------------------
3358
+
3359
+ #[test]
3360
+ fn nbody_factory_chain_resolves() {
3361
+ // The actual nbody front-end shape: body() factory, makeBodies() array, bodies local.
3362
+ let src = "\
3363
+ function body(x, y, z, vx, vy, vz, mass) {\n\
3364
+ return { x: x, y: y, z: z, vx: vx, vy: vy, vz: vz, mass: mass }\n\
3365
+ }\n\
3366
+ function makeBodies() {\n\
3367
+ let sun = body(0, 0, 0, 0, 0, 0, 1)\n\
3368
+ let jup = body(1, 1, 1, 1, 1, 1, 1)\n\
3369
+ return [sun, jup]\n\
3370
+ }\n\
3371
+ let bodies = makeBodies()";
3372
+ let a = analyze(src);
3373
+ // S-0: all body params numeric.
3374
+ assert_eq!(a.fn_numeric_params.get("body").map(|s| s.len()), Some(7));
3375
+ // S-A: body is a struct factory.
3376
+ assert!(matches!(a.fn_return.get("body"), Some(AggReturn::Struct(_))));
3377
+ // S-B: makeBodies returns array-of-struct; `bodies` carries it.
3378
+ assert!(matches!(a.fn_return.get("makeBodies"), Some(AggReturn::ArrayOfStruct(_))));
3379
+ assert!(matches!(a.local_agg.get("bodies"), Some(AggReturn::ArrayOfStruct(_))));
3380
+ }
3381
+
3382
+ // ---- writeback safety: only S-0 scalar params are stamped ------------
3383
+
3384
+ #[test]
3385
+ fn writeback_stamps_only_s0_numeric_params() {
3386
+ // The WRITEBACK must add `: number` to body's params (soundly consumed) and must NOT
3387
+ // turn any param into a struct/array annotation (which codegen can't yet back).
3388
+ let src = "function body(x, y, z, vx, vy, vz, mass) {\n return { x: x, y: y, z: z, vx: vx, vy: vy, vz: vz, mass: mass }\n}\nlet b = body(1,2,3,4,5,6,7)";
3389
+ let got = numeric_params_after_writeback(src, "body");
3390
+ assert_eq!(got, vec!["mass", "vx", "vy", "vz", "x", "y", "z"]);
3391
+ }
3392
+
3393
+ #[test]
3394
+ fn writeback_does_not_stamp_call_local_with_struct() {
3395
+ // `let b = body(…)` must remain UN-annotated after writeback — stamping a `Named`
3396
+ // annotation here would miscompile (the call still returns a boxed Value). Guards the
3397
+ // soundness boundary until S-F lands.
3398
+ let src = "function body(x) { return { x: x } }\nlet b = body(1)";
3399
+ let parsed = parse(src).unwrap();
3400
+ let base = Program {
3401
+ statements: infer_statements(&parsed.statements, &mut InferCtx::new()),
3402
+ };
3403
+ let prog = aggregate_infer_program(base);
3404
+ for s in &prog.statements {
3405
+ if let Statement::VarDecl { name, type_ann, .. } = s {
3406
+ if name.as_ref() == "b" {
3407
+ // No array factory / array-param fns ⇒ S-D candidacy fails ⇒ `b` unstamped.
3408
+ assert!(type_ann.is_none(), "non-array struct local must stay unannotated");
3409
+ }
3410
+ }
3411
+ }
3412
+ }
3413
+
3414
+ // ---- S-D: whole-program unboxing candidacy + stamping --------------------
3415
+
3416
+ /// The full nbody factory + array-fn chain, trimmed to the structural essentials.
3417
+ const NBODY_SHAPE: &str = "\
3418
+ let SOLAR_MASS = 39.47841760435743\n\
3419
+ function body(x, y, z, vx, vy, vz, mass) {\n\
3420
+ return { x: x, y: y, z: z, vx: vx, vy: vy, vz: vz, mass: mass }\n\
3421
+ }\n\
3422
+ function makeBodies() {\n\
3423
+ let sun = body(0, 0, 0, 0, 0, 0, SOLAR_MASS)\n\
3424
+ let jup = body(1, 1, 1, 1, 1, 1, 1)\n\
3425
+ return [sun, jup]\n\
3426
+ }\n\
3427
+ function offsetMomentum(bodies) {\n\
3428
+ let px = 0\n\
3429
+ for (let i = 0; i < bodies.length; i++) { let b = bodies[i]; px = px + b.vx * b.mass }\n\
3430
+ bodies[0].vx = -px / SOLAR_MASS\n\
3431
+ }\n\
3432
+ function advance(bodies, dt) {\n\
3433
+ let n = bodies.length\n\
3434
+ for (let i = 0; i < n; i++) {\n\
3435
+ let bi = bodies[i]\n\
3436
+ for (let j = i + 1; j < n; j++) {\n\
3437
+ let bj = bodies[j]\n\
3438
+ let dx = bi.x - bj.x\n\
3439
+ bi.vx = bi.vx - dx\n\
3440
+ bj.vx = bj.vx + dx\n\
3441
+ }\n\
3442
+ }\n\
3443
+ }\n\
3444
+ function energy(bodies) {\n\
3445
+ let e = 0\n\
3446
+ let n = bodies.length\n\
3447
+ for (let i = 0; i < n; i++) { let bi = bodies[i]; e = e + bi.mass }\n\
3448
+ return e\n\
3449
+ }\n\
3450
+ let bodies = makeBodies()\n\
3451
+ offsetMomentum(bodies)\n\
3452
+ for (let s = 0; s < 10; s++) { advance(bodies, 0.01) }\n\
3453
+ let check = energy(bodies)\n\
3454
+ console.log(check)";
3455
+
3456
+ #[test]
3457
+ fn sd_nbody_shape_is_unbox_candidate() {
3458
+ let a = analyze(NBODY_SHAPE);
3459
+ // Exactly one struct alias, and the whole group passes candidacy.
3460
+ assert_eq!(a.struct_decls.len(), 1);
3461
+ let alias = a.unbox_alias.clone();
3462
+ assert!(alias.is_some(), "nbody shape must be an unbox candidate");
3463
+ let alias = alias.unwrap();
3464
+ // body=Struct, makeBodies=ArrayOfStruct, bodies local = ArrayOfStruct.
3465
+ assert!(matches!(a.fn_return.get("body"), Some(AggReturn::Struct(s)) if *s == alias));
3466
+ assert!(matches!(a.fn_return.get("makeBodies"), Some(AggReturn::ArrayOfStruct(s)) if *s == alias));
3467
+ assert!(a.array_locals.contains(&"bodies".to_string()));
3468
+ // advance/energy/offsetMomentum all recognised as array-param fns at param 0.
3469
+ for f in ["advance", "energy", "offsetMomentum"] {
3470
+ let (pi, pn) = a.array_param_fns.get(f).unwrap_or_else(|| panic!("{f} not in group"));
3471
+ assert_eq!(*pi, 0);
3472
+ assert_eq!(pn, "bodies");
3473
+ }
3474
+ }
3475
+
3476
+ #[test]
3477
+ fn sd_stamps_struct_and_array_annotations() {
3478
+ let parsed = parse(NBODY_SHAPE).unwrap();
3479
+ let base = Program {
3480
+ statements: infer_statements(&parsed.statements, &mut InferCtx::new()),
3481
+ };
3482
+ let prog = aggregate_infer_program(base);
3483
+ let alias = analyze_aggregate(&Program {
3484
+ statements: infer_statements(&parse(NBODY_SHAPE).unwrap().statements, &mut InferCtx::new()),
3485
+ })
3486
+ .unbox_alias
3487
+ .unwrap();
3488
+ let mut saw_body_ret = false;
3489
+ let mut saw_makebodies_ret = false;
3490
+ let mut saw_advance_param = false;
3491
+ let mut saw_bodies_local = false;
3492
+ for s in &prog.statements {
3493
+ match s {
3494
+ Statement::FunDecl { name, params, return_type, .. } => match name.as_ref() {
3495
+ "body" => {
3496
+ saw_body_ret = matches!(return_type, Some(TypeAnnotation::Simple(a, _)) if a.as_ref() == alias);
3497
+ }
3498
+ "makeBodies" => {
3499
+ saw_makebodies_ret = matches!(return_type,
3500
+ Some(TypeAnnotation::Array(b)) if matches!(b.as_ref(), TypeAnnotation::Simple(a, _) if a.as_ref() == alias));
3501
+ }
3502
+ "advance" => {
3503
+ if let Some(FunParam::Simple(tp)) = params.first() {
3504
+ saw_advance_param = matches!(&tp.type_ann,
3505
+ Some(TypeAnnotation::Array(b)) if matches!(b.as_ref(), TypeAnnotation::Simple(a, _) if a.as_ref() == alias));
3506
+ }
3507
+ }
3508
+ _ => {}
3509
+ },
3510
+ Statement::VarDecl { name, type_ann, .. } if name.as_ref() == "bodies" => {
3511
+ saw_bodies_local = matches!(type_ann,
3512
+ Some(TypeAnnotation::Array(b)) if matches!(b.as_ref(), TypeAnnotation::Simple(a, _) if a.as_ref() == alias));
3513
+ }
3514
+ _ => {}
3515
+ }
3516
+ }
3517
+ assert!(saw_body_ret, "body return must be stamped `: alias`");
3518
+ assert!(saw_makebodies_ret, "makeBodies return must be stamped `: alias[]`");
3519
+ assert!(saw_advance_param, "advance bodies param must be stamped `: alias[]`");
3520
+ assert!(saw_bodies_local, "top-level bodies local must be stamped `: alias[]`");
3521
+ }
3522
+
3523
+ #[test]
3524
+ fn sd_bails_on_strict_eq_on_body() {
3525
+ // `===` on an element alias compares references in JS — not unboxable. Whole group bails.
3526
+ let src = "\
3527
+ function body(x) { return { x: x } }\n\
3528
+ function makeBodies() { let a = body(1); return [a] }\n\
3529
+ function check(bodies) { let b0 = bodies[0]; let b1 = bodies[0]; return b0 === b1 }\n\
3530
+ let bodies = makeBodies()\n\
3531
+ check(bodies)";
3532
+ let a = analyze(src);
3533
+ assert!(a.unbox_alias.is_none(), "=== on a body must bail the unbox group");
3534
+ }
3535
+
3536
+ #[test]
3537
+ fn sd_bails_on_array_push_reshape() {
3538
+ // `bodies.push(...)` reshapes the array — not a fixed `Vec`. Bail.
3539
+ let src = "\
3540
+ function body(x) { return { x: x } }\n\
3541
+ function makeBodies() { let a = body(1); return [a] }\n\
3542
+ function grow(bodies) { bodies.push(body(2)) }\n\
3543
+ let bodies = makeBodies()\n\
3544
+ grow(bodies)";
3545
+ let a = analyze(src);
3546
+ assert!(a.unbox_alias.is_none(), "array reshape must bail the unbox group");
3547
+ }
3548
+
3549
+ #[test]
3550
+ fn sd_bails_on_array_escape_to_console() {
3551
+ // Passing the whole array to console.log escapes it to the boxed world. Bail.
3552
+ let src = "\
3553
+ function body(x) { return { x: x } }\n\
3554
+ function makeBodies() { let a = body(1); return [a] }\n\
3555
+ function energy(bodies) { let b = bodies[0]; return b.x }\n\
3556
+ let bodies = makeBodies()\n\
3557
+ energy(bodies)\n\
3558
+ console.log(bodies)";
3559
+ let a = analyze(src);
3560
+ assert!(a.unbox_alias.is_none(), "array escape to console.log must bail");
3561
+ }
3562
+
3563
+ #[test]
3564
+ fn sd_bails_on_string_field_struct() {
3565
+ // A string field ⇒ no struct factory at all ⇒ no unbox candidacy.
3566
+ let src = "\
3567
+ function body(x) { return { x: x, name: \"a\" } }\n\
3568
+ function makeBodies() { let a = body(1); return [a] }\n\
3569
+ function energy(bodies) { let b = bodies[0]; return b.x }\n\
3570
+ let bodies = makeBodies()\n\
3571
+ energy(bodies)";
3572
+ let a = analyze(src);
3573
+ assert!(a.unbox_alias.is_none(), "string field bails the struct factory → no unbox");
1680
3574
  }
1681
3575
  }