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