jz 0.6.0 → 0.7.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 (49) hide show
  1. package/README.md +66 -43
  2. package/bench/README.md +128 -78
  3. package/bench/bench.svg +32 -42
  4. package/cli.js +73 -7
  5. package/dist/interop.js +1 -0
  6. package/dist/jz.js +6024 -0
  7. package/index.d.ts +126 -0
  8. package/index.js +190 -34
  9. package/interop.js +71 -27
  10. package/layout.js +5 -0
  11. package/module/array.js +71 -39
  12. package/module/collection.js +41 -5
  13. package/module/core.js +2 -4
  14. package/module/math.js +138 -2
  15. package/module/number.js +21 -0
  16. package/module/object.js +26 -0
  17. package/module/simd.js +37 -5
  18. package/module/string.js +41 -12
  19. package/module/typedarray.js +415 -26
  20. package/package.json +21 -6
  21. package/src/autoload.js +3 -0
  22. package/src/compile/analyze-scans.js +174 -11
  23. package/src/compile/analyze.js +38 -3
  24. package/src/compile/emit-assign.js +9 -6
  25. package/src/compile/emit.js +347 -36
  26. package/src/compile/index.js +307 -29
  27. package/src/compile/infer.js +36 -1
  28. package/src/compile/loop-divmod.js +155 -0
  29. package/src/compile/narrow.js +108 -11
  30. package/src/compile/peel-stencil.js +261 -0
  31. package/src/compile/plan/advise.js +55 -1
  32. package/src/compile/plan/index.js +4 -0
  33. package/src/compile/plan/inline.js +5 -2
  34. package/src/compile/plan/literals.js +220 -5
  35. package/src/compile/plan/scope.js +115 -39
  36. package/src/compile/program-facts.js +21 -2
  37. package/src/ctx.js +45 -7
  38. package/src/ir.js +55 -7
  39. package/src/kind-traits.js +27 -0
  40. package/src/kind.js +65 -3
  41. package/src/optimize/index.js +344 -45
  42. package/src/optimize/vectorize.js +2968 -182
  43. package/src/prepare/index.js +64 -7
  44. package/src/prepare/lift-iife.js +149 -0
  45. package/src/reps.js +3 -2
  46. package/src/static.js +9 -0
  47. package/src/type.js +10 -6
  48. package/src/wat/assemble.js +195 -13
  49. package/src/wat/optimize.js +363 -185
@@ -29,6 +29,7 @@ import { collectProgramFacts, refreshProgramFacts } from '../program-facts.js'
29
29
  import narrowSignatures, {
30
30
  specializeBimorphicTyped, refineDynKeys,
31
31
  applyJsstringBoundaryCarrierStandalone, narrowBoolResults,
32
+ strictBoundaryTypeCheck,
32
33
  } from '../narrow.js'
33
34
 
34
35
  import { optimizing } from './common.js'
@@ -94,6 +95,7 @@ export default function plan(ast, profiler) {
94
95
  sweep('scalarizeTypedArrays', () => scalarizeFunctionTypedArrays(programFacts))
95
96
  }
96
97
  ctx.types.dynKeyVars = programFacts.dynVars
98
+ ctx.types.dynWriteVars = programFacts.dynWriteVars
97
99
  ctx.types.anyDynKey = programFacts.anyDyn
98
100
 
99
101
  t('materializeAutoBoxSchemas', () => materializeAutoBoxSchemas(programFacts))
@@ -105,6 +107,7 @@ export default function plan(ast, profiler) {
105
107
  // fact, so `export let f = (a) => a > 2` boxes its boundary atom.
106
108
  applyJsstringBoundaryCarrierStandalone(programFacts)
107
109
  narrowBoolResults()
110
+ strictBoundaryTypeCheck(programFacts)
108
111
  adviseProgram(programFacts)
109
112
  return programFacts
110
113
  }
