jz 0.0.0 → 0.1.1

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.
package/src/analyze.js ADDED
@@ -0,0 +1,1927 @@
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.repByLocal` (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
+ * - valTypeOf: expression-level value-type inference (pure)
12
+ * - lookupValType: name→VAL.* resolver (func scope ∪ global scope)
13
+ * - analyzeBody: single unified walk — body-keyed cache, returns
14
+ * { locals, valTypes, arrElemSchemas, arrElemValTypes, typedElems }
15
+ * - analyzeValTypes: ctx-mutating pass — writes types + tracks regex/typed + localProps
16
+ * - analyzeLocals: thin clone-and-extend facade over analyzeBody().locals
17
+ * - analyzeDynKeys: cross-function scan for `obj[runtimeKey]` → sets ctx.types.dynKeyVars
18
+ * - analyzeBoxedCaptures:detect mutably-captured vars → ctx.func.boxed cells
19
+ * - extractParams/classifyParam/collectParamNames: arrow param AST normalization helpers
20
+ *
21
+ * Ordering: analyzeDynKeys runs once per compile; others run per function during compile().
22
+ *
23
+ * @module analyze
24
+ */
25
+
26
+ import { ctx, err } from './ctx.js'
27
+
28
+ export const T = '\uE000'
29
+
30
+ /** Statement operators — used to distinguish block bodies from object literals. */
31
+ export const STMT_OPS = new Set([';', 'let', 'const', 'return', 'if', 'for', 'for-in', 'while', 'break', 'continue', 'switch',
32
+ '=', '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '>>=', '<<=', '>>>=', '||=', '&&=', '??=',
33
+ 'throw', 'try', 'catch', 'finally', '++', '--', '()'])
34
+
35
+ /** Distinguish a function block body `{ ... }` from an expression-bodied object literal `({a:1})`.
36
+ * Both share the `'{}'` op tag; blocks have a non-`':'` first child (object literals start with key:val pairs). */
37
+ export const isBlockBody = (body) =>
38
+ Array.isArray(body) && body[0] === '{}' && body[1]?.[0] !== ':'
39
+
40
+ /** Collect all `return X` expressions (X != null) from a function body, skipping nested arrow funcs.
41
+ * Pushes into `out`. Non-returning paths are silently skipped — pair with `alwaysReturns` if total
42
+ * coverage matters, or with `hasBareReturn` to detect `return;` (undef result). */
43
+ export const collectReturnExprs = (node, out) => {
44
+ if (!Array.isArray(node)) return
45
+ const [op, ...args] = node
46
+ if (op === '=>') return
47
+ if (op === 'return') { if (args[0] != null) out.push(args[0]); return }
48
+ for (const a of args) collectReturnExprs(a, out)
49
+ }
50
+
51
+ /** True if every control-flow path through `n` is guaranteed to terminate via return/throw.
52
+ * Conservative: only recognizes block-trailing return, both arms of complete if/else. Loops/switches
53
+ * count as non-terminating since fall-through is possible. Used by ptr-narrowing to ensure
54
+ * fallthrough fallback won't produce a wrong-typed undef. */
55
+ export const alwaysReturns = (n) => {
56
+ if (!Array.isArray(n)) return false
57
+ const op = n[0]
58
+ if (op === '=>') return false
59
+ if (op === 'return' || op === 'throw') return true
60
+ if (op === '{}' || op === ';') return alwaysReturns(n[n.length - 1])
61
+ if (op === 'if') return n.length >= 4 && alwaysReturns(n[2]) && alwaysReturns(n[3])
62
+ return false
63
+ }
64
+
65
+ /** True if `n` contains a bare `return;` (no value → undefined).
66
+ * Bare returns force the result type to f64 (undef sentinel) — narrowing must skip such bodies. */
67
+ export const hasBareReturn = (n) => {
68
+ if (!Array.isArray(n)) return false
69
+ if (n[0] === '=>') return false
70
+ if (n[0] === 'return' && n[1] == null) return true
71
+ return n.some(hasBareReturn)
72
+ }
73
+
74
+ /** Unify body→return-expressions: block bodies collect via `collectReturnExprs`,
75
+ * expression bodies wrap into `[body]`. Pure convenience over the
76
+ * `if (isBlock) collect(...) else exprs.push(body)` pattern repeated across
77
+ * narrowing passes. */
78
+ export const returnExprs = (body) => {
79
+ if (isBlockBody(body)) {
80
+ const out = []
81
+ collectReturnExprs(body, out)
82
+ return out
83
+ }
84
+ return [body]
85
+ }
86
+
87
+ // Value types — what a variable holds (for method dispatch, schema resolution)
88
+ export const VAL = {
89
+ NUMBER: 'number', ARRAY: 'array', STRING: 'string',
90
+ OBJECT: 'object', HASH: 'hash', SET: 'set', MAP: 'map',
91
+ CLOSURE: 'closure', TYPED: 'typed', REGEX: 'regex',
92
+ BIGINT: 'bigint', BUFFER: 'buffer',
93
+ }
94
+
95
+ /**
96
+ * ValueRep — unified per-local + per-param representation record. (S2.)
97
+ *
98
+ * One shape, two storages:
99
+ * - per-local (current func): ctx.func.repByLocal: Map<name, ValueRep>
100
+ * - per-param (cross-call): programFacts.paramReps: Map<funcName, Map<paramIdx, ValueRep>>
101
+ *
102
+ * Lattice per field: undefined = unobserved, null = sticky-poison
103
+ * (cross-site disagreement), value = consensus. Local reps don't use the null
104
+ * sentinel (locals are intra-function — single point of truth). Param reps do
105
+ * (cross-call fixpoint convergence).
106
+ *
107
+ * Fields:
108
+ * val: VAL.* — value-type for method dispatch / schema / length
109
+ * wasm: 'i32'|'f64' — narrowed wasm type at param boundary (param-only today)
110
+ * ptrKind: VAL.* — local stores unboxed i32 pointer offset (local-only today)
111
+ * ptrAux: i32 — kind-dependent aux (TYPED elem code, schemaId, …)
112
+ * schemaId: i32 — schema binding for known-shape OBJECTs
113
+ * arrayElemSchema: i32 — Array<schemaId> element shape
114
+ * arrayElemValType: VAL.* — Array<VAL.*> element val-kind
115
+ * jsonShape: obj — { vt, props?, elem? } for HASH/ARRAY trees parsed
116
+ * from a compile-time JSON.parse source. Propagates
117
+ * through `.prop` and `[i]` so nested chains stay typed.
118
+ * typedCtor: str — TypedArray ctor name (`Float64Array`, …)
119
+ * intCertain: bool — proven integer-valued (every defining RHS is integer-shaped).
120
+ * Pure analysis fact; codegen extensions may use it to choose
121
+ * i32-shaped emission inside hot regions where range fits.
122
+ * Boundary ABI is NOT narrowed by this fact alone — narrowing
123
+ * at param/result level remains a separate, opt-in decision.
124
+ * intConst: number — proven same integer literal at every static call site.
125
+ * Param-only (cross-call fixpoint). Drives constant substitution
126
+ * at readVar: every `local.get $param` lowers to `i32.const N`
127
+ * (or `f64.const N`), letting the WAT optimizer fold guards,
128
+ * unroll fixed-bound loops, and treeshake the read entirely.
129
+ * Cleared if the param is written inside the body.
130
+ *
131
+ * Future (S2 stage 4 follow-ups): boxed, intLikely, nullable.
132
+ */
133
+
134
+ // === ParamReps lattice helpers (cross-call fixpoint) ===
135
+ // programFacts.paramReps: Map<funcName, Map<paramIdx, ValueRep>>. Per-field lattice:
136
+ // undefined unobserved, null sticky-poison (cross-site disagreement), value = consensus.
137
+
138
+ /** Per-call-site fact merge into a param's ValueRep field, with sticky-null poison
139
+ * on disagreement. undefined→observed (set); same value→stay; conflict→null (sticky).
140
+ * null is "no consensus" — readers treat it as missing. */
141
+ export const mergeParamFact = (rep, key, observed) => {
142
+ if (rep[key] === null) return // sticky poison
143
+ if (observed == null) { rep[key] = null; return } // unknown → poison
144
+ if (rep[key] === undefined) rep[key] = observed
145
+ else if (rep[key] !== observed) rep[key] = null
146
+ }
147
+
148
+ /** Get-or-create per-param rep at (funcName, paramIdx) on a paramReps map. */
149
+ export const ensureParamRep = (paramReps, funcName, k) => {
150
+ let m = paramReps.get(funcName)
151
+ if (!m) { m = new Map(); paramReps.set(funcName, m) }
152
+ let r = m.get(k)
153
+ if (!r) { r = {}; m.set(k, r) }
154
+ return r
155
+ }
156
+
157
+ /** Build `paramName → fact` lookup for a caller's already-narrowed param facts.
158
+ * Used to flow caller's param info into its callees during the cross-call
159
+ * fixpoint (transitive propagation). Returns null if caller has no facts. */
160
+ export const callerParamFactMap = (paramReps, callerFunc, key) => {
161
+ if (!callerFunc) return null
162
+ const m = paramReps.get(callerFunc.name)
163
+ if (!m) return null
164
+ let out = null
165
+ for (const [k, r] of m) {
166
+ const v = r[key]
167
+ if (v != null && k < callerFunc.sig.params.length) {
168
+ out ||= new Map()
169
+ out.set(callerFunc.sig.params[k].name, v)
170
+ }
171
+ }
172
+ return out
173
+ }
174
+
175
+ /** Reset sticky-null on a single field across all params program-wide.
176
+ * Used between fixpoint phases when newly-narrowed facts unblock previously-
177
+ * poisoned observations (e.g. valResult set after first pass). */
178
+ export const clearStickyNull = (paramReps, key) => {
179
+ for (const m of paramReps.values()) for (const r of m.values()) {
180
+ if (r[key] === null) r[key] = undefined
181
+ }
182
+ }
183
+
184
+ /** Get the rep for a local name, or undefined if not tracked. */
185
+ export const repOf = name => ctx.func.repByLocal?.get(name)
186
+
187
+ /** Merge fields into a local's rep. Lazily allocates the map and the rep.
188
+ * Field set to `undefined` removes that field; empty rep is dropped from the map. */
189
+ export const updateRep = (name, fields) => {
190
+ const m = ctx.func.repByLocal ||= new Map()
191
+ const prev = m.get(name) || {}
192
+ const next = { ...prev, ...fields }
193
+ for (const k of Object.keys(next)) if (next[k] === undefined) delete next[k]
194
+ if (Object.keys(next).length === 0) m.delete(name)
195
+ else m.set(name, next)
196
+ }
197
+
198
+ /** Get the rep for a global name, or undefined if not tracked. */
199
+ export const repOfGlobal = name => ctx.scope.repByGlobal?.get(name)
200
+
201
+ /** Merge fields into a global's rep. Lazily allocates the map and the rep. */
202
+ export const updateGlobalRep = (name, fields) => {
203
+ const m = ctx.scope.repByGlobal ||= new Map()
204
+ const prev = m.get(name)
205
+ m.set(name, prev ? { ...prev, ...fields } : { ...fields })
206
+ }
207
+
208
+ /** Look up value type for a variable name. Order: flow-sensitive refinement (if any) →
209
+ * in-progress analyzeBody overlay (if any) → function-local scope → module-global scope.
210
+ * Refinements are pushed by the 'if' emitter when the condition is a type guard
211
+ * (typeof x === 't', Array.isArray(x), etc.) and popped after the then-branch.
212
+ * The overlay (`ctx.func.localValTypesOverlay`) is set by analyzeBody/observeSlots passes
213
+ * pre-emit, when `repByLocal` isn't populated yet but a local Map<name, VAL.*> is
214
+ * available — lets `const x = new Float64Array(); const y = x[0]` resolve y as NUMBER. */
215
+ export const lookupValType = name => {
216
+ const r = ctx.func.refinements
217
+ if (r && r.size) { const v = r.get(name); if (v) return v }
218
+ const ov = ctx.func.localValTypesOverlay
219
+ if (ov) { const v = ov.get(name); if (v) return v }
220
+ return ctx.func.repByLocal?.get(name)?.val || ctx.scope.globalValTypes?.get(name) || null
221
+ }
222
+
223
+ /** Infer value type of an AST expression (without emitting). */
224
+ export function valTypeOf(expr) {
225
+ if (expr == null) return null
226
+ if (typeof expr === 'number') return VAL.NUMBER
227
+ if (typeof expr === 'bigint') return VAL.BIGINT
228
+ if (typeof expr === 'string') return lookupValType(expr)
229
+ if (!Array.isArray(expr)) return null
230
+
231
+ const [op, ...args] = expr
232
+ if (op == null) {
233
+ // Literal forms: [] = undefined, [null, null] = null, [null, n] = number/bigint
234
+ if (args.length === 0) return null // undefined literal
235
+ if (args[0] == null) return null // null literal
236
+ return typeof args[0] === 'bigint' ? VAL.BIGINT : VAL.NUMBER
237
+ }
238
+
239
+ if (op === '[') return VAL.ARRAY
240
+ if (op === 'str' || op === 'strcat') return VAL.STRING
241
+ if (op === '=>') return VAL.CLOSURE
242
+ if (op === '//') return VAL.REGEX
243
+ if (op === '{}' && args[0]?.[0] === ':') return VAL.OBJECT
244
+ // `[]` op covers both array literals (1 arg) and index access (2 args).
245
+ // Array literal: `[]` → ['[]', null]; `[1,2]` → ['[]', [',', ...]]; `[x]` → ['[]', x].
246
+ // Index access: `arr[i]` → ['[]', arr, i].
247
+ if (op === '[]') {
248
+ if (args.length < 2) return VAL.ARRAY
249
+ // Indexed read on a known typed-array receiver yields a number (BigInt64/BigUint64Array
250
+ // would yield BigInt, but they're rare and we don't track per-elem type here — the
251
+ // .typed:[] emit path already handles their f64-cast correctly; this only affects
252
+ // arithmetic-time __to_num elision, where assuming Number is safe-by-construction).
253
+ if (typeof args[0] === 'string' && lookupValType(args[0]) === VAL.TYPED) return VAL.NUMBER
254
+ // Indexed read on a known Array<VAL> receiver: bind by rep.arrayElemValType.
255
+ // Set by analyzeValTypes from body observations + emitFunc preseed for params.
256
+ if (typeof args[0] === 'string') {
257
+ const elemVt = ctx.func.repByLocal?.get(args[0])?.arrayElemValType
258
+ if (elemVt) return elemVt
259
+ }
260
+ }
261
+ // Schema slot read: when `varName` has a bound schemaId and `.prop` resolves
262
+ // to a slot whose VAL kind is monomorphic across program-wide observations,
263
+ // return that kind. Lets `+`, `===`, method dispatch skip runtime str-key
264
+ // checks on numeric properties of known shapes. Precise-only — see
265
+ // ctx.schema.slotVT for why structural subtyping is intentionally off.
266
+ if (op === '.' && typeof args[1] === 'string' && ctx.schema?.slotVT) {
267
+ const slotVT = ctx.schema.slotVT(args[0], args[1])
268
+ if (slotVT) return slotVT
269
+ }
270
+ // VAL.HASH `.prop` propagation: when the receiver chain roots at a binding
271
+ // sourced from `JSON.parse(stringConst)`, walk the shape tree to recover the
272
+ // child's val-type. Generic for any compile-time-known JSON literal.
273
+ if (op === '.' && typeof args[1] === 'string') {
274
+ const sh = shapeOf(args[0])
275
+ if (sh?.vt === VAL.HASH) {
276
+ const child = sh.props[args[1]]
277
+ if (child) return child.vt
278
+ }
279
+ }
280
+ // Arithmetic expressions: BigInt if either operand is BigInt, else number
281
+ if (['-', 'u-', '*', '/', '%', '&', '|', '^', '<<', '>>'].includes(op)) {
282
+ if (valTypeOf(args[0]) === VAL.BIGINT || valTypeOf(args[1]) === VAL.BIGINT) return VAL.BIGINT
283
+ return VAL.NUMBER
284
+ }
285
+ if (['**', '++', '--', '~', '>>>', 'u+'].includes(op)) return VAL.NUMBER
286
+ if (op === '+') {
287
+ const ta = valTypeOf(args[0]), tb = valTypeOf(args[1])
288
+ if (ta === VAL.STRING || tb === VAL.STRING) return VAL.STRING
289
+ if (ta === VAL.BIGINT || tb === VAL.BIGINT) return VAL.BIGINT
290
+ return VAL.NUMBER
291
+ }
292
+
293
+ if (op === '()') {
294
+ const callee = args[0]
295
+ // Ternary is parsed as call to '?' operator: ['()', ['?', cond, a, b]]
296
+ if (Array.isArray(callee) && callee[0] === '?') {
297
+ const ta = valTypeOf(callee[2]), tb = valTypeOf(callee[3])
298
+ return ta && ta === tb ? ta : null
299
+ }
300
+ // Constructor results + user function return-type inference
301
+ if (typeof callee === 'string') {
302
+ if (callee === 'new.Set') return VAL.SET
303
+ if (callee === 'new.Map') return VAL.MAP
304
+ if (callee === 'new.ArrayBuffer') return VAL.BUFFER
305
+ if (callee === 'new.DataView') return VAL.BUFFER
306
+ if (callee.startsWith('new.')) return VAL.TYPED
307
+ if (callee === 'String.fromCharCode' || callee === 'String') return VAL.STRING
308
+ if (callee === 'BigInt' || callee === 'BigInt.asIntN' || callee === 'BigInt.asUintN') return VAL.BIGINT
309
+ if (callee === 'JSON.parse') {
310
+ const src = jsonConstString(args[1])
311
+ if (src != null) {
312
+ const c = src.trimStart()[0]
313
+ if (c === '{') return VAL.HASH
314
+ if (c === '[') return VAL.ARRAY
315
+ if (c === '"') return VAL.STRING
316
+ if (c === 't' || c === 'f' || c === '-' || (c >= '0' && c <= '9')) return VAL.NUMBER
317
+ }
318
+ }
319
+ // Math.* always returns Number — let `+` skip string-concat dispatch and
320
+ // let exprType propagate i32 for the integer-returning subset.
321
+ if (typeof callee === 'string' && callee.startsWith('math.')) return VAL.NUMBER
322
+ // Clock helpers always return Number — lets `t0 = performance.now()` propagate
323
+ // VAL.NUMBER through subsequent reads, eliding `__to_num` wrappers in arithmetic.
324
+ if (callee === 'performance.now' || callee === 'Date.now') return VAL.NUMBER
325
+ const hostVT = ctx.module.hostImportValTypes?.get(callee)
326
+ if (hostVT) return hostVT
327
+ // User-defined func with monomorphic VAL return (populated in compile.js E2 pass).
328
+ const f = ctx.func.map?.get(callee)
329
+ if (f?.valResult) return f.valResult
330
+ }
331
+ // Method return types
332
+ if (Array.isArray(callee) && callee[0] === '.') {
333
+ const [, obj, method] = callee
334
+ if (method === 'map' || method === 'filter') {
335
+ // Typed-array .map/.filter preserve element type → return VAL.TYPED.
336
+ // Unknown receiver: don't claim (stay null) — runtime-dispatched index handles both.
337
+ const objType = valTypeOf(obj)
338
+ if (objType === VAL.TYPED) return VAL.TYPED
339
+ if (objType === VAL.ARRAY) return VAL.ARRAY
340
+ return null
341
+ }
342
+ if (method === 'push') return VAL.ARRAY
343
+ if (method === 'add' || method === 'delete') return VAL.SET
344
+ if (method === 'set') return VAL.MAP
345
+ // String-returning methods
346
+ if (['toUpperCase', 'toLowerCase', 'trim', 'trimStart', 'trimEnd',
347
+ 'repeat', 'padStart', 'padEnd', 'replace', 'charAt', 'substring'].includes(method)) return VAL.STRING
348
+ // slice/concat preserve caller type (string.slice → string, array.slice → array)
349
+ if (method === 'slice' || method === 'concat') {
350
+ const objType = valTypeOf(obj)
351
+ if (objType) return objType
352
+ return VAL.ARRAY // default to array when unknown
353
+ }
354
+ }
355
+ }
356
+ return null
357
+ }
358
+
359
+ function jsonConstString(expr) {
360
+ if (Array.isArray(expr) && expr[0] === 'str' && typeof expr[1] === 'string') return expr[1]
361
+ if (Array.isArray(expr) && expr[0] == null && typeof expr[1] === 'string') return expr[1]
362
+ if (typeof expr === 'string') return ctx.scope.constStrs?.get(expr) ?? null
363
+ return null
364
+ }
365
+
366
+ /** Build a structural shape tree from a parsed JSON value. Each node is
367
+ * `{ vt, props?, elem? }`. Lets `valTypeOf` propagate VAL kinds through
368
+ * `.prop` chains and `[i]` reads on bindings sourced from `JSON.parse`
369
+ * of a compile-time-known string. Polymorphic arrays drop their `elem`. */
370
+ function shapeOfJsonValue(v) {
371
+ if (v === null || v === undefined) return null
372
+ if (typeof v === 'number') return { vt: VAL.NUMBER }
373
+ if (typeof v === 'string') return { vt: VAL.STRING }
374
+ if (typeof v === 'boolean') return { vt: VAL.NUMBER }
375
+ if (Array.isArray(v)) {
376
+ let elem = null
377
+ for (const x of v) {
378
+ const s = shapeOfJsonValue(x)
379
+ if (!s) { elem = null; break }
380
+ if (!elem) elem = s
381
+ else if (!shapeUnifies(elem, s)) { elem = null; break }
382
+ }
383
+ return { vt: VAL.ARRAY, elem }
384
+ }
385
+ if (typeof v === 'object') {
386
+ const props = Object.create(null)
387
+ for (const k of Object.keys(v)) {
388
+ const s = shapeOfJsonValue(v[k])
389
+ if (s) props[k] = s
390
+ }
391
+ return { vt: VAL.HASH, props }
392
+ }
393
+ return null
394
+ }
395
+
396
+ function shapeUnifies(a, b) {
397
+ if (!a || !b || a.vt !== b.vt) return false
398
+ if (a.vt === VAL.HASH) {
399
+ const ak = Object.keys(a.props), bk = Object.keys(b.props)
400
+ if (ak.length !== bk.length) return false
401
+ for (const k of ak) {
402
+ if (!b.props[k] || !shapeUnifies(a.props[k], b.props[k])) return false
403
+ }
404
+ }
405
+ if (a.vt === VAL.ARRAY) {
406
+ if ((a.elem == null) !== (b.elem == null)) return false
407
+ if (a.elem && !shapeUnifies(a.elem, b.elem)) return false
408
+ }
409
+ return true
410
+ }
411
+
412
+ const _jsonShapeCache = new WeakMap()
413
+ function parseJsonShape(src) {
414
+ if (typeof src !== 'string') return null
415
+ if (_jsonShapeCache.has(src)) return _jsonShapeCache.get(src)
416
+ let parsed
417
+ try { parsed = JSON.parse(src) } catch { _jsonShapeCache.set(Object(src), null); return null }
418
+ const sh = shapeOfJsonValue(parsed)
419
+ // WeakMap requires object keys; cache via a wrapper. Skip caching for cold path.
420
+ return sh
421
+ }
422
+
423
+ /** Resolve the json shape for an expression by walking name → rep.jsonShape and
424
+ * `.prop` / `[i]` indirection. Returns null when shape is unknown at this site. */
425
+ export function shapeOf(expr) {
426
+ if (typeof expr === 'string') return ctx.func.repByLocal?.get(expr)?.jsonShape || null
427
+ if (!Array.isArray(expr)) return null
428
+ const [op, ...args] = expr
429
+ if (op === '()' && args[0] === 'JSON.parse') {
430
+ const src = jsonConstString(args[1])
431
+ if (src != null) return parseJsonShape(src)
432
+ }
433
+ if (op === '.' && typeof args[1] === 'string') {
434
+ const parent = shapeOf(args[0])
435
+ if (parent?.vt === VAL.HASH) return parent.props[args[1]] || null
436
+ }
437
+ if (op === '[]' && args.length === 2) {
438
+ const parent = shapeOf(args[0])
439
+ if (parent?.vt === VAL.ARRAY) return parent.elem || null
440
+ }
441
+ return null
442
+ }
443
+
444
+
445
+ /** Decode a `['{}', ...]` AST's children into `{names, values}`, or null if any
446
+ * property is non-static-key (computed key, spread, shorthand). Matches the
447
+ * emitter's flatten rule for comma-grouped props. Used by collectProgramFacts,
448
+ * narrowSignatures, and objLiteralSchemaId; the emitter (module/object.js)
449
+ * does its own decoding because it must handle the spread/computed-key paths. */
450
+ export function staticObjectProps(args) {
451
+ const raw = args.length === 1 && Array.isArray(args[0]) && args[0][0] === ',' ? args[0].slice(1) : args
452
+ const names = [], values = []
453
+ for (const p of raw) {
454
+ if (!Array.isArray(p) || p[0] !== ':' || typeof p[1] !== 'string') return null
455
+ names.push(p[1]); values.push(p[2])
456
+ }
457
+ return names.length ? { names, values } : null
458
+ }
459
+
460
+ /** Schema-id for an object literal expression. Returns null on dynamic keys, spread, shorthand. */
461
+ function objLiteralSchemaId(expr) {
462
+ if (!Array.isArray(expr) || expr[0] !== '{}' || !ctx.schema?.register) return null
463
+ const parsed = staticObjectProps(expr.slice(1))
464
+ return parsed ? ctx.schema.register(parsed.names) : null
465
+ }
466
+
467
+ /** Resolve schemaId of an expression, given a per-function schemaId map for locals.
468
+ * Used for both intra-function arr elem-schema observation and func.arrayElemSchema
469
+ * return inference. Recognizes: object literals, var names with bound schemaId,
470
+ * user fn calls with narrowed result schema, ?: / && / || when both branches agree. */
471
+ function exprSchemaId(expr, localSchemaMap) {
472
+ if (typeof expr === 'string') {
473
+ if (localSchemaMap?.has(expr)) return localSchemaMap.get(expr)
474
+ return ctx.schema?.idOf?.(expr) ?? null
475
+ }
476
+ if (!Array.isArray(expr)) return null
477
+ const op = expr[0]
478
+ if (op === '{}') return objLiteralSchemaId(expr)
479
+ if (op === '()' && typeof expr[1] === 'string') {
480
+ const f = ctx.func.map?.get(expr[1])
481
+ if (f?.valResult === VAL.OBJECT && f.sig?.ptrAux != null) return f.sig.ptrAux
482
+ return null
483
+ }
484
+ if (op === '?:') {
485
+ const a = exprSchemaId(expr[2], localSchemaMap)
486
+ const b = exprSchemaId(expr[3], localSchemaMap)
487
+ return a != null && a === b ? a : null
488
+ }
489
+ if (op === '&&' || op === '||') {
490
+ const a = exprSchemaId(expr[1], localSchemaMap)
491
+ const b = exprSchemaId(expr[2], localSchemaMap)
492
+ return a != null && a === b ? a : null
493
+ }
494
+ return null
495
+ }
496
+
497
+ /** Extract typed-array ctor name ('new.Float32Array', 'new.Int8Array.view', etc) from RHS,
498
+ * or null if RHS isn't a typed-array/ArrayBuffer/DataView constructor. */
499
+ export function typedElemCtor(rhs) {
500
+ if (!Array.isArray(rhs) || rhs[0] !== '()' || typeof rhs[1] !== 'string' || !rhs[1].startsWith('new.')) return null
501
+ const args = rhs[2]
502
+ const isView = rhs[1].endsWith('Array') && rhs[1] !== 'new.ArrayBuffer'
503
+ && Array.isArray(args) && args[0] === ',' && args.length >= 4
504
+ return isView ? rhs[1] + '.view' : rhs[1]
505
+ }
506
+
507
+ // Element-type byte mapping (mirror of module/typedarray.js ELEM). Bit 3 (|8) marks a view.
508
+ const _ELEM_AUX = {
509
+ Int8Array: 0, Uint8Array: 1, Int16Array: 2, Uint16Array: 3,
510
+ Int32Array: 4, Uint32Array: 5, Float32Array: 6, Float64Array: 7,
511
+ BigInt64Array: 7, BigUint64Array: 7,
512
+ }
513
+ /** Encode a `typedElemCtor` string ('new.Int32Array' | 'new.Int32Array.view') to the 4-bit
514
+ * aux value used in PTR.TYPED NaN-boxing. Returns null for unknown ctors (ArrayBuffer/DataView). */
515
+ export function typedElemAux(ctor) {
516
+ if (!ctor || !ctor.startsWith('new.')) return null
517
+ const isView = ctor.endsWith('.view')
518
+ const name = isView ? ctor.slice(4, -5) : ctor.slice(4)
519
+ const et = _ELEM_AUX[name]
520
+ if (et == null) return null
521
+ return isView ? et | 8 : et
522
+ }
523
+ const _ELEM_NAMES = ['Int8Array', 'Uint8Array', 'Int16Array', 'Uint16Array',
524
+ 'Int32Array', 'Uint32Array', 'Float32Array', 'Float64Array']
525
+ /** Reverse of typedElemAux: pick a canonical ctor string for a 4-bit elem aux. Used
526
+ * to round-trip TYPED-narrowed call results through ctx.types.typedElem so the
527
+ * unboxed local's rep picks up the same aux. aux=7 is shared with BigInt typed
528
+ * arrays — Float64Array is canonical (read-side compares aux only). */
529
+ export function ctorFromElemAux(aux) {
530
+ if (aux == null) return null
531
+ const isView = (aux & 8) !== 0
532
+ const name = _ELEM_NAMES[aux & 7]
533
+ if (!name) return null
534
+ return isView ? `new.${name}.view` : `new.${name}`
535
+ }
536
+
537
+ // === Cross-call argument inference helpers (used by narrowSignatures fixpoint) ===
538
+ // Each `inferArg*(expr, ...callerCtx)` resolves an argument expression to a single
539
+ // fact (val/schemaId/elem*/typedCtor) using caller-local observations and program
540
+ // facts, returning null when the fact can't be determined at this call site.
541
+
542
+ /** Infer arg val type using caller's body-local valTypes and module globals. */
543
+ export function inferArgType(expr, callerValTypes) {
544
+ if (typeof expr === 'string') return callerValTypes?.get(expr) || ctx.scope.globalValTypes?.get(expr) || null
545
+ return valTypeOf(expr)
546
+ }
547
+
548
+ /** Infer arg schemaId. Sources: caller's per-param schemaId map, module-level
549
+ * ctx.schema.vars binding, or a static-key object literal. */
550
+ export function inferArgSchema(expr, callerSchemas) {
551
+ if (typeof expr === 'string') {
552
+ if (callerSchemas?.has(expr)) return callerSchemas.get(expr)
553
+ const id = ctx.schema.vars.get(expr)
554
+ return id != null ? id : null
555
+ }
556
+ if (Array.isArray(expr) && expr[0] === '{}') {
557
+ const parsed = staticObjectProps(expr.slice(1))
558
+ return parsed ? ctx.schema.register(parsed.names) : null
559
+ }
560
+ return null
561
+ }
562
+
563
+ /** Infer arg arr-elem-schema. Sources: caller's body-local arr-elem map, caller's
564
+ * per-param arr-elem (transitive), or a call to an arr-narrowed user fn. */
565
+ export function inferArgArrElemSchema(expr, callerArrElems, callerArrParams) {
566
+ if (typeof expr === 'string') {
567
+ if (callerArrElems?.has(expr)) {
568
+ const v = callerArrElems.get(expr)
569
+ if (v != null) return v
570
+ }
571
+ if (callerArrParams?.has(expr)) {
572
+ const v = callerArrParams.get(expr)
573
+ if (v != null) return v
574
+ }
575
+ return null
576
+ }
577
+ if (Array.isArray(expr) && expr[0] === '()' && typeof expr[1] === 'string') {
578
+ const f = ctx.func.map?.get(expr[1])
579
+ if (f?.arrayElemSchema != null) return f.arrayElemSchema
580
+ }
581
+ return null
582
+ }
583
+
584
+ /** Infer arg arr-elem-VAL. Mirrors inferArgArrElemSchema but tracks VAL.* element kind. */
585
+ export function inferArgArrElemValType(expr, callerArrElemVals, callerArrValParams) {
586
+ if (typeof expr === 'string') {
587
+ if (callerArrElemVals?.has(expr)) {
588
+ const v = callerArrElemVals.get(expr)
589
+ if (v != null) return v
590
+ }
591
+ if (callerArrValParams?.has(expr)) {
592
+ const v = callerArrValParams.get(expr)
593
+ if (v != null) return v
594
+ }
595
+ return null
596
+ }
597
+ if (Array.isArray(expr) && expr[0] === '()' && typeof expr[1] === 'string') {
598
+ const f = ctx.func.map?.get(expr[1])
599
+ if (f?.arrayElemValType != null) return f.arrayElemValType
600
+ }
601
+ return null
602
+ }
603
+
604
+ /** Infer typed-array ctor (`new.Float64Array` etc.) of an arg expression at a call site.
605
+ * Sources: caller's body-local typedElems, caller's typed params, literal `new TypedArray(...)`,
606
+ * calls to typed-narrowed user funcs. Returns null when the ctor can't be determined. */
607
+ export function inferArgTypedCtor(expr, callerTypedElems, callerTypedParams) {
608
+ if (typeof expr === 'string') {
609
+ if (callerTypedElems?.has(expr)) return callerTypedElems.get(expr)
610
+ if (callerTypedParams?.has(expr)) return callerTypedParams.get(expr)
611
+ return null
612
+ }
613
+ const ctor = typedElemCtor(expr)
614
+ if (ctor) return ctor
615
+ if (Array.isArray(expr) && expr[0] === '()' && typeof expr[1] === 'string') {
616
+ const f = ctx.func.map?.get(expr[1])
617
+ if (f?.sig?.ptrKind === VAL.TYPED && f.sig.ptrAux != null) return ctorFromElemAux(f.sig.ptrAux)
618
+ }
619
+ return null
620
+ }
621
+
622
+ // Per-body memoization: analyzeBody is a pure function of `body` plus a small
623
+ // set of ctx fields (func.locals, func.repByLocal, func.map[*][field]). compile.js
624
+ // calls slices of it many times per function (scan-fixpoint, narrowing, final
625
+ // lowering); the unified cache absorbs that traffic. Caller-mutation safety is
626
+ // preserved by cloning every Map on read (entry value stored once, copies handed out).
627
+ // Invalidation: emitFunc calls `invalidateLocalsCache` after seeding cross-call
628
+ // param facts; compile.js' E2 pass calls `invalidateValTypesCache` after valResult
629
+ // narrowing; narrowReturnArrayElems clears entries between fixpoint iters.
630
+
631
+ /**
632
+ * Unified per-body analysis. Single AST traversal producing every per-binding
633
+ * fact the emitter needs:
634
+ *
635
+ * {
636
+ * locals: Map<name, 'i32'|'f64'> // wasm type per local
637
+ * valTypes: Map<name, VAL.*> // value-type for dispatch
638
+ * arrElemSchemas: Map<name, schemaId|null> // Array<schema> facts
639
+ * arrElemValTypes: Map<name, VAL.*|null> // Array<val-kind> facts
640
+ * typedElems: Map<name, ctorString> // typed-array ctor binding
641
+ * }
642
+ *
643
+ * Recursion shape: after a `let`/`const` decl, the rhs is walked but the `=`
644
+ * node itself is skipped — arrElemSchemas/ValTypes have a reassignment
645
+ * invalidation rule that would misfire on init. Other slices' `=`-visit is
646
+ * idempotent with the decl handler, so skipping it is safe for them too.
647
+ *
648
+ * Forward-only observation order: every rule reads only state already produced
649
+ * earlier in the same walk (alias chains, push observations, etc.), so a single
650
+ * traversal is sound.
651
+ *
652
+ * After the walk a `widenPass` runs to widen `i32` locals compared against `f64`
653
+ * operands.
654
+ *
655
+ * Caching: body-keyed via `_bodyFactsCache`. See
656
+ * `invalidateLocalsCache` / `invalidateValTypesCache` for the invalidation hooks.
657
+ */
658
+ const _bodyFactsCache = new WeakMap()
659
+
660
+ /**
661
+ * Returns the cached facts object directly — DO NOT MUTATE the returned maps.
662
+ * Callers that need to extend (e.g. add params to locals) must clone explicitly.
663
+ * `analyzeLocals` is the canonical clone-then-extend facade; everywhere else
664
+ * reads slices via `analyzeBody(body).<slice>`.
665
+ */
666
+ export function analyzeBody(body) {
667
+ // Non-object bodies (`() => 0`, `() => x`, missing) have nothing to observe
668
+ // for any slice and can't be WeakMap-keyed. Return empty maps without caching.
669
+ if (body === null || typeof body !== 'object') return {
670
+ locals: new Map(), valTypes: new Map(), arrElemSchemas: new Map(),
671
+ arrElemValTypes: new Map(), typedElems: new Map(),
672
+ }
673
+ const hit = _bodyFactsCache.get(body)
674
+ if (hit) return hit
675
+
676
+ const locals = new Map()
677
+ const valTypes = new Map()
678
+ const arrElemSchemas = new Map()
679
+ const arrElemValTypes = new Map()
680
+ const typedElems = new Map()
681
+
682
+ const doSchemas = !!ctx.schema?.register
683
+ // Per-walk local schema map for chained `arr.push(name)` resolution.
684
+ const localSchemaMap = new Map()
685
+
686
+ // === Observation helpers ===
687
+ //
688
+ // These trust the AST: any `arr.push(...)` syntactically present has `arr` as
689
+ // a body-relevant name (decl, param, or global) since closure boundaries are
690
+ // skipped at walk time. Pure typo names produce harmless dead Map entries
691
+ // that are never queried (consumers index by known local/param names).
692
+ // Removing the legacy `ctx.func.locals.has(arr)` filter makes analyzeBody's
693
+ // output context-pure — cache hits don't depend on transient ctx state.
694
+
695
+ const observeArrSchema = (arr, sid) => {
696
+ if (!doSchemas) return
697
+ if (typeof arr !== 'string') return
698
+ if (arrElemSchemas.get(arr) === null) return
699
+ if (sid == null) { arrElemSchemas.set(arr, null); return }
700
+ if (!arrElemSchemas.has(arr)) arrElemSchemas.set(arr, sid)
701
+ else if (arrElemSchemas.get(arr) !== sid) arrElemSchemas.set(arr, null)
702
+ }
703
+
704
+ const observeArrValType = (arr, vt) => {
705
+ if (typeof arr !== 'string') return
706
+ if (arrElemValTypes.get(arr) === null) return
707
+ if (!vt) { arrElemValTypes.set(arr, null); return }
708
+ if (!arrElemValTypes.has(arr)) arrElemValTypes.set(arr, vt)
709
+ else if (arrElemValTypes.get(arr) !== vt) arrElemValTypes.set(arr, null)
710
+ }
711
+
712
+ const elemValOf = (name) => {
713
+ if (typeof name !== 'string') return null
714
+ const repVt = ctx.func.repByLocal?.get(name)?.arrayElemValType
715
+ if (repVt) return repVt
716
+ return arrElemValTypes.get(name) || null
717
+ }
718
+
719
+ const exprElemSourceVal = (expr) => {
720
+ if (typeof expr === 'string') {
721
+ const repVt = ctx.func.repByLocal?.get(expr)?.val
722
+ if (repVt) return repVt
723
+ return ctx.scope.globalValTypes?.get(expr) || null
724
+ }
725
+ return valTypeOf(expr)
726
+ }
727
+
728
+ const trackTyped = (name, rhs) => {
729
+ const ctor = typedElemCtor(rhs)
730
+ if (ctor) { typedElems.set(name, ctor); return }
731
+ if (Array.isArray(rhs) && rhs[0] === '()' && typeof rhs[1] === 'string') {
732
+ const f = ctx.func.map?.get(rhs[1])
733
+ if (f?.sig?.ptrKind === VAL.TYPED && f.sig.ptrAux != null) {
734
+ const c = ctorFromElemAux(f.sig.ptrAux)
735
+ if (c) typedElems.set(name, c)
736
+ }
737
+ }
738
+ }
739
+
740
+ // === Per-decl observation (called for each `let`/`const` `name = rhs`) ===
741
+ const processDecl = (name, rhs) => {
742
+ // wasm type (locals slice)
743
+ const wt = exprType(rhs, locals)
744
+ if (!locals.has(name)) locals.set(name, wt)
745
+ else if (locals.get(name) === 'i32' && wt === 'f64') locals.set(name, 'f64')
746
+
747
+ // val type (valTypes slice)
748
+ const vt = valTypeOf(rhs)
749
+ if (vt) valTypes.set(name, vt); else valTypes.delete(name)
750
+
751
+ // typed-array element ctor (typedElems slice)
752
+ trackTyped(name, rhs)
753
+
754
+ // arr-elem schema (arrElemSchemas slice) — schema bindings + array-literal init + alias + call return
755
+ if (doSchemas) {
756
+ const sid = exprSchemaId(rhs, localSchemaMap)
757
+ if (sid != null) localSchemaMap.set(name, sid)
758
+ if (Array.isArray(rhs) && rhs[0] === '[]') {
759
+ const elems = rhs.slice(1).filter(e => e != null)
760
+ if (elems.length) {
761
+ let common = exprSchemaId(elems[0], localSchemaMap)
762
+ for (let k = 1; k < elems.length && common != null; k++) {
763
+ if (exprSchemaId(elems[k], localSchemaMap) !== common) common = null
764
+ }
765
+ if (common != null) observeArrSchema(name, common)
766
+ }
767
+ }
768
+ if (Array.isArray(rhs) && rhs[0] === '()' && typeof rhs[1] === 'string') {
769
+ const f = ctx.func.map?.get(rhs[1])
770
+ if (f?.arrayElemSchema != null) observeArrSchema(name, f.arrayElemSchema)
771
+ }
772
+ if (typeof rhs === 'string' && arrElemSchemas.has(rhs)) {
773
+ const sid2 = arrElemSchemas.get(rhs)
774
+ if (sid2 != null) observeArrSchema(name, sid2)
775
+ }
776
+ if (typeof rhs === 'string') {
777
+ const repSid = ctx.func.repByLocal?.get(rhs)?.arrayElemSchema
778
+ if (repSid != null) observeArrSchema(name, repSid)
779
+ }
780
+ }
781
+
782
+ // arr-elem val type (arrElemValTypes slice) — array-literal init + call return + alias + .map/.filter/.slice/.concat chain
783
+ if (Array.isArray(rhs) && rhs[0] === '[]') {
784
+ const elems = rhs.slice(1).filter(e => e != null)
785
+ if (elems.length) {
786
+ let common = exprElemSourceVal(elems[0])
787
+ for (let k = 1; k < elems.length && common != null; k++) {
788
+ if (exprElemSourceVal(elems[k]) !== common) common = null
789
+ }
790
+ if (common != null) observeArrValType(name, common)
791
+ }
792
+ }
793
+ if (Array.isArray(rhs) && rhs[0] === '()' && typeof rhs[1] === 'string') {
794
+ const f = ctx.func.map?.get(rhs[1])
795
+ if (f?.arrayElemValType) observeArrValType(name, f.arrayElemValType)
796
+ }
797
+ if (typeof rhs === 'string') {
798
+ const v = elemValOf(rhs)
799
+ if (v) observeArrValType(name, v)
800
+ }
801
+ if (Array.isArray(rhs) && rhs[0] === '()' &&
802
+ Array.isArray(rhs[1]) && rhs[1][0] === '.' &&
803
+ typeof rhs[1][1] === 'string') {
804
+ const recvName = rhs[1][1], method = rhs[1][2]
805
+ if (method === 'filter' || method === 'slice' || method === 'concat') {
806
+ const v = elemValOf(recvName)
807
+ if (v) observeArrValType(name, v)
808
+ } else if (method === 'map') {
809
+ const arrowFn = rhs[2]
810
+ const recvVt = elemValOf(recvName)
811
+ const param = Array.isArray(arrowFn) && arrowFn[0] === '=>' ? arrowFn[1] : null
812
+ const paramName = typeof param === 'string' ? param :
813
+ (Array.isArray(param) && param[0] === '()' && typeof param[1] === 'string' ? param[1] : null)
814
+ const arrowBody = paramName ? arrowFn[2] : null
815
+ const exprBody = (Array.isArray(arrowBody) && arrowBody[0] === '{}' &&
816
+ Array.isArray(arrowBody[1]) && arrowBody[1][0] === 'return') ? arrowBody[1][1] : arrowBody
817
+ if (paramName && exprBody != null) {
818
+ const refs = ctx.func.refinements
819
+ const hadParam = refs?.has(paramName)
820
+ const prev = hadParam ? refs.get(paramName) : undefined
821
+ if (refs && recvVt) refs.set(paramName, recvVt)
822
+ let bodyVt = null
823
+ try { bodyVt = valTypeOf(exprBody) }
824
+ finally {
825
+ if (refs && recvVt) {
826
+ if (hadParam) refs.set(paramName, prev); else refs.delete(paramName)
827
+ }
828
+ }
829
+ if (bodyVt) observeArrValType(name, bodyVt)
830
+ }
831
+ }
832
+ }
833
+ }
834
+
835
+ // arrElem invalidation rule — fires on `=` reassign of tracked name to non-array
836
+ const isArrayProducingRhs = (rhs) =>
837
+ Array.isArray(rhs) && (rhs[0] === '[]' ||
838
+ (rhs[0] === '()' && Array.isArray(rhs[1]) && rhs[1][0] === '.' &&
839
+ (rhs[1][2] === 'slice' || rhs[1][2] === 'concat')))
840
+
841
+ // === Single walk ===
842
+ function walk(node) {
843
+ if (!Array.isArray(node)) return
844
+ const op = node[0]
845
+ if (op === '=>') return // don't cross closure boundary
846
+
847
+ if (op === 'let' || op === 'const') {
848
+ for (let i = 1; i < node.length; i++) {
849
+ const a = node[i]
850
+ // analyzeLocals: bare-name decl
851
+ if (typeof a === 'string') { if (!locals.has(a)) locals.set(a, 'f64'); continue }
852
+ if (!Array.isArray(a) || a[0] !== '=') continue
853
+ // analyzeLocals: destructuring decl — set destructured names to f64, walk rhs only
854
+ if (typeof a[1] !== 'string') {
855
+ for (const n of collectParamNames([a[1]])) if (!locals.has(n)) locals.set(n, 'f64')
856
+ walk(a[2])
857
+ continue
858
+ }
859
+ const name = a[1], rhs = a[2]
860
+ processDecl(name, rhs)
861
+ // Walk rhs only — never enter the `=` node so the reassignment-invalidation
862
+ // rule won't misfire on the binding's own initializer.
863
+ walk(rhs)
864
+ }
865
+ return
866
+ }
867
+
868
+ // arr.push(...) — observe both schemas and val types in one pass
869
+ if (op === '()' && Array.isArray(node[1]) && node[1][0] === '.' && node[1][2] === 'push' && typeof node[1][1] === 'string') {
870
+ const arr = node[1][1]
871
+ const callArgs = node[2]
872
+ const list = callArgs == null ? [] :
873
+ (Array.isArray(callArgs) && callArgs[0] === ',') ? callArgs.slice(1) : [callArgs]
874
+ for (const a of list) {
875
+ if (Array.isArray(a) && a[0] === '...') {
876
+ observeArrSchema(arr, null); observeArrValType(arr, null); continue
877
+ }
878
+ observeArrSchema(arr, exprSchemaId(a, localSchemaMap))
879
+ observeArrValType(arr, exprElemSourceVal(a))
880
+ }
881
+ }
882
+
883
+ // `=` reassignment — locals widen, valTypes/typedElems track,
884
+ // arrElemSchemas/ValTypes invalidate when rhs isn't array-producing.
885
+ if (op === '=' && typeof node[1] === 'string') {
886
+ const name = node[1], rhs = node[2]
887
+ const wt = exprType(rhs, locals)
888
+ if (locals.has(name) && locals.get(name) === 'i32' && wt === 'f64') locals.set(name, 'f64')
889
+ const vt = valTypeOf(rhs)
890
+ if (vt) valTypes.set(name, vt); else valTypes.delete(name)
891
+ trackTyped(name, rhs)
892
+ if (arrElemSchemas.has(name) && !isArrayProducingRhs(rhs)) observeArrSchema(name, null)
893
+ if (arrElemValTypes.has(name) && !isArrayProducingRhs(rhs)) observeArrValType(name, null)
894
+ }
895
+
896
+ // compound-assign widening (locals slice)
897
+ if ((op === '+=' || op === '-=' || op === '*=' || op === '%=') && typeof node[1] === 'string') {
898
+ const name = node[1], opChar = op[0]
899
+ const t = exprType([opChar, node[1], node[2]], locals)
900
+ if (locals.has(name) && locals.get(name) === 'i32' && t === 'f64') locals.set(name, 'f64')
901
+ }
902
+ if (op === '/=' && typeof node[1] === 'string') {
903
+ if (locals.has(node[1])) locals.set(node[1], 'f64')
904
+ }
905
+
906
+ for (let i = 1; i < node.length; i++) walk(node[i])
907
+ }
908
+
909
+ // Install the in-progress valTypes as a lookup overlay so successive decls
910
+ // resolve chains (`const a = new TypedArr(); const b = a[0]` → b: NUMBER)
911
+ // and shorthand-bound `{a}` props see a's type. Restored after walk completes.
912
+ const prevOverlay = ctx.func.localValTypesOverlay
913
+ ctx.func.localValTypesOverlay = valTypes
914
+ try { walk(body) } finally { ctx.func.localValTypesOverlay = prevOverlay }
915
+
916
+ // Second pass: widen i32 locals compared against f64.
917
+ const CMP_OPS = new Set(['<', '>', '<=', '>=', '==', '!='])
918
+ function widenPass(node) {
919
+ if (!Array.isArray(node)) return
920
+ const [op, ...args] = node
921
+ if (CMP_OPS.has(op)) {
922
+ const [a, b] = args
923
+ const ta = exprType(a, locals), tb = exprType(b, locals)
924
+ if (ta === 'i32' && tb === 'f64' && typeof a === 'string' && locals.has(a)) locals.set(a, 'f64')
925
+ if (tb === 'i32' && ta === 'f64' && typeof b === 'string' && locals.has(b)) locals.set(b, 'f64')
926
+ }
927
+ if (op !== '=>') for (const a of args) widenPass(a)
928
+ }
929
+ widenPass(body)
930
+
931
+ const result = { locals, valTypes, arrElemSchemas, arrElemValTypes, typedElems }
932
+ _bodyFactsCache.set(body, result)
933
+ return result
934
+ }
935
+
936
+ /** Drop the cached analyzeBody entry for this body. Used by emitFunc after
937
+ * seeding cross-call param VAL facts so the next walk picks up fresh
938
+ * `ctx.func.repByLocal` (drives exprType receiver-type lookups).
939
+ * Same hook as `invalidateValTypesCache` — split names preserve caller intent. */
940
+ export function invalidateLocalsCache(body) {
941
+ if (body && typeof body === 'object') _bodyFactsCache.delete(body)
942
+ }
943
+
944
+ /** Drop the cached analyzeBody entry. Used after E2-phase valResult narrowing
945
+ * so the next walk re-evaluates `valTypeOf(call)` with up-to-date `f.valResult`
946
+ * — required for the D-pass paramReps val/arrayElemSchema re-fixpoint to see
947
+ * `const rows = initRows()` as VAL.ARRAY (initRows.valResult set by E2). */
948
+ export function invalidateValTypesCache(body) {
949
+ if (body && typeof body === 'object') _bodyFactsCache.delete(body)
950
+ }
951
+
952
+ /**
953
+ * Analyze all local value types from declarations and assignments.
954
+ * Writes the per-name `val` field of `ctx.func.repByLocal` for method dispatch
955
+ * and schema resolution.
956
+ */
957
+ export function analyzeValTypes(body) {
958
+ const setVal = (name, vt) => updateRep(name, { val: vt || undefined })
959
+ const getVal = name => ctx.func.repByLocal?.get(name)?.val
960
+ // Pre-walk: observe Array<schema> facts so `const p = arr[i]` can bind a schemaId
961
+ // on `p`, unlocking schema slot reads + skipping str_key dispatch on `.prop` access.
962
+ // Parallel arrElemValTypes walk records VAL.* element kinds into
963
+ // rep.arrayElemValType so valTypeOf's `arr[i]` rule can elide __to_num and route
964
+ // method dispatch on `arr[i].method()`. Both come from a single unified walk.
965
+ const facts = analyzeBody(body)
966
+ const arrElems = facts.arrElemSchemas
967
+ for (const [name, vt] of facts.arrElemValTypes) {
968
+ if (vt != null) updateRep(name, { arrayElemValType: vt })
969
+ }
970
+ // Resolve a name's array-elem-schema, preferring rep.arrayElemSchema (set from
971
+ // paramReps[k].arrayElemSchema at emit start) over local body observations.
972
+ const arrElemSchemaOf = (name) => {
973
+ if (typeof name !== 'string') return null
974
+ const repSid = ctx.func.repByLocal?.get(name)?.arrayElemSchema
975
+ if (repSid != null) return repSid
976
+ const localSid = arrElems.get(name)
977
+ return localSid != null ? localSid : null
978
+ }
979
+ function trackRegex(name, rhs) {
980
+ if (ctx.runtime.regex && Array.isArray(rhs) && rhs[0] === '//') ctx.runtime.regex.vars.set(name, rhs)
981
+ }
982
+ function trackTyped(name, rhs) {
983
+ if (!ctx.types.typedElem) ctx.types.typedElem = new Map() // first use in this function scope
984
+ const ctor = typedElemCtor(rhs)
985
+ if (ctor) { ctx.types.typedElem.set(name, ctor); return }
986
+ // TYPED-narrowed call result carries elem aux on f.sig.ptrAux — reverse-map it
987
+ // back to a canonical ctor string so analyzePtrUnboxable's typedElemAux lookup
988
+ // (compile.js) restores the same aux on the unboxed local's rep.
989
+ if (Array.isArray(rhs) && rhs[0] === '()' && typeof rhs[1] === 'string') {
990
+ const f = ctx.func.map?.get(rhs[1])
991
+ if (f?.sig?.ptrKind === VAL.TYPED && f.sig.ptrAux != null) {
992
+ const ctor = ctorFromElemAux(f.sig.ptrAux)
993
+ if (ctor) ctx.types.typedElem.set(name, ctor)
994
+ }
995
+ }
996
+ }
997
+ function walk(node) {
998
+ if (!Array.isArray(node)) return
999
+ const [op, ...args] = node
1000
+ if (op === '=>') return // don't leak inner-closure val types
1001
+ // Propagate typed array type through method calls (e.g. buf.map → typed)
1002
+ function propagateTyped(name, rhs) {
1003
+ if (!Array.isArray(rhs) || rhs[0] !== '()') return
1004
+ const callee = rhs[1]
1005
+ if (!Array.isArray(callee) || callee[0] !== '.') return
1006
+ const src = callee[1], method = callee[2]
1007
+ if (typeof src === 'string' && getVal(src) === VAL.TYPED && method === 'map') {
1008
+ setVal(name, VAL.TYPED)
1009
+ if (ctx.types.typedElem?.has(src)) {
1010
+ const srcCtor = ctx.types.typedElem.get(src)
1011
+ ctx.types.typedElem.set(name, srcCtor.endsWith('.view') ? srcCtor.slice(0, -5) : srcCtor)
1012
+ }
1013
+ }
1014
+ }
1015
+ if (op === 'let' || op === 'const') {
1016
+ for (const a of args) {
1017
+ if (!Array.isArray(a) || a[0] !== '=' || typeof a[1] !== 'string') continue
1018
+ const vt = valTypeOf(a[2])
1019
+ setVal(a[1], vt)
1020
+ if (vt === VAL.REGEX) trackRegex(a[1], a[2])
1021
+ if (vt === VAL.TYPED || vt === VAL.BUFFER) trackTyped(a[1], a[2])
1022
+ propagateTyped(a[1], a[2])
1023
+ // JSON-shape propagation. When the RHS resolves to a known JSON shape
1024
+ // (root: `JSON.parse(literal)`; nested: `o.meta`, `items[j]` from a known
1025
+ // root), record it on the binding so subsequent `.prop`/`[i]` accesses
1026
+ // skip dynamic dispatch and propagate VAL kinds. Generic for any
1027
+ // compile-time JSON literal.
1028
+ const sh = shapeOf(a[2])
1029
+ if (sh) {
1030
+ updateRep(a[1], { jsonShape: sh })
1031
+ if (sh.vt === VAL.ARRAY && sh.elem?.vt) {
1032
+ updateRep(a[1], { arrayElemValType: sh.elem.vt })
1033
+ }
1034
+ }
1035
+ // Propagate schemaId from a narrowed call result so subsequent valTypeOf
1036
+ // calls in this function body see the precise schema. emitDecl rebinds
1037
+ // this at emission time too — analyze-time binding is what unlocks the
1038
+ // slotVT lookup chain in `analyzeValTypes`'s own walk + per-func emit
1039
+ // dispatch reading repByLocal.
1040
+ if (vt === VAL.OBJECT && Array.isArray(a[2]) && a[2][0] === '()' && typeof a[2][1] === 'string') {
1041
+ const f = ctx.func.map?.get(a[2][1])
1042
+ if (f?.sig?.ptrAux != null) updateRep(a[1], { schemaId: f.sig.ptrAux })
1043
+ }
1044
+ // `const p = arr[i]` — when arr's element schema is known (from .push observations
1045
+ // or from paramReps arrayElemSchema binding), p inherits the schema. Unlocks slotVT-driven
1046
+ // numeric typing on `.prop` reads + slot-direct loads.
1047
+ if (Array.isArray(a[2]) && a[2][0] === '[]' && typeof a[2][1] === 'string') {
1048
+ const elemSid = arrElemSchemaOf(a[2][1])
1049
+ if (elemSid != null) {
1050
+ updateRep(a[1], { schemaId: elemSid })
1051
+ // Also set the val so structural call dispatch + valTypeOf see VAL.OBJECT.
1052
+ setVal(a[1], VAL.OBJECT)
1053
+ }
1054
+ }
1055
+ }
1056
+ }
1057
+ if (op === '=' && typeof args[0] === 'string') {
1058
+ const vt = valTypeOf(args[1])
1059
+ setVal(args[0], vt)
1060
+ if (vt === VAL.REGEX) trackRegex(args[0], args[1])
1061
+ if (vt === VAL.TYPED || vt === VAL.BUFFER) trackTyped(args[0], args[1])
1062
+ propagateTyped(args[0], args[1])
1063
+ }
1064
+ // Track property assignments for auto-boxing: x.prop = val
1065
+ if (op === '=' && Array.isArray(args[0]) && args[0][0] === '.' && typeof args[0][1] === 'string') {
1066
+ const [, obj, prop] = args[0]
1067
+ const vt = getVal(obj)
1068
+ if ((vt === VAL.NUMBER || vt === VAL.BIGINT) && ctx.func.locals?.has(obj) && ctx.schema.register) {
1069
+ if (!ctx.func.localProps) ctx.func.localProps = new Map()
1070
+ if (!ctx.func.localProps.has(obj)) ctx.func.localProps.set(obj, new Set())
1071
+ ctx.func.localProps.get(obj).add(prop)
1072
+ }
1073
+ }
1074
+ for (const a of args) walk(a)
1075
+ }
1076
+ walk(body)
1077
+
1078
+ // Register boxed schemas for local variables with property assignments
1079
+ if (ctx.func.localProps) {
1080
+ for (const [name, props] of ctx.func.localProps) {
1081
+ if (ctx.schema.vars.has(name)) continue
1082
+ const schema = ['__inner__', ...props]
1083
+ const sid = ctx.schema.register(schema)
1084
+ ctx.schema.vars.set(name, sid)
1085
+ updateRep(name, { schemaId: sid })
1086
+ }
1087
+ }
1088
+ }
1089
+
1090
+ const INT_BIT_OPS = new Set(['|', '&', '^', '~', '<<', '>>', '>>>'])
1091
+ const INT_CMP_OPS = new Set(['<', '>', '<=', '>=', '==', '!=', '===', '!==', '!'])
1092
+ const INT_CLOSED_OPS = new Set(['+', '-', '*', '%'])
1093
+ const INT_MATH_FNS = new Set(['imul', 'clz32', 'floor', 'ceil', 'round', 'trunc'])
1094
+
1095
+ /**
1096
+ * Forward-propagate `intCertain` across local bindings (S2 Stage 4a — pure analysis).
1097
+ *
1098
+ * A binding is `intCertain` iff every defining RHS evaluates to an integer-valued
1099
+ * expression. Reassignments widen — any non-int RHS poisons the binding, regardless
1100
+ * of order in source. Multi-pass fixpoint converges when RHSs read other bindings
1101
+ * transitively (`let j = i + 1` resolves only after `i` is known intCertain).
1102
+ *
1103
+ * Integer-shaped RHS (closed under composition):
1104
+ * - integer Number literal, boolean literal
1105
+ * - bitwise ops `& | ^ ~ << >> >>>` — i32 result by spec
1106
+ * - comparisons `< > <= >= == != === !== !` — 0/1 result
1107
+ * - `.length` / `.byteLength` on TYPED/ARRAY/STRING/BUFFER receiver
1108
+ * - `+ - * %` and unary `+ -` of intCertain operands (overflow OK — value is mathematically integer)
1109
+ * - `?: && ||` when both branches are intCertain
1110
+ * - `Math.{imul, clz32, floor, ceil, round, trunc}`
1111
+ * - self-mutation ops `++` `--` `+=` `-=` `*=` `%=` (preserve when operand is int);
1112
+ * `&= |= ^= <<= >>= >>>=` (always int by op result type);
1113
+ * `/=` `**=` poison.
1114
+ *
1115
+ * Writes `intCertain: true` on `ctx.func.repByLocal[name]`. No emit impact —
1116
+ * codegen extensions consume this in follow-up passes.
1117
+ */
1118
+ export function analyzeIntCertain(body) {
1119
+ // Pass 1: collect every defining RHS per binding name. Compound assignments
1120
+ // are desugared to their `=` equivalent (`x += y` → `x = x + y`) so the
1121
+ // existing `isIntExpr` op rules apply uniformly.
1122
+ const defs = new Map()
1123
+ const pushDef = (name, rhs) => {
1124
+ let list = defs.get(name)
1125
+ if (!list) { list = []; defs.set(name, list) }
1126
+ list.push(rhs)
1127
+ }
1128
+ const collect = (node) => {
1129
+ if (!Array.isArray(node)) return
1130
+ const [op, ...args] = node
1131
+ if (op === '=>') return
1132
+ if (op === 'let' || op === 'const') {
1133
+ for (const a of args) {
1134
+ if (Array.isArray(a) && a[0] === '=' && typeof a[1] === 'string') pushDef(a[1], a[2])
1135
+ }
1136
+ } else if (op === '=' && typeof args[0] === 'string') {
1137
+ pushDef(args[0], args[1])
1138
+ } else if (typeof op === 'string' && op.length > 1 && op.endsWith('=') &&
1139
+ !INT_CMP_OPS.has(op) && op !== '=>' && typeof args[0] === 'string') {
1140
+ // Compound assign: desugar `x <op>= rhs` → `x = x <op> rhs`. The base op
1141
+ // result is fed back through isIntExpr — bitwise compounds become int by
1142
+ // the bitwise rule; +=/-=/*=/%= preserve via int-closed rule.
1143
+ pushDef(args[0], [op.slice(0, -1), args[0], args[1]])
1144
+ } else if ((op === '++' || op === '--') && typeof args[0] === 'string') {
1145
+ // `x++` / `x--` desugars to `x = x ± 1`. 1 is int → preserves intCertain.
1146
+ pushDef(args[0], [op === '++' ? '+' : '-', args[0], [null, 1]])
1147
+ }
1148
+ for (const a of args) collect(a)
1149
+ }
1150
+ collect(body)
1151
+ if (defs.size === 0) return
1152
+
1153
+ // Pass 2: monotone-down fixpoint. Start optimistic (every defined binding
1154
+ // assumed intCertain), then for each iteration mark false any binding whose
1155
+ // RHS list contains a non-int expression. Once false, stays false — defs is
1156
+ // fixed and isIntExpr only reads back through bindings that themselves can
1157
+ // only flip true→false. Converges when no further bindings flip.
1158
+ //
1159
+ // (Naive bottom-up `false→true` direction is unsound for recursive bindings
1160
+ // like `let i = 0; i = i + 1` — first iteration sees i unobserved → false →
1161
+ // i+1 false → i stays false, missing the fact that all RHSs are int.)
1162
+ const intCertain = new Map()
1163
+ for (const name of defs.keys()) intCertain.set(name, true)
1164
+
1165
+ const isIntExpr = (expr) => {
1166
+ if (typeof expr === 'number') return Number.isInteger(expr)
1167
+ if (typeof expr === 'boolean') return true
1168
+ if (typeof expr === 'string') return intCertain.get(expr) === true
1169
+ if (!Array.isArray(expr)) return false
1170
+ const [op, ...args] = expr
1171
+ if (op == null) {
1172
+ // `[, value]` / `[null, value]` literal form
1173
+ const v = args[0]
1174
+ if (typeof v === 'number') return Number.isInteger(v)
1175
+ if (typeof v === 'boolean') return true
1176
+ return false
1177
+ }
1178
+ if (INT_BIT_OPS.has(op) || INT_CMP_OPS.has(op)) return true
1179
+ if (op === '.') {
1180
+ if ((args[1] === 'length' || args[1] === 'byteLength') && typeof args[0] === 'string') {
1181
+ const vt = lookupValType(args[0])
1182
+ return vt === VAL.TYPED || vt === VAL.ARRAY || vt === VAL.STRING || vt === VAL.BUFFER
1183
+ }
1184
+ if (args[1] === 'size' && typeof args[0] === 'string') {
1185
+ const vt = lookupValType(args[0])
1186
+ return vt === VAL.SET || vt === VAL.MAP
1187
+ }
1188
+ return false
1189
+ }
1190
+ if (INT_CLOSED_OPS.has(op)) {
1191
+ const a = isIntExpr(args[0])
1192
+ const b = args[1] != null ? isIntExpr(args[1]) : a
1193
+ return a && b
1194
+ }
1195
+ if (op === 'u-' || op === 'u+') return isIntExpr(args[0])
1196
+ if (op === '?:') return isIntExpr(args[1]) && isIntExpr(args[2])
1197
+ if (op === '&&' || op === '||') return isIntExpr(args[0]) && isIntExpr(args[1])
1198
+ // Math.{imul,clz32,floor,ceil,round,trunc} — prepare normalizes the callee to
1199
+ // the string `math.<fn>`. The pre-prepare `['.', 'Math', '<fn>']` shape is
1200
+ // matched too so this analyzer is robust if invoked on a non-normalized AST.
1201
+ if (op === '()') {
1202
+ const c = args[0]
1203
+ if (typeof c === 'string' && c.startsWith('math.') && INT_MATH_FNS.has(c.slice(5))) return true
1204
+ if (Array.isArray(c) && c[0] === '.' && c[1] === 'Math' && INT_MATH_FNS.has(c[2])) return true
1205
+ }
1206
+ return false
1207
+ }
1208
+
1209
+ let changed = true
1210
+ while (changed) {
1211
+ changed = false
1212
+ for (const [name, rhsList] of defs) {
1213
+ if (!intCertain.get(name)) continue
1214
+ if (!rhsList.every(isIntExpr)) { intCertain.set(name, false); changed = true }
1215
+ }
1216
+ }
1217
+
1218
+ for (const [name, intC] of intCertain) {
1219
+ if (intC) updateRep(name, { intCertain: true })
1220
+ }
1221
+ }
1222
+
1223
+ /**
1224
+ * Infer expression result type from AST (without emitting).
1225
+ * Used to determine local variable types before compilation.
1226
+ * Looks up `locals` first, then current-function params (for i32-specialized params).
1227
+ */
1228
+ export function exprType(expr, locals) {
1229
+ if (expr == null) return 'f64'
1230
+ if (typeof expr === 'number')
1231
+ return Number.isInteger(expr) && expr >= -2147483648 && expr <= 2147483647 ? 'i32' : 'f64'
1232
+ if (typeof expr === 'string') {
1233
+ if (locals?.has?.(expr)) return locals.get(expr)
1234
+ const paramType = ctx.func.current?.params?.find(p => p.name === expr)?.type
1235
+ if (paramType) return paramType
1236
+ return 'f64'
1237
+ }
1238
+ if (!Array.isArray(expr)) return 'f64'
1239
+
1240
+ const [op, ...args] = expr
1241
+ if (op == null) return exprType(args[0], locals) // literal [, value]
1242
+
1243
+ // Always f64
1244
+ if (op === '/' || op === '**' || op === '[' || op === '[]' || op === '{}' || op === 'str') return 'f64'
1245
+ // `.length` on a known sized receiver returns i32 directly (__len/__str_byteLen
1246
+ // both return i32). Letting it stay i32 lets analyzeLocals keep the counter
1247
+ // local i32 too, eliminating the per-iteration `f64.convert_i32_s` widen and
1248
+ // the matching `i32.trunc_sat_f64_s` truncs at every `arr[i]` / `i*k` site.
1249
+ // Only safe when receiver type is statically known to expose an integer length.
1250
+ if (op === '.') {
1251
+ if (args[1] === 'length' && typeof args[0] === 'string') {
1252
+ const vt = lookupValType(args[0])
1253
+ if (vt === VAL.TYPED || vt === VAL.ARRAY || vt === VAL.STRING || vt === VAL.BUFFER) return 'i32'
1254
+ }
1255
+ if (args[1] === 'size' && typeof args[0] === 'string') {
1256
+ const vt = lookupValType(args[0])
1257
+ if (vt === VAL.SET || vt === VAL.MAP) return 'i32'
1258
+ }
1259
+ if (args[1] === 'byteLength' && typeof args[0] === 'string') {
1260
+ const vt = lookupValType(args[0])
1261
+ if (vt === VAL.BUFFER || vt === VAL.TYPED) return 'i32'
1262
+ }
1263
+ return 'f64'
1264
+ }
1265
+ // Always i32
1266
+ if (['>', '<', '>=', '<=', '==', '!=', '!', '&', '|', '^', '~', '<<', '>>', '>>>'].includes(op)) return 'i32'
1267
+ // Preserve i32 if both operands i32
1268
+ if (['+', '-', '*', '%'].includes(op)) {
1269
+ const ta = exprType(args[0], locals)
1270
+ const tb = args[1] != null ? exprType(args[1], locals) : ta // unary: inherit
1271
+ return ta === 'i32' && tb === 'i32' ? 'i32' : 'f64'
1272
+ }
1273
+ // Unary preserves type
1274
+ if (op === 'u-' || op === 'u+') return exprType(args[0], locals)
1275
+ // Ternary / logical: conciliate
1276
+ if (op === '?:' || op === '&&' || op === '||') {
1277
+ const branches = op === '?:' ? [args[1], args[2]] : [args[0], args[1]]
1278
+ const ta = exprType(branches[0], locals), tb = exprType(branches[1], locals)
1279
+ return ta === 'i32' && tb === 'i32' ? 'i32' : 'f64'
1280
+ }
1281
+ if (op === '[') return 'f64'
1282
+ // Builtin calls with known i32 result. Math.imul / Math.clz32 always produce
1283
+ // a 32-bit integer; recognising this here keeps `let x = Math.imul(...)` (and
1284
+ // chains like `x = Math.imul(x, k) + 12345`) on the i32 ABI all the way
1285
+ // through, instead of widening the local to f64 because exprType defaulted.
1286
+ if (op === '()') {
1287
+ if (args[0] === 'math.imul' || args[0] === 'math.clz32') return 'i32'
1288
+ // Method calls returning i32: charCodeAt → byte (0..255). Lets tokenizer-shape
1289
+ // hot loops keep `c` as i32 across `c >= 48 && c <= 57`, `c - 48`, etc.,
1290
+ // skipping the f64.convert_i32_u widen at every char read.
1291
+ if (Array.isArray(args[0]) && args[0][0] === '.' && args[0][2] === 'charCodeAt') return 'i32'
1292
+ // User-function call: consult the callee's narrowed result type. By the time
1293
+ // analyzeLocals runs in emitFunc, narrowSignatures has set sig.results[0]='i32'
1294
+ // on every body-i32-only func. Propagating this lets `let h = userFn(...)`
1295
+ // (mix in callback bench: i32-FNV) keep h as an i32 local instead of widening
1296
+ // to f64 and round-tripping i32↔f64 every iteration.
1297
+ if (typeof args[0] === 'string') {
1298
+ const f = ctx.func.map?.get(args[0])
1299
+ if (f?.sig?.results?.length === 1 && f.sig.results[0] === 'i32' && f.sig.ptrKind == null) return 'i32'
1300
+ }
1301
+ }
1302
+ return 'f64'
1303
+ }
1304
+
1305
+ /**
1306
+ * Analyze all local declarations and assignments to determine types.
1307
+ * A local is i32 if ALL assignments produce i32. Any f64 widens to f64.
1308
+ *
1309
+ * Thin slice of `analyzeBody` (single unified walk).
1310
+ */
1311
+ export function analyzeLocals(body) {
1312
+ return analyzeBody(body).locals
1313
+ }
1314
+
1315
+ /**
1316
+ * Identify locals that can be stored as an unboxed i32 pointer offset instead of
1317
+ * a NaN-boxed f64. Static type is tracked out-of-band so reads skip `__ptr_offset`
1318
+ * and `__ptr_type` entirely and writes unbox once at the assignment site.
1319
+ *
1320
+ * Criteria — the local must be:
1321
+ * - declared once with `let`/`const`, never reassigned or compound-assigned
1322
+ * - valType is an unambiguous non-forwarding pointer kind:
1323
+ * OBJECT, SET, MAP, CLOSURE, TYPED, BUFFER
1324
+ * (excluded: ARRAY — forwards on realloc; STRING — SSO/heap dual encoding.)
1325
+ * - initialized from a form that guarantees a fresh, non-null pointer of that VAL:
1326
+ * OBJECT ← `{…}`
1327
+ * SET ← `new Set(...)`
1328
+ * MAP ← `new Map(...)`
1329
+ * CLOSURE← `=>` literal
1330
+ * BUFFER ← `new ArrayBuffer(...)` / `new DataView(...)`
1331
+ * TYPED ← `new XxxArray(...)` / method returning typed array
1332
+ * - not captured in boxed storage (boxed locals stay f64 for the heap slot)
1333
+ * - never compared to null/undefined (we lose the nullish NaN representation)
1334
+ *
1335
+ * Returns Map<name, VAL> of locals to unbox.
1336
+ */
1337
+ export function analyzePtrUnboxable(body, locals, boxed) {
1338
+ const candidates = new Set()
1339
+ const disqualified = new Set()
1340
+ const valOf = name => ctx.func.repByLocal?.get(name)?.val
1341
+
1342
+ const UNBOXABLE_KINDS = new Set([VAL.OBJECT, VAL.SET, VAL.MAP, VAL.BUFFER, VAL.TYPED, VAL.CLOSURE])
1343
+
1344
+ // RHS must produce a fresh, non-null pointer of the declared VAL kind.
1345
+ // OBJECT ← `{…}`
1346
+ // CLOSURE ← `=>`
1347
+ // SET/MAP/BUFFER/TYPED ← `new X(...)`
1348
+ // Validating the exact ctor→VAL match keeps the analysis tied to valTypeOf, so when
1349
+ // that helper grows (e.g. `Array.from` → ARRAY), we don't drift out of sync.
1350
+ const isFreshInit = (expr, kind) => {
1351
+ if (!Array.isArray(expr)) return false
1352
+ if (kind === VAL.OBJECT) {
1353
+ if (expr[0] === '{}') return true
1354
+ // Call to a narrow-ABI'd helper: returns i32 ptr-offset of the same VAL kind.
1355
+ // Unboxing skips the f64-rebox at the callsite. Verifying via sig (not just
1356
+ // valResult) ensures the call already produces an i32 — which dual-write picks
1357
+ // up to bind ptrKind/schemaId on the local.
1358
+ if (expr[0] === '()' && typeof expr[1] === 'string') {
1359
+ const f = ctx.func.map?.get(expr[1])
1360
+ return f?.sig?.ptrKind === kind
1361
+ }
1362
+ // `let p = arr[i]` where arr has a known elem schema: the runtime helper
1363
+ // returns f64 (NaN-box of an OBJECT pointer), but its low 32 bits are
1364
+ // exactly the pointer offset. Dual-write coerces once via reinterpret/wrap;
1365
+ // subsequent `p.x` reads then become direct `f64.load offset=K (local.get $p)`
1366
+ // (since ptrOffsetIR sees ptrKind=OBJECT and skips the per-access wrap).
1367
+ if (expr[0] === '[]' && typeof expr[1] === 'string') {
1368
+ const repSid = ctx.func.repByLocal?.get(expr[1])?.arrayElemSchema
1369
+ return repSid != null
1370
+ }
1371
+ return false
1372
+ }
1373
+ if (kind === VAL.CLOSURE) return expr[0] === '=>'
1374
+ if (expr[0] === '()' && typeof expr[1] === 'string') {
1375
+ const callee = expr[1]
1376
+ if (callee.startsWith('new.')) {
1377
+ if (kind === VAL.SET) return callee === 'new.Set'
1378
+ if (kind === VAL.MAP) return callee === 'new.Map'
1379
+ if (kind === VAL.BUFFER) return callee === 'new.ArrayBuffer' || callee === 'new.DataView'
1380
+ if (kind === VAL.TYPED) return callee.endsWith('Array') && callee !== 'new.ArrayBuffer'
1381
+ }
1382
+ // Call to narrow-ABI'd helper of matching VAL kind.
1383
+ const f = ctx.func.map?.get(callee)
1384
+ if (f?.sig?.ptrKind === kind) return true
1385
+ }
1386
+ // Method call returning TYPED: `arr.map(fn)` where `arr` is in typedElem
1387
+ // (locally TYPED with a known elem ctor). Only `.typed:map` is registered
1388
+ // as TYPED-returning — `.filter`/`.slice` fall back to ARRAY emit. The
1389
+ // typedElem.has(src) gate ensures we don't accept the polymorphic-receiver
1390
+ // path that emits a plain ARRAY result. propagateTyped already mirrored
1391
+ // the src ctor onto the receiver, so the unbox path picks up its aux.
1392
+ if (kind === VAL.TYPED && expr[0] === '()' &&
1393
+ Array.isArray(expr[1]) && expr[1][0] === '.' &&
1394
+ typeof expr[1][1] === 'string' && expr[1][2] === 'map' &&
1395
+ ctx.types.typedElem?.has(expr[1][1])) {
1396
+ return true
1397
+ }
1398
+ return false
1399
+ }
1400
+ const isNullishLit = (expr) =>
1401
+ expr === 'null' || expr === 'undefined' ||
1402
+ (Array.isArray(expr) && expr[0] == null &&
1403
+ (expr[1] === null || expr[1] === undefined))
1404
+
1405
+ function collect(node) {
1406
+ if (!Array.isArray(node)) return
1407
+ const [op, ...args] = node
1408
+ if (op === '=>') return
1409
+ if (op === 'let' || op === 'const') {
1410
+ for (const a of args) {
1411
+ if (!Array.isArray(a) || a[0] !== '=' || typeof a[1] !== 'string') continue
1412
+ const name = a[1]
1413
+ const vt = valOf(name)
1414
+ if (!UNBOXABLE_KINDS.has(vt)) continue
1415
+ if (locals.get(name) !== 'f64') continue
1416
+ if (boxed?.has(name)) continue
1417
+ if (!isFreshInit(a[2], vt)) continue
1418
+ candidates.add(name)
1419
+ }
1420
+ }
1421
+ for (const a of args) collect(a)
1422
+ }
1423
+
1424
+ const ASSIGN_OPS = new Set(['=', '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=',
1425
+ '<<=', '>>=', '>>>=', '||=', '&&=', '??='])
1426
+ const NULL_CMP_OPS = new Set(['==', '!=', '===', '!=='])
1427
+
1428
+ function check(node) {
1429
+ if (!Array.isArray(node)) return
1430
+ const [op, ...args] = node
1431
+ if (op === '=>') return
1432
+ if (ASSIGN_OPS.has(op) && typeof args[0] === 'string' && candidates.has(args[0])) {
1433
+ if (op !== '=') disqualified.add(args[0])
1434
+ // Initial `let x = {…}` arrives here too as op='='; the `let` pass already vetted it.
1435
+ // A later `x = …` in the same body is a re-assignment — disqualify unless it's the init.
1436
+ // We detect by tracking count: if we see '=' twice for the same name, disqualify.
1437
+ }
1438
+ if ((op === '++' || op === '--') && typeof args[0] === 'string' && candidates.has(args[0]))
1439
+ disqualified.add(args[0])
1440
+ if (NULL_CMP_OPS.has(op)) {
1441
+ for (let i = 0; i < 2; i++) {
1442
+ const side = args[i], other = args[1 - i]
1443
+ if (typeof side === 'string' && candidates.has(side) && isNullishLit(other)) disqualified.add(side)
1444
+ }
1445
+ }
1446
+ for (const a of args) check(a)
1447
+ }
1448
+
1449
+ // Count bare `=` assignments per candidate. Init `let x = …` is NOT parsed as `['=', x, …]`
1450
+ // at the statement level — it's inside `['let', ['=', x, …]]`. A standalone `['=', x, …]`
1451
+ // at statement level IS a reassignment.
1452
+ const assignCount = new Map()
1453
+ function countAssigns(node, inLet) {
1454
+ if (!Array.isArray(node)) return
1455
+ const [op, ...args] = node
1456
+ if (op === '=>') return
1457
+ if (op === '=' && !inLet && typeof args[0] === 'string' && candidates.has(args[0])) {
1458
+ assignCount.set(args[0], (assignCount.get(args[0]) || 0) + 1)
1459
+ }
1460
+ const childInLet = op === 'let' || op === 'const'
1461
+ for (const a of args) countAssigns(a, childInLet)
1462
+ }
1463
+
1464
+ collect(body)
1465
+ check(body)
1466
+ countAssigns(body, false)
1467
+
1468
+ for (const [name, count] of assignCount) if (count > 0) disqualified.add(name)
1469
+
1470
+ const result = new Map()
1471
+ for (const name of candidates) if (!disqualified.has(name)) result.set(name, valOf(name))
1472
+ return result
1473
+ }
1474
+
1475
+ // === Param / closure helpers ===
1476
+
1477
+ export function extractParams(rawParams) {
1478
+ let p = rawParams
1479
+ if (Array.isArray(p) && p[0] === '()') p = p[1]
1480
+ return p == null ? [] : Array.isArray(p) ? (p[0] === ',' ? p.slice(1) : [p]) : [p]
1481
+ }
1482
+
1483
+ export function classifyParam(r) {
1484
+ if (Array.isArray(r) && r[0] === '...') return { kind: 'rest', name: r[1] }
1485
+ if (Array.isArray(r) && r[0] === '=') {
1486
+ if (typeof r[1] === 'string') return { kind: 'default', name: r[1], defValue: r[2] }
1487
+ return { kind: 'destruct-default', pattern: r[1], defValue: r[2] }
1488
+ }
1489
+ if (Array.isArray(r) && (r[0] === '[]' || r[0] === '{}')) return { kind: 'destruct', pattern: r }
1490
+ return { kind: 'plain', name: r }
1491
+ }
1492
+
1493
+ export function collectParamNames(raw, out = new Set()) {
1494
+ for (const r of raw) {
1495
+ if (typeof r === 'string') out.add(r)
1496
+ else if (Array.isArray(r)) {
1497
+ if (r[0] === '=' && typeof r[1] === 'string') out.add(r[1])
1498
+ else if (r[0] === '...' && typeof r[1] === 'string') out.add(r[1])
1499
+ else if (r[0] === '=' && Array.isArray(r[1])) collectParamNames([r[1]], out)
1500
+ else if (r[0] === '[]' || r[0] === '{}' || r[0] === ',') collectParamNames(r.slice(1), out)
1501
+ }
1502
+ }
1503
+ return out
1504
+ }
1505
+
1506
+ /** Find free variables in AST: referenced in node, not in `bound`, present in `scope`. */
1507
+ export function findFreeVars(node, bound, free, scope) {
1508
+ if (node == null) return
1509
+ if (typeof node === 'string') {
1510
+ if (bound.has(node) || free.includes(node)) return
1511
+ const inScope = scope
1512
+ ? scope.has(node)
1513
+ : (ctx.func.locals?.has(node) || ctx.func.current?.params.some(p => p.name === node))
1514
+ if (inScope) free.push(node)
1515
+ return
1516
+ }
1517
+ if (!Array.isArray(node)) return
1518
+ const [op, ...args] = node
1519
+ if (op === '=>') {
1520
+ const innerBound = collectParamNames(extractParams(args[0]), new Set(bound))
1521
+ findFreeVars(args[1], innerBound, free, scope)
1522
+ return
1523
+ }
1524
+ if (op === 'let' || op === 'const') {
1525
+ collectParamNames(args, bound)
1526
+ if (scope) collectParamNames(args, scope)
1527
+ }
1528
+ if (op === 'for' && Array.isArray(args[0]) && (args[0][0] === 'let' || args[0][0] === 'const')) {
1529
+ collectParamNames(args[0].slice(1), bound)
1530
+ if (scope) collectParamNames(args[0].slice(1), scope)
1531
+ }
1532
+ for (const a of args) findFreeVars(a, bound, free, scope)
1533
+ }
1534
+
1535
+ const ASSIGN_OPS = new Set(['=', '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '>>=', '<<=', '>>>=', '||=', '&&=', '??='])
1536
+
1537
+ /** Check if any of the given variable names are assigned anywhere in the AST. */
1538
+ export function findMutations(node, names, mutated) {
1539
+ if (node == null || typeof node !== 'object' || !Array.isArray(node)) return
1540
+ const [op, ...args] = node
1541
+ if (op === 'let' || op === 'const') {
1542
+ for (const decl of args)
1543
+ if (Array.isArray(decl) && decl[0] === '=') findMutations(decl[2], names, mutated)
1544
+ return
1545
+ }
1546
+ if (ASSIGN_OPS.has(op) && typeof args[0] === 'string' && names.has(args[0]))
1547
+ mutated.add(args[0])
1548
+ if ((op === '++' || op === '--') && typeof args[0] === 'string' && names.has(args[0]))
1549
+ mutated.add(args[0])
1550
+ for (const a of args) findMutations(a, names, mutated)
1551
+ }
1552
+
1553
+ /**
1554
+ * Pre-scan AST for variables that need a `__dyn_props` shadow sidecar.
1555
+ *
1556
+ * The shadow exists so `obj[runtimeKey]` can read a value via `__dyn_get`,
1557
+ * and `obj.prop = v` keeps the sidecar in sync. Most object literals are only
1558
+ * accessed via `.prop` or `obj['lit']`, both of which resolve through the
1559
+ * schema directly and bypass the shadow. Allocating + populating the sidecar
1560
+ * for those literals is pure waste.
1561
+ *
1562
+ * Populates:
1563
+ * - ctx.types.dynKeyVars: Set<string> — names accessed via runtime key
1564
+ * - ctx.types.anyDynKey: boolean — any dynamic key access exists in program
1565
+ * (used for escaping literals where no target var is known)
1566
+ */
1567
+ export function analyzeDynKeys(...roots) {
1568
+ const dynVars = new Set()
1569
+ let anyDyn = false
1570
+ const isLiteralStr = idx => Array.isArray(idx) && idx[0] === 'str' && typeof idx[1] === 'string'
1571
+
1572
+ function walk(node) {
1573
+ if (!Array.isArray(node)) return
1574
+ const [op, ...args] = node
1575
+ if (op === '[]') {
1576
+ const [obj, idx] = args
1577
+ if (!isLiteralStr(idx)) {
1578
+ anyDyn = true
1579
+ if (typeof obj === 'string') dynVars.add(obj)
1580
+ }
1581
+ }
1582
+ // Runtime for-in (compile-time unroll didn't fire) → walks via shadow
1583
+ if (op === 'for-in') {
1584
+ anyDyn = true
1585
+ if (typeof args[1] === 'string') dynVars.add(args[1])
1586
+ }
1587
+ for (const a of args) walk(a)
1588
+ }
1589
+ for (const r of roots) walk(r)
1590
+ if (ctx.func.list) for (const f of ctx.func.list) if (f.body) walk(f.body)
1591
+ const initFacts = ctx.module.initFacts
1592
+ if (initFacts?.anyDyn) {
1593
+ anyDyn = true
1594
+ for (const v of initFacts.dynVars) dynVars.add(v)
1595
+ }
1596
+
1597
+ ctx.types.dynKeyVars = dynVars
1598
+ ctx.types.anyDynKey = anyDyn
1599
+ }
1600
+
1601
+ /**
1602
+ * Pre-scan function body for captured variables that are mutated.
1603
+ * Marks mutably-captured vars in ctx.func.boxed for cell-based capture.
1604
+ */
1605
+ export function analyzeBoxedCaptures(body) {
1606
+ const outerScope = new Set()
1607
+ ;(function collectDecls(node) {
1608
+ if (!Array.isArray(node)) return
1609
+ const [op, ...args] = node
1610
+ if (op === '=>') return
1611
+ if (op === 'let' || op === 'const')
1612
+ for (const a of args)
1613
+ if (Array.isArray(a) && a[0] === '=' && typeof a[1] === 'string') outerScope.add(a[1])
1614
+ for (const a of args) collectDecls(a)
1615
+ })(body)
1616
+ if (ctx.func.current?.params) for (const p of ctx.func.current.params) outerScope.add(p.name)
1617
+ if (ctx.func.locals) for (const k of ctx.func.locals.keys()) outerScope.add(k)
1618
+
1619
+ ;(function walk(node, assignTarget) {
1620
+ if (!Array.isArray(node)) return
1621
+ const [op, ...args] = node
1622
+ if (op === '=>') {
1623
+ let p = args[0]
1624
+ if (Array.isArray(p) && p[0] === '()') p = p[1]
1625
+ const raw = p == null ? [] : Array.isArray(p) ? (p[0] === ',' ? p.slice(1) : [p]) : [p]
1626
+ const paramSet = new Set(raw.map(r => Array.isArray(r) && r[0] === '...' ? r[1] : r))
1627
+ const captures = []
1628
+ findFreeVars(args[1], paramSet, captures, outerScope)
1629
+ if (captures.length === 0) return
1630
+ const captureSet = new Set(captures)
1631
+ const mutated = new Set()
1632
+ findMutations(body, captureSet, mutated)
1633
+ if (assignTarget && captureSet.has(assignTarget)) mutated.add(assignTarget)
1634
+ for (const v of mutated) if (!ctx.func.boxed.has(v)) ctx.func.boxed.set(v, `${T}cell_${v}`)
1635
+ return
1636
+ }
1637
+ if (op === '=' && typeof args[0] === 'string' && Array.isArray(args[1]) && args[1][0] === '=>')
1638
+ return walk(args[1], args[0])
1639
+ for (const a of args) walk(a)
1640
+ })(body)
1641
+ }
1642
+
1643
+ /**
1644
+ * Narrow return arr-elem-{schema|valType}: for each non-exported, non-value-used
1645
+ * user func with `valResult === VAL.ARRAY` and `func[field] == null`, walk return
1646
+ * exprs (and trailing-fallthrough literal), resolve each via body-local elem map
1647
+ * + caller-param facts + transitive user-fn results, and if all agree set `func[field]`.
1648
+ * Lets callers' `const rows = initRows()` gain the elem fact, propagating to
1649
+ * runKernel params via paramReps. `field` selects which fact ('arrayElemSchema'
1650
+ * | 'arrayElemValType') — slice key is derived.
1651
+ */
1652
+ const _FIELD_TO_SLICE = {
1653
+ arrayElemSchema: 'arrElemSchemas',
1654
+ arrayElemValType: 'arrElemValTypes',
1655
+ }
1656
+ export function narrowReturnArrayElems(field, paramReps, valueUsed) {
1657
+ const sliceKey = _FIELD_TO_SLICE[field]
1658
+ const targets = ctx.func.list.filter(f =>
1659
+ !f.raw && !f.exported && !valueUsed.has(f.name) &&
1660
+ f.valResult === VAL.ARRAY && f[field] == null
1661
+ )
1662
+ let changed = true
1663
+ while (changed) {
1664
+ changed = false
1665
+ // Cache-staleness barrier: the fixpoint mutates target funcs' [field]
1666
+ // between iterations. analyzeBody reads ctx.func.map[*][field] when
1667
+ // resolving `const x = callee()` and similar chains, so any cached entry
1668
+ // from a prior iter would freeze cross-func propagation. Clear all target
1669
+ // bodies before each sweep.
1670
+ for (const f of targets) _bodyFactsCache.delete(f.body)
1671
+ for (const func of targets) {
1672
+ if (func[field] != null) continue
1673
+ const isBlock = isBlockBody(func.body)
1674
+ if (isBlock && !alwaysReturns(func.body)) continue
1675
+ const exprs = returnExprs(func.body)
1676
+ if (!exprs.length) continue
1677
+ // analyzeBody is context-pure for the arrElem slices, so a single walk
1678
+ // gives both `locals` (for ctx.func.locals seeding — observe filter for
1679
+ // param-aware downstream consumers) and the requested slice.
1680
+ const savedLocals = ctx.func.locals
1681
+ const facts = analyzeBody(func.body)
1682
+ ctx.func.locals = new Map(facts.locals)
1683
+ for (const p of func.sig.params) if (!ctx.func.locals.has(p.name)) ctx.func.locals.set(p.name, p.type)
1684
+ const localElems = facts[sliceKey]
1685
+ ctx.func.locals = savedLocals
1686
+ const paramElemMap = callerParamFactMap(paramReps, func, field) || new Map()
1687
+ const resolveExpr = (expr) => {
1688
+ if (typeof expr === 'string') {
1689
+ if (localElems.has(expr)) {
1690
+ const v = localElems.get(expr)
1691
+ if (v != null) return v
1692
+ }
1693
+ if (paramElemMap.has(expr)) return paramElemMap.get(expr)
1694
+ return null
1695
+ }
1696
+ if (Array.isArray(expr) && expr[0] === '()' && typeof expr[1] === 'string') {
1697
+ const f = ctx.func.map?.get(expr[1])
1698
+ if (f?.[field] != null) return f[field]
1699
+ }
1700
+ if (Array.isArray(expr) && expr[0] === '?:') {
1701
+ const a = resolveExpr(expr[2]), b = resolveExpr(expr[3])
1702
+ return a != null && a === b ? a : null
1703
+ }
1704
+ if (Array.isArray(expr) && (expr[0] === '&&' || expr[0] === '||')) {
1705
+ const a = resolveExpr(expr[1]), b = resolveExpr(expr[2])
1706
+ return a != null && a === b ? a : null
1707
+ }
1708
+ return null
1709
+ }
1710
+ const v0 = resolveExpr(exprs[0])
1711
+ if (v0 == null) continue
1712
+ if (!exprs.every(e => resolveExpr(e) === v0)) continue
1713
+ func[field] = v0
1714
+ changed = true
1715
+ }
1716
+ }
1717
+ }
1718
+
1719
+ /**
1720
+ * Phase: program-fact collection.
1721
+ *
1722
+ * Single whole-program walk over the module AST + each user function body
1723
+ * + all moduleInits. Collects:
1724
+ * dynVars/anyDyn — vars accessed via runtime key (drives strict mode +
1725
+ * 1 __dyn_get fallback gating)
1726
+ * propMap — property assignments per receiver (auto-box schemas)
1727
+ * valueUsed — ctx.func.names passed as first-class values (excluded
1728
+ * from internal narrowing — they need uniform $ftN ABI)
1729
+ * maxDef/maxCall — closure ABI width inputs
1730
+ * hasRest/hasSpread
1731
+ * callSites — `{ callee, argList, callerFunc, node }` for static-name
1732
+ * calls (drives the type/schema fixpoint without
1733
+ * re-walking the AST). `node` is the call AST itself,
1734
+ * mutable for bimorphic-typed clone routing.
1735
+ * paramReps — Map<funcName, Map<paramIdx, ValueRep>>, empty here;
1736
+ * populated by narrowSignatures (per-field lattice) and
1737
+ * read by emitFunc.
1738
+ *
1739
+ * Also writes ctx.schema.slotTypes (static-key object literal slot val types).
1740
+ *
1741
+ * Three visit modes:
1742
+ * full=true (ast + user funcs) → all facts including call-site collection
1743
+ * full=false (moduleInits) → dyn + arity only (no propMap/valueUsed/
1744
+ * callSites: moduleInits don't own user
1745
+ * props/funcs)
1746
+ * inArrow=true → flips off call-site collection so
1747
+ * closure-internal calls don't poison
1748
+ * caller-context type inference.
1749
+ */
1750
+ export function collectProgramFacts(ast) {
1751
+ const paramReps = new Map()
1752
+ const valueUsed = new Set()
1753
+ const dynVars = new Set()
1754
+ let anyDyn = false
1755
+ const propMap = new Map()
1756
+ const callSites = []
1757
+ const doSchema = ast && ctx.schema.register
1758
+ const doArity = !!ctx.closure.make
1759
+ let hasSchemaLiterals = false
1760
+ let maxDef = 0, maxCall = 0, hasRest = false, hasSpread = false
1761
+ const isLiteralStr = idx => Array.isArray(idx) && idx[0] === 'str' && typeof idx[1] === 'string'
1762
+ // Slot-type observation lives in the dedicated `observeProgramSlots` pass below;
1763
+ // walkFacts only registers schemas (which is local to the AST node).
1764
+ const walkFacts = (node, full, inArrow, callerFunc) => {
1765
+ if (!Array.isArray(node)) return
1766
+ const [op, ...args] = node
1767
+ // dyn-key detection. Strict check deferred to emit time (e.g. `buf[i]` on a
1768
+ // Float64Array uses typed-array load, not __dyn_get — only the actual
1769
+ // dynamic-dispatch fallback should error in strict mode).
1770
+ if (op === '[]') {
1771
+ const [obj, idx] = args
1772
+ if (!isLiteralStr(idx)) { anyDyn = true; if (typeof obj === 'string') dynVars.add(obj) }
1773
+ } else if (op === 'for-in') {
1774
+ if (ctx.transform.strict) err(`strict mode: \`for (... in ...)\` is not allowed (dynamic enumeration). Pass { strict: false } to enable.`)
1775
+ anyDyn = true
1776
+ if (typeof args[1] === 'string') dynVars.add(args[1])
1777
+ }
1778
+ // Object literal: register schema. Slot val-type observation is deferred to a
1779
+ // second pass (observeSlotsIn below) so that shorthand `{x}` (expanded by prepare
1780
+ // to `[':', x, x]`) resolves x's val type via per-function locals, not just globals.
1781
+ if (op === '{}' && doSchema) {
1782
+ const parsed = staticObjectProps(args)
1783
+ if (parsed) {
1784
+ ctx.schema.register(parsed.names)
1785
+ hasSchemaLiterals = true
1786
+ }
1787
+ }
1788
+ // closure ABI arity
1789
+ if (doArity) {
1790
+ if (op === '=>') {
1791
+ let fixedN = 0
1792
+ for (const r of extractParams(args[0])) {
1793
+ if (classifyParam(r).kind === 'rest') hasRest = true
1794
+ else fixedN++
1795
+ }
1796
+ if (fixedN > maxDef) maxDef = fixedN
1797
+ } else if (op === '()') {
1798
+ const a = args[1]
1799
+ const callArgs = a == null ? [] : (Array.isArray(a) && a[0] === ',') ? a.slice(1) : [a]
1800
+ if (callArgs.some(x => Array.isArray(x) && x[0] === '...')) hasSpread = true
1801
+ if (callArgs.length > maxCall) maxCall = callArgs.length
1802
+ }
1803
+ }
1804
+ // Crossing into a closure body: from now on, no call-site collection (matches the
1805
+ // pre-fusion scanCalls bailing at '=>'). Still walks children for arity/dyn.
1806
+ if (op === '=>') {
1807
+ for (const a of args) walkFacts(a, full, true, callerFunc)
1808
+ return
1809
+ }
1810
+ if (full) {
1811
+ // property-assignment scan for auto-box
1812
+ if (doSchema && op === '=' && Array.isArray(args[0]) && args[0][0] === '.') {
1813
+ const [, obj, prop] = args[0]
1814
+ if (typeof obj === 'string' && (ctx.scope.globals.has(obj) || ctx.func.names.has(obj))) {
1815
+ if (!propMap.has(obj)) propMap.set(obj, new Set())
1816
+ propMap.get(obj).add(prop)
1817
+ }
1818
+ }
1819
+ // first-class function-value + static-call-site scan
1820
+ if (op === '()' && typeof args[0] === 'string' && ctx.func.names.has(args[0])) {
1821
+ if (!inArrow) {
1822
+ // Record call site for the type/schema fixpoint. Filtering by
1823
+ // exported/raw/valueUsed happens later (valueUsed isn't fully populated yet).
1824
+ // `node` is the call AST node itself; specializeBimorphicTyped mutates
1825
+ // node[1] (the callee name) to point at a per-ctor clone.
1826
+ const a = args[1]
1827
+ const argList = a == null ? [] : (Array.isArray(a) && a[0] === ',') ? a.slice(1) : [a]
1828
+ callSites.push({ callee: args[0], argList, callerFunc, node })
1829
+ }
1830
+ for (let i = 1; i < args.length; i++) {
1831
+ const a = args[i]
1832
+ if (typeof a === 'string' && ctx.func.names.has(a)) valueUsed.add(a)
1833
+ else walkFacts(a, true, inArrow, callerFunc)
1834
+ }
1835
+ return
1836
+ }
1837
+ if ((op === '.' || op === '?.') && typeof args[0] === 'string' && ctx.func.names.has(args[0])) return
1838
+ for (const a of args) {
1839
+ if (typeof a === 'string' && ctx.func.names.has(a)) valueUsed.add(a)
1840
+ else walkFacts(a, true, inArrow, callerFunc)
1841
+ }
1842
+ } else {
1843
+ for (const a of args) walkFacts(a, false, inArrow, callerFunc)
1844
+ }
1845
+ }
1846
+ walkFacts(ast, true, false, null)
1847
+ for (const func of ctx.func.list) if (func.body && !func.raw) walkFacts(func.body, true, false, func)
1848
+ const initFacts = ctx.module.initFacts
1849
+ if (initFacts) {
1850
+ if (initFacts.anyDyn) {
1851
+ anyDyn = true
1852
+ for (const v of initFacts.dynVars) dynVars.add(v)
1853
+ }
1854
+ if (doArity) {
1855
+ if (initFacts.maxDef > maxDef) maxDef = initFacts.maxDef
1856
+ if (initFacts.maxCall > maxCall) maxCall = initFacts.maxCall
1857
+ if (initFacts.hasRest) hasRest = true
1858
+ if (initFacts.hasSpread) hasSpread = true
1859
+ }
1860
+ if (doSchema && initFacts.hasSchemaLiterals) hasSchemaLiterals = true
1861
+ }
1862
+
1863
+ // Slot-type observation pass: walk every `{}` literal with the right scope's
1864
+ // valTypes installed as `ctx.func.localValTypesOverlay` so shorthand `{x}`
1865
+ // (expanded by prepare to `[':', x, x]`) and chained typed-array reads resolve
1866
+ // through valTypeOf → lookupValType. Skips into closures — they're observed via
1867
+ // their own func.list entry. The overlay is the per-function analyzeBody.valTypes
1868
+ // map (already populated with the same overlay-aware walk).
1869
+ if (doSchema && hasSchemaLiterals) observeProgramSlots(ast)
1870
+
1871
+ return {
1872
+ dynVars, anyDyn, propMap, valueUsed, callSites,
1873
+ maxDef, maxCall, hasRest, hasSpread,
1874
+ paramReps, hasSchemaLiterals,
1875
+ }
1876
+ }
1877
+
1878
+ /** Walk `ast` + every user function body + module inits, observing slot types
1879
+ * on each `{}` literal. Per-function bodies have their analyzeBody.valTypes
1880
+ * installed as overlay so shorthand `{x}` resolves through local consts.
1881
+ *
1882
+ * Re-runnable: compile.js calls this once during collectProgramFacts (before
1883
+ * E2 valResult inference), then again after E2 — on the second pass, valTypeOf
1884
+ * on user-function calls resolves via `f.valResult`, lifting slots whose value
1885
+ * is `const x = userFn(...)` from `undefined` to `NUMBER`/etc.
1886
+ * observeSlot's first-wins-then-clash rule means later precise observations
1887
+ * upgrade undefined slots without re-poisoning already-monomorphic ones. */
1888
+ export function observeProgramSlots(ast) {
1889
+ if (!ctx.schema?.register) return
1890
+ const slotTypes = ctx.schema.slotTypes
1891
+ const observeSlot = (sid, idx, vt) => {
1892
+ if (!vt) return
1893
+ let arr = slotTypes.get(sid)
1894
+ if (!arr) { arr = []; slotTypes.set(sid, arr) }
1895
+ while (arr.length <= idx) arr.push(undefined)
1896
+ if (arr[idx] === null) return
1897
+ if (arr[idx] === undefined) arr[idx] = vt
1898
+ else if (arr[idx] !== vt) arr[idx] = null
1899
+ }
1900
+ const visit = (node) => {
1901
+ if (!Array.isArray(node)) return
1902
+ const op = node[0]
1903
+ if (op === '=>') return
1904
+ if (op === '{}') {
1905
+ const parsed = staticObjectProps(node.slice(1))
1906
+ if (parsed) {
1907
+ const sid = ctx.schema.register(parsed.names)
1908
+ for (let i = 0; i < parsed.values.length; i++) {
1909
+ observeSlot(sid, i, valTypeOf(parsed.values[i]))
1910
+ }
1911
+ }
1912
+ }
1913
+ for (let i = 1; i < node.length; i++) visit(node[i])
1914
+ }
1915
+ const prevOverlay = ctx.func.localValTypesOverlay
1916
+ if (ast) { ctx.func.localValTypesOverlay = null; visit(ast) }
1917
+ for (const func of ctx.func.list) {
1918
+ if (!func.body || func.raw) continue
1919
+ ctx.func.localValTypesOverlay = analyzeBody(func.body).valTypes
1920
+ visit(func.body)
1921
+ }
1922
+ if (ctx.module.initFacts?.hasSchemaLiterals && ctx.module.moduleInits) {
1923
+ ctx.func.localValTypesOverlay = null
1924
+ for (const mi of ctx.module.moduleInits) visit(mi)
1925
+ }
1926
+ ctx.func.localValTypesOverlay = prevOverlay
1927
+ }