jz 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +99 -72
  2. package/bench/README.md +253 -100
  3. package/bench/bench.svg +58 -81
  4. package/cli.js +85 -12
  5. package/dist/interop.js +1 -0
  6. package/dist/jz.js +6196 -0
  7. package/index.d.ts +126 -0
  8. package/index.js +222 -35
  9. package/interop.js +240 -141
  10. package/layout.js +34 -18
  11. package/module/array.js +99 -111
  12. package/module/collection.js +124 -30
  13. package/module/console.js +1 -1
  14. package/module/core.js +163 -19
  15. package/module/json.js +3 -3
  16. package/module/math.js +162 -3
  17. package/module/number.js +268 -13
  18. package/module/object.js +37 -5
  19. package/module/regex.js +8 -7
  20. package/module/simd.js +37 -5
  21. package/module/string.js +203 -168
  22. package/module/typedarray.js +565 -112
  23. package/package.json +26 -7
  24. package/src/abi/string.js +29 -29
  25. package/src/ast.js +19 -2
  26. package/src/autoload.js +3 -0
  27. package/src/compile/analyze-scans.js +174 -11
  28. package/src/compile/analyze.js +101 -4
  29. package/src/compile/cse-load.js +200 -0
  30. package/src/compile/emit-assign.js +82 -20
  31. package/src/compile/emit.js +592 -51
  32. package/src/compile/index.js +504 -89
  33. package/src/compile/infer.js +36 -1
  34. package/src/compile/loop-divmod.js +109 -0
  35. package/src/compile/loop-model.js +91 -0
  36. package/src/compile/loop-square.js +102 -0
  37. package/src/compile/narrow.js +275 -41
  38. package/src/compile/peel-stencil.js +215 -0
  39. package/src/compile/plan/advise.js +55 -1
  40. package/src/compile/plan/common.js +29 -0
  41. package/src/compile/plan/index.js +8 -1
  42. package/src/compile/plan/inline.js +180 -22
  43. package/src/compile/plan/literals.js +313 -24
  44. package/src/compile/plan/scope.js +115 -39
  45. package/src/compile/program-facts.js +21 -2
  46. package/src/ctx.js +96 -19
  47. package/src/helper-counters.js +137 -0
  48. package/src/ir.js +157 -20
  49. package/src/kind-traits.js +34 -3
  50. package/src/kind.js +79 -4
  51. package/src/op-policy.js +5 -2
  52. package/src/ops.js +119 -0
  53. package/src/optimize/index.js +1274 -151
  54. package/src/optimize/recurse.js +182 -0
  55. package/src/optimize/vectorize.js +4187 -253
  56. package/src/prepare/index.js +75 -14
  57. package/src/prepare/lift-iife.js +149 -0
  58. package/src/reps.js +6 -2
  59. package/src/static.js +9 -0
  60. package/src/type.js +63 -51
  61. package/src/wat/assemble.js +286 -21
  62. package/src/widen.js +21 -0
  63. package/src/wat/optimize.js +0 -3760
@@ -25,10 +25,10 @@
25
25
 
26
26
  import { ctx } from '../../ctx.js'
27
27
  import {
28
- some, T, stmtList, refsName, ASSIGN_OPS, isReassigned, hasControlTransfer,
28
+ some, T, stmtList, refsName, REFS_IN_EXPR, ASSIGN_OPS, isReassigned, hasControlTransfer,
29
29
  } from '../../ast.js'
30
30
  import {
31
- intLiteralValue, constIntExpr, staticObjectProps, staticPropertyKey,
31
+ intLiteralValue, nonNegIntLiteral, constIntExpr, staticObjectProps, staticPropertyKey,
32
32
  } from '../../static.js'