@@ -112,6 +115,7 @@ export default function plan(ast, profiler) {
112
115
  t('narrowSignatures', () => narrowSignatures(programFacts, ast))
113
116
  t('specializeBimorphicTyped', () => specializeBimorphicTyped(programFacts))
114
117
  t('refineDynKeys', () => refineDynKeys(programFacts))
118
+ strictBoundaryTypeCheck(programFacts)
115
119
 
116
120
  adviseProgram(programFacts)
117
121
  return programFacts
@@ -359,12 +359,15 @@ export const inlineHotInternalCalls = (programFacts, ast) => {
359
359
  if (some(func.body, n => n[0] === '()' && typeof n[1] === 'string' && ctx.func.names.has(n[1]))) continue
360
360
  // Per-iteration call overhead dwarfs body-size bloat when EVERY site sits
361
361
  // inside a caller's loop (game-of-life's rot: ~40 nodes × 2 sites, fired
362
- // for most of 260k cells/frame). V8's pre-Turboshaft wasm tiers never
362
+ // for most of 260k cells/frame; cloth's relax: ~160 nodes × 2 sites, fired
363
+ // per link every relaxation pass). V8's pre-Turboshaft wasm tiers never
363
364
  // inline cross-function, so an out-of-line leaf in a hot loop is a hard
364
365
  // per-cell tax on Node ≤ 22 — and still saves call setup on newer tiers.
366
+ // The in-loop cap is generous because the gate above bounds non-tiny leaves
367
+ // to ≤2 sites, so the spliced duplication is at most ~2× a bounded body.
365
368
  const allSitesInLoop = sites.every(site =>
366
369
  site.callerFunc?.body && containsNode(site.callerFunc.body, site.node, false))
367
- if (nodeSize(func.body) > (allSitesInLoop ? 80 : 30)) continue
370
+ if (nodeSize(func.body) > (allSitesInLoop ? 200 : 30)) continue
368
371
  }
369
372
  if (some(func.body, n => n[0] === '()' && n[1] === func.name)) continue
370
373
  // Kernels with nested loops (depth ≥ 2) are typically large and the inner
@@ -25,7 +25,7 @@
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
31
  intLiteralValue, constIntExpr, staticObjectProps, staticPropertyKey,
@@ -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 })
@@ -621,6 +651,191 @@ function scalarizeObjectLiterals(node, escapes) {
621
651
  return changed ? { node: out, changed: true } : { node, changed: false }
622
652
  }
623
653
 
