jz 0.5.1 → 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 (86) hide show
  1. package/README.md +315 -318
  2. package/bench/README.md +369 -0
  3. package/bench/bench.svg +102 -0
  4. package/cli.js +104 -30
  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 +362 -84
  9. package/interop.js +159 -186
  10. package/jz.svg +5 -0
  11. package/jzify/arguments.js +97 -0
  12. package/jzify/bundler.js +382 -0
  13. package/jzify/classes.js +328 -0
  14. package/jzify/hoist-vars.js +177 -0
  15. package/jzify/index.js +51 -0
  16. package/jzify/names.js +37 -0
  17. package/jzify/switch.js +106 -0
  18. package/jzify/transform.js +349 -0
  19. package/layout.js +184 -0
  20. package/module/array.js +377 -176
  21. package/module/collection.js +639 -145
  22. package/module/console.js +55 -43
  23. package/module/core.js +264 -153
  24. package/module/date.js +15 -3
  25. package/module/function.js +73 -6
  26. package/module/index.js +2 -1
  27. package/module/json.js +226 -61
  28. package/module/math.js +551 -187
  29. package/module/number.js +327 -60
  30. package/module/object.js +474 -184
  31. package/module/regex.js +255 -25
  32. package/module/schema.js +24 -6
  33. package/module/simd.js +117 -0
  34. package/module/string.js +621 -226
  35. package/module/symbol.js +1 -1
  36. package/module/timer.js +9 -14
  37. package/module/typedarray.js +459 -73
  38. package/package.json +58 -14
  39. package/src/abi/index.js +39 -23
  40. package/src/abi/string.js +38 -41
  41. package/src/ast.js +460 -0
  42. package/src/autoload.js +29 -24
  43. package/src/bridge.js +111 -0
  44. package/src/compile/analyze-scans.js +824 -0
  45. package/src/compile/analyze.js +1600 -0
  46. package/src/compile/emit-assign.js +411 -0
  47. package/src/compile/emit.js +3512 -0
  48. package/src/compile/flow-types.js +103 -0
  49. package/src/{compile.js → compile/index.js} +778 -128
  50. package/src/{infer.js → compile/infer.js} +62 -98
  51. package/src/compile/loop-divmod.js +155 -0
  52. package/src/{narrow.js → compile/narrow.js} +409 -106
  53. package/src/compile/peel-stencil.js +261 -0
  54. package/src/compile/plan/advise.js +370 -0
  55. package/src/compile/plan/common.js +150 -0
  56. package/src/compile/plan/index.js +122 -0
  57. package/src/compile/plan/inline.js +682 -0
  58. package/src/compile/plan/literals.js +1199 -0
  59. package/src/compile/plan/loops.js +472 -0
  60. package/src/compile/plan/scope.js +649 -0
  61. package/src/compile/program-facts.js +423 -0
  62. package/src/ctx.js +220 -64
  63. package/src/ir.js +589 -172
  64. package/src/kind-traits.js +132 -0
  65. package/src/kind.js +524 -0
  66. package/src/op-policy.js +57 -0
  67. package/src/{optimize.js → optimize/index.js} +1432 -473
  68. package/src/optimize/vectorize.js +4660 -0
  69. package/src/param-reps.js +65 -0
  70. package/src/parse.js +44 -0
  71. package/src/{prepare.js → prepare/index.js} +649 -205
  72. package/src/prepare/lift-iife.js +149 -0
  73. package/src/reps.js +116 -0
  74. package/src/resolve.js +12 -3
  75. package/src/static.js +208 -0
  76. package/src/type.js +651 -0
  77. package/src/{assemble.js → wat/assemble.js} +275 -55
  78. package/src/wat/optimize.js +3938 -0
  79. package/transform.js +21 -0
  80. package/wasi.js +47 -5
  81. package/src/analyze.js +0 -3818
  82. package/src/emit.js +0 -3040
  83. package/src/jzify.js +0 -1580
  84. package/src/plan.js +0 -2132
  85. package/src/vectorize.js +0 -1088
  86. /package/src/{codegen.js → wat/codegen.js} +0 -0