33
33
  import {
34
34
  smallConstForTripCount, containsDeclOf, cloneWithSubst,
@@ -37,7 +37,7 @@ import { VAL } from '../../reps.js'
37
37
  import { includeModule } from '../../autoload.js'
38
38
  import { analyzeBody, invalidateLocalsCache } from '../analyze.js'
39
39
  import {
40
- isSimpleArg, fixedScalarTypedArray, fixedTypedArraysInBody, maxScalarTypedArrayLen,
40
+ isSimpleArg, fixedScalarTypedArray, fixedTypedArraysInBody, maxScalarTypedArrayLen, freshTypedArrayLocals,
41
41
  } from './common.js'
42
42
 
43
43
  // === Loop unrolling & scalarization ===
@@ -90,7 +90,7 @@ const safeScalarArrayUse = (node, name, len, parentOp = null) => {
90
90
  const op = node[0]
91
91
  if (ASSIGN_TARGET_OPS.has(op) && node[1] === name) return false
92
92
  if (isMemberWriteTarget(op, node, name)) return false
93
- if ((op === 'let' || op === 'const') && node.slice(1).some(d => d === name || (Array.isArray(d) && d[1] === name))) return false
93
+ if (isDeclOp(op) && node.slice(1).some(d => d === name || (Array.isArray(d) && d[1] === name))) return false
94
94
  if ((op === '.' || op === '?.') && node[1] === name) return node[2] === 'length'
95
95
  // Element write `name[idx] (op)= v` / `name[idx]++`: an out-of-bounds index
96
96
  // grows the array (sparse-array semantics), which the fixed scalar slot set
@@ -141,7 +141,7 @@ const safeScalarObjectUse = (node, name, keys) => {
141
141
  if (!Array.isArray(node)) return true
142
142
  const op = node[0]
143
143
  if (ASSIGN_TARGET_OPS.has(op) && node[1] === name) return false
144
- if ((op === 'let' || op === 'const') && node.slice(1).some(d => d === name || (Array.isArray(d) && d[1] === name))) return false
144
+ if (isDeclOp(op) && node.slice(1).some(d => d === name || (Array.isArray(d) && d[1] === name))) return false
145
145
  if ((op === '.' || op === '?.') && node[1] === name) return keys.has(node[2])
146
146
  if (op === '[]' && node[1] === name) {
147
147
  const key = staticPropertyKey(node[2])
@@ -181,7 +181,7 @@ const safeScalarTypedArrayUse = (node, name, len, coerce = '') => {
181
181
  if (typeof node === 'string') return node !== name
182
182
  if (!Array.isArray(node)) return true
183
183
  const op = node[0]
184
- if ((op === 'let' || op === 'const') && node.slice(1).some(d => d === name || (Array.isArray(d) && d[1] === name))) return false
184
+ if (isDeclOp(op) && node.slice(1).some(d => d === name || (Array.isArray(d) && d[1] === name))) return false
185
185
  if (isMemberWriteTarget(op, node, name)) return false
186
186
  if ((op === '.' || op === '?.') && node[1] === name) return node[2] === 'length'
187
187
  if (op === '[]' && node[1] === name) return typedArraySlotIndex(node[2], len) != null
@@ -200,6 +200,34 @@ const safeScalarTypedArrayUse = (node, name, len, coerce = '') => {
200
200
  for (let i = 1; i < node.length; i++) if (!safeScalarTypedArrayUse(node[i], name, len, coerce)) return false
201
201
  return true
202
202
  }
203
+
204
+ // `name`'s reference used as a bare VALUE — anywhere except as the base of `name[i]`,
205
+ // `name.prop`, or `name.method(...)`. That captures a second handle to its backing memory.
206
+ const refsAsValue = (node, name) => {
207
+ if (node === name) return true
208
+ if (!Array.isArray(node)) return false
209
+ if (node[0] === '[]' && node[1] === name) return refsAsValue(node[2], name) // name[i]: vet the index only
210
+ if ((node[0] === '.' || node[0] === '?.') && node[1] === name) return false // name.prop / name.method()
211
+ if (node[0] === '()') return false // a call RESULT, not name itself
212
+ return node.slice(1).some(e => refsAsValue(e, name))
213
+ }
214
+
215
+ // True when `name`'s backing memory ESCAPES into a persistent alias: bound to another
216
+ // variable (`let b = name`), stored into a field or literal (`o.x = name`, `[name]`), or
217
+ // captured as a `.subarray(...)` view. Mirrored scalarization syncs scalars↔memory only
218
+ // AROUND each unsafe statement, so a write through the captured alias in a LATER statement
219
+ // never reaches `name`'s scalar slots (and vice-versa) — the array must stay memory-backed.
220
+ // A bare `name` passed only as a call ARGUMENT is transient (the callee touches it during
221
+ // the call, already covered by the surrounding sync), so it is NOT a capture.
222
+ const createsTypedArrayAlias = (node, name) => {
223
+ if (!Array.isArray(node)) return false
224
+ if (node[0] === '()' && Array.isArray(node[1]) && node[1][0] === '.'
225
+ && node[1][1] === name && node[1][2] === 'subarray') return true // zero-copy view
226
+ if (node[0] === '=' && refsAsValue(node[2], name)) return true // let b = name / x = name
227
+ if ((node[0] === '[' || node[0] === '{}') && node.slice(1).some(e => refsAsValue(e, name))) return true // [name] / {k:name}
228
+ for (let i = 1; i < node.length; i++) if (createsTypedArrayAlias(node[i], name)) return true
229
+ return false
230
+ }
203
231
  const rewriteScalarTypedArrayUses = (node, arrays) => {
204
232
  if (!Array.isArray(node)) return node
205
233
  const op = node[0]
@@ -286,14 +314,16 @@ const scalarizeTypedArrayLiteralSeq = (seq) => {
286
314
  const fixed = fixedScalarTypedArray(decl[2])
287
315
  if (fixed == null) continue
288
316
  const { len, coerce } = fixed
289
- let hasSafeUse = false, hasUnsafeUse = false
317
+ let hasSafeUse = false, hasUnsafeUse = false, hasAliasUse = false
290
318
  for (let j = 0; j < stmts.length; j++) {
291
319
  if (j === i) continue
292
320
  if (!refsName(stmts[j], decl[1])) continue
293
321
  const safe = safeScalarTypedArrayUse(stmts[j], decl[1], len, coerce)
294
322
  hasSafeUse ||= safe
295
323
  hasUnsafeUse ||= !safe
324
+ hasAliasUse ||= createsTypedArrayAlias(stmts[j], decl[1])
296
325
  }
326
+ if (hasAliasUse) continue // persistent aliasing view (subarray) — keep memory-backed
297
327
  if (hasUnsafeUse && (!hasSafeUse || coerce)) continue
298
328
  if (!hasUnsafeUse) candidates.set(decl[1], { index: i, len, coerce, mirrored: false })
299
329
  else mirrored.set(decl[1], { index: i, len, coerce, mirrored: true })
@@ -496,6 +526,44 @@ export const scalarizeFunctionTypedArrays = (programFacts) => {
496
526
  return changed
497
527
  }
498
528
 
529
+ // Param-distinctness (alias analysis). Marks a function's typed-array params MUTUALLY DISTINCT
530
+ // (provably different buffers) when EVERY call site passes a distinct fresh `new TypedArray` local
531
+ // for each of them. This is what lets the optimizer's LICM hoist a load from one such param across
532
+ // a store to another (the load can't be clobbered) — the alias-analysis-enabled LICM that
533
+ // rust/clang get for free (raytrace's sphere loads vs the framebuffer store). Sound because:
534
+ // • a fresh `new TypedArray(N)` is a unique buffer (the allocator returns fresh memory);
535
+ // • requiring ALL typed-array-param args to be fresh-new excludes views/subarrays (not fresh)
536
+ // and forwarded params (not fresh), the only ways two args could alias;
537
+ // • pairwise-distinct arg names rule out the same buffer passed twice;
538
+ // • scalar (non-TYPED) params are ignored — they can't alias a buffer.
539
+ // Conservatively all-or-nothing per function: any non-fresh/duplicate typed arg ⇒ no fact.
540
+ export const analyzeParamDistinctness = (programFacts) => {
541
+ const freshByFunc = new Map(ctx.func.list.map(func => [func, freshTypedArrayLocals(func.body)]))
542
+ const sitesByCallee = new Map()
543
+ for (const site of programFacts.callSites) {
544
+ if (!site.callerFunc) continue
545
+ const l = sitesByCallee.get(site.callee); l ? l.push(site) : sitesByCallee.set(site.callee, [site])
546
+ }
547
+ for (const func of ctx.func.list) {
548
+ const params = func.sig?.params, sites = sitesByCallee.get(func.name)
549
+ if (!params || !sites?.length) continue
550
+ const typedIdx = []
551
+ for (let i = 0; i < params.length; i++) if (params[i].ptrKind === VAL.TYPED) typedIdx.push(i)
552
+ if (typedIdx.length < 2) continue // distinctness only matters with ≥2 typed-array params
553
+ let ok = true
554
+ for (const site of sites) {
555
+ const seen = new Set()
556
+ for (const i of typedIdx) {
557
+ const arg = site.argList?.[i]
558
+ if (typeof arg !== 'string' || !freshByFunc.get(site.callerFunc)?.has(arg) || seen.has(arg)) { ok = false; break }
559
+ seen.add(arg)
560
+ }
561
+ if (!ok) break
562
+ }
563
+ if (ok) func.distinctParams = new Set(typedIdx.map(i => params[i].name))
564
+ }
565
+ }
566
+
499
567
  const scalarizeArrayLiteralSeq = (seq) => {
500
568
  if (!Array.isArray(seq) || seq[0] !== ';') return { node: seq, changed: false }
501
569
  let changed = false
@@ -621,6 +689,191 @@ function scalarizeObjectLiterals(node, escapes) {
621
689
  return changed ? { node: out, changed: true } : { node, changed: false }
622
690
  }
623
691
 
692
+ // === Whole-program constant fold of module-scope aggregate literals ===
693
+ //
694
+ // `var x = [1,2,3]; export const y = x[0]` materializes a data-segment array and
695
+ // indexes it through __arr_idx_known (bounds + grow-forwarding) — all dead weight
696
+ // for a never-grown constant. When EVERY reference to a module-scope const aggregate
697
+ // is a static READ, replace each `x[k]` / `x.length` / `o.key` by its literal value
698
+ // program-wide; the now-unused decl is dropped, so no array is built (no data, no
699
+ // memory, no index helper) and the global is never declared. The per-function
700
+ // scalarizers can't do this: the decl lives at module scope and may be read from
701
+ // several function bodies, so the check must span the whole program.
702
+
703
+ const ASSIGN_OR_UPDATE = (op) => ASSIGN_TARGET_OPS.has(op) || op === '++' || op === '--'
704
+ // Module-scope binding ops. `var` survives to compile at module scope (jzify only
705
+ // lowers it to `let` inside functions), so fold it too — the reassignment guard
706
+ // below keeps a re-bound `var` heap-backed.
707
+ const isDeclOp = (op) => op === 'let' || op === 'const' || op === 'var'
708
+
709
+ // Reject `delete x`, `delete x.k`, `delete x[k]` — a deletion mutates the aggregate.
710
+ const isDeleteOf = (node, name) =>
711
+ node[0] === 'delete' && (node[1] === name || (Array.isArray(node[1]) && node[1][1] === name))
712
+
713
+ // A reference is fold-safe only if it READS `name` via a static index/key (or
714
+ // `.length` for arrays). Any write/update/delete, reassignment, second declaration,
715
+ // bare value use (escape), spread, dynamic key, or non-own member (method / proto
716
+ // chain) escapes the literal model and disqualifies the binding.
717
+ const foldSafeArrayUse = (node, name, len) => {
718
+ if (typeof node === 'string') return node !== name
719
+ if (!Array.isArray(node)) return true
720
+ const op = node[0]
721
+ if (isDeclOp(op) && node.slice(1).some(d => d === name || (Array.isArray(d) && d[1] === name))) return false
722
+ if (isDeleteOf(node, name)) return false
723
+ if (ASSIGN_OR_UPDATE(op)) {
724
+ const t = node[1]
725
+ if (t === name) return false
726
+ if (Array.isArray(t) && (t[0] === '[]' || t[0] === '.' || t[0] === '?.') && t[1] === name) return false
727
+ }
728
+ if ((op === '.' || op === '?.') && node[1] === name) return node[2] === 'length'
729
+ if (op === '[]' && node[1] === name) return constIntExpr(node[2]) != null
730
+ if (op === '...' && node[1] === name) return false
731
+ for (let i = 1; i < node.length; i++) if (!foldSafeArrayUse(node[i], name, len)) return false
732
+ return true
733
+ }
734
+
735
+ const foldSafeObjectUse = (node, name, keys) => {
736
+ if (typeof node === 'string') return node !== name
737
+ if (!Array.isArray(node)) return true
738
+ const op = node[0]
739
+ if (isDeclOp(op) && node.slice(1).some(d => d === name || (Array.isArray(d) && d[1] === name))) return false
740
+ if (isDeleteOf(node, name)) return false
741
+ if (ASSIGN_OR_UPDATE(op)) {
742
+ const t = node[1]
743
+ if (t === name) return false
744
+ if (Array.isArray(t) && (t[0] === '[]' || t[0] === '.' || t[0] === '?.') && t[1] === name) return false
745
+ }
746
+ if ((op === '.' || op === '?.') && node[1] === name) return keys.has(node[2]) // own key only — proto-safe
747
+ if (op === '[]' && node[1] === name) { const k = staticPropertyKey(node[2]); return k != null && keys.has(k) }
748
+ if (op === '...' && node[1] === name) return false
749
+ for (let i = 1; i < node.length; i++) if (!foldSafeObjectUse(node[i], name, keys)) return false
750
+ return true
751
+ }
752
+
753
+ const moduleStmtsOf = (seq) =>
754
+ Array.isArray(seq) && seq[0] === ';' ? seq.slice(1)
755
+ : Array.isArray(seq) && isDeclOp(seq[0]) ? [seq]
756
+ : []
757
+
758
+ export function foldStaticConstAggregates(ast) {
759
+ // Span the main module AST and every bundled sub-module init — a const declared in
760
+ // one can be read from another or from a function body (mirrors the constInts fold).
761
+ const seqs = [ast, ...(ctx.module.moduleInits || [])]
762
+ const moduleStmts = seqs.flatMap(moduleStmtsOf)
763
+ const funcs = ctx.func.list.filter(f => f.body && !f.raw)
764
+ // A function parameter named `x` rebinds `x` for the whole body — its `x[…]` reads
765
+ // the param, not the module binding (params live on `f.sig`, separate from `.body`,
766
+ // so the body scan can't see them). Such a function is skipped (scan) / excluded
767
+ // (rewrite) for that name.
768
+ const paramNames = (f) => (f.sig?.params || []).map(p => p.name)
769
+ // Every AST a function can reference an outer binding from: its body PLUS each
770
+ // default-parameter expression (`(v = x[0]) => …`), which prepare extracts to
771
+ // `f.defaults` — separate from the body, so the body scan/rewrite would miss it.
772
+ const funcNodes = (f) => f.defaults ? [f.body, ...Object.values(f.defaults)] : [f.body]
773
+
774
+ // Classify module statements. A binding's value comes from an inline decl
775
+ // (`const x = […]`) OR — for `var`, which jzify lowers to `let x; x = […]` — a
776
+ // lone module-scope assignment after an uninitialized decl. The init statement(s)
777
+ // are excluded from the read-only scan and dropped on fold.
778
+ const inlineInit = new Map() // name -> {value, stmt} | null (poisoned: >1 decl)
779
+ const assigns = new Map() // name -> [assignStmt…]
780
+ const uninitDecl = new Map() // name -> declStmt (`let x` / `var x`, string declarator)
781
+ for (const stmt of moduleStmts) {
782
+ if (!Array.isArray(stmt)) continue
783
+ const op = stmt[0]
784
+ if (isDeclOp(op) && stmt.length === 2 && Array.isArray(stmt[1]) && stmt[1][0] === '=' && typeof stmt[1][1] === 'string') {
785
+ const name = stmt[1][1]
786
+ inlineInit.set(name, inlineInit.has(name) ? null : { value: stmt[1][2], stmt })
787
+ } else if (isDeclOp(op) && stmt.length === 2 && typeof stmt[1] === 'string') {
788
+ uninitDecl.set(stmt[1], stmt)
789
+ } else if (op === '=' && typeof stmt[1] === 'string') {
790
+ (assigns.get(stmt[1]) ?? assigns.set(stmt[1], []).get(stmt[1])).push(stmt)
791
+ }
792
+ }
793
+
794
+ // index of each statement in module-execution order — used to prove a `var`'s
795
+ // assignment dominates its reads.
796
+ const pos = new Map()
797
+ moduleStmts.forEach((s, i) => pos.set(s, i))
798
+
799
+ const arr = new Map(), obj = new Map(), initStmts = new Map()
800
+ const consider = (name, value, init) => {
801
+ if (ctx.func.exports?.[name]) return // exported → escapes to JS
802
+ const elems = scalarArrayElems(value)
803
+ if (elems) { arr.set(name, elems); initStmts.set(name, init); return }
804
+ const props = scalarObjectProps(value)
805
+ if (props) { obj.set(name, props); initStmts.set(name, init) }
806
+ }
807
+ for (const [name, info] of inlineInit) {
808
+ // a decl-initialized binding that is ALSO assigned is reassigned → not constant.
809
+ if (info && !assigns.has(name)) consider(name, info.value, new Set([info.stmt]))
810
+ }
811
+ for (const [name, list] of assigns) {
812
+ // `var` lowering: exactly one assignment, a matching uninit decl, and no
813
+ // competing inline decl. The assignment must dominate every read — conservatively,
814
+ // it precedes all other module references and the name is unused in any function
815
+ // body (a function could run before the assignment). `let`/`const` need no such
816
+ // guard (TDZ forbids use-before-init).
817
+ if (inlineInit.has(name) || list.length !== 1 || !uninitDecl.has(name)) continue
818
+ const assign = list[0], at = pos.get(assign)
819
+ const refsBefore = moduleStmts.some((s, i) => i < at && s !== uninitDecl.get(name) && refsName(s, name, REFS_IN_EXPR))
820
+ const refsInFn = funcs.some(f => !paramNames(f).includes(name) && funcNodes(f).some(n => refsName(n, name, REFS_IN_EXPR)))
821
+ if (refsBefore || refsInFn) continue
822
+ consider(name, assign[2], new Set([assign, uninitDecl.get(name)]))
823
+ }
824
+ if (!arr.size && !obj.size) return false
825
+
826
+ // Every mention outside the binding's init statement(s) — across all module
827
+ // statements and all function bodies — must be a fold-safe static read.
828
+ const checkAll = (name, pred) => {
829
+ const skip = initStmts.get(name)
830
+ return moduleStmts.every(s => skip.has(s) || pred(s))
831
+ && funcs.every(f => paramNames(f).includes(name) || funcNodes(f).every(pred))
832
+ }
833
+ for (const [name, elems] of [...arr]) if (!checkAll(name, n => foldSafeArrayUse(n, name, elems.length))) arr.delete(name)
834
+ for (const [name, props] of [...obj]) {
835
+ const keys = new Set(props.names)
836
+ if (!checkAll(name, n => foldSafeObjectUse(n, name, keys))) obj.delete(name)
837
+ }
838
+ if (!arr.size && !obj.size) return false
839
+
840
+ const objects = new Map()
841
+ for (const [name, props] of obj) {
842
+ const fields = new Map()
843
+ props.names.forEach((k, i) => fields.set(k, props.values[i]))
844
+ objects.set(name, fields)
845
+ }
846
+ const rewrite = (n) => rewriteScalarObjectUses(rewriteScalarArrayUses(n, arr), objects)
847
+ // Every init statement (the decl and, for `var`, its lone assignment) is dropped —
848
+ // nothing reads the binding anymore, so no aggregate is built.
849
+ const dropped = new Set()
850
+ for (const name of [...arr.keys(), ...obj.keys()]) for (const s of initStmts.get(name)) dropped.add(s)
851
+
852
+ // Rewrite + drop init statements in each module sequence (mutate in place so
853
+ // callers holding `ast`/moduleInits references see the result).
854
+ for (const seq of seqs) {
855
+ if (!Array.isArray(seq) || seq[0] !== ';') continue
856
+ const kept = []
857
+ for (const stmt of seq.slice(1)) {
858
+ if (dropped.has(stmt)) continue
859
+ kept.push(rewrite(stmt))
860
+ }
861
+ seq.splice(1, seq.length - 1, ...kept)
862
+ }
863
+ // Rewrite each function body AND its default-parameter expressions, excluding the
864
+ // folded names its params shadow.
865
+ for (const f of funcs) {
866
+ const pn = paramNames(f)
867
+ const shadows = pn.some(p => arr.has(p) || objects.has(p))
868
+ const rw = shadows
869
+ ? (n) => rewriteScalarObjectUses(rewriteScalarArrayUses(n, new Map([...arr].filter(([k]) => !pn.includes(k)))), new Map([...objects].filter(([k]) => !pn.includes(k))))
870
+ : rewrite
871
+ f.body = rw(f.body)
872
+ if (f.defaults) for (const k of Object.keys(f.defaults)) f.defaults[k] = rw(f.defaults[k])
873
+ }
874
+ return true
875
+ }
876
+
624
877
  function scalarizeArrayLiterals(node) {
625
878
  if (!Array.isArray(node)) return { node, changed: false }
626
879
  if (node[0] === '=>') {
@@ -725,13 +978,41 @@ const _intArrayLitElems = (expr) => {
725
978
  return out
726
979
  }
727
980
 
981
+ // Arithmetic and bitwise operators that always produce a numeric result —
982
+ // regardless of operand types — so `name[expr]` is index-safe after promotion.
983
+ // Bitwise ops (`&`, `|`, etc.) ToInt32 their operands; pure-arithmetic ops with
984
+ // numeric leaves stay numeric. `+` is excluded: `"a" + "b"` is a string.
985
+ const _NUMERIC_INDEX_OPS = new Set(['-', '*', '/', '%', '&', '|', '^', '<<', '>>', '>>>', 'u-', 'u+'])
986
+
987
+ // Returns true when `key` is provably a numeric index at plan time: an integer
988
+ // literal, a local name whose val-type is VAL.NUMBER in `valTypes`, or a
989
+ // compound expression that always produces a number (arithmetic/bitwise ops).
990
+ // Mirrors the `idxNumericName` / `intIndexIR` guard in emit so that the same
991
+ // index shapes that skip `__is_str_key` at emit-time also pass here. Used by
992
+ // `_disqualifyPromotion` to gate index reads: a non-numeric key on a promoted
993
+ // Int32Array NaN-coerces to 0 (trunc_sat_f64_s(NaN) = 0) instead of returning
994
+ // undefined — the correct JS behaviour for an out-of-range or string index.
995
+ const _isNumericKey = (key, valTypes) => {
996
+ if (key == null) return false
997
+ if (nonNegIntLiteral(key) != null) return true // literal integer
998
+ if (typeof key === 'string') return valTypes?.get(key) === VAL.NUMBER
999
+ if (!Array.isArray(key)) return false
1000
+ const op = key[0]
1001
+ if (op == null) return typeof key[1] === 'number' // [null, n] literal
1002
+ if (_NUMERIC_INDEX_OPS.has(op)) return true // always produces Number
1003
+ // `+` is numeric only when both operands are proven numeric.
1004
+ if (op === '+' && key.length === 3)
1005
+ return _isNumericKey(key[1], valTypes) && _isNumericKey(key[2], valTypes)
1006
+ return false
1007
+ }
1008
+
728
1009
  // Walks `node` and disqualifies every candidate name that appears in an
729
1010
  // unsafe context. `initSet` holds the candidate's own init-decl AST nodes
730
1011
  // (their LHS reference is the binding being defined, not an escape).
731
- const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
1012
+ const _disqualifyPromotion = (node, candidates, disqualified, initSet, valTypes) => {
732
1013
  if (initSet.has(node)) {
733
1014
  // The init decl itself: only walk the RHS (skip the LHS `name`).
734
- return _disqualifyPromotion(node[2], candidates, disqualified, initSet)
1015
+ return _disqualifyPromotion(node[2], candidates, disqualified, initSet, valTypes)
735
1016
  }
736
1017
  if (typeof node === 'string') {
737
1018
  // Bare identifier outside any handled parent context — escape.
@@ -757,7 +1038,7 @@ const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
757
1038
  Array.isArray(node[1]) && (node[1][0] === '.' || node[1][0] === '?.') &&
758
1039
  typeof node[1][1] === 'string' && candidates.has(node[1][1])) {
759
1040
  disqualified.add(node[1][1])
760
- for (let i = 2; i < node.length; i++) _disqualifyPromotion(node[i], candidates, disqualified, initSet)
1041
+ for (let i = 2; i < node.length; i++) _disqualifyPromotion(node[i], candidates, disqualified, initSet, valTypes)
761
1042
  return
762
1043
  }
763
1044
 
@@ -779,7 +1060,7 @@ const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
779
1060
  typeof callee[1] === 'string' && candidates.has(callee[1])) {
780
1061
  if (!_TYPED_SAFE_METHODS.has(callee[2])) disqualified.add(callee[1])
781
1062
  // Walk method args (skip the receiver — already validated above).
782
- for (let i = 2; i < node.length; i++) _disqualifyPromotion(node[i], candidates, disqualified, initSet)
1063
+ for (let i = 2; i < node.length; i++) _disqualifyPromotion(node[i], candidates, disqualified, initSet, valTypes)
783
1064
  return
784
1065
  }
785
1066
  // Array.isArray flips true→false under promotion.
@@ -788,7 +1069,7 @@ const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
788
1069
  const list = raw == null ? [] : (Array.isArray(raw) && raw[0] === ',') ? raw.slice(1) : [raw]
789
1070
  for (const a of list) {
790
1071
  if (typeof a === 'string' && candidates.has(a)) disqualified.add(a)
791
- else _disqualifyPromotion(a, candidates, disqualified, initSet)
1072
+ else _disqualifyPromotion(a, candidates, disqualified, initSet, valTypes)
792
1073
  }
793
1074
  return
794
1075
  }
@@ -796,10 +1077,15 @@ const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
796
1077
  // bare-name leaf above and disqualify.
797
1078
  }
798
1079
 
799
- // Index read `name[k]` — read access is TYPED-safe. Walk the key in case
800
- // it contains references to other candidate names.
1080
+ // Index read `name[k]` — TYPED-safe only when the key is provably numeric.
1081
+ // A string or unknown key on a promoted Int32Array would NaN-coerce to 0
1082
+ // (i32.trunc_sat_f64_s(NaN) = 0) instead of returning undefined — silently
1083
+ // wrong. Mirror the `idxNumericName` / `intIndexIR` guard in emit: disqualify
1084
+ // the candidate unless `k` is an integer literal, a VAL.NUMBER local, or an
1085
+ // expression that always produces a Number (bitwise/arithmetic ops).
801
1086
  if (op === '[]' && typeof node[1] === 'string' && candidates.has(node[1])) {
802
- _disqualifyPromotion(node[2], candidates, disqualified, initSet)
1087
+ _disqualifyPromotion(node[2], candidates, disqualified, initSet, valTypes)
1088
+ if (!_isNumericKey(node[2], valTypes)) disqualified.add(node[1])
803
1089
  return
804
1090
  }
805
1091
 
@@ -808,15 +1094,15 @@ const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
808
1094
  if (ASSIGN_OPS.has(op) && Array.isArray(node[1]) && node[1][0] === '[]' &&
809
1095
  typeof node[1][1] === 'string' && candidates.has(node[1][1])) {
810
1096
  disqualified.add(node[1][1])
811
- _disqualifyPromotion(node[1][2], candidates, disqualified, initSet)
812
- _disqualifyPromotion(node[2], candidates, disqualified, initSet)
1097
+ _disqualifyPromotion(node[1][2], candidates, disqualified, initSet, valTypes)
1098
+ _disqualifyPromotion(node[2], candidates, disqualified, initSet, valTypes)
813
1099
  return
814
1100
  }
815
1101
 
816
1102
  // Whole-binding reassign: `name = …` / `name += …` / etc.
817
1103
  if (ASSIGN_OPS.has(op) && typeof node[1] === 'string' && candidates.has(node[1])) {
818
1104
  disqualified.add(node[1])
819
- _disqualifyPromotion(node[2], candidates, disqualified, initSet)
1105
+ _disqualifyPromotion(node[2], candidates, disqualified, initSet, valTypes)
820
1106
  return
821
1107
  }
822
1108
 
@@ -826,7 +1112,7 @@ const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
826
1112
  if (typeof t === 'string' && candidates.has(t)) { disqualified.add(t); return }
827
1113
  if (Array.isArray(t) && t[0] === '[]' && typeof t[1] === 'string' && candidates.has(t[1])) {
828
1114
  disqualified.add(t[1])
829
- _disqualifyPromotion(t[2], candidates, disqualified, initSet)
1115
+ _disqualifyPromotion(t[2], candidates, disqualified, initSet, valTypes)
830
1116
  return
831
1117
  }
832
1118
  }
@@ -841,9 +1127,9 @@ const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
841
1127
  else if (Array.isArray(d) && d[0] === '=' && typeof d[1] === 'string' &&
842
1128
  candidates.has(d[1]) && !initSet.has(d)) {
843
1129
  disqualified.add(d[1])
844
- _disqualifyPromotion(d[2], candidates, disqualified, initSet)
1130
+ _disqualifyPromotion(d[2], candidates, disqualified, initSet, valTypes)
845
1131
  } else {
846
- _disqualifyPromotion(d, candidates, disqualified, initSet)
1132
+ _disqualifyPromotion(d, candidates, disqualified, initSet, valTypes)
847
1133
  }
848
1134
  }
849
1135
  return
@@ -865,14 +1151,14 @@ const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
865
1151
  for (let i = 1; i < node.length; i++) {
866
1152
  const child = node[i]
867
1153
  if (i === 2 && typeof child === 'string' && candidates.has(child)) continue
868
- _disqualifyPromotion(child, candidates, disqualified, initSet)
1154
+ _disqualifyPromotion(child, candidates, disqualified, initSet, valTypes)
869
1155
  }
870
1156
  return
871
1157
  }
872
1158
 
873
1159
  // Generic — recurse into children. Bare-name refs at unhandled positions
874
1160
  // hit the string-leaf branch above and disqualify on contact.
875
- for (let i = 1; i < node.length; i++) _disqualifyPromotion(node[i], candidates, disqualified, initSet)
1161
+ for (let i = 1; i < node.length; i++) _disqualifyPromotion(node[i], candidates, disqualified, initSet, valTypes)
876
1162
  }
877
1163
 
878
1164
  // Walk `body` to collect every `let X = [intLit, …]` candidate. Each entry
@@ -940,8 +1226,11 @@ const promoteIntArrayLiteralsInBody = (body) => {
940
1226
  if (!candidates.size) return { node: body, changed: false }
941
1227
  const initSet = new Set()
942
1228
  for (const { initDecl } of candidates.values()) initSet.add(initDecl)
1229
+ // valTypes from analyzeBody gives per-local VAL.* kinds, used by
1230
+ // _disqualifyPromotion to prove index keys are numeric (see _isNumericKey).
1231
+ const { valTypes } = analyzeBody(body)
943
1232
  const disqualified = new Set()
944
- _disqualifyPromotion(body, candidates, disqualified, initSet)
1233
+ _disqualifyPromotion(body, candidates, disqualified, initSet, valTypes)
945
1234
  const validated = new Set()
946
1235
  for (const name of candidates.keys()) if (!disqualified.has(name)) validated.add(name)
947
1236
  if (!validated.size) return { node: body, changed: false }