654
+ // === Whole-program constant fold of module-scope aggregate literals ===
655
+ //
656
+ // `var x = [1,2,3]; export const y = x[0]` materializes a data-segment array and
657
+ // indexes it through __arr_idx_known (bounds + grow-forwarding) — all dead weight
658
+ // for a never-grown constant. When EVERY reference to a module-scope const aggregate
659
+ // is a static READ, replace each `x[k]` / `x.length` / `o.key` by its literal value
660
+ // program-wide; the now-unused decl is dropped, so no array is built (no data, no
661
+ // memory, no index helper) and the global is never declared. The per-function
662
+ // scalarizers can't do this: the decl lives at module scope and may be read from
663
+ // several function bodies, so the check must span the whole program.
664
+
665
+ const ASSIGN_OR_UPDATE = (op) => ASSIGN_TARGET_OPS.has(op) || op === '++' || op === '--'
666
+ // Module-scope binding ops. `var` survives to compile at module scope (jzify only
667
+ // lowers it to `let` inside functions), so fold it too — the reassignment guard
668
+ // below keeps a re-bound `var` heap-backed.
669
+ const isDeclOp = (op) => op === 'let' || op === 'const' || op === 'var'
670
+
671
+ // Reject `delete x`, `delete x.k`, `delete x[k]` — a deletion mutates the aggregate.
672
+ const isDeleteOf = (node, name) =>
673
+ node[0] === 'delete' && (node[1] === name || (Array.isArray(node[1]) && node[1][1] === name))
674
+
675
+ // A reference is fold-safe only if it READS `name` via a static index/key (or
676
+ // `.length` for arrays). Any write/update/delete, reassignment, second declaration,
677
+ // bare value use (escape), spread, dynamic key, or non-own member (method / proto
678
+ // chain) escapes the literal model and disqualifies the binding.
679
+ const foldSafeArrayUse = (node, name, len) => {
680
+ if (typeof node === 'string') return node !== name
681
+ if (!Array.isArray(node)) return true
682
+ const op = node[0]
683
+ if (isDeclOp(op) && node.slice(1).some(d => d === name || (Array.isArray(d) && d[1] === name))) return false
684
+ if (isDeleteOf(node, name)) return false
685
+ if (ASSIGN_OR_UPDATE(op)) {
686
+ const t = node[1]
687
+ if (t === name) return false
688
+ if (Array.isArray(t) && (t[0] === '[]' || t[0] === '.' || t[0] === '?.') && t[1] === name) return false
689
+ }
690
+ if ((op === '.' || op === '?.') && node[1] === name) return node[2] === 'length'
691
+ if (op === '[]' && node[1] === name) return constIntExpr(node[2]) != null
692
+ if (op === '...' && node[1] === name) return false
693
+ for (let i = 1; i < node.length; i++) if (!foldSafeArrayUse(node[i], name, len)) return false
694
+ return true
695
+ }
696
+
697
+ const foldSafeObjectUse = (node, name, keys) => {
698
+ if (typeof node === 'string') return node !== name
699
+ if (!Array.isArray(node)) return true
700
+ const op = node[0]
701
+ if (isDeclOp(op) && node.slice(1).some(d => d === name || (Array.isArray(d) && d[1] === name))) return false
702
+ if (isDeleteOf(node, name)) return false
703
+ if (ASSIGN_OR_UPDATE(op)) {
704
+ const t = node[1]
705
+ if (t === name) return false
706
+ if (Array.isArray(t) && (t[0] === '[]' || t[0] === '.' || t[0] === '?.') && t[1] === name) return false
707
+ }
708
+ if ((op === '.' || op === '?.') && node[1] === name) return keys.has(node[2]) // own key only — proto-safe
709
+ if (op === '[]' && node[1] === name) { const k = staticPropertyKey(node[2]); return k != null && keys.has(k) }
710
+ if (op === '...' && node[1] === name) return false
711
+ for (let i = 1; i < node.length; i++) if (!foldSafeObjectUse(node[i], name, keys)) return false
712
+ return true
713
+ }
714
+
715
+ const moduleStmtsOf = (seq) =>
716
+ Array.isArray(seq) && seq[0] === ';' ? seq.slice(1)
717
+ : Array.isArray(seq) && isDeclOp(seq[0]) ? [seq]
718
+ : []
719
+
720
+ export function foldStaticConstAggregates(ast) {
721
+ // Span the main module AST and every bundled sub-module init — a const declared in
722
+ // one can be read from another or from a function body (mirrors the constInts fold).
723
+ const seqs = [ast, ...(ctx.module.moduleInits || [])]
724
+ const moduleStmts = seqs.flatMap(moduleStmtsOf)
725
+ const funcs = ctx.func.list.filter(f => f.body && !f.raw)
726
+ // A function parameter named `x` rebinds `x` for the whole body — its `x[…]` reads
727
+ // the param, not the module binding (params live on `f.sig`, separate from `.body`,
728
+ // so the body scan can't see them). Such a function is skipped (scan) / excluded
729
+ // (rewrite) for that name.
730
+ const paramNames = (f) => (f.sig?.params || []).map(p => p.name)
731
+ // Every AST a function can reference an outer binding from: its body PLUS each
732
+ // default-parameter expression (`(v = x[0]) => …`), which prepare extracts to
733
+ // `f.defaults` — separate from the body, so the body scan/rewrite would miss it.
734
+ const funcNodes = (f) => f.defaults ? [f.body, ...Object.values(f.defaults)] : [f.body]
735
+
736
+ // Classify module statements. A binding's value comes from an inline decl
737
+ // (`const x = […]`) OR — for `var`, which jzify lowers to `let x; x = […]` — a
738
+ // lone module-scope assignment after an uninitialized decl. The init statement(s)
739
+ // are excluded from the read-only scan and dropped on fold.
740
+ const inlineInit = new Map() // name -> {value, stmt} | null (poisoned: >1 decl)
741
+ const assigns = new Map() // name -> [assignStmt…]
742
+ const uninitDecl = new Map() // name -> declStmt (`let x` / `var x`, string declarator)
743
+ for (const stmt of moduleStmts) {
744
+ if (!Array.isArray(stmt)) continue
745
+ const op = stmt[0]
746
+ if (isDeclOp(op) && stmt.length === 2 && Array.isArray(stmt[1]) && stmt[1][0] === '=' && typeof stmt[1][1] === 'string') {
747
+ const name = stmt[1][1]
748
+ inlineInit.set(name, inlineInit.has(name) ? null : { value: stmt[1][2], stmt })
749
+ } else if (isDeclOp(op) && stmt.length === 2 && typeof stmt[1] === 'string') {
750
+ uninitDecl.set(stmt[1], stmt)
751
+ } else if (op === '=' && typeof stmt[1] === 'string') {
752
+ (assigns.get(stmt[1]) ?? assigns.set(stmt[1], []).get(stmt[1])).push(stmt)
753
+ }
754
+ }
755
+
756
+ // index of each statement in module-execution order — used to prove a `var`'s
757
+ // assignment dominates its reads.
758
+ const pos = new Map()
759
+ moduleStmts.forEach((s, i) => pos.set(s, i))
760
+
761
+ const arr = new Map(), obj = new Map(), initStmts = new Map()
762
+ const consider = (name, value, init) => {
763
+ if (ctx.func.exports?.[name]) return // exported → escapes to JS
764
+ const elems = scalarArrayElems(value)
765
+ if (elems) { arr.set(name, elems); initStmts.set(name, init); return }
766
+ const props = scalarObjectProps(value)
767
+ if (props) { obj.set(name, props); initStmts.set(name, init) }
768
+ }
769
+ for (const [name, info] of inlineInit) {
770
+ // a decl-initialized binding that is ALSO assigned is reassigned → not constant.
771
+ if (info && !assigns.has(name)) consider(name, info.value, new Set([info.stmt]))
772
+ }
773
+ for (const [name, list] of assigns) {
774
+ // `var` lowering: exactly one assignment, a matching uninit decl, and no
775
+ // competing inline decl. The assignment must dominate every read — conservatively,
776
+ // it precedes all other module references and the name is unused in any function
777
+ // body (a function could run before the assignment). `let`/`const` need no such
778
+ // guard (TDZ forbids use-before-init).
779
+ if (inlineInit.has(name) || list.length !== 1 || !uninitDecl.has(name)) continue
780
+ const assign = list[0], at = pos.get(assign)
781
+ const refsBefore = moduleStmts.some((s, i) => i < at && s !== uninitDecl.get(name) && refsName(s, name, REFS_IN_EXPR))
782
+ const refsInFn = funcs.some(f => !paramNames(f).includes(name) && funcNodes(f).some(n => refsName(n, name, REFS_IN_EXPR)))
783
+ if (refsBefore || refsInFn) continue
784
+ consider(name, assign[2], new Set([assign, uninitDecl.get(name)]))
785
+ }
786
+ if (!arr.size && !obj.size) return false
787
+
788
+ // Every mention outside the binding's init statement(s) — across all module
789
+ // statements and all function bodies — must be a fold-safe static read.
790
+ const checkAll = (name, pred) => {
791
+ const skip = initStmts.get(name)
792
+ return moduleStmts.every(s => skip.has(s) || pred(s))
793
+ && funcs.every(f => paramNames(f).includes(name) || funcNodes(f).every(pred))
794
+ }
795
+ for (const [name, elems] of [...arr]) if (!checkAll(name, n => foldSafeArrayUse(n, name, elems.length))) arr.delete(name)
796
+ for (const [name, props] of [...obj]) {
797
+ const keys = new Set(props.names)
798
+ if (!checkAll(name, n => foldSafeObjectUse(n, name, keys))) obj.delete(name)
799
+ }
800
+ if (!arr.size && !obj.size) return false
801
+
802
+ const objects = new Map()
803
+ for (const [name, props] of obj) {
804
+ const fields = new Map()
805
+ props.names.forEach((k, i) => fields.set(k, props.values[i]))
806
+ objects.set(name, fields)
807
+ }
808
+ const rewrite = (n) => rewriteScalarObjectUses(rewriteScalarArrayUses(n, arr), objects)
809
+ // Every init statement (the decl and, for `var`, its lone assignment) is dropped —
810
+ // nothing reads the binding anymore, so no aggregate is built.
811
+ const dropped = new Set()
812
+ for (const name of [...arr.keys(), ...obj.keys()]) for (const s of initStmts.get(name)) dropped.add(s)
813
+
814
+ // Rewrite + drop init statements in each module sequence (mutate in place so
815
+ // callers holding `ast`/moduleInits references see the result).
816
+ for (const seq of seqs) {
817
+ if (!Array.isArray(seq) || seq[0] !== ';') continue
818
+ const kept = []
819
+ for (const stmt of seq.slice(1)) {
820
+ if (dropped.has(stmt)) continue
821
+ kept.push(rewrite(stmt))
822
+ }
823
+ seq.splice(1, seq.length - 1, ...kept)
824
+ }
825
+ // Rewrite each function body AND its default-parameter expressions, excluding the
826
+ // folded names its params shadow.
827
+ for (const f of funcs) {
828
+ const pn = paramNames(f)
829
+ const shadows = pn.some(p => arr.has(p) || objects.has(p))
830
+ const rw = shadows
831
+ ? (n) => rewriteScalarObjectUses(rewriteScalarArrayUses(n, new Map([...arr].filter(([k]) => !pn.includes(k)))), new Map([...objects].filter(([k]) => !pn.includes(k))))
832
+ : rewrite
833
+ f.body = rw(f.body)
834
+ if (f.defaults) for (const k of Object.keys(f.defaults)) f.defaults[k] = rw(f.defaults[k])
835
+ }
836
+ return true
837
+ }
838
+
624
839
  function scalarizeArrayLiterals(node) {
625
840
  if (!Array.isArray(node)) return { node, changed: false }
626
841
  if (node[0] === '=>') {
@@ -44,62 +44,138 @@ import { invalidateProgramFactsCache } from '../program-facts.js'
44
44
  // mixed ctors) clears the candidacy, keeping the read site polymorphic.
45
45
  export const inferModuleLetTypes = (ast) => {
46
46
  if (!ctx.scope.userGlobals) return
47
- // candidates: name { ctor: string|null, valid: true } | { valid: false }
48
- // valid=true with ctor=null means "still no positive evidence"; we promote
49
- // only when ctor is non-null at the end. Assignments to nullish (undef/null)
50
- // don't change ctor they're consistent with any typed-array value.
51
- // Per-candidate state in PARALLEL maps, not a shared mutable `{ctor, valid}` object
52
- // per name: `ctorOf` holds the positive ctor evidence (name absent not a candidate),
53
- // `invalid` the demoted names. A prior shape stored one mutable object per name in a
54
- // Map; under self-host those per-name objects aliased (every candidate ended up sharing
55
- // one record), so a second global of a different kind invalidated the typed one and any
56
- // two-global program lost its typed-array fast path. String/Set values can't alias.
57
- const ctorOf = new Map()
58
- const invalid = new Set()
59
- for (const name of ctx.scope.userGlobals) ctorOf.set(name, null)
47
+ // Build an assignment/alias graph over EVERY `=`/`let`/`const` binding in the
48
+ // program (globals and locals alike), then resolve each global's typed-array
49
+ // ctor by least-fixed-point. A single forward pass can't see the double-buffer
50
+ // swap idiom`let tmp = a; a = b; b = tmp` assigns `a` from `b` and `b` from
51
+ // a local `tmp` that aliases `a`, so neither ref resolves until its sibling is
52
+ // already known. The fixpoint closes that cycle: `a`/`b` each anchor on their
53
+ // `new Float64Array(...)` decl, the alias edges carry the ctor around the loop,
54
+ // and they promote to VAL.TYPED. Without it the swap poisoned both globals and
55
+ // every `a[i]` read forked __str_idx/__typed_idx, every `+` forked __str_concat.
56
+ //
57
+ // Lattice (per name): null (no evidence) < ctor < MIXED. `bad` evidence (a non-
58
+ // typed, non-alias RHS — number, string, call, arithmetic, compound-assign) jumps
59
+ // straight to MIXED; conflicting ctors join to MIXED. We promote a global only
60
+ // when its fixed point is a single concrete ctor — sound: every assignment then
61
+ // provably yields that typed-array kind or nullish.
62
+ const MIXED = MIXED_CTORS
63
+ const defs = new Map() // name → { ctors:Set<string>, refs:Set<string>, bad:bool }
64
+ const getDef = (name) => {
65
+ let d = defs.get(name)
66
+ if (!d) defs.set(name, d = { ctors: new Set(), refs: new Set(), bad: false })
67
+ return d
68
+ }
60
69
 
61
70
  const isNullishLit = (e) => e == null || e === 'undefined' || e === 'null'
62
71
  || (Array.isArray(e) && e[0] == null && (e[1] === undefined || e[1] === null))
63
72
 
64
- const observe = (name, rhs) => {
65
- if (!ctorOf.has(name) || invalid.has(name)) return
73
+ // User-function names a call to one is an alias edge to its return value
74
+ // (virtual node `@ret:<fn>`, populated from each `return`). Lets a global
75
+ // assigned `a = makeBuffer(n)` inherit makeBuffer's typed-array ctor without
76
+ // relying on the call being inlined (locals get it via inlining; globals,
77
+ // typed before inlining runs, did not). `@`/`:` can't occur in a JS identifier,
78
+ // so the virtual key never collides with a real binding.
79
+ const fnames = new Set()
80
+ for (const f of ctx.func.list) if (f.body && !f.raw && typeof f.name === 'string') fnames.add(f.name)
81
+ // Typed-array methods that preserve the receiver's element ctor: `.subarray`
82
+ // and `.slice` (same-kind view/copy), `.map` (same-kind, per propagateTyped).
83
+ const CTOR_PRESERVING = new Set(['subarray', 'slice', 'map'])
84
+
85
+ // Record one assignment `name = rhs` as evidence. Nullish contributes nothing
86
+ // (consistent with any typed-array value); a bare identifier, a ctor-preserving
87
+ // method on a name, or a call to a user function are alias edges; anything else
88
+ // that isn't a typed ctor poisons the name.
89
+ // Scope-qualified binding key. A module global is ONE node program-wide (bare
90
+ // name); a function-local is unique to its scope `sid`. Keying locals by bare
91
+ // name made a numeric counter `let s = 0` in one function poison a typed
92
+ // swap-temp `let s = a` in another — cascading MIXED into the double-buffer
93
+ // globals so every `a[i]` fell back to runtime __str_idx/__typed_idx dispatch
94
+ // (lbm: 3.9× slower than JS). `@ret:` virtual nodes stay bare (module-wide
95
+ // return-value anchors).
96
+ const key = (name, sid) => name[0] === '@' || ctx.scope.userGlobals.has(name) ? name : sid + '\x00' + name
97
+
98
+ const observe = (name, rhs, sid) => {
99
+ const d = getDef(key(name, sid))
66
100
  if (isNullishLit(rhs)) return
67
- // Resolve typed-array ctor from `new TypedArrayCtor(...)`, ternary of typed,
68
- // or a reference to a name we already know is typed.
69
- let ctor = typedElemCtor(rhs) ?? ternaryCtorOfRhs(rhs)
70
- if (ctor === MIXED_CTORS) { invalid.add(name); return }
71
- if (!ctor && typeof rhs === 'string') {
72
- if (ctx.scope.globalValTypes?.get(rhs) === VAL.TYPED)
73
- ctor = ctx.scope.globalTypedElem?.get(rhs) ?? null
101
+ const ctor = typedElemCtor(rhs) ?? ternaryCtorOfRhs(rhs)
102
+ if (ctor === MIXED) { d.bad = true; return }
103
+ if (ctor) { d.ctors.add(ctor); return }
104
+ if (typeof rhs === 'string') { d.refs.add(key(rhs, sid)); return }
105
+ if (Array.isArray(rhs) && rhs[0] === '()') {
106
+ const callee = rhs[1]
107
+ // `recv.subarray(...)` / `recv.slice(...)` / `recv.map(...)` inherit recv's ctor.
108
+ if (Array.isArray(callee) && callee[0] === '.' && typeof callee[1] === 'string'
109
+ && CTOR_PRESERVING.has(callee[2])) { d.refs.add(key(callee[1], sid)); return }
110
+ // `fn(...)` to a user function → inherit its return ctor.
111
+ if (typeof callee === 'string' && fnames.has(callee)) { d.refs.add('@ret:' + callee); return }
74
112
  }
75
- if (!ctor) { invalid.add(name); return }
76
- const prev = ctorOf.get(name)
77
- if (prev && prev !== ctor) { invalid.add(name); return }
78
- ctorOf.set(name, ctor)
113
+ d.bad = true
79
114
  }
80
115
 
81
- const walk = (node) => {
116
+ // Scope-aware walk. Every `=>` opens a fresh scope so same-named locals across
117
+ // functions (and sibling closures) stay distinct. A function bound to a name
118
+ // descends in a name-stable scope (`fn\0name`) so the ast descent and the
119
+ // func.list sweep below visit it identically (idempotent), and so `return`
120
+ // exprs anchor on `@ret:name`.
121
+ let sidc = 0
122
+ const walk = (node, sid, retFn) => {
82
123
  if (!Array.isArray(node)) return
83
124
  const op = node[0]
84
- if (op === '=' && typeof node[1] === 'string' && ctorOf.has(node[1])) observe(node[1], node[2])
125
+ if (op === '=>') { walk(node[2], 's' + (++sidc), null); return }
126
+ if (op === 'return' && retFn != null) observe('@ret:' + retFn, node[1], sid)
127
+ const assign = (name, rhs) => {
128
+ if (Array.isArray(rhs) && rhs[0] === '=>') { observe(name, rhs, sid); enterFn(rhs[2], name); return }
129
+ observe(name, rhs, sid); walk(rhs, sid, retFn)
130
+ }
131
+ if (op === '=' && typeof node[1] === 'string') return assign(node[1], node[2])
85
132
  if ((op === 'let' || op === 'const') && node.length > 1) {
86
133
  for (let i = 1; i < node.length; i++) {
87
134
  const d = node[i]
88
- if (Array.isArray(d) && d[0] === '=' && typeof d[1] === 'string' && ctorOf.has(d[1]))
89
- observe(d[1], d[2])
135
+ if (Array.isArray(d) && d[0] === '=' && typeof d[1] === 'string') assign(d[1], d[2])
136
+ else walk(d, sid, retFn)
90
137
  }
138
+ return
139
+ }
140
+ // Compound-assigns (`+=`, `++`, …) can't preserve a typed-array kind — poison.
141
+ if (ASSIGN_OPS.has(op) && typeof node[1] === 'string') { getDef(key(node[1], sid)).bad = true; walk(node[2], sid, retFn); return }
142
+ for (let i = 1; i < node.length; i++) walk(node[i], sid, retFn)
143
+ }
144
+ // Descend into a function body anchored on `@ret:fn`. An arrow expr-body IS the
145
+ // implicit return (`(n) => new Float64Array(n)`); a `{}` block uses explicit
146
+ // `return` nodes (captured in walk). Without the implicit-return capture, a
147
+ // global assigned `a = mk(n)` from an expr-body fn never inherited mk's ctor.
148
+ const enterFn = (body, fn) => {
149
+ if (Array.isArray(body) && body[0] !== '{}') observe('@ret:' + fn, body, 'fn\x00' + fn)
150
+ walk(body, 'fn\x00' + fn, fn)
151
+ }
152
+ walk(ast, 'mod', null)
153
+ // Defensive sweep: cover any func.list body not reachable by descent from `ast`
154
+ // (hoisted / submodule). Name-stable scope keeps it idempotent with the descent.
155
+ for (const f of ctx.func.list) if (f.body && !f.raw) enterFn(f.body, f.name)
156
+
157
+ // Least-fixed-point over the alias graph. join: null is bottom, MIXED is top.
158
+ const join = (a, b) => a === MIXED || b === MIXED ? MIXED : a == null ? b : b == null ? a : a === b ? a : MIXED
159
+ const state = new Map() // name → null | ctor | MIXED
160
+ // A ref to a name with no tracked defs resolves via an already-known typed
161
+ // global (const typed array / earlier-recorded rep); otherwise it's opaque → MIXED.
162
+ const refState = (r) => defs.has(r) ? (state.get(r) ?? null)
163
+ : ctx.scope.globalValTypes?.get(r) === VAL.TYPED ? (ctx.scope.globalTypedElem?.get(r) ?? MIXED)
164
+ : MIXED
165
+ let changed = true
166
+ while (changed) {
167
+ changed = false
168
+ for (const [name, d] of defs) {
169
+ let cur = d.bad ? MIXED : null
170
+ if (cur !== MIXED) for (const c of d.ctors) cur = join(cur, c)
171
+ if (cur !== MIXED) for (const r of d.refs) cur = join(cur, refState(r))
172
+ if (cur !== (state.get(name) ?? null)) { state.set(name, cur); changed = true }
91
173
  }
92
- // Compound-assigns (`+=`, etc.) to a typed-array binding can't preserve
93
- // the typed-array kind — invalidate.
94
- if (ASSIGN_OPS.has(op) && op !== '=' && typeof node[1] === 'string' && ctorOf.has(node[1]))
95
- invalid.add(node[1])
96
- for (let i = 1; i < node.length; i++) walk(node[i])
97
174
  }
98
- walk(ast)
99
- for (const f of ctx.func.list) if (f.body && !f.raw) walk(f.body)
100
175
 
101
- for (const [name, ctor] of ctorOf) {
102
- if (invalid.has(name) || !ctor) continue
176
+ for (const name of ctx.scope.userGlobals) {
177
+ const ctor = state.get(name)
178
+ if (!ctor || ctor === MIXED) continue
103
179
  if (ctx.scope.globalValTypes?.get(name) === VAL.TYPED) continue
104
180
  ;(ctx.scope.globalValTypes ||= new Map()).set(name, VAL.TYPED)
105
181
  ;(ctx.scope.globalTypedElem ||= new Map()).set(name, ctor)
@@ -23,6 +23,24 @@ export function observeNodeFacts(node, f) {
23
23
  if (PROP_WRITE_OPS.has(op) && Array.isArray(args[0]) &&
24
24
  (args[0][0] === '.' || args[0][0] === '?.') && typeof args[0][2] === 'string')
25
25
  f.writtenProps.add(args[0][2])
26
+ // Computed-key WRITES (`o[k]=v`, `o[k]+=v`, `o[k]++`) are the ONLY operations
27
+ // that add ENUMERABLE keys beyond the static schema — computed reads and dot-adds
28
+ // (`o.b=2`) do not enumerate in jz. Tracked separately from `dynVars` (which also
29
+ // counts reads) so for-in / Object.keys key pooling can trust the static schema
30
+ // for a receiver that is only computed-READ. (`isLiteralStr` excludes literal
31
+ // string keys, which place in fixed schema slots.)
32
+ if (PROP_WRITE_OPS.has(op) && Array.isArray(args[0]) && args[0][0] === '[]') {
33
+ const [, wobj, widx] = args[0]
34
+ // Flag the ROOT array var. `o[k]=v` → o; a NESTED write `o[i][j]=v` mutates an
35
+ // element of o, so walk the receiver chain to its root identifier and flag that
36
+ // too — else o's recorded (nested) element types would be wrongly trusted at a
37
+ // later `o[i][j]` read. Strictly more conservative for every dynWriteVars consumer.
38
+ if (!isLiteralStr(widx)) {
39
+ let root = wobj
40
+ while (Array.isArray(root) && root[0] === '[]') root = root[1]
41
+ if (typeof root === 'string') f.dynWriteVars?.add(root)
42
+ }
43
+ }
26
44
  if (op === '[]') {
27
45
  const [obj, idx] = args
28
46
  if (!isLiteralStr(idx)) { f.anyDyn = true; if (typeof obj === 'string') f.dynVars.add(obj) }
@@ -68,7 +86,7 @@ export function invalidateProgramFactsCache(...roots) {
68
86
 
69
87
  function emptyWalkFacts() {
70
88
  return {
71
- dynVars: new Set(), anyDyn: false, hasSchemaLiterals: false,
89
+ dynVars: new Set(), dynWriteVars: new Set(), anyDyn: false, hasSchemaLiterals: false,
72
90
  maxDef: 0, maxCall: 0, hasRest: false, hasSpread: false,
73
91
  propMap: new Map(), valueUsed: new Set(), callSites: [],
74
92
  writtenProps: new Set(),
@@ -78,6 +96,7 @@ function emptyWalkFacts() {
78
96
  function mergeWalkFacts(into, from) {
79
97
  if (from.anyDyn) into.anyDyn = true
80
98
  for (const v of from.dynVars) into.dynVars.add(v)
99
+ for (const v of from.dynWriteVars) into.dynWriteVars.add(v)
81
100
  if (from.hasSchemaLiterals) into.hasSchemaLiterals = true
82
101
  if (from.maxDef > into.maxDef) into.maxDef = from.maxDef
83
102
  if (from.maxCall > into.maxCall) into.maxCall = from.maxCall
@@ -234,7 +253,7 @@ export function collectProgramFacts(ast) {
234
253
  // ctx — a mutated prop name anywhere disqualifies sharing a static instance.
235
254
  ctx.module.writtenProps = f.writtenProps
236
255
  return {
237
- dynVars: f.dynVars, anyDyn: f.anyDyn, propMap, valueUsed, callSites,
256
+ dynVars: f.dynVars, dynWriteVars: f.dynWriteVars, anyDyn: f.anyDyn, propMap, valueUsed, callSites,
238
257
  maxDef: f.maxDef, maxCall: f.maxCall, hasRest: f.hasRest, hasSpread: f.hasSpread,
239
258
  paramReps, hasSchemaLiterals: f.hasSchemaLiterals, writtenProps: f.writtenProps,
240
259
  }