@@ -0,0 +1,1600 @@
1
+ /**
2
+ * Pre-analysis passes — type inference, local analysis, capture detection.
3
+ *
4
+ * # Stage contract
5
+ * IN: prepared AST + ctx.func.list (from prepare).
6
+ * OUT: per-function populated `ctx.func.localReps` (val field) + `ctx.func.locals` + `ctx.func.boxed`,
7
+ * module-global `ctx.scope.globalValTypes`, type-analysis `ctx.types.typedElem` /
8
+ * `.dynKeyVars` / `.anyDynKey`.
9
+ *
10
+ * # Passes (all walk AST; none mutate AST itself — only ctx)
11
+ * - boxedCaptures: detect mutably-captured vars → ctx.func.boxed cells
12
+ *
13
+ * Value KIND inference: src/kind.js. WASM local typing: src/type.js. Static eval: src/static.js.
14
+ *
15
+ * Ordering: all passes run per function during compile(). plan.js owns the
16
+ * cross-function dynKey scan via programFacts (results land in ctx.types.dynKeyVars).
17
+ *
18
+ * @module analyze
19
+ */
20
+
21
+ import { commaList, ASSIGN_OPS, isReassigned, STMT_OPS, isBlockBody, isLiteralStr, isFuncRef, I32_MIN, I32_MAX, isI32, T, extractParams, classifyParam, collectParamNames, alwaysReturns, returnExprs, refsName, REFS_IN_EXPR } from '../ast.js'
22
+ import { ctx, err } from '../ctx.js'
23
+ import { VAL, repOf, repOfGlobal, updateRep, updateGlobalRep, lookupValType, lookupNotString } from '../reps.js'
24
+ import { valTypeOf, jsonConstString, shapeOf, shapeOfObjectLiteralAst } from '../kind.js'
25
+ import { intLiteralValue, nonNegIntLiteral, constIntExpr, NO_VALUE, staticPropertyKey, staticValue, staticObjectProps, staticArrayElems, objLiteralSchemaId, exprSchemaId, inlineArraySid } from '../static.js'
26
+ import { typedElemCtor, MIXED_CTORS, isCondExpr, ternaryCtorOfRhs, scanBoundedLoops, inBoundsCharCodeAt, exprType, intCertainMap } from '../type.js'
27
+ import { TYPED_ELEM_CODE, TYPED_ELEM_VIEW_FLAG, TYPED_ELEM_BIGINT_FLAG, encodeTypedElemAux, typedElemAux, TYPED_ELEM_NAMES, ctorFromElemAux } from '../../layout.js'
28
+
29
+ // ValueRep field docs + ParamReps lattice helpers — storage lives in src/reps.js.
30
+
31
+ // === ParamReps lattice helpers (cross-call fixpoint) ===
32
+ // programFacts.paramReps: Map<funcName, Map<paramIdx, ValueRep>>. Per-field lattice:
33
+ // undefined unobserved, null sticky-poison (cross-site disagreement), value = consensus.
34
+
35
+ // Cross-call argument inference helpers (`infer*`) live in src/infer.js.
36
+ // paramReps lattice lives in src/param-reps.js.
37
+
38
+ /**
39
+ * Per-binding use-summary — the substrate the representation analyses share.
40
+ *
41
+ * One traversal classifies every mention of each `let`/`const` binding into a
42
+ * closed taxonomy of use-kinds (`USE.*`). The eligibility analyses below
43
+ * (`scanFlatObjects`, `scanSliceViews`, `unboxablePtrs`) are then *policies* —
44
+ * subset predicates over this summary — rather than three independent body
45
+ * re-walks. The classification logic is the union of those walks; a use-kind
46
+ * exists for every distinction at least one consumer needs.
47
+ *
48
+ * Deliberately NOT a consumer: `analyzeBody`'s `escapes` map. It looks like a
49
+ * fourth escape analysis but is not — it is context-sensitive *taint* over
50
+ * value-expression positions (member access short-circuits, a static index
51
+ * does not escape but a dynamic one does), woven into the main typing walk it
52
+ * shares with six other facts. Re-expressing it here would need `BARE` split
53
+ * by enclosing construct (a plain `name;` statement vs `return name`) and a
54
+ * second traversal — adding a walk, not removing one. It stays where it is.
55
+ *
56
+ * Returns `Map<name, { decls, initRhs, uses }>` for every name the body
57
+ * `let`/`const`-declares. `uses` is a `UseRecord[]`; a record is
58
+ * `{ kind, key?, optional?, computed?, compound?, nullCmp?, callee?, argIndex? }`.
59
+ *
60
+ * Scoping: decls are collected only outside closures (a `let` inside a nested
61
+ * `=>` is a different scope), but uses ARE collected inside closures — every
62
+ * mention there becomes a `CAPTURE`, which every policy treats as
63
+ * disqualifying. A binding shadowed by an inner closure decl is conservatively
64
+ * still flagged `CAPTURE`; sound, since it only ever forfeits an optimization.
65
+ *
66
+ * Order-independent: uses bucket by name, so a mention before its decl is fine.
67
+ */
68
+
69
+
70
+
71
+ // === param helpers / AST predicates ===
72
+
73
+ // === Param / closure helpers ===
74
+
75
+ /** Find free variables in AST: referenced in node, not in `bound`, present in `scope`. */
76
+ import {
77
+ findFreeVars, findMutations, boxedCaptures,
78
+ collectI32SafeIndexVars, collectF64StridedIndexVars, narrowUint32, scanBindingUses,
79
+ scanFlatObjects, scanSliceViews, scanNeverGrown, scanNumericFill, USE,
80
+ } from './analyze-scans.js'
81
+
82
+ export { findFreeVars, findMutations, boxedCaptures } from './analyze-scans.js'
83
+
84
+ const _bodyFactsCache = new WeakMap()
85
+
86
+ // Per-name monotone fact trackers over a pluggable {get,set,delete} store. First
87
+ // observation wins; a conflicting later one poisons the name (and clears the store
88
+ // entry) so a sibling-scope decl (jz hoists `let` to function scope) can't lock in
89
+ // the wrong value. The get/set/del closures abstract WHERE the slice lives, letting
90
+ // analyzeBody (local Map) and analyzeValTypes (ctx.func.localReps / ctx.types.typedElem)
91
+ // share one definition — so the two body walks can't drift. Passed as three positional
92
+ // closures rather than a {get,set,delete} store object: a Map satisfies that interface
93
+ // natively (analyzeBody's slices) but the analyzeValTypes slices need custom logic
94
+ // (updateRep), so the param would be polymorphic (Map | object) — which the self-host
95
+ // kernel cannot statically type, mis-dispatching `store.set` on the object form to the
96
+ // Map builtin. Direct closure calls sidestep method dispatch entirely.
97
+ const makeValTracker = (get, set, del) => {
98
+ const poison = new Set()
99
+ return (name, vt) => {
100
+ if (poison.has(name)) return
101
+ const prev = get(name)
102
+ if (!vt) { if (prev) poison.add(name); del(name); return }
103
+ if (prev && prev !== vt) { poison.add(name); del(name); return }
104
+ set(name, vt)
105
+ }
106
+ }
107
+ const makeTypedTracker = (get, set, del) => {
108
+ const poison = new Set()
109
+ const invalidate = (name) => { poison.add(name); del(name) }
110
+ // Resolve a variable-name ternary branch to its known typed-array ctor: a
111
+ // local typed binding (`get`), or a module global promoted typed by plan
112
+ // (`inferModuleLetTypes` populates `globalTypedElem`, copied into
113
+ // `ctx.types.typedElem` per-func). Lets `let cur = flip ? bufA : bufB` keep
114
+ // the fast typed-load path instead of decaying to `$__typed_idx`.
115
+ const resolveName = (n) =>
116
+ get(n) ?? ctx.types.typedElem?.get(n) ?? ctx.scope.globalTypedElem?.get(n) ?? null
117
+ return (name, rhs) => {
118
+ if (poison.has(name)) return
119
+ const setOrInvalidate = (c) => {
120
+ if (c === MIXED_CTORS) return invalidate(name)
121
+ const prev = get(name)
122
+ if (prev && prev !== c) invalidate(name)
123
+ else set(name, c)
124
+ }
125
+ const ctor = typedElemCtor(rhs)
126
+ if (ctor) return setOrInvalidate(ctor)
127
+ // `recv.subarray(...)` is a zero-copy VIEW aliasing the receiver's buffer — its elem
128
+ // ctor is the receiver's type with the `.view` flag, so the binding unboxes to a typed
129
+ // pointer and element writes take the descriptor-indirected path (not desc-as-data).
130
+ if (Array.isArray(rhs) && rhs[0] === '()' && Array.isArray(rhs[1]) && rhs[1][0] === '.'
131
+ && rhs[1][2] === 'subarray' && typeof rhs[1][1] === 'string') {
132
+ const recvCtor = resolveName(rhs[1][1])
133
+ if (recvCtor) return setOrInvalidate((recvCtor.endsWith('.view') ? recvCtor.slice(0, -5) : recvCtor) + '.view')
134
+ return
135
+ }
136
+ // TYPED-narrowed call result carries its elem aux on f.sig.ptrAux — reverse-map
137
+ // to a canonical ctor so the unboxed local's rep restores the same aux.
138
+ if (Array.isArray(rhs) && rhs[0] === '()' && typeof rhs[1] === 'string') {
139
+ const f = ctx.func.map?.get(rhs[1])
140
+ if (f?.sig?.ptrKind === VAL.TYPED && f.sig.ptrAux != null) {
141
+ const c = ctorFromElemAux(f.sig.ptrAux)
142
+ if (c) setOrInvalidate(c)
143
+ }
144
+ return
145
+ }
146
+ // Heterogeneous ternary (`n===16 ? new Uint8Array(16) : new Uint16Array(8)`):
147
+ // ctors that don't unify must invalidate so a sibling-scope decl can't lock in
148
+ // the wrong store width.
149
+ const tc = ternaryCtorOfRhs(rhs, resolveName)
150
+ if (tc) setOrInvalidate(tc)
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Unified per-body analysis — see module header for slice overview.
156
+ * Returns cached facts; DO NOT MUTATE the returned maps.
157
+ *
158
+ * NOTE on the cache (root A): entries are body-keyed and CAN read ctx that mutates
159
+ * during narrowing (a `let x = f()` local's wasm type shifts when f's result
160
+ * narrows). The cache is therefore *intentionally staleable* — invalidateLocalsCache
161
+ * is placed at the phase boundaries where a stale read would matter, not "everywhere".
162
+ * A recompute-vs-cache assertion was tried (JZ_DEBUG_CACHE) and abandoned: it fires
163
+ * on benign staleness (the suite stays green through the divergence), so it can't
164
+ * tell a real missing-invalidation from a harmless one. See .work/todo.md.
165
+ */
166
+ export function analyzeBody(body) {
167
+ // Non-object bodies (`() => 0`, `() => x`, missing) have nothing to observe
168
+ // for any slice and can't be WeakMap-keyed. Return empty maps without caching.
169
+ if (body === null || typeof body !== 'object') return {
170
+ locals: new Map(), valTypes: new Map(), arrElemSchemas: new Map(),
171
+ arrElemValTypes: new Map(), typedElems: new Map(), escapes: new Map(),
172
+ flatObjects: new Map(),
173
+ }
174
+ const hit = _bodyFactsCache.get(body)
175
+ if (hit) return hit
176
+
177
+ const locals = new Map()
178
+ const valTypes = new Map()
179
+ const arrElemSchemas = new Map()
180
+ const arrElemValTypes = new Map()
181
+ // Nested element kind: `name`'s elements are themselves arrays whose elements
182
+ // share this VAL.*. Lets `chord = padChord[i]; chord[j]` (floatbeat pad voicings,
183
+ // `padChord = [[0,2,4],…]`) bind `chord`'s arrayElemValType through one index step,
184
+ // so `chord[j]` is a Number and skips __to_num. Single-level only — enough for the
185
+ // 2-D table pattern without a general nested-type lattice.
186
+ const arrElemElemValTypes = new Map()
187
+ const typedElems = new Map()
188
+ const escapes = new Map() // name → bool: local holds allocation, true if it escapes
189
+
190
+ const doSchemas = !!ctx.schema?.register
191
+ // Per-walk local schema map for chained `arr.push(name)` resolution.
192
+ const localSchemaMap = new Map()
193
+
194
+ // === Observation helpers ===
195
+ //
196
+ // These trust the AST: any `arr.push(...)` syntactically present has `arr` as
197
+ // a body-relevant name (decl, param, or global) since closure boundaries are
198
+ // skipped at walk time. Pure typo names produce harmless dead Map entries
199
+ // that are never queried (consumers index by known local/param names).
200
+ // Removing the legacy `ctx.func.locals.has(arr)` filter makes analyzeBody's
201
+ // output context-pure — cache hits don't depend on transient ctx state.
202
+
203
+ const observeArrSchema = (arr, sid) => {
204
+ if (!doSchemas) return
205
+ if (typeof arr !== 'string') return
206
+ if (arrElemSchemas.get(arr) === null) return
207
+ if (sid == null) { arrElemSchemas.set(arr, null); return }
208
+ if (!arrElemSchemas.has(arr)) arrElemSchemas.set(arr, sid)
209
+ else if (arrElemSchemas.get(arr) !== sid) arrElemSchemas.set(arr, null)
210
+ }
211
+
212
+ const observeArrValType = (arr, vt) => {
213
+ if (typeof arr !== 'string') return
214
+ if (arrElemValTypes.get(arr) === null) return
215
+ if (!vt) { arrElemValTypes.set(arr, null); return }
216
+ if (!arrElemValTypes.has(arr)) arrElemValTypes.set(arr, vt)
217
+ else if (arrElemValTypes.get(arr) !== vt) arrElemValTypes.set(arr, null)
218
+ }
219
+
220
+ const elemValOf = (name) => {
221
+ if (typeof name !== 'string') return null
222
+ const repVt = ctx.func.localReps?.get(name)?.arrayElemValType
223
+ if (repVt) return repVt
224
+ return arrElemValTypes.get(name) || null
225
+ }
226
+
227
+ const exprElemSourceVal = (expr) => {
228
+ if (typeof expr === 'string') {
229
+ const repVt = ctx.func.localReps?.get(expr)?.val
230
+ if (repVt) return repVt
231
+ return ctx.scope.globalValTypes?.get(expr) || null
232
+ }
233
+ return valTypeOf(expr)
234
+ }
235
+
236
+ // Common element VAL of an array-literal node (`[a,b,c]`), or null if not a literal
237
+ // or its elements disagree. Used to read one level into an array-of-arrays literal.
238
+ const arrLitElemCommonVal = (litNode) => {
239
+ const raw = staticArrayElems(litNode)
240
+ if (!raw) return null
241
+ const items = raw.filter(e => e != null)
242
+ if (!items.length || items.length !== raw.length) return null
243
+ let common = exprElemSourceVal(items[0])
244
+ for (let k = 1; k < items.length && common != null; k++) {
245
+ if (exprElemSourceVal(items[k]) !== common) common = null
246
+ }
247
+ return common
248
+ }
249
+
250
+ // Local-Map slices: bind the Map's get/set/delete as the tracker's three ops.
251
+ const trackVal = makeValTracker(n => valTypes.get(n), (n, vt) => valTypes.set(n, vt), n => valTypes.delete(n))
252
+ const trackTyped = makeTypedTracker(n => typedElems.get(n), (n, c) => typedElems.set(n, c), n => typedElems.delete(n))
253
+
254
+ // === Per-decl observation (called for each `let`/`const` `name = rhs`) ===
255
+ const processDecl = (name, rhs) => {
256
+ // wasm type (locals slice). A `>>> 0` result is an unsigned uint32 that doesn't fit a
257
+ // *signed* i32, so a binding initialized from one must be f64 — else reads and arithmetic
258
+ // see the value as negative for inputs ≥ 2³¹. But `x >>> k` with a constant shift k where
259
+ // (k & 31) ≥ 1 lands in [0, 2³¹−1] (max 0xFFFFFFFF >>> 1 = 0x7FFFFFFF), which DOES fit a
260
+ // signed i32 — keep it on the fast integer path (FFT index math: `nn >>> 1`, `n2 >>> 2`).
261
+ // Only `>>> 0` (and variable shifts, which could be 0) need widening. (ToUint32 accumulators
262
+ // init from a literal and narrowUint32 re-narrows them — so this only governs `let u = x >>> k`.)
263
+ const shr = Array.isArray(rhs) && rhs[0] === '>>>'
264
+ const shrFitsI32 = shr && Array.isArray(rhs[2]) && rhs[2][0] == null
265
+ && typeof rhs[2][1] === 'number' && (rhs[2][1] & 31) >= 1
266
+ const wt = (shr && !shrFitsI32) ? 'f64' : exprType(rhs, locals)
267
+ if (!locals.has(name)) locals.set(name, wt)
268
+ else if (locals.get(name) === 'i32' && wt === 'f64') locals.set(name, 'f64')
269
+
270
+ // val type (valTypes slice)
271
+ trackVal(name, valTypeOf(rhs))
272
+
273
+ // typed-array element ctor (typedElems slice)
274
+ trackTyped(name, rhs)
275
+
276
+ // arr-elem schema (arrElemSchemas slice) — schema bindings + array-literal init + alias + call return
277
+ if (doSchemas) {
278
+ const sid = exprSchemaId(rhs, localSchemaMap)
279
+ if (sid != null) localSchemaMap.set(name, sid)
280
+ {
281
+ const rawElems = staticArrayElems(rhs)
282
+ if (rawElems) {
283
+ const elems = rawElems.filter(e => e != null)
284
+ if (elems.length && elems.length === rawElems.length) {
285
+ let common = exprSchemaId(elems[0], localSchemaMap)
286
+ for (let k = 1; k < elems.length && common != null; k++) {
287
+ if (exprSchemaId(elems[k], localSchemaMap) !== common) common = null
288
+ }
289
+ if (common != null) observeArrSchema(name, common)
290
+ }
291
+ }
292
+ }
293
+ if (Array.isArray(rhs) && rhs[0] === '()' && typeof rhs[1] === 'string') {
294
+ const f = ctx.func.map?.get(rhs[1])
295
+ if (f?.arrayElemSchema != null) observeArrSchema(name, f.arrayElemSchema)
296
+ }
297
+ if (typeof rhs === 'string' && arrElemSchemas.has(rhs)) {
298
+ const sid2 = arrElemSchemas.get(rhs)
299
+ if (sid2 != null) observeArrSchema(name, sid2)
300
+ }
301
+ if (typeof rhs === 'string') {
302
+ const repSid = ctx.func.localReps?.get(rhs)?.arrayElemSchema
303
+ if (repSid != null) observeArrSchema(name, repSid)
304
+ }
305
+ }
306
+
307
+ // arr-elem val type (arrElemValTypes slice) — array-literal init + call return + alias + .map/.filter/.slice/.concat chain
308
+ {
309
+ const rawElems = staticArrayElems(rhs)
310
+ if (rawElems) {
311
+ const elems = rawElems.filter(e => e != null)
312
+ if (elems.length && elems.length === rawElems.length) {
313
+ let common = exprElemSourceVal(elems[0])
314
+ for (let k = 1; k < elems.length && common != null; k++) {
315
+ if (exprElemSourceVal(elems[k]) !== common) common = null
316
+ }
317
+ if (common != null) observeArrValType(name, common)
318
+ // Array-of-arrays literal: record the common element-of-element kind so a
319
+ // later `x = name[i]` binds `x`'s element type one level down.
320
+ if (common === VAL.ARRAY) {
321
+ let nested = arrLitElemCommonVal(elems[0])
322
+ for (let k = 1; k < elems.length && nested != null; k++) {
323
+ if (arrLitElemCommonVal(elems[k]) !== nested) nested = null
324
+ }
325
+ if (nested != null) arrElemElemValTypes.set(name, nested)
326
+ }
327
+ }
328
+ }
329
+ // `x = arr[i]` where `arr` is a known array-of-arrays → `x`'s elements take
330
+ // `arr`'s nested element kind (the missing index-step in observeArrValType).
331
+ // `arr` may be a function-local (arrElemElemValTypes) or a module-level const
332
+ // table (global rep, recorded by recordGlobalRep) — the latter dynWrite-guarded.
333
+ if (Array.isArray(rhs) && rhs[0] === '[]' && rhs.length === 3 && typeof rhs[1] === 'string') {
334
+ const nested = arrElemElemValTypes.get(rhs[1])
335
+ ?? (!ctx.func.localReps?.has(rhs[1]) && !ctx.types?.dynWriteVars?.has(rhs[1])
336
+ ? ctx.scope.globalReps?.get(rhs[1])?.arrayElemElemValType : null)
337
+ if (nested) observeArrValType(name, nested)
338
+ }
339
+ }
340
+ if (Array.isArray(rhs) && rhs[0] === '()' && typeof rhs[1] === 'string') {
341
+ const f = ctx.func.map?.get(rhs[1])
342
+ if (f?.arrayElemValType) observeArrValType(name, f.arrayElemValType)
343
+ }
344
+ if (typeof rhs === 'string') {
345
+ const v = elemValOf(rhs)
346
+ if (v) observeArrValType(name, v)
347
+ }
348
+ if (Array.isArray(rhs) && rhs[0] === '()' &&
349
+ Array.isArray(rhs[1]) && rhs[1][0] === '.' &&
350
+ typeof rhs[1][1] === 'string') {
351
+ const recvName = rhs[1][1], method = rhs[1][2]
352
+ if (method === 'filter' || method === 'slice' || method === 'concat') {
353
+ const v = elemValOf(recvName)
354
+ if (v) observeArrValType(name, v)
355
+ } else if (method === 'split' && valTypeOf(recvName) === VAL.STRING) {
356
+ observeArrValType(name, VAL.STRING)
357
+ } else if (method === 'map') {
358
+ const arrowFn = rhs[2]
359
+ const recvVt = elemValOf(recvName)
360
+ const param = Array.isArray(arrowFn) && arrowFn[0] === '=>' ? arrowFn[1] : null
361
+ const paramName = typeof param === 'string' ? param :
362
+ (Array.isArray(param) && param[0] === '()' && typeof param[1] === 'string' ? param[1] : null)
363
+ const arrowBody = paramName ? arrowFn[2] : null
364
+ const exprBody = (Array.isArray(arrowBody) && arrowBody[0] === '{}' &&
365
+ Array.isArray(arrowBody[1]) && arrowBody[1][0] === 'return') ? arrowBody[1][1] : arrowBody
366
+ if (paramName && exprBody != null) {
367
+ const refs = ctx.func.refinements
368
+ const hadParam = refs?.has(paramName)
369
+ const prev = hadParam ? refs.get(paramName) : undefined
370
+ if (refs && recvVt) refs.set(paramName, { val: recvVt })
371
+ let bodyVt = null
372
+ try { bodyVt = valTypeOf(exprBody) }
373
+ finally {
374
+ if (refs && recvVt) {
375
+ if (hadParam) refs.set(paramName, prev); else refs.delete(paramName)
376
+ }
377
+ }
378
+ if (bodyVt) observeArrValType(name, bodyVt)
379
+ }
380
+ }
381
+ }
382
+ if (Array.isArray(rhs) && rhs[0] === '()' &&
383
+ Array.isArray(rhs[1]) && rhs[1][0] === '.' && rhs[1][2] === 'split' &&
384
+ valTypeOf(rhs[1][1]) === VAL.STRING) {
385
+ observeArrValType(name, VAL.STRING)
386
+ }
387
+ }
388
+
389
+ // arrElem invalidation rule — fires on `=` reassign of tracked name to non-array
390
+ const isArrayProducingRhs = (rhs) =>
391
+ Array.isArray(rhs) && (staticArrayElems(rhs) != null ||
392
+ (rhs[0] === '()' && Array.isArray(rhs[1]) && rhs[1][0] === '.' &&
393
+ (rhs[1][2] === 'slice' || rhs[1][2] === 'concat')))
394
+
395
+ const markEscape = (name) => { if (escapes.has(name)) escapes.set(name, true) }
396
+
397
+ const isStaticIndex = (key) =>
398
+ typeof key === 'number' || typeof key === 'string' ||
399
+ (Array.isArray(key) && ((key[0] == null && Number.isInteger(key[1])) || key[0] === 'str')) ||
400
+ staticPropertyKey(key) != null
401
+
402
+ const markEscapeValue = (expr) => {
403
+ if (typeof expr === 'string') { markEscape(expr); return }
404
+ if (!Array.isArray(expr)) return
405
+ const op = expr[0]
406
+ if (op === 'str') return
407
+ if (op === ':') { markEscapeValue(expr[2]); return }
408
+ if ((op === '.' || op === '?.') && typeof expr[1] === 'string' && escapes.has(expr[1])) return
409
+ if (op === '[]' && typeof expr[1] === 'string' && escapes.has(expr[1])) {
410
+ if (!isStaticIndex(expr[2])) markEscape(expr[1])
411
+ markEscapeValue(expr[2])
412
+ return
413
+ }
414
+ for (let i = 1; i < expr.length; i++) markEscapeValue(expr[i])
415
+ }
416
+
417
+ const markEscapeArgs = (args) => {
418
+ if (args == null) return
419
+ const list = Array.isArray(args) && args[0] === ',' ? args.slice(1) : [args]
420
+ for (const a of list) markEscapeValue(Array.isArray(a) && a[0] === '...' ? a[1] : a)
421
+ }
422
+
423
+ // === Single walk ===
424
+ function walk(node) {
425
+ if (!Array.isArray(node)) return
426
+ const op = node[0]
427
+ if (op === '=>') return // don't cross closure boundary
428
+
429
+ if (op === 'let' || op === 'const') {
430
+ for (let i = 1; i < node.length; i++) {
431
+ const a = node[i]
432
+ // analyzeBody: bare-name decl
433
+ if (typeof a === 'string') { if (!locals.has(a)) locals.set(a, 'f64'); continue }
434
+ if (!Array.isArray(a) || a[0] !== '=') continue
435
+ // analyzeBody: destructuring decl — set destructured names to f64, walk rhs only
436
+ if (typeof a[1] !== 'string') {
437
+ for (const n of collectParamNames([a[1]])) if (!locals.has(n)) locals.set(n, 'f64')
438
+ walk(a[2])
439
+ continue
440
+ }
441
+ const name = a[1], rhs = a[2]
442
+ processDecl(name, rhs)
443
+ if (Array.isArray(rhs) && (rhs[0] === '[' || rhs[0] === '{}')) {
444
+ escapes.set(name, false)
445
+ }
446
+ markEscapeValue(rhs)
447
+ // Walk rhs only — never enter the `=` node so the reassignment-invalidation
448
+ // rule won't misfire on the binding's own initializer.
449
+ walk(rhs)
450
+ }
451
+ return
452
+ }
453
+
454
+ if (op === 'return' && node[1] != null) {
455
+ markEscapeValue(node[1])
456
+ }
457
+
458
+ if (op === '()' && node.length > 2) {
459
+ markEscapeArgs(node[2])
460
+ }
461
+
462
+ // arr.push(...) — observe both schemas and val types in one pass
463
+ if (op === '()' && Array.isArray(node[1]) && node[1][0] === '.' && node[1][2] === 'push' && typeof node[1][1] === 'string') {
464
+ const arr = node[1][1]
465
+ const list = commaList(node[2])
466
+ for (const a of list) {
467
+ if (Array.isArray(a) && a[0] === '...') {
468
+ observeArrSchema(arr, null); observeArrValType(arr, null); continue
469
+ }
470
+ observeArrSchema(arr, exprSchemaId(a, localSchemaMap))
471
+ observeArrValType(arr, exprElemSourceVal(a))
472
+ }
473
+ }
474
+
475
+ // `=` reassignment — locals widen, valTypes/typedElems track,
476
+ // arrElemSchemas/ValTypes invalidate when rhs isn't array-producing.
477
+ if (op === '=' && typeof node[1] === 'string') {
478
+ const name = node[1], rhs = node[2]
479
+ walk(rhs)
480
+ markEscape(name)
481
+ markEscapeValue(rhs)
482
+ const wt = exprType(rhs, locals)
483
+ if (locals.has(name) && locals.get(name) === 'i32' && wt === 'f64') locals.set(name, 'f64')
484
+ trackVal(name, valTypeOf(rhs))
485
+ trackTyped(name, rhs)
486
+ if (arrElemSchemas.has(name) && !isArrayProducingRhs(rhs)) observeArrSchema(name, null)
487
+ if (arrElemValTypes.has(name) && !isArrayProducingRhs(rhs)) observeArrValType(name, null)
488
+ return
489
+ }
490
+
491
+ // compound-assign widening (locals slice)
492
+ if ((op === '+=' || op === '-=' || op === '*=' || op === '%=') && typeof node[1] === 'string') {
493
+ const name = node[1], opChar = op[0]
494
+ const t = exprType([opChar, node[1], node[2]], locals)
495
+ if (locals.has(name) && locals.get(name) === 'i32' && t === 'f64') locals.set(name, 'f64')
496
+ }
497
+ if (op === '/=' && typeof node[1] === 'string') {
498
+ if (locals.has(node[1])) locals.set(node[1], 'f64')
499
+ }
500
+
501
+ if (op === 'for' || op === 'for-in' || op === 'for-of') {
502
+ if (node[1] != null) markEscapeValue(node[1])
503
+ }
504
+
505
+ if (op === '[' || op === '{}') {
506
+ for (let i = 1; i < node.length; i++) {
507
+ const c = node[i]
508
+ if (Array.isArray(c) && c[0] === ',') {
509
+ for (let j = 1; j < c.length; j++) {
510
+ if (Array.isArray(c[j]) && c[j][0] === '...') markEscapeValue(c[j][1])
511
+ }
512
+ } else if (Array.isArray(c) && c[0] === '...') {
513
+ markEscapeValue(c[1])
514
+ }
515
+ }
516
+ }
517
+
518
+ if (op === '[]' && typeof node[1] === 'string' && escapes.has(node[1])) {
519
+ const key = node[2]
520
+ if (!isStaticIndex(key)) markEscape(node[1])
521
+ }
522
+
523
+ for (let i = 1; i < node.length; i++) walk(node[i])
524
+ }
525
+
526
+ // Install the in-progress valTypes as a lookup overlay so successive decls
527
+ // resolve chains (`const a = new TypedArr(); const b = a[0]` → b: NUMBER)
528
+ // and shorthand-bound `{a}` props see a's type. Restored after walk completes.
529
+ const prevOverlay = ctx.func.localValTypesOverlay
530
+ const prevTypedOverlay = ctx.func.localTypedElemsOverlay
531
+ ctx.func.localValTypesOverlay = valTypes
532
+ ctx.func.localTypedElemsOverlay = typedElems
533
+ let unsignedLocals, numericFill
534
+ try {
535
+ walk(body)
536
+ widenLocalTypes(body, locals)
537
+ // Narrow proven uint32 accumulator locals to unsigned i32. Runs post-widen so
538
+ // a local already demoted to f64 above (e.g. compared against an f64) is
539
+ // reconsidered with final types — and stays f64, since a relational compare
540
+ // is a non-transparent read that disqualifies narrowing anyway.
541
+ unsignedLocals = narrowUint32(body, locals)
542
+ // Numeric-fill arrays — fresh `Array(n)`/`[]` whose every element write stores a
543
+ // Number, so `a[i]` reads can skip __to_num (the win `[1,2,3]` already gets, for the
544
+ // construct-then-fill kernel shape). Runs HERE, inside the val-type overlay, so a
545
+ // write of a bare numeric local (`a[i] = out`) resolves via the just-built `valTypes`.
546
+ // A bare read of the array's OWN elements (`a[i] = a[j]`, heapsort) is Numeric by
547
+ // induction; any genuinely non-numeric write still fails the test and disqualifies.
548
+ const numericFillRhs = (rhs, selfName) => {
549
+ if (Array.isArray(rhs) && rhs[0] === '[]' && rhs[1] === selfName) return true
550
+ if (typeof rhs === 'string') return valTypes.get(rhs) === VAL.NUMBER || exprElemSourceVal(rhs) === VAL.NUMBER
551
+ return valTypeOf(rhs) === VAL.NUMBER
552
+ }
553
+ numericFill = scanNumericFill(body, numericFillRhs)
554
+ } finally {
555
+ ctx.func.localValTypesOverlay = prevOverlay
556
+ ctx.func.localTypedElemsOverlay = prevTypedOverlay
557
+ }
558
+
559
+ // SRoA: dissolve non-escaping object-literal bindings into field locals.
560
+ // The dead `o` local is dropped — every `o` reference is rewritten by the
561
+ // codegen flat hooks, so a stray `local.get $o` becomes a loud wasm
562
+ // validation error instead of a silent miscompile.
563
+ const flatObjects = doSchemas ? scanFlatObjects(body) : new Map()
564
+ for (const [name, props] of flatObjects) {
565
+ for (let i = 0; i < props.names.length; i++) locals.set(`${name}#${i}`, 'f64')
566
+ locals.delete(name)
567
+ }
568
+
569
+ // No-copy slice views — `let t = s.slice(...)` bindings proven non-escaping.
570
+ // Consumed by emitDecl, which lowers the initializer to a SLICE_BIT view.
571
+ const sliceViews = doSchemas ? scanSliceViews(body) : new Set()
572
+
573
+ // Never-relocated array bindings — reads may skip the realloc-forwarding follow.
574
+ const neverGrown = doSchemas ? scanNeverGrown(body) : new Set()
575
+
576
+ const result = { locals, valTypes, arrElemSchemas, arrElemValTypes, typedElems, escapes, flatObjects, sliceViews, unsignedLocals, neverGrown, numericFill }
577
+ _bodyFactsCache.set(body, result)
578
+ return result
579
+ }
580
+
581
+ /**
582
+ * Post-walk wasm-type widening over `locals`, in place — analyzeBody stage 2.
583
+ *
584
+ * Pass A (widenPass): i32 locals compared against f64 widen — EXCEPT integer
585
+ * counters used as affine array indices (collectI32SafeIndexVars: i32-range
586
+ * proven, direct indexing with no per-access trunc_sat) and integer-certain
587
+ * locals (intCertainMap: every definition integer-valued). An f64 counter
588
+ * would poison the loop body's arithmetic and the increment (f64.add per
589
+ * iteration), the dominant cost of `for (i<n) acc=(acc+i)|0` — measured ~18×
590
+ * vs V8 before this. The compare coerces the counter once. Sound for n ≤ 2³¹
591
+ * (the asm.js-style integer contract); a fractional assignment poisons
592
+ * intCertain → widens normally.
593
+ *
594
+ * Pass B (assignment fixpoint): re-resolve decl/assign RHS types now that
595
+ * pass A widened. `let x2 = zx*zx` declared i32 because zx was i32 at scan
596
+ * time must widen when zx re-types to f64 — else trunc_sat silently floors
597
+ * the fractional value (mandelbrot escape: 3.515 → 3). Re-checks `=` and
598
+ * compound assigns too: a single-pass walk sees each assign once with stale
599
+ * operand types, missing widens through loop back-edges. keepI32 vars are
600
+ * exempt: a hoisted product `o = y*w` types f64 but is proven integer.
601
+ * Monotonic (i32 → f64 only), bounded by locals count.
602
+ */
603
+ function widenLocalTypes(body, locals) {
604
+ const i32SafeIdx = collectI32SafeIndexVars(body, locals)
605
+ const intCounters = intCertainMap(body)
606
+ const f64IdxVars = collectF64StridedIndexVars(body, locals) // counters that trunc anyway — don't keep i32
607
+ const keepI32 = (name) => i32SafeIdx.has(name) || (intCounters.get(name) === true && !f64IdxVars.has(name))
608
+ const CMP_OPS = new Set(['<', '>', '<=', '>=', '==', '!='])
609
+ const widenPass = (node) => {
610
+ if (!Array.isArray(node)) return
611
+ const [op, ...args] = node
612
+ if (CMP_OPS.has(op)) {
613
+ const [a, b] = args
614
+ const ta = exprType(a, locals), tb = exprType(b, locals)
615
+ if (ta === 'i32' && tb === 'f64' && typeof a === 'string' && locals.has(a) && !keepI32(a)) locals.set(a, 'f64')
616
+ if (tb === 'i32' && ta === 'f64' && typeof b === 'string' && locals.has(b) && !keepI32(b)) locals.set(b, 'f64')
617
+ }
618
+ if (op !== '=>') for (const a of args) widenPass(a)
619
+ }
620
+ widenPass(body)
621
+
622
+ let widened = true
623
+ while (widened) {
624
+ widened = false
625
+ const recheck = (node) => {
626
+ if (!Array.isArray(node)) return
627
+ const op = node[0]
628
+ if (op === '=>') return
629
+ if (op === 'let' || op === 'const') {
630
+ for (let i = 1; i < node.length; i++) {
631
+ const a = node[i]
632
+ if (Array.isArray(a) && a[0] === '=' && typeof a[1] === 'string') {
633
+ const name = a[1], rhs = a[2]
634
+ if (locals.get(name) === 'i32' && exprType(rhs, locals) === 'f64' && !keepI32(name)) {
635
+ locals.set(name, 'f64'); widened = true
636
+ }
637
+ }
638
+ }
639
+ }
640
+ if (op === '=' && typeof node[1] === 'string') {
641
+ const name = node[1], rhs = node[2]
642
+ if (locals.get(name) === 'i32' && exprType(rhs, locals) === 'f64' && !keepI32(name)) {
643
+ locals.set(name, 'f64'); widened = true
644
+ }
645
+ }
646
+ if ((op === '+=' || op === '-=' || op === '*=' || op === '%=') && typeof node[1] === 'string') {
647
+ const name = node[1]
648
+ if (locals.get(name) === 'i32' && exprType([op[0], name, node[2]], locals) === 'f64' && !keepI32(name)) {
649
+ locals.set(name, 'f64'); widened = true
650
+ }
651
+ }
652
+ if (op === '/=' && typeof node[1] === 'string') {
653
+ const name = node[1]
654
+ if (locals.get(name) === 'i32') { locals.set(name, 'f64'); widened = true }
655
+ }
656
+ for (let i = 1; i < node.length; i++) recheck(node[i])
657
+ }
658
+ recheck(body)
659
+ }
660
+ }
661
+
662
+ /** Drop the cached analyzeBody entry for this body. Used by emitFunc after
663
+ * seeding cross-call param VAL facts so the next walk picks up fresh
664
+ * `ctx.func.localReps` (drives exprType receiver-type lookups).
665
+ * Same hook as `invalidateValTypesCache` — split names preserve caller intent. */
666
+ export function invalidateLocalsCache(body) {
667
+ if (body && typeof body === 'object') _bodyFactsCache.delete(body)
668
+ }
669
+
670
+ // Can this RHS expression produce null/undefined? A direct nullish literal
671
+ // (`[null, null]` covers both null and undefined), or a `?:`/`&&`/`||` with a
672
+ // nullish branch. Drives the `nullable` rep flag so `x === null` on a binding
673
+ // that was ever assigned null isn't constant-folded to false (emit.js
674
+ // strictSentinel). Conservative — opaque sources (calls, member reads) aren't
675
+ // flagged; the fold they'd suppress is rare and a runtime nullish check is cheap.
676
+ function mayBeNullish(n) {
677
+ if (!Array.isArray(n)) return false
678
+ if (n.length === 2 && n[0] == null && n[1] == null) return true
679
+ if (n[0] === '?' || n[0] === '?:') return mayBeNullish(n[2]) || mayBeNullish(n[3])
680
+ if (n[0] === '&&' || n[0] === '||') return mayBeNullish(n[1]) || mayBeNullish(n[2])
681
+ return false
682
+ }
683
+
684
+ /**
685
+ * Analyze all local value types from declarations and assignments.
686
+ * Writes the per-name `val` field of `ctx.func.localReps` for method dispatch
687
+ * and schema resolution.
688
+ */
689
+ export function analyzeValTypes(body) {
690
+ // localReps slice: store reads/writes the rep's `val` field (updateRep clears it
691
+ // when set to undefined, matching the old explicit delete).
692
+ const setVal = makeValTracker(
693
+ (n) => ctx.func.localReps?.get(n)?.val,
694
+ (n, vt) => updateRep(n, { val: vt }),
695
+ (n) => updateRep(n, { val: undefined }),
696
+ )
697
+ const getVal = name => ctx.func.localReps?.get(name)?.val
698
+ // Pre-walk: observe Array<schema> facts so `const p = arr[i]` can bind a schemaId
699
+ // on `p`, unlocking schema slot reads + skipping str_key dispatch on `.prop` access.
700
+ // Parallel arrElemValTypes walk records VAL.* element kinds into
701
+ // rep.arrayElemValType so valTypeOf's `arr[i]` rule can elide __to_num and route
702
+ // method dispatch on `arr[i].method()`. Both come from a single unified walk.
703
+ const facts = analyzeBody(body)
704
+ const arrElems = facts.arrElemSchemas
705
+ for (const [name, vt] of facts.arrElemValTypes) {
706
+ if (vt != null) updateRep(name, { arrayElemValType: vt })
707
+ }
708
+ // Construct-then-fill numeric arrays (`let a = Array(n); a[i] = expr`) carry no
709
+ // element evidence at their decl, so the walk above leaves them untyped. scanNumericFill
710
+ // proved every write Numeric and every other use a pure read — record NUMBER so `arr[i]`
711
+ // reads skip __to_num, unless an observation already poisoned the slot to a conflict.
712
+ for (const name of facts.numericFill || []) {
713
+ if (facts.arrElemValTypes.get(name) !== null) updateRep(name, { arrayElemValType: VAL.NUMBER })
714
+ }
715
+ // Propagate body-observed array-elem schemas to localReps so unboxablePtrs's
716
+ // `let p = arr[i]` rule (which only consults rep) sees the schema and can unbox `p`
717
+ // to an i32 offset. Without this, `arr.push({x,y,z})` followed by `arr[i].x` reads
718
+ // pay an i64.reinterpret/i32.wrap on every slot access (no aliasing → CSE can't fold).
719
+ for (const [name, sid] of arrElems) {
720
+ if (sid != null) updateRep(name, { arrayElemSchema: sid })
721
+ }
722
+ // Resolve a name's array-elem-schema, preferring rep.arrayElemSchema (set from
723
+ // paramReps[k].arrayElemSchema at emit start) over local body observations.
724
+ const arrElemSchemaOf = (name) => {
725
+ if (typeof name !== 'string') return null
726
+ const repSid = ctx.func.localReps?.get(name)?.arrayElemSchema
727
+ if (repSid != null) return repSid
728
+ const localSid = arrElems.get(name)
729
+ return localSid != null ? localSid : null
730
+ }
731
+ function trackRegex(name, rhs) {
732
+ if (ctx.runtime.regex && Array.isArray(rhs) && rhs[0] === '//') ctx.runtime.regex.vars.set(name, rhs)
733
+ }
734
+ // ctx.types.typedElem slice (lazily created on first write, as before — readers
735
+ // tolerate null). Disagreeing decls poison the name (jz hoists `let` to function
736
+ // scope, so sibling-scope decls share a name and must not lock in a wrong width).
737
+ const trackTyped = makeTypedTracker(
738
+ (n) => ctx.types.typedElem?.get(n),
739
+ (n, c) => (ctx.types.typedElem ??= new Map()).set(n, c),
740
+ (n) => ctx.types.typedElem?.delete(n),
741
+ )
742
+ // Total write count for `name` across the whole body, recursing into nested
743
+ // closures so a closure that reassigns the var is also counted. Capped at 2 —
744
+ // callers only need the "exactly one write" verdict.
745
+ function writeCount(node, name, n) {
746
+ if (n > 1 || !Array.isArray(node)) return n
747
+ const o = node[0]
748
+ if ((ASSIGN_OPS.has(o) || o === '++' || o === '--') && node[1] === name) n++
749
+ if (o === 'let' || o === 'const') {
750
+ for (let i = 1; i < node.length && n <= 1; i++) {
751
+ const d = node[i]
752
+ if (Array.isArray(d) && d[0] === '=' && d[2] != null) n = writeCount(d[2], name, n)
753
+ }
754
+ return n
755
+ }
756
+ for (let i = 1; i < node.length && n <= 1; i++) n = writeCount(node[i], name, n)
757
+ return n
758
+ }
759
+ // Bind an object-literal's schemaId onto its holding local's rep so that
760
+ // `o.prop` / `o.method()` dispatch is precise instead of falling back to
761
+ // structural subtyping (which mis-resolves when another in-scope object
762
+ // shares a member at a different slot). `shapeOf` already covers plain-data
763
+ // literals on a direct `let o = {…}` decl, but not literals with
764
+ // function-valued props — and `var o = {…}` is rewritten by jzify into
765
+ // `let o; o = {…}`, so the schemaId never reaches `o` either way.
766
+ // `expectWrites` is the reassignment count that marks `o` single-assignment:
767
+ // 1 for the jzify `=` form (the synthesized assignment IS the only write),
768
+ // 0 for a direct `let`/`const` decl (the initializer is not counted as a
769
+ // write). A polymorphically reassigned holder keeps dynamic dispatch.
770
+ // A name already in `ctx.schema.vars` carries a prepare-phase schema
771
+ // (Object.assign merge via `inferAssignSchema`, destructure tracking) that
772
+ // supersedes the bare-literal one — binding here would shadow the merged
773
+ // schema (rep schemaId wins over `ctx.schema.vars` in `idOf`).
774
+ function bindObjSchema(name, rhs, expectWrites = 1) {
775
+ if (ctx.func.current?.params?.some(p => p.name === name)) return
776
+ if (ctx.schema.vars?.has(name)) return
777
+ const sid = objLiteralSchemaId(rhs)
778
+ if (sid != null && writeCount(body, name, 0) === expectWrites) updateRep(name, { schemaId: sid })
779
+ }
780
+ function walk(node) {
781
+ if (!Array.isArray(node)) return
782
+ const [op, ...args] = node
783
+ if (op === '=>') return // don't leak inner-closure val types
784
+ // Propagate typed array type through method calls (e.g. buf.map → typed)
785
+ function propagateTyped(name, rhs) {
786
+ if (!Array.isArray(rhs) || rhs[0] !== '()') return
787
+ const callee = rhs[1]
788
+ if (!Array.isArray(callee) || callee[0] !== '.') return
789
+ const src = callee[1], method = callee[2]
790
+ if (typeof src === 'string' && getVal(src) === VAL.TYPED && method === 'map') {
791
+ setVal(name, VAL.TYPED)
792
+ if (ctx.types.typedElem?.has(src)) {
793
+ const srcCtor = ctx.types.typedElem.get(src)
794
+ ctx.types.typedElem.set(name, srcCtor.endsWith('.view') ? srcCtor.slice(0, -5) : srcCtor)
795
+ }
796
+ }
797
+ }
798
+ if (op === 'let' || op === 'const') {
799
+ for (const a of args) {
800
+ if (!Array.isArray(a) || a[0] !== '=' || typeof a[1] !== 'string') continue
801
+ const vt = valTypeOf(a[2])
802
+ setVal(a[1], vt)
803
+ if (mayBeNullish(a[2])) updateRep(a[1], { nullable: true })
804
+ if (vt === VAL.REGEX) trackRegex(a[1], a[2])
805
+ // VAL gate covers definite-typed RHS; `?:`/`&&`/`||` slip through valTypeOf
806
+ // returning null but may still need ctor unification (or poisoning when
807
+ // branches disagree, since jz hoists `let` to function scope).
808
+ if (vt === VAL.TYPED || vt === VAL.BUFFER || isCondExpr(a[2])) trackTyped(a[1], a[2])
809
+ propagateTyped(a[1], a[2])
810
+ // JSON-shape propagation. When the RHS resolves to a known JSON shape
811
+ // (root: `JSON.parse(literal)`; nested: `o.meta`, `items[j]` from a known
812
+ // root), record it on the binding so subsequent `.prop`/`[i]` accesses
813
+ // skip dynamic dispatch and propagate VAL kinds. Generic for any
814
+ // compile-time JSON literal.
815
+ const sh = shapeOf(a[2])
816
+ if (sh) {
817
+ updateRep(a[1], { jsonShape: sh })
818
+ if (sh.val === VAL.ARRAY && sh.elem?.val) {
819
+ updateRep(a[1], { arrayElemValType: sh.elem.val })
820
+ // Array of fixed-shape OBJECTs: register elem schema so `it = items[j]`
821
+ // → `it.prop` lowers to slot read via the existing arr-elem-schema path.
822
+ if (sh.elem.val === VAL.OBJECT && sh.elem.names && ctx.schema.register) {
823
+ const elemSid = ctx.schema.register(sh.elem.names)
824
+ updateRep(a[1], { arrayElemSchema: elemSid })
825
+ }
826
+ }
827
+ if (sh.val === VAL.OBJECT && sh.names && ctx.schema.register) {
828
+ const sid = ctx.schema.register(sh.names)
829
+ updateRep(a[1], { schemaId: sid })
830
+ ctx.schema.vars.set(a[1], sid)
831
+ }
832
+ }
833
+ // `shapeOf` misses object literals with function-valued props; bind
834
+ // their schemaId here so number-hint ToPrimitive (valueOf/toString slot
835
+ // dispatch) resolves. expectWrites=0: a decl initializer is not a write.
836
+ if (vt === VAL.OBJECT) bindObjSchema(a[1], a[2], 0)
837
+ // Propagate schemaId from a narrowed call result so subsequent valTypeOf
838
+ // calls in this function body see the precise schema. emitDecl rebinds
839
+ // this at emission time too — analyze-time binding is what unlocks the
840
+ // slotVT lookup chain in `analyzeValTypes`'s own walk + per-func emit
841
+ // dispatch reading localReps.
842
+ if (vt === VAL.OBJECT && Array.isArray(a[2]) && a[2][0] === '()' && typeof a[2][1] === 'string') {
843
+ const f = ctx.func.map?.get(a[2][1])
844
+ if (f?.sig?.ptrAux != null) updateRep(a[1], { schemaId: f.sig.ptrAux })
845
+ }
846
+ // `const p = arr[i]` — when arr's element schema is known (from .push observations
847
+ // or from paramReps arrayElemSchema binding), p inherits the schema. Unlocks slotVT-driven
848
+ // numeric typing on `.prop` reads + slot-direct loads.
849
+ if (Array.isArray(a[2]) && a[2][0] === '[]' && typeof a[2][1] === 'string') {
850
+ const elemSid = arrElemSchemaOf(a[2][1])
851
+ if (elemSid != null) {
852
+ updateRep(a[1], { schemaId: elemSid })
853
+ // Also set the val so structural call dispatch + valTypeOf see VAL.OBJECT.
854
+ setVal(a[1], VAL.OBJECT)
855
+ }
856
+ }
857
+ }
858
+ }
859
+ if (op === '=' && typeof args[0] === 'string') {
860
+ walk(args[1])
861
+ const vt = valTypeOf(args[1])
862
+ setVal(args[0], vt)
863
+ if (mayBeNullish(args[1])) updateRep(args[0], { nullable: true })
864
+ if (vt === VAL.REGEX) trackRegex(args[0], args[1])
865
+ if (vt === VAL.TYPED || vt === VAL.BUFFER || isCondExpr(args[1])) trackTyped(args[0], args[1])
866
+ propagateTyped(args[0], args[1])
867
+ if (vt === VAL.OBJECT) bindObjSchema(args[0], args[1])
868
+ return
869
+ }
870
+ // Track property assignments for auto-boxing: x.prop = val
871
+ if (op === '=' && Array.isArray(args[0]) && args[0][0] === '.' && typeof args[0][1] === 'string') {
872
+ const [, obj, prop] = args[0]
873
+ const vt = getVal(obj)
874
+ if ((vt === VAL.NUMBER || vt === VAL.BIGINT) && ctx.func.locals?.has(obj) && ctx.schema.register) {
875
+ if (!ctx.func.localProps) ctx.func.localProps = new Map()
876
+ if (!ctx.func.localProps.has(obj)) ctx.func.localProps.set(obj, new Set())
877
+ ctx.func.localProps.get(obj).add(prop)
878
+ }
879
+ }
880
+ for (const a of args) walk(a)
881
+ }
882
+ walk(body)
883
+
884
+ // Register boxed schemas for local variables with property assignments
885
+ if (ctx.func.localProps) {
886
+ for (const [name, props] of ctx.func.localProps) {
887
+ if (ctx.schema.vars.has(name)) continue
888
+ const schema = ['__inner__', ...props]
889
+ const sid = ctx.schema.register(schema)
890
+ ctx.schema.vars.set(name, sid)
891
+ updateRep(name, { schemaId: sid })
892
+ }
893
+ }
894
+ }
895
+
896
+ /** Forward-propagate `intCertain` on local bindings. Fixpoint lives in type.js. */
897
+ export function analyzeIntCertain(body) {
898
+ for (const [name, intC] of intCertainMap(body)) {
899
+ if (intC) updateRep(name, { intCertain: true })
900
+ }
901
+ }
902
+
903
+ // A directly-uint32 expression: `x >>> 0` (zero-fill shift) or a call to a function
904
+ // already proven `unsignedResult`. Such a value lives in i32 but ranges [0, 2^32),
905
+ // so signed i32 ops on it are wrong — exprType widens its arithmetic to f64 to
906
+ // match emit (which reboxes via `f64.convert_i32_u`). Unsignedness through a local
907
+ // assignment is intentionally not tracked here — kept in lockstep with narrow.js's
908
+ // `isUnsignedTail`, so emit and exprType agree (no trunc_sat saturation).
909
+
910
+ // `analyzeBody` was inlined to `analyzeBody(body).locals` at its three real
911
+ // call sites in src/compile.js and src/narrow.js — the one-line facade existed
912
+ // only as a historical surface and obscured the unified-walk relationship.
913
+
914
+ /**
915
+ * Identify locals that can be stored as an unboxed i32 pointer offset instead of
916
+ * a NaN-boxed f64. Static type is tracked out-of-band so reads skip `__ptr_offset`
917
+ * and `__ptr_type` entirely and writes unbox once at the assignment site.
918
+ *
919
+ * Criteria — the local must be:
920
+ * - declared once with `let`/`const`, never reassigned or compound-assigned
921
+ * - valType is an unambiguous non-forwarding pointer kind:
922
+ * OBJECT, SET, MAP, CLOSURE, TYPED, BUFFER
923
+ * (excluded: ARRAY — forwards on realloc; STRING — SSO/heap dual encoding.)
924
+ * - initialized from a form that guarantees a fresh, non-null pointer of that VAL:
925
+ * OBJECT ← `{…}`
926
+ * SET ← `new Set(...)`
927
+ * MAP ← `new Map(...)`
928
+ * CLOSURE← `=>` literal
929
+ * BUFFER ← `new ArrayBuffer(...)`
930
+ * TYPED ← `new XxxArray(...)` / method returning typed array
931
+ * (`new DataView(...)` is TYPED but stays boxed — no elem aux)
932
+ * - not captured in boxed storage (boxed locals stay f64 for the heap slot)
933
+ * - never compared to null/undefined (we lose the nullish NaN representation)
934
+ *
935
+ * Returns Map<name, VAL> of locals to unbox.
936
+ */
937
+ export function unboxablePtrs(body, locals, boxed) {
938
+ const valOf = name => ctx.func.localReps?.get(name)?.val
939
+ const UNBOXABLE_KINDS = new Set([VAL.OBJECT, VAL.SET, VAL.MAP, VAL.BUFFER, VAL.TYPED, VAL.CLOSURE, VAL.DATE])
940
+
941
+ // RHS must produce a fresh, non-null pointer of the declared VAL kind.
942
+ // OBJECT ← `{…}`
943
+ // CLOSURE ← `=>`
944
+ // SET/MAP/BUFFER/TYPED ← `new X(...)`
945
+ // Validating the exact ctor→VAL match keeps the analysis tied to valTypeOf, so when
946
+ // that helper grows (e.g. `Array.from` → ARRAY), we don't drift out of sync.
947
+ const isFreshInit = (expr, kind) => {
948
+ if (!Array.isArray(expr)) return false
949
+ if (kind === VAL.OBJECT) {
950
+ if (expr[0] === '{}') return true
951
+ // Call to a narrow-ABI'd helper: returns i32 ptr-offset of the same VAL kind.
952
+ // Unboxing skips the f64-rebox at the callsite. Verifying via sig (not just
953
+ // valResult) ensures the call already produces an i32 — which dual-write picks
954
+ // up to bind ptrKind/schemaId on the local.
955
+ if (expr[0] === '()' && typeof expr[1] === 'string') {
956
+ const f = ctx.func.map?.get(expr[1])
957
+ return f?.sig?.ptrKind === kind
958
+ }
959
+ // `let p = arr[i]` where arr has a known elem schema: the runtime helper
960
+ // returns f64 (NaN-box of an OBJECT pointer), but its low 32 bits are
961
+ // exactly the pointer offset. Dual-write coerces once via reinterpret/wrap;
962
+ // subsequent `p.x` reads then become direct `f64.load offset=K (local.get $p)`
963
+ // (since ptrOffsetIR sees ptrKind=OBJECT and skips the per-access wrap).
964
+ if (expr[0] === '[]' && typeof expr[1] === 'string') {
965
+ const repSid = ctx.func.localReps?.get(expr[1])?.arrayElemSchema
966
+ return repSid != null
967
+ }
968
+ return false
969
+ }
970
+ if (kind === VAL.CLOSURE) return expr[0] === '=>'
971
+ if (expr[0] === '()' && typeof expr[1] === 'string') {
972
+ const callee = expr[1]
973
+ if (callee.startsWith('new.')) {
974
+ if (kind === VAL.SET) return callee === 'new.Set'
975
+ if (kind === VAL.MAP) return callee === 'new.Map'
976
+ if (kind === VAL.DATE) return callee === 'new.Date'
977
+ if (kind === VAL.BUFFER) return callee === 'new.ArrayBuffer'
978
+ if (kind === VAL.TYPED) return callee.endsWith('Array') && callee !== 'new.ArrayBuffer'
979
+ }
980
+ // Call to narrow-ABI'd helper of matching VAL kind.
981
+ const f = ctx.func.map?.get(callee)
982
+ if (f?.sig?.ptrKind === kind) return true
983
+ }
984
+ // Method call returning TYPED: `arr.map(fn)` where `arr` is in typedElem
985
+ // (locally TYPED with a known elem ctor). Only `.typed:map` is registered
986
+ // as TYPED-returning — `.filter`/`.slice` fall back to ARRAY emit. The
987
+ // typedElem.has(src) gate ensures we don't accept the polymorphic-receiver
988
+ // path that emits a plain ARRAY result. propagateTyped already mirrored
989
+ // the src ctor onto the receiver, so the unbox path picks up its aux.
990
+ if (kind === VAL.TYPED && expr[0] === '()' &&
991
+ Array.isArray(expr[1]) && expr[1][0] === '.' &&
992
+ typeof expr[1][1] === 'string' && expr[1][2] === 'map' &&
993
+ ctx.types.typedElem?.has(expr[1][1])) {
994
+ return true
995
+ }
996
+ return false
997
+ }
998
+ // A policy over `scanBindingUses`: an UNBOXABLE-kind `let/const` local with a
999
+ // fresh-pointer initializer stays unboxable unless some use forbids it. The
1000
+ // only forbidding uses are a reassignment (`=`/compound/`++`/`--`) or a
1001
+ // null/undefined comparison (an unboxed pointer has no nullish NaN form).
1002
+ // Closure captures do not disqualify — a capture-*mutated* local is already
1003
+ // in `boxed`, and a capture-*read* leaves the pointer in its own slot.
1004
+ const result = new Map()
1005
+ for (const [name, s] of scanBindingUses(body)) {
1006
+ const vt = valOf(name)
1007
+ if (!UNBOXABLE_KINDS.has(vt)) continue
1008
+ if (locals.get(name) !== 'f64') continue
1009
+ if (boxed?.has(name)) continue
1010
+ if (!isFreshInit(s.initRhs, vt)) continue
1011
+ const ok = s.uses.every(u =>
1012
+ u.kind !== USE.REASSIGN && !(u.kind === USE.COMPARE && u.nullCmp))
1013
+ if (ok) result.set(name, vt)
1014
+ }
1015
+ return result
1016
+ }
1017
+
1018
+ /**
1019
+ * CSE-safe load bases — `let/const` pointer locals whose `(f64.load offset=K $X)`
1020
+ * reads `cseScalarLoad` (src/optimize.js) may scalar-replace without a store
1021
+ * clobbering them. `cseScalarLoad` is module-wide disabled because it scanned
1022
+ * *every* i32 local; a store through an i32 local legitimately aliasing the load
1023
+ * base returned stale bytes. This pass is the missing soundness gate: a
1024
+ * per-function whitelist, each entry proven non-aliasing — guarantee, not guess.
1025
+ *
1026
+ * `X` qualifies iff ALL hold:
1027
+ * (a) X is an unboxed pointer — `localReps.get(X).ptrKind` set, `locals[X]==='i32'`.
1028
+ * (b) X is bound exactly once (no re-decl, no `=`/`++`/`--`/compound reassign).
1029
+ * (c) Every occurrence of X is the receiver of a `.`/`?.`/`[]` *read* — never a
1030
+ * write target, never a bare value (alias / arg / return / stored element),
1031
+ * never captured by a closure. So X's pointer lives only in `$X`; nothing
1032
+ * else holds it, and no store names it.
1033
+ * (d) The allocation X's bytes live in is disjoint from every store target.
1034
+ * jz allocations carry one kind each and distinct kinds never share bytes,
1035
+ * so X is store-safe when every store's base has a determinable kind ≠ X's
1036
+ * source kind. Any indeterminable store target disqualifies the whole set
1037
+ * (a store through unknown memory could alias anything).
1038
+ *
1039
+ * (c)+(d): no store in the function can touch a cell reachable via `$X + K`, so
1040
+ * a load on `$X` is invariant between two control-flow boundaries — exactly
1041
+ * `cseScalarLoad`'s straight-line region model. Method-call mutations (`.push`,
1042
+ * …) need no accounting here: the pass already flushes its table on every call.
1043
+ *
1044
+ * Returns `Set<name>` — names only, no `$` prefix (the caller stamps it).
1045
+ */
1046
+ export function cseSafeLoadBases(body, locals, localReps) {
1047
+ if (body === null || typeof body !== 'object') return new Set()
1048
+
1049
+ // Allocation kind a pointer name's bytes live in: ptrKind (unboxed) wins,
1050
+ // else value-kind, else an array-schema'd binding is an ARRAY, else unknown.
1051
+ const kindOf = (name) => {
1052
+ if (typeof name !== 'string') return null
1053
+ const r = localReps?.get(name)
1054
+ return r?.ptrKind || r?.val || (r?.arrayElemSchema != null ? VAL.ARRAY : null) ||
1055
+ ctx.scope.globalValTypes?.get(name) || null
1056
+ }
1057
+ // X's bytes live in: the array/object an element read drew it from
1058
+ // (`X = src[i]` / `X = src.f`), else a fresh `{}`/`new` (X's own kind).
1059
+ const srcKind = (rhs) =>
1060
+ Array.isArray(rhs) && (rhs[0] === '[]' || rhs[0] === '.' || rhs[0] === '?.') &&
1061
+ typeof rhs[1] === 'string' ? kindOf(rhs[1]) : valTypeOf(rhs)
1062
+
1063
+ // Pass 1 — bound-once unboxed-pointer candidates; record each source kind.
1064
+ const cand = new Map() // name → source allocation kind
1065
+ const declCount = new Map()
1066
+ const collect = (node) => {
1067
+ if (!Array.isArray(node)) return
1068
+ const op = node[0]
1069
+ if (op === '=>') return
1070
+ if (op === 'let' || op === 'const') {
1071
+ for (let i = 1; i < node.length; i++) {
1072
+ const a = node[i]
1073
+ if (typeof a === 'string') { declCount.set(a, (declCount.get(a) || 0) + 1); continue }
1074
+ if (Array.isArray(a) && a[0] === '=' && typeof a[1] === 'string') {
1075
+ const name = a[1]
1076
+ declCount.set(name, (declCount.get(name) || 0) + 1)
1077
+ if (localReps?.get(name)?.ptrKind != null && locals.get(name) === 'i32')
1078
+ cand.set(name, srcKind(a[2]))
1079
+ collect(a[2])
1080
+ } else collect(a)
1081
+ }
1082
+ return
1083
+ }
1084
+ for (let i = 1; i < node.length; i++) collect(node[i])
1085
+ }
1086
+ collect(body)
1087
+ for (const [n, c] of declCount) if (c > 1) cand.delete(n)
1088
+ if (!cand.size) return new Set()
1089
+
1090
+ // Pass 2 — every occurrence must be a `.`/`?.`/`[]` read receiver (c).
1091
+ const live = new Set(cand.keys())
1092
+ const walk = (node, inClosure) => {
1093
+ if (!Array.isArray(node)) return
1094
+ const op = node[0]
1095
+ if (op === 'str') return
1096
+ const closured = inClosure || op === '=>'
1097
+ if (op === 'let' || op === 'const') { // decl `=` — bound name is not a use
1098
+ for (let i = 1; i < node.length; i++) {
1099
+ const a = node[i]
1100
+ if (typeof a === 'string') continue
1101
+ if (Array.isArray(a) && a[0] === '=') {
1102
+ if (typeof a[1] !== 'string') walk(a[1], closured)
1103
+ walk(a[2], closured)
1104
+ } else walk(a, closured)
1105
+ }
1106
+ return
1107
+ }
1108
+ if (op === '.' || op === '?.' || op === '[]') { // member READ — receiver is safe
1109
+ const o = node[1]
1110
+ if (typeof o === 'string') { if (inClosure && cand.has(o)) live.delete(o) }
1111
+ else walk(o, closured)
1112
+ if (op === '[]' && node[2] != null) walk(node[2], closured)
1113
+ return
1114
+ }
1115
+ if (ASSIGN_OPS.has(op) || op === '++' || op === '--' || op === 'delete') {
1116
+ const t = node[1] // write target — X here disqualifies
1117
+ if (typeof t === 'string') { if (cand.has(t)) live.delete(t) }
1118
+ else if (Array.isArray(t) && (t[0] === '.' || t[0] === '?.' || t[0] === '[]') &&
1119
+ typeof t[1] === 'string' && cand.has(t[1])) live.delete(t[1])
1120
+ else walk(t, closured)
1121
+ for (let i = 2; i < node.length; i++) walk(node[i], closured)
1122
+ return
1123
+ }
1124
+ for (let i = 1; i < node.length; i++) { // any other position — bare X escapes
1125
+ const c = node[i]
1126
+ if (typeof c === 'string') { if (cand.has(c)) live.delete(c) }
1127
+ else walk(c, closured)
1128
+ }
1129
+ }
1130
+ walk(body, false)
1131
+ if (!live.size) return live
1132
+
1133
+ // Pass 3 — store-target disjointness (d). A store lands in `base`'s allocation.
1134
+ let unknownStore = false
1135
+ const storeKinds = new Set()
1136
+ const scanStores = (node) => {
1137
+ if (!Array.isArray(node)) return
1138
+ const op = node[0]
1139
+ if ((ASSIGN_OPS.has(op) || op === '++' || op === '--') && Array.isArray(node[1]) &&
1140
+ (node[1][0] === '.' || node[1][0] === '?.' || node[1][0] === '[]') &&
1141
+ typeof node[1][1] === 'string') {
1142
+ const k = kindOf(node[1][1])
1143
+ if (k == null) unknownStore = true
1144
+ else storeKinds.add(k)
1145
+ }
1146
+ for (let i = 1; i < node.length; i++) scanStores(node[i])
1147
+ }
1148
+ scanStores(body)
1149
+ if (unknownStore) return new Set()
1150
+
1151
+ const safe = new Set()
1152
+ for (const name of live) {
1153
+ const k = cand.get(name)
1154
+ if (k != null && !storeKinds.has(k)) safe.add(name)
1155
+ }
1156
+ return safe
1157
+ }
1158
+
1159
+
1160
+ /**
1161
+ * Whole-program SRoA eligibility — decides which object schemas may back an
1162
+ * `Array<S>` with the `structInline` carrier (the K f64 schema fields inlined
1163
+ * per element, no per-row heap object). Writes `ctx.schema.inlineArray:
1164
+ * Set<sid>`, read by the array push / index / length codegen.
1165
+ *
1166
+ * Default-disqualify: a schema is inlinable only when *every* observed use of
1167
+ * every `Array<S>` binding — across all user functions and module inits — is
1168
+ * one the structInline codegen handles. A missed or unrecognized use poisons
1169
+ * the schema, so the worst outcome is a lost optimization, never a stride
1170
+ * mismatch (miscompile).
1171
+ *
1172
+ * Handled uses of an `Array<S>` binding `a`:
1173
+ * - decl/reassign from `[]` (empty), a call returning `Array<S>`, or an alias
1174
+ * - `a.push({S-literal})` — struct push (K-cell store)
1175
+ * - `a.length` — physical len / K
1176
+ * - `a[i]` consumed as `const p = a[i]` cursor, or directly `a[i].field`
1177
+ * - `a` passed where the callee param is `Array<S>` (paramReps agreement)
1178
+ * - `return a` when the enclosing function returns `Array<S>`
1179
+ * A cursor `p` (`const p = a[i]`) may only be read/written as `p.field`.
1180
+ * Anything else — bare ref, value escape, other array method, `a[i] = …`
1181
+ * element-replace — poisons S.
1182
+ *
1183
+ * Reads codegen truth: a binding is `Array<S>` iff its settled rep
1184
+ * (`funcFacts.get(func).localReps`) carries `arrayElemSchema = S` — the exact
1185
+ * map the emitter consults — so the analysis and the emitter never disagree on
1186
+ * which bindings are inline-carried.
1187
+ *
1188
+ * Conservative corners (sound, give up the optimization): closures and module
1189
+ * inits are not walked in detail — any schema reachable as a `.push({S})`
1190
+ * argument, an `Array<S>`-returning call, an `[{S}, …]` literal, or a captured
1191
+ * tracked array inside one is poisoned.
1192
+ */
1193
+ export function analyzeStructInline(funcFacts, programFacts) {
1194
+ const inlineArray = ctx.schema?.inlineArray
1195
+ if (!inlineArray || !ctx.schema?.list) return
1196
+ const { paramReps } = programFacts
1197
+ const cand = new Set() // sids observed as an `Array<S>` element schema
1198
+ const black = new Set() // sids disqualified by some use
1199
+
1200
+ const propsOf = (sid) => ctx.schema.list[sid] || []
1201
+ const inSchema = (sid, p) => typeof p === 'string' && propsOf(sid).includes(p)
1202
+ const isStrLit = (k) => Array.isArray(k) && k[0] === 'str' && typeof k[1] === 'string'
1203
+
1204
+ // Argument list of a `['()', callee, argNode]` call node.
1205
+ const argsOf = (node) => {
1206
+ const a = node[2]
1207
+ return a == null ? [] : (Array.isArray(a) && a[0] === ',') ? a.slice(1) : [a]
1208
+ }
1209
+
1210
+ // `name` referenced anywhere as a value (skips `:`/`.` property-name slots).
1211
+ const mentions = (node, name) => {
1212
+ if (typeof node === 'string') return node === name
1213
+ if (!Array.isArray(node)) return false
1214
+ const op = node[0]
1215
+ if (op === 'str') return false
1216
+ if (op === ':') return mentions(node[2], name)
1217
+ if (op === '.' || op === '?.') return mentions(node[1], name)
1218
+ for (let i = 1; i < node.length; i++) if (mentions(node[i], name)) return true
1219
+ return false
1220
+ }
1221
+
1222
+ // Poison every schema whose `Array<S>` could materialize inside an un-walked
1223
+ // subtree (closure body / module init): `.push({S})` args, `Array<S>`-returning
1224
+ // calls, `[{S}, …]` array literals. Standalone `{S}` objects are independent
1225
+ // of array layout and intentionally left alone.
1226
+ const poisonAll = (node) => {
1227
+ if (!Array.isArray(node)) return
1228
+ const op = node[0]
1229
+ if (op === '()') {
1230
+ const callee = node[1]
1231
+ if (typeof callee === 'string') {
1232
+ const sid = ctx.func.map?.get(callee)?.arrayElemSchema
1233
+ if (sid != null) black.add(sid)
1234
+ } else if (Array.isArray(callee) && callee[0] === '.' && callee[2] === 'push') {
1235
+ for (const a of argsOf(node)) {
1236
+ const sid = objLiteralSchemaId(a)
1237
+ if (sid != null) black.add(sid)
1238
+ }
1239
+ }
1240
+ } else if (op === '[' || op === '[]') {
1241
+ for (const el of staticArrayElems(node) || []) {
1242
+ const sid = objLiteralSchemaId(el)
1243
+ if (sid != null) black.add(sid)
1244
+ }
1245
+ }
1246
+ for (let i = 1; i < node.length; i++) poisonAll(node[i])
1247
+ }
1248
+
1249
+ for (const [func, facts] of funcFacts) {
1250
+ const body = func?.body
1251
+ const reps = facts?.localReps
1252
+ if (func?.raw || !reps || body == null || typeof body !== 'object') continue
1253
+
1254
+ // `Array<S>` bindings of this function (codegen truth) and their schemas.
1255
+ const arrName = new Map() // name → sid
1256
+ for (const [name, r] of reps) {
1257
+ const sid = r?.arrayElemSchema
1258
+ if (sid == null) continue
1259
+ if ((propsOf(sid).length || 0) < 1) continue // K=0 — not inlinable
1260
+ cand.add(sid)
1261
+ arrName.set(name, sid)
1262
+ }
1263
+ if (!arrName.size) continue
1264
+
1265
+ // A structInline `Array<S>` value is only ever born from an empty `[]`
1266
+ // grown by structInline `.push`. `expr` is such a producer of `Array<sid>`
1267
+ // iff it is: a tracked `Array<sid>` alias, an empty `[]` literal, or a call
1268
+ // to a user function (whose returned array is structInline whenever sid
1269
+ // survives this whole-program pass). Every other source — a non-empty
1270
+ // `[{S},…]` literal, a builtin call (`JSON.parse`, `Object.values`, `.map`,
1271
+ // `.slice`, a member access onto a parsed object) — yields a taggedLinear
1272
+ // array and must poison sid.
1273
+ const safeArrSource = (expr, sid) => {
1274
+ if (typeof expr === 'string') return arrName.get(expr) === sid
1275
+ if (!Array.isArray(expr)) return false
1276
+ const elems = staticArrayElems(expr)
1277
+ if (elems) return elems.length === 0
1278
+ return expr[0] === '()' && typeof expr[1] === 'string' && !!ctx.func.map?.has(expr[1])
1279
+ }
1280
+
1281
+ // Pass 1 — collect `const p = a[i]` cursors; drop on name clash / re-decl.
1282
+ const cursor = new Map() // name → sid
1283
+ const declSeen = new Set()
1284
+ const collectCursors = (node) => {
1285
+ if (!Array.isArray(node) || node[0] === '=>') return
1286
+ if (node[0] === 'let' || node[0] === 'const') {
1287
+ for (let i = 1; i < node.length; i++) {
1288
+ const d = node[i]
1289
+ if (!Array.isArray(d) || d[0] !== '=' || typeof d[1] !== 'string') continue
1290
+ const name = d[1], rhs = d[2]
1291
+ if (declSeen.has(name)) { const s = cursor.get(name); if (s != null) black.add(s) }
1292
+ declSeen.add(name)
1293
+ if (Array.isArray(rhs) && rhs[0] === '[]' && rhs.length === 3 &&
1294
+ typeof rhs[1] === 'string' && arrName.has(rhs[1]) && !isStrLit(rhs[2])) {
1295
+ const sid = arrName.get(rhs[1])
1296
+ if (cursor.has(name) || arrName.has(name)) black.add(sid)
1297
+ else cursor.set(name, sid)
1298
+ }
1299
+ }
1300
+ }
1301
+ for (let i = 1; i < node.length; i++) collectCursors(node[i])
1302
+ }
1303
+ collectCursors(body)
1304
+
1305
+ // A `['[]', arrName, idx]` element read of a tracked array → its sid.
1306
+ const elemArrSid = (n) =>
1307
+ Array.isArray(n) && n[0] === '[]' && n.length === 3 &&
1308
+ typeof n[1] === 'string' && arrName.has(n[1]) && !isStrLit(n[2])
1309
+ ? arrName.get(n[1]) : null
1310
+
1311
+ // Pass 2 — verify every occurrence is a structInline-handled use.
1312
+ const flag = (c) => {
1313
+ if (typeof c !== 'string') return false
1314
+ if (arrName.has(c)) { black.add(arrName.get(c)); return true }
1315
+ if (cursor.has(c)) { black.add(cursor.get(c)); return true }
1316
+ return false
1317
+ }
1318
+ const visitChild = (c) => { if (!flag(c)) verify(c) }
1319
+
1320
+ function verify(node) {
1321
+ if (!Array.isArray(node)) return
1322
+ const op = node[0]
1323
+ if (op === 'str') return
1324
+ if (op === '=>') { // closure — un-walked, poison
1325
+ for (const n of arrName.keys()) if (mentions(node, n)) black.add(arrName.get(n))
1326
+ for (const [n, s] of cursor) if (mentions(node, n)) black.add(s)
1327
+ poisonAll(node)
1328
+ return
1329
+ }
1330
+ if (op === ':') { visitChild(node[2]); return }
1331
+
1332
+ if (op === '.' || op === '?.') {
1333
+ const o = node[1], p = node[2]
1334
+ if (typeof o === 'string') {
1335
+ if (arrName.has(o)) { if (!(op === '.' && p === 'length')) black.add(arrName.get(o)) }
1336
+ else if (cursor.has(o)) { if (!(op === '.' && inSchema(cursor.get(o), p))) black.add(cursor.get(o)) }
1337
+ return
1338
+ }
1339
+ const esid = elemArrSid(o)
1340
+ if (esid != null) {
1341
+ if (!(op === '.' && inSchema(esid, p))) black.add(esid)
1342
+ visitChild(o[2])
1343
+ return
1344
+ }
1345
+ visitChild(o)
1346
+ return
1347
+ }
1348
+
1349
+ if (op === '[]') {
1350
+ const o = node[1], k = node[2]
1351
+ if (typeof o === 'string') {
1352
+ if (arrName.has(o)) black.add(arrName.get(o)) // element value escape
1353
+ else if (cursor.has(o)) { if (!(isStrLit(k) && inSchema(cursor.get(o), k[1]))) black.add(cursor.get(o)) }
1354
+ if (k != null) visitChild(k)
1355
+ return
1356
+ }
1357
+ const esid = elemArrSid(o)
1358
+ if (esid != null) {
1359
+ if (!(isStrLit(k) && inSchema(esid, k[1]))) black.add(esid)
1360
+ visitChild(o[2])
1361
+ } else if (o != null) visitChild(o)
1362
+ if (k != null) visitChild(k)
1363
+ return
1364
+ }
1365
+
1366
+ // Reassignment of the array binding — the rhs must be a structInline
1367
+ // `Array<S>` producer; an alias is left un-walked (flagging it would
1368
+ // self-poison), other producers are walked to verify their subtree.
1369
+ if (op === '=' && typeof node[1] === 'string' && arrName.has(node[1])) {
1370
+ const sid = arrName.get(node[1])
1371
+ if (!safeArrSource(node[2], sid)) black.add(sid)
1372
+ else if (typeof node[2] !== 'string') visitChild(node[2])
1373
+ return
1374
+ }
1375
+
1376
+ if (op === '()') {
1377
+ const callee = node[1]
1378
+ if (Array.isArray(callee) && callee[0] === '.') {
1379
+ const recv = callee[1], method = callee[2]
1380
+ if (typeof recv === 'string' && arrName.has(recv)) {
1381
+ const sid = arrName.get(recv)
1382
+ const args = argsOf(node)
1383
+ if (method !== 'push' || !args.length) black.add(sid)
1384
+ else for (const arg of args) {
1385
+ if (Array.isArray(arg) && arg[0] === '{}' && objLiteralSchemaId(arg) === sid) {
1386
+ for (let i = 1; i < arg.length; i++) {
1387
+ const pr = arg[i]
1388
+ visitChild(Array.isArray(pr) && pr[0] === ':' ? pr[2] : pr)
1389
+ }
1390
+ } else black.add(sid)
1391
+ }
1392
+ return
1393
+ }
1394
+ if (typeof recv === 'string' && cursor.has(recv)) { black.add(cursor.get(recv)); return }
1395
+ const esid = elemArrSid(recv)
1396
+ if (esid != null) { black.add(esid); visitChild(recv[2]) }
1397
+ else visitChild(recv)
1398
+ for (const a of argsOf(node)) visitChild(a)
1399
+ return
1400
+ }
1401
+ if (typeof callee === 'string') {
1402
+ const args = argsOf(node)
1403
+ const known = ctx.func.map?.has(callee)
1404
+ const cParams = paramReps?.get(callee)
1405
+ for (let k = 0; k < args.length; k++) {
1406
+ const arg = args[k]
1407
+ if (typeof arg === 'string' && arrName.has(arg)) {
1408
+ const sid = arrName.get(arg)
1409
+ if (!(known && cParams?.get(k)?.arrayElemSchema === sid)) black.add(sid)
1410
+ } else if (!flag(arg)) verify(arg)
1411
+ }
1412
+ return
1413
+ }
1414
+ visitChild(callee)
1415
+ for (const a of argsOf(node)) visitChild(a)
1416
+ return
1417
+ }
1418
+
1419
+ if (op === 'return') {
1420
+ const e = node[1]
1421
+ if (typeof e === 'string') {
1422
+ if (arrName.has(e)) { if (func.arrayElemSchema !== arrName.get(e)) black.add(arrName.get(e)) }
1423
+ else flag(e)
1424
+ return
1425
+ }
1426
+ // A function typed `Array<S>` must return a structInline producer —
1427
+ // a non-empty literal / builtin call here yields a taggedLinear array.
1428
+ if (func.arrayElemSchema != null && !safeArrSource(e, func.arrayElemSchema))
1429
+ black.add(func.arrayElemSchema)
1430
+ const esid = elemArrSid(e)
1431
+ if (esid != null) { black.add(esid); visitChild(e[2]); return }
1432
+ if (e != null) visitChild(e)
1433
+ return
1434
+ }
1435
+
1436
+ if (op === 'let' || op === 'const') {
1437
+ for (let i = 1; i < node.length; i++) {
1438
+ const d = node[i]
1439
+ if (!Array.isArray(d) || d[0] !== '=') { if (Array.isArray(d)) visitChild(d); continue }
1440
+ const name = d[1], rhs = d[2]
1441
+ if (typeof name === 'string' && cursor.has(name) &&
1442
+ Array.isArray(rhs) && rhs[0] === '[]') {
1443
+ if (rhs[2] != null) visitChild(rhs[2]) // cursor decl — verify index only
1444
+ continue
1445
+ }
1446
+ if (typeof name === 'string' && arrName.has(name)) {
1447
+ const sid = arrName.get(name)
1448
+ if (!safeArrSource(rhs, sid)) black.add(sid) // non-structInline producer
1449
+ else if (typeof rhs !== 'string') visitChild(rhs) // [] / user-call — verify subtree
1450
+ continue
1451
+ }
1452
+ if (typeof name !== 'string') visitChild(name)
1453
+ visitChild(rhs)
1454
+ }
1455
+ return
1456
+ }
1457
+
1458
+ for (let i = 1; i < node.length; i++) visitChild(node[i])
1459
+ }
1460
+ verify(body)
1461
+ }
1462
+
1463
+ // Module inits are not walked in detail — poison any schema whose array form
1464
+ // could appear there (struct-array consumed/built at module scope).
1465
+ if (ctx.module?.moduleInits) for (const mi of ctx.module.moduleInits) poisonAll(mi)
1466
+
1467
+ for (const sid of cand) if (!black.has(sid)) inlineArray.add(sid)
1468
+ }
1469
+
1470
+ /** Schema id when `name` is bound (codegen truth) to a structInline `Array<S>`,
1471
+ * else null. `ctx.func.localReps` is the per-function rep map the emitter
1472
+ * consults for element-schema facts; `ctx.schema.inlineArray` is the
1473
+ * whole-program eligibility set filled by `analyzeStructInline`. Read together
1474
+ * so the emitter never inline-carries a binding the analysis rejected. */
1475
+
1476
+ /**
1477
+ * Whole-program function-namespace SRoA analysis.
1478
+ *
1479
+ * A user function used as a property bag — `parse.space = …; parse.step()` —
1480
+ * is otherwise compiled as a dynamic object: each `f.prop` write becomes a
1481
+ * `__dyn_set` into a hash side-table keyed by the closure pointer, each read a
1482
+ * `__dyn_get`. But a function's property table can never be observed by the
1483
+ * host (the host gets only the callable; the table lives in jz linear memory),
1484
+ * so the property set is statically closed — jz sees every `f.PROP` site. When
1485
+ * `f` never escapes as a bare value, each property dissolves:
1486
+ * - written once, at module top level, to a known function → the property is
1487
+ * constant: every `f.PROP` site rewrites straight to that function name
1488
+ * (direct calls, no storage at all).
1489
+ * - otherwise → a mutable f64 module global (`global.get` / `global.set`).
1490
+ *
1491
+ * Returns `Map<funcName, { disq, props:Set, valRead:Set,
1492
+ * writes:Map<prop,[{rhs,atInit}]> }>` — `valRead` is the subset of props read
1493
+ * as a value (not merely called). `flattenFuncNamespaces` (plan.js) turns it
1494
+ * into the rewrite. A name carrying `disq` escapes / is reassigned / is
1495
+ * computed-indexed and must not be touched.
1496
+ */
1497
+ export function analyzeFuncNamespaces(ast) {
1498
+ const funcNames = ctx.func.names
1499
+ if (!funcNames || !funcNames.size) return new Map()
1500
+
1501
+ const ns = new Map()
1502
+ const rec = (name) => {
1503
+ let r = ns.get(name)
1504
+ if (!r) ns.set(name, r = { disq: false, props: new Set(), valRead: new Set(), writes: new Map() })
1505
+ return r
1506
+ }
1507
+ // `['.'|'?.', f, P]` member where f is a known function and P a string key.
1508
+ const memberOf = (n) =>
1509
+ Array.isArray(n) && (n[0] === '.' || n[0] === '?.') &&
1510
+ isFuncRef(n[1], funcNames) && typeof n[2] === 'string' ? n : null
1511
+
1512
+ // `atInit` — node is a direct top-level statement (constant-fold candidate);
1513
+ // read only at the `=` handler, never propagated into sub-expressions.
1514
+ function visit(node, atInit) {
1515
+ if (typeof node === 'string') {
1516
+ // Bare mention of a known function in value position — it escapes; an
1517
+ // alias could reach its property table. Disqualify.
1518
+ if (funcNames.has(node)) rec(node).disq = true
1519
+ return
1520
+ }
1521
+ if (!Array.isArray(node)) return
1522
+ const op = node[0]
1523
+
1524
+ if (op === 'let' || op === 'const') {
1525
+ for (let i = 1; i < node.length; i++) {
1526
+ const d = node[i]
1527
+ if (Array.isArray(d) && d[0] === '=') {
1528
+ if (!isFuncRef(d[1], funcNames)) visit(d[1], false) // skip f's own decl
1529
+ // `let f = f` is prepare's self-name placeholder for a lifted function —
1530
+ // skip it (a bare visit would falsely disqualify f). Any other funcRef
1531
+ // rhs (`let g = f`) is a real alias and must disqualify f.
1532
+ if (!(isFuncRef(d[2], funcNames) && d[2] === d[1])) visit(d[2], false)
1533
+ } else visit(d, false)
1534
+ }
1535
+ return
1536
+ }
1537
+
1538
+ if (op === 'export') {
1539
+ // Exporting the function value is safe — the host gets the callable,
1540
+ // never the linear-memory property table. Skip bare function children.
1541
+ for (let i = 1; i < node.length; i++)
1542
+ if (!isFuncRef(node[i], funcNames)) visit(node[i], false)
1543
+ return
1544
+ }
1545
+
1546
+ if (op === '=') {
1547
+ const m = memberOf(node[1])
1548
+ if (m) {
1549
+ const r = rec(m[1]); r.props.add(m[2])
1550
+ let w = r.writes.get(m[2]); if (!w) r.writes.set(m[2], w = [])
1551
+ w.push({ rhs: node[2], atInit })
1552
+ visit(node[2], false)
1553
+ return
1554
+ }
1555
+ if (isFuncRef(node[1], funcNames)) rec(node[1]).disq = true // reassignment
1556
+ else visit(node[1], false)
1557
+ visit(node[2], false)
1558
+ return
1559
+ }
1560
+
1561
+ if (op === '()') {
1562
+ const m = memberOf(node[1])
1563
+ if (m) rec(m[1]).props.add(m[2])
1564
+ else if (!isFuncRef(node[1], funcNames)) visit(node[1], false) // bare f(...) ok
1565
+ for (let i = 2; i < node.length; i++) visit(node[i], false)
1566
+ return
1567
+ }
1568
+
1569
+ // `f.PROP` / `f?.PROP` as a plain value (read) — not the callee of a call
1570
+ // (those are handled by the `()` branch above). A value-read means the
1571
+ // property's stored value must stay retrievable; devirt cannot drop it.
1572
+ const m = memberOf(node)
1573
+ if (m) { const r = rec(m[1]); r.props.add(m[2]); r.valRead.add(m[2]); return }
1574
+
1575
+ // Computed `f[k]` — the key set is no longer static.
1576
+ if (op === '[]' && isFuncRef(node[1], funcNames)) {
1577
+ rec(node[1]).disq = true
1578
+ for (let i = 2; i < node.length; i++) visit(node[i], false)
1579
+ return
1580
+ }
1581
+
1582
+ for (let i = 1; i < node.length; i++) visit(node[i], false)
1583
+ }
1584
+
1585
+ const visitTop = (n) => {
1586
+ if (Array.isArray(n) && n[0] === ';')
1587
+ for (let i = 1; i < n.length; i++) visit(n[i], true)
1588
+ else visit(n, true)
1589
+ }
1590
+ visitTop(ast)
1591
+ // Bundled multi-module programs keep each module's top-level statements in
1592
+ // moduleInits, not `ast` — the `f.prop = …` writes that define a namespace
1593
+ // live there. Walk them at init scope so writes are recorded and an escape
1594
+ // inside init code still disqualifies.
1595
+ for (const mi of ctx.module.moduleInits || []) visitTop(mi)
1596
+ for (const fn of ctx.func.list) if (fn.body && !fn.raw) visit(fn.body, false)
1597
+
1598
+ return ns
1599
+ }
1600
+