jz 0.4.0 → 0.5.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.
package/src/analyze.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * # Stage contract
5
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`,
6
+ * OUT: per-function populated `ctx.func.localReps` (val field) + `ctx.func.locals` + `ctx.func.boxed`,
7
7
  * module-global `ctx.scope.globalValTypes`, type-analysis `ctx.types.typedElem` /
8
8
  * `.dynKeyVars` / `.anyDynKey`.
9
9
  *
@@ -13,18 +13,17 @@
13
13
  * - analyzeBody: single unified walk — body-keyed cache, returns
14
14
  * { locals, valTypes, arrElemSchemas, arrElemValTypes, typedElems }
15
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
16
+ * - boxedCaptures: detect mutably-captured vars ctx.func.boxed cells
19
17
  * - extractParams/classifyParam/collectParamNames: arrow param AST normalization helpers
20
18
  *
21
- * Ordering: analyzeDynKeys runs once per compile; others run per function during compile().
19
+ * Ordering: all passes run per function during compile(). plan.js owns the
20
+ * cross-function dynKey scan via programFacts (results land in ctx.types.dynKeyVars).
22
21
  *
23
22
  * @module analyze
24
23
  */
25
24
 
26
25
  import { ctx, err } from './ctx.js'
27
- import { isLiteralStr, isFuncRef } from './ir.js'
26
+ import { isLiteralStr, isFuncRef, I32_MIN, I32_MAX, isI32 } from './ir.js'
28
27
 
29
28
  export const T = '\uE000'
30
29
 
@@ -48,7 +47,7 @@ export function intLiteralValue(expr) {
48
47
  else if (Array.isArray(expr) && expr[0] == null && typeof expr[1] === 'number') v = expr[1]
49
48
  else if (Array.isArray(expr) && expr[0] === 'u-' && typeof expr[1] === 'number') v = -expr[1]
50
49
  else if (typeof expr === 'string') v = repOf(expr)?.intConst ?? ctx.scope.constInts?.get(expr) ?? null
51
- return v != null && Number.isInteger(v) && v >= -2147483648 && v <= 2147483647 ? v : null
50
+ return v != null && Number.isInteger(v) && v >= I32_MIN && v <= I32_MAX ? v : null
52
51
  }
53
52
 
54
53
  /** Non-negative integer literal — used for string/typed-array index bounds. */
@@ -57,7 +56,7 @@ export const nonNegIntLiteral = (node) => { const n = intLiteralValue(node); ret
57
56
  /** Collect all `return X` expressions (X != null) from a function body, skipping nested arrow funcs.
58
57
  * Pushes into `out`. Non-returning paths are silently skipped — pair with `alwaysReturns` if total
59
58
  * coverage matters, or with `hasBareReturn` to detect `return;` (undef result). */
60
- export const collectReturnExprs = (node, out) => {
59
+ const collectReturnExprs = (node, out) => {
61
60
  if (!Array.isArray(node)) return
62
61
  const [op, ...args] = node
63
62
  if (op === '=>') return
@@ -107,13 +106,20 @@ export const VAL = {
107
106
  OBJECT: 'object', HASH: 'hash', SET: 'set', MAP: 'map',
108
107
  CLOSURE: 'closure', TYPED: 'typed', REGEX: 'regex',
109
108
  BIGINT: 'bigint', BUFFER: 'buffer', DATE: 'date',
109
+ // BOOL is a *type fact* only: the working representation stays i32/f64 0/1
110
+ // (so branches, arithmetic and the optimizer are untouched). The boxed-atom
111
+ // carrier (TRUE_NAN/FALSE_NAN, see src/ir.js) is materialized lazily, only at
112
+ // observation/escape sites — `typeof`, `String`, `JSON.stringify`, the host
113
+ // return boundary — where boolean identity must survive. Everywhere else a
114
+ // VAL.BOOL value is just a 0/1 number, so no perf is paid for the distinction.
115
+ BOOL: 'boolean',
110
116
  }
111
117
 
112
118
  /**
113
119
  * ValueRep — unified per-local + per-param representation record. (S2.)
114
120
  *
115
121
  * One shape, two storages:
116
- * - per-local (current func): ctx.func.repByLocal: Map<name, ValueRep>
122
+ * - per-local (current func): ctx.func.localReps: Map<name, ValueRep>
117
123
  * - per-param (cross-call): programFacts.paramReps: Map<funcName, Map<paramIdx, ValueRep>>
118
124
  *
119
125
  * Lattice per field: undefined = unobserved, null = sticky-poison
@@ -129,7 +135,7 @@ export const VAL = {
129
135
  * schemaId: i32 — schema binding for known-shape OBJECTs
130
136
  * arrayElemSchema: i32 — Array<schemaId> element shape
131
137
  * arrayElemValType: VAL.* — Array<VAL.*> element val-kind
132
- * jsonShape: obj — { vt, props?, elem? } for HASH/ARRAY trees parsed
138
+ * jsonShape: obj — { val, props?, elem? } for HASH/ARRAY trees parsed
133
139
  * from a compile-time JSON.parse source. Propagates
134
140
  * through `.prop` and `[i]` so nested chains stay typed.
135
141
  * typedCtor: str — TypedArray ctor name (`Float64Array`, …)
@@ -144,37 +150,33 @@ export const VAL = {
144
150
  * (or `f64.const N`), letting the WAT optimizer fold guards,
145
151
  * unroll fixed-bound loops, and treeshake the read entirely.
146
152
  * Cleared if the param is written inside the body.
153
+ * notString: bool — write-shape evidence proves the binding isn't a primitive
154
+ * string. Gates STRING-vs-typed dispatch elision at `.length`
155
+ * and `xs[i]` reads. Body-walk source: `notStringEvidence` in
156
+ * src/infer.js. Flow-scoped overlay: see `lookupNotString`.
147
157
  *
148
- * Future (S2 stage 4 follow-ups): boxed, intLikely, nullable.
158
+ * Out-of-band tracking (not rep fields):
159
+ * - boxed captures: `ctx.func.boxed: Map<name, cellName>` — set by boxedCaptures
160
+ * for mutably-captured locals. Parallel to rep because the cell-based storage is a
161
+ * storage decision, not a value-shape fact.
162
+ * - flow-sensitive refinements: `ctx.func.refinements: Map<name, {val?, notString?}>` —
163
+ * set by emitBody's post-terminator narrowing for the duration of a syntactic suffix.
149
164
  */
150
165
 
151
166
  // === ParamReps lattice helpers (cross-call fixpoint) ===
152
167
  // programFacts.paramReps: Map<funcName, Map<paramIdx, ValueRep>>. Per-field lattice:
153
168
  // undefined unobserved, null sticky-poison (cross-site disagreement), value = consensus.
154
169
 
155
- /** Per-call-site fact merge into a param's ValueRep field, with sticky-null poison
156
- * on disagreement. undefined→observed (set); same value→stay; conflict→null (sticky).
157
- * null is "no consensus" readers treat it as missing. */
158
- export const mergeParamFact = (rep, key, observed) => {
159
- if (rep[key] === null) return // sticky poison
160
- if (observed == null) { rep[key] = null; return } // unknown → poison
161
- if (rep[key] === undefined) rep[key] = observed
162
- else if (rep[key] !== observed) rep[key] = null
163
- }
164
-
165
- /** Get-or-create per-param rep at (funcName, paramIdx) on a paramReps map. */
166
- export const ensureParamRep = (paramReps, funcName, k) => {
167
- let m = paramReps.get(funcName)
168
- if (!m) { m = new Map(); paramReps.set(funcName, m) }
169
- let r = m.get(k)
170
- if (!r) { r = {}; m.set(k, r) }
171
- return r
172
- }
170
+ // Lattice primitives for `paramReps` (`mergeParamFact`, `ensureParamRep`,
171
+ // `clearStickyNull`) live in src/infer.js with the call-site evidence
172
+ // extractors that produce facts for them. `paramFactsOf` (next) stays
173
+ // here because `narrowReturnArrayElems` below consumes it; moving it would
174
+ // invert the analyze.js↔infer.js import direction.
173
175
 
174
176
  /** Build `paramName → fact` lookup for a caller's already-narrowed param facts.
175
177
  * Used to flow caller's param info into its callees during the cross-call
176
178
  * fixpoint (transitive propagation). Returns null if caller has no facts. */
177
- export const callerParamFactMap = (paramReps, callerFunc, key) => {
179
+ export const paramFactsOf = (paramReps, callerFunc, key) => {
178
180
  if (!callerFunc) return null
179
181
  const m = paramReps.get(callerFunc.name)
180
182
  if (!m) return null
@@ -189,22 +191,13 @@ export const callerParamFactMap = (paramReps, callerFunc, key) => {
189
191
  return out
190
192
  }
191
193
 
192
- /** Reset sticky-null on a single field across all params program-wide.
193
- * Used between fixpoint phases when newly-narrowed facts unblock previously-
194
- * poisoned observations (e.g. valResult set after first pass). */
195
- export const clearStickyNull = (paramReps, key) => {
196
- for (const m of paramReps.values()) for (const r of m.values()) {
197
- if (r[key] === null) r[key] = undefined
198
- }
199
- }
200
-
201
194
  /** Get the rep for a local name, or undefined if not tracked. */
202
- export const repOf = name => ctx.func.repByLocal?.get(name)
195
+ export const repOf = name => ctx.func.localReps?.get(name)
203
196
 
204
197
  /** Merge fields into a local's rep. Lazily allocates the map and the rep.
205
198
  * Field set to `undefined` removes that field; empty rep is dropped from the map. */
206
199
  export const updateRep = (name, fields) => {
207
- const m = ctx.func.repByLocal ||= new Map()
200
+ const m = ctx.func.localReps ||= new Map()
208
201
  const prev = m.get(name) || {}
209
202
  const next = { ...prev, ...fields }
210
203
  for (const k of Object.keys(next)) if (next[k] === undefined) delete next[k]
@@ -213,11 +206,11 @@ export const updateRep = (name, fields) => {
213
206
  }
214
207
 
215
208
  /** Get the rep for a global name, or undefined if not tracked. */
216
- export const repOfGlobal = name => ctx.scope.repByGlobal?.get(name)
209
+ export const repOfGlobal = name => ctx.scope.globalReps?.get(name)
217
210
 
218
211
  /** Merge fields into a global's rep. Lazily allocates the map and the rep. */
219
212
  export const updateGlobalRep = (name, fields) => {
220
- const m = ctx.scope.repByGlobal ||= new Map()
213
+ const m = ctx.scope.globalReps ||= new Map()
221
214
  const prev = m.get(name)
222
215
  m.set(name, prev ? { ...prev, ...fields } : { ...fields })
223
216
  }
@@ -227,33 +220,50 @@ export const updateGlobalRep = (name, fields) => {
227
220
  * Refinements are pushed by the 'if' emitter when the condition is a type guard
228
221
  * (typeof x === 't', Array.isArray(x), etc.) and popped after the then-branch.
229
222
  * The overlay (`ctx.func.localValTypesOverlay`) is set by analyzeBody/observeSlots passes
230
- * pre-emit, when `repByLocal` isn't populated yet but a local Map<name, VAL.*> is
223
+ * pre-emit, when `localReps` isn't populated yet but a local Map<name, VAL.*> is
231
224
  * available — lets `const x = new Float64Array(); const y = x[0]` resolve y as NUMBER. */
232
225
  export const lookupValType = name => {
233
226
  const r = ctx.func.refinements
234
- if (r && r.size) { const v = r.get(name); if (v) return v }
227
+ if (r && r.size) { const v = r.get(name)?.val; if (v) return v }
235
228
  const ov = ctx.func.localValTypesOverlay
236
229
  if (ov) { const v = ov.get(name); if (v) return v }
237
- return ctx.func.repByLocal?.get(name)?.val || ctx.scope.globalValTypes?.get(name) || null
230
+ return ctx.func.localReps?.get(name)?.val || ctx.scope.globalValTypes?.get(name) || null
231
+ }
232
+
233
+ /** Resolve `notString` for a binding, overlaying flow-sensitive refinements
234
+ * on top of the function-global rep proof. Mirrors `lookupValType`'s precedence:
235
+ * refinement first (scope-local), then rep (whole-function). */
236
+ export const lookupNotString = name => {
237
+ const r = ctx.func.refinements
238
+ if (r && r.size && r.get(name)?.notString) return true
239
+ return ctx.func.localReps?.get(name)?.notString === true
238
240
  }
239
241
 
240
242
  /** Infer value type of an AST expression (without emitting). */
241
243
  export function valTypeOf(expr) {
242
244
  if (expr == null) return null
243
245
  if (typeof expr === 'number') return VAL.NUMBER
246
+ if (typeof expr === 'boolean') return VAL.BOOL
244
247
  if (typeof expr === 'bigint') return VAL.BIGINT
245
248
  if (typeof expr === 'string') return lookupValType(expr)
246
249
  if (!Array.isArray(expr)) return null
247
250
 
248
251
  const [op, ...args] = expr
249
252
  if (op == null) {
250
- // Literal forms: [] = undefined, [null, null] = null, [null, n] = number/bigint
253
+ // Literal forms: [] = undefined, [null, null] = null, [null, n] = number/bigint, [, bool] = boolean
251
254
  if (args.length === 0) return null // undefined literal
252
255
  if (args[0] == null) return null // null literal
256
+ if (typeof args[0] === 'boolean') return VAL.BOOL
253
257
  if (typeof args[0] === 'symbol') return null // prepared null sentinel
254
258
  return typeof args[0] === 'bigint' ? VAL.BIGINT : VAL.NUMBER
255
259
  }
256
260
 
261
+ // Boolean-result operators: relational/equality compares and logical-not always
262
+ // yield a boolean. (`&&`/`||` are value-preserving, not boolean — excluded.)
263
+ if (op === '!' || op === '<' || op === '<=' || op === '>' || op === '>=' ||
264
+ op === '==' || op === '!=' || op === '===' || op === '!==' || op === 'in' || op === 'instanceof')
265
+ return VAL.BOOL
266
+
257
267
  if (op === '[') return VAL.ARRAY
258
268
  if (op === 'str' || op === 'strcat') return VAL.STRING
259
269
  if (op === '=>') return VAL.CLOSURE
@@ -278,7 +288,7 @@ export function valTypeOf(expr) {
278
288
  // Indexed read on a known Array<VAL> receiver: bind by rep.arrayElemValType.
279
289
  // Set by analyzeValTypes from body observations + emitFunc preseed for params.
280
290
  if (typeof args[0] === 'string') {
281
- const elemVt = ctx.func.repByLocal?.get(args[0])?.arrayElemValType
291
+ const elemVt = ctx.func.localReps?.get(args[0])?.arrayElemValType
282
292
  if (elemVt) return elemVt
283
293
  }
284
294
  }
@@ -296,9 +306,9 @@ export function valTypeOf(expr) {
296
306
  // child's val-type. Generic for any compile-time-known JSON literal.
297
307
  if (op === '.' && typeof args[1] === 'string') {
298
308
  const sh = shapeOf(args[0])
299
- if (sh?.vt === VAL.OBJECT || sh?.vt === VAL.HASH) {
309
+ if (sh?.val === VAL.OBJECT || sh?.val === VAL.HASH) {
300
310
  const child = sh.props[args[1]]
301
- if (child) return child.vt
311
+ if (child) return child.val
302
312
  }
303
313
  }
304
314
  // Arithmetic expressions: BigInt if either operand is BigInt, else number
@@ -313,6 +323,23 @@ export function valTypeOf(expr) {
313
323
  if (ta === VAL.BIGINT || tb === VAL.BIGINT) return VAL.BIGINT
314
324
  return VAL.NUMBER
315
325
  }
326
+ // Assignment & compound-assign expressions return the rhs value. Without this,
327
+ // `(a = x*x) + (b = y*y)` falls through to null and `+` emits the polymorphic
328
+ // string-concat dispatch on two pure-numeric subexpressions.
329
+ if (op === '=') return valTypeOf(args[1])
330
+ if (op === '+=') {
331
+ const ta = typeof args[0] === 'string' ? lookupValType(args[0]) : null
332
+ const tb = valTypeOf(args[1])
333
+ if (ta === VAL.STRING || tb === VAL.STRING) return VAL.STRING
334
+ if (ta === VAL.BIGINT || tb === VAL.BIGINT) return VAL.BIGINT
335
+ return VAL.NUMBER
336
+ }
337
+ if (['-=', '*=', '/=', '%=', '**=', '&=', '|=', '^=', '<<=', '>>=', '>>>='].includes(op)) {
338
+ const ta = typeof args[0] === 'string' ? lookupValType(args[0]) : null
339
+ const tb = valTypeOf(args[1])
340
+ if (ta === VAL.BIGINT || tb === VAL.BIGINT) return VAL.BIGINT
341
+ return VAL.NUMBER
342
+ }
316
343
 
317
344
  if (op === '()') {
318
345
  const callee = args[0]
@@ -327,9 +354,16 @@ export function valTypeOf(expr) {
327
354
  if (callee === 'new.Map') return VAL.MAP
328
355
  if (callee === 'new.Date') return VAL.DATE
329
356
  if (callee === 'new.ArrayBuffer') return VAL.BUFFER
330
- if (callee === 'new.DataView') return VAL.BUFFER
357
+ // `new Array(...)` is a plain growable Array, not a TypedArray — index
358
+ // stores must route through __arr_set_idx_ptr (grow + persist), so it
359
+ // must NOT fall into the new.* → VAL.TYPED catch-all below.
360
+ if (callee === 'new.Array') return VAL.ARRAY
361
+ // `new DataView(...)` falls through to VAL.TYPED: a DataView is a proper
362
+ // view object (TYPED|view ptr over a 16-byte descriptor), same shape as a
363
+ // typed-array subview — see module/typedarray.js `new.DataView`.
331
364
  if (callee.startsWith('new.')) return VAL.TYPED
332
365
  if (callee === 'String.fromCharCode' || callee === 'String') return VAL.STRING
366
+ if (callee === 'Boolean') return VAL.BOOL
333
367
  if (callee === 'BigInt' || callee === 'BigInt.asIntN' || callee === 'BigInt.asUintN') return VAL.BIGINT
334
368
  if (callee === 'JSON.parse') {
335
369
  const src = jsonConstString(args[1])
@@ -369,7 +403,7 @@ export function valTypeOf(expr) {
369
403
  }
370
404
  if (method === 'push') return VAL.ARRAY
371
405
  if ((method === 'shift' || method === 'pop') && typeof obj === 'string') {
372
- const elemVt = ctx.func.repByLocal?.get(obj)?.arrayElemValType
406
+ const elemVt = ctx.func.localReps?.get(obj)?.arrayElemValType
373
407
  if (elemVt) return elemVt
374
408
  }
375
409
  if (method === 'add' || method === 'delete') return VAL.SET
@@ -377,6 +411,12 @@ export function valTypeOf(expr) {
377
411
  // String-returning methods
378
412
  if (['toUpperCase', 'toLowerCase', 'toLocaleLowerCase', 'trim', 'trimStart', 'trimEnd',
379
413
  'repeat', 'padStart', 'padEnd', 'replace', 'replaceAll', 'charAt', 'substring'].includes(method)) return VAL.STRING
414
+ // `charCodeAt`/`codePointAt` always yield a number (a UTF-16 code unit or
415
+ // NaN for an out-of-range index — both VAL.NUMBER). Without this, the f64
416
+ // OOB-NaN result has an unknown val-type and any reader (`+`, `-`, ...)
417
+ // wraps it in a runtime `__to_num` coercion, pulling the whole number↔
418
+ // string stdlib tree (`__ftoa`/`__str_concat`/…) for nothing.
419
+ if (method === 'charCodeAt' || method === 'codePointAt') return VAL.NUMBER
380
420
  if (method === 'split') return VAL.ARRAY
381
421
  // slice/concat preserve caller type (string.slice → string, array.slice → array)
382
422
  if (method === 'slice' || method === 'concat') {
@@ -389,128 +429,8 @@ export function valTypeOf(expr) {
389
429
  return null
390
430
  }
391
431
 
392
- function jsonConstString(expr) {
393
- if (Array.isArray(expr) && expr[0] === 'str' && typeof expr[1] === 'string') return expr[1]
394
- if (Array.isArray(expr) && expr[0] == null && typeof expr[1] === 'string') return expr[1]
395
- if (typeof expr === 'string') {
396
- // Prefer shapeStrs (broader: includes effectively-const `let` string literals
397
- // at module scope) over constStrs (const-only). Module/json's static-fold
398
- // path uses its own constStrs-only resolver to avoid folding `let`-bound
399
- // initializers — preserving the user-controlled distinction. Shape
400
- // inference is sound either way: an effectively-const literal's value is
401
- // invariant, so the parsed shape it produces is too.
402
- return ctx.scope.shapeStrs?.get(expr) ?? ctx.scope.constStrs?.get(expr) ?? null
403
- }
404
- return null
405
- }
406
-
407
- function jsonShapeStrings(expr) {
408
- const single = jsonConstString(expr)
409
- if (single != null) return [single]
410
- if (Array.isArray(expr) && expr[0] === '[]' && typeof expr[1] === 'string') return ctx.scope.shapeStrArrays?.get(expr[1]) ?? null
411
- return null
412
- }
413
-
414
- /** Build a structural shape tree from a parsed JSON value. Each node is
415
- * `{ vt, props?, elem? }`. Lets `valTypeOf` propagate VAL kinds through
416
- * `.prop` chains and `[i]` reads on bindings sourced from `JSON.parse`
417
- * of a compile-time-known string. Polymorphic arrays drop their `elem`. */
418
- function shapeOfJsonValue(v) {
419
- if (v === null || v === undefined) return null
420
- if (typeof v === 'number') return { vt: VAL.NUMBER }
421
- if (typeof v === 'string') return { vt: VAL.STRING }
422
- if (typeof v === 'boolean') return { vt: VAL.NUMBER }
423
- if (Array.isArray(v)) {
424
- let elem = null
425
- for (const x of v) {
426
- const s = shapeOfJsonValue(x)
427
- if (!s) { elem = null; break }
428
- if (!elem) elem = s
429
- else if (!shapeUnifies(elem, s)) { elem = null; break }
430
- }
431
- return { vt: VAL.ARRAY, elem }
432
- }
433
- if (typeof v === 'object') {
434
- const props = Object.create(null)
435
- const names = Object.keys(v)
436
- for (const k of names) {
437
- const s = shapeOfJsonValue(v[k])
438
- if (s) props[k] = s
439
- }
440
- return { vt: VAL.OBJECT, props, names }
441
- }
442
- return null
443
- }
444
-
445
- function shapeUnifies(a, b) {
446
- if (!a || !b || a.vt !== b.vt) return false
447
- if (a.vt === VAL.OBJECT || a.vt === VAL.HASH) {
448
- const ak = Object.keys(a.props), bk = Object.keys(b.props)
449
- if (ak.length !== bk.length) return false
450
- for (const k of ak) {
451
- if (!b.props[k] || !shapeUnifies(a.props[k], b.props[k])) return false
452
- }
453
- }
454
- if (a.vt === VAL.ARRAY) {
455
- if ((a.elem == null) !== (b.elem == null)) return false
456
- if (a.elem && !shapeUnifies(a.elem, b.elem)) return false
457
- }
458
- return true
459
- }
460
-
461
- function shapeLayoutUnifies(a, b) {
462
- if (!shapeUnifies(a, b)) return false
463
- if (a.vt === VAL.OBJECT || a.vt === VAL.HASH) {
464
- if (a.names?.length !== b.names?.length) return false
465
- for (let i = 0; i < a.names.length; i++) if (a.names[i] !== b.names[i]) return false
466
- }
467
- if (a.vt === VAL.ARRAY && a.elem) return shapeLayoutUnifies(a.elem, b.elem)
468
- return true
469
- }
470
-
471
- const _jsonShapeCache = new WeakMap()
472
- function parseJsonShape(src) {
473
- if (typeof src !== 'string') return null
474
- if (_jsonShapeCache.has(src)) return _jsonShapeCache.get(src)
475
- let parsed
476
- try { parsed = JSON.parse(src) } catch { _jsonShapeCache.set(Object(src), null); return null }
477
- const sh = shapeOfJsonValue(parsed)
478
- // WeakMap requires object keys; cache via a wrapper. Skip caching for cold path.
479
- return sh
480
- }
481
-
482
- function parseUnifiedJsonShape(srcs) {
483
- if (!srcs?.length) return null
484
- let out = null
485
- for (const src of srcs) {
486
- const sh = parseJsonShape(src)
487
- if (!sh) return null
488
- if (!out) out = sh
489
- else if (!shapeLayoutUnifies(out, sh)) return null
490
- }
491
- return out
492
- }
493
-
494
- /** Resolve the json shape for an expression by walking name → rep.jsonShape and
495
- * `.prop` / `[i]` indirection. Returns null when shape is unknown at this site. */
496
- export function shapeOf(expr) {
497
- if (typeof expr === 'string') return ctx.func.repByLocal?.get(expr)?.jsonShape || null
498
- if (!Array.isArray(expr)) return null
499
- const [op, ...args] = expr
500
- if (op === '()' && args[0] === 'JSON.parse') {
501
- const srcs = jsonShapeStrings(args[1])
502
- if (srcs) return parseUnifiedJsonShape(srcs)
503
- }
504
- if (op === '.' && typeof args[1] === 'string') {
505
- const parent = shapeOf(args[0])
506
- if (parent?.vt === VAL.OBJECT || parent?.vt === VAL.HASH) return parent.props[args[1]] || null
507
- }
508
- if (op === '[]' && args.length === 2) {
509
- const parent = shapeOf(args[0])
510
- if (parent?.vt === VAL.ARRAY) return parent.elem || null
511
- }
512
- return null
513
- }
432
+ // JSON-shape inference (formerly in src/shape.js) — `shapeOf` + `jsonConstString`
433
+ // are defined further down in this file and exported for module consumers.
514
434
 
515
435
 
516
436
  /** Static property-key evaluation for computed member names: folds a node into
@@ -560,8 +480,10 @@ function staticValue(node) {
560
480
  if (args.length === 1) {
561
481
  const value = staticValue(args[0])
562
482
  if (value === NO_VALUE) return NO_VALUE
563
- if (op === 'u+') return +value
564
- if (op === 'u-') return -value
483
+ // Parser emits raw `-`/`+` for both unary and binary; prep later normalizes
484
+ // unary to `u-`/`u+`, but staticPropertyKey runs on raw parser AST.
485
+ if (op === 'u+' || op === '+') return +value
486
+ if (op === 'u-' || op === '-') return -value
565
487
  if (op === '!') return !value
566
488
  if (op === '~') return ~value
567
489
  return NO_VALUE
@@ -616,7 +538,7 @@ function staticArrayElems(expr) {
616
538
  }
617
539
 
618
540
  /** Schema-id for an object literal expression. Returns null on dynamic keys, spread, shorthand. */
619
- function objLiteralSchemaId(expr) {
541
+ export function objLiteralSchemaId(expr) {
620
542
  if (!Array.isArray(expr) || expr[0] !== '{}' || !ctx.schema?.register) return null
621
543
  const parsed = staticObjectProps(expr.slice(1))
622
544
  return parsed ? ctx.schema.register(parsed.names) : null
@@ -695,7 +617,7 @@ export function ctorFromElemAux(aux) {
695
617
  /** Sentinel returned by `ternaryCtorOfRhs` when ternary branches resolve to
696
618
  * different typed-array ctors — caller should drop any cached entry rather
697
619
  * than leave a stale ctor (which would lock the wrong store width). */
698
- export const MIXED_CTORS = Symbol('MIXED_CTORS')
620
+ const MIXED_CTORS = Symbol('MIXED_CTORS')
699
621
 
700
622
  const typedCtorElemValType = (ctor) => {
701
623
  if (!ctor) return null
@@ -712,7 +634,7 @@ const isCondExpr = e => Array.isArray(e) && (e[0] === '?:' || e[0] === '&&' || e
712
634
  * - a single ctor string when every branch resolves to the same ctor,
713
635
  * - MIXED_CTORS when branches resolve to different ctors,
714
636
  * - null when no branch resolves (caller's behavior unchanged). */
715
- export function ternaryCtorOfRhs(rhs) {
637
+ function ternaryCtorOfRhs(rhs) {
716
638
  if (!Array.isArray(rhs)) return null
717
639
  const op = rhs[0]
718
640
  const lo = op === '?:' ? 2 : (op === '&&' || op === '||') ? 1 : 0
@@ -722,99 +644,358 @@ export function ternaryCtorOfRhs(rhs) {
722
644
  return a && b ? (a === b ? a : MIXED_CTORS) : (a || b || null)
723
645
  }
724
646
 
725
- // === Cross-call argument inference helpers (used by narrowSignatures fixpoint) ===
726
- // Each `inferArg*(expr, ...callerCtx)` resolves an argument expression to a single
727
- // fact (val/schemaId/elem*/typedCtor) using caller-local observations and program
728
- // facts, returning null when the fact can't be determined at this call site.
647
+ // Cross-call argument inference helpers (`infer*`) live in src/infer.js
648
+ // they're the call-site mirror of the body-walk evidence sources and pair
649
+ // naturally with that registry. Consumed by src/narrow.js' signature fixpoint.
729
650
 
730
- /** Infer arg val type using caller's body-local valTypes and module globals. */
731
- export function inferArgType(expr, callerValTypes) {
732
- if (typeof expr === 'string') return callerValTypes?.get(expr) || ctx.scope.globalValTypes?.get(expr) || null
733
- return valTypeOf(expr)
734
- }
651
+ // Per-body memoization: analyzeBody is a pure function of `body` plus a small
652
+ // set of ctx fields (func.locals, func.localReps, func.map[*][field]). compile.js
653
+ // calls slices of it many times per function (scan-fixpoint, narrowing, final
654
+ // lowering); the unified cache absorbs that traffic. Caller-mutation safety is
655
+ // preserved by cloning every Map on read (entry value stored once, copies handed out).
656
+ // Invalidation: emitFunc calls `invalidateLocalsCache` after seeding cross-call
657
+ // param facts; compile.js' E2 pass calls `invalidateValTypesCache` after valResult
658
+ // narrowing; narrowReturnArrayElems clears entries between fixpoint iters.
735
659
 
736
- /** Infer arg schemaId. Sources: caller's per-param schemaId map, module-level
737
- * ctx.schema.vars binding, or a static-key object literal. */
738
- export function inferArgSchema(expr, callerSchemas) {
739
- if (typeof expr === 'string') {
740
- if (callerSchemas?.has(expr)) return callerSchemas.get(expr)
741
- const id = ctx.schema.vars.get(expr)
742
- return id != null ? id : null
743
- }
744
- if (Array.isArray(expr) && expr[0] === '{}') {
745
- const parsed = staticObjectProps(expr.slice(1))
746
- return parsed ? ctx.schema.register(parsed.names) : null
747
- }
748
- return null
660
+ /**
661
+ * Per-binding use-summary the substrate the representation analyses share.
662
+ *
663
+ * One traversal classifies every mention of each `let`/`const` binding into a
664
+ * closed taxonomy of use-kinds (`USE.*`). The eligibility analyses below
665
+ * (`scanFlatObjects`, `scanSliceViews`, `unboxablePtrs`) are then *policies* —
666
+ * subset predicates over this summary — rather than three independent body
667
+ * re-walks. The classification logic is the union of those walks; a use-kind
668
+ * exists for every distinction at least one consumer needs.
669
+ *
670
+ * Deliberately NOT a consumer: `analyzeBody`'s `escapes` map. It looks like a
671
+ * fourth escape analysis but is not — it is context-sensitive *taint* over
672
+ * value-expression positions (member access short-circuits, a static index
673
+ * does not escape but a dynamic one does), woven into the main typing walk it
674
+ * shares with six other facts. Re-expressing it here would need `BARE` split
675
+ * by enclosing construct (a plain `name;` statement vs `return name`) and a
676
+ * second traversal — adding a walk, not removing one. It stays where it is.
677
+ *
678
+ * Returns `Map<name, { decls, initRhs, uses }>` for every name the body
679
+ * `let`/`const`-declares. `uses` is a `UseRecord[]`; a record is
680
+ * `{ kind, key?, optional?, computed?, compound?, nullCmp?, callee?, argIndex? }`.
681
+ *
682
+ * Scoping: decls are collected only outside closures (a `let` inside a nested
683
+ * `=>` is a different scope), but uses ARE collected inside closures — every
684
+ * mention there becomes a `CAPTURE`, which every policy treats as
685
+ * disqualifying. A binding shadowed by an inner closure decl is conservatively
686
+ * still flagged `CAPTURE`; sound, since it only ever forfeits an optimization.
687
+ *
688
+ * Order-independent: uses bucket by name, so a mention before its decl is fine.
689
+ */
690
+ const USE = {
691
+ MEMBER_R: 1, // receiver of a `.`/`?.`/`[]` READ — {key, optional, computed}
692
+ MEMBER_W: 2, // base of a `.`/`[]` WRITE — {key, computed, compound}
693
+ REASSIGN: 3, // `=`(non-init) / `++` / `--` / compound-assign of the name
694
+ CALL_ARG: 4, // passed as a call argument — {callee, argIndex}
695
+ CALL_CALLEE: 5, // invoked: `name(...)`
696
+ RETURN: 6, // `return name`
697
+ CAPTURE: 7, // mentioned inside a nested `=>`
698
+ COMPARE: 8, // operand of a comparison — {nullCmp}
699
+ CONCAT: 9, // operand of `+`
700
+ BOOL_TEST: 10, // operand of `!`/`typeof`/`void`, or an `if`/`while`/`?:` test
701
+ DELETE_MEMBER: 11, // `delete name.member`
702
+ BARE: 12, // any other value position — the conservative catch-all
749
703
  }
704
+ const _bindingUsesCache = new WeakMap()
705
+ const _CMP_OPS = new Set(['==', '!=', '===', '!==', '<', '>', '<=', '>='])
706
+ const _isNullishLit = (e) =>
707
+ e === 'null' || e === 'undefined' ||
708
+ (Array.isArray(e) && e[0] == null && (e[1] === null || e[1] === undefined))
709
+
710
+ function scanBindingUses(body) {
711
+ const hit = _bindingUsesCache.get(body)
712
+ if (hit) return hit
750
713
 
751
- /** Infer arg arr-elem-schema. Sources: caller's body-local arr-elem map, caller's
752
- * per-param arr-elem (transitive), or a call to an arr-narrowed user fn. */
753
- export function inferArgArrElemSchema(expr, callerArrElems, callerArrParams) {
754
- if (typeof expr === 'string') {
755
- if (callerArrElems?.has(expr)) {
756
- const v = callerArrElems.get(expr)
757
- if (v != null) return v
714
+ const summary = new Map() // name { decls, initRhs, uses }
715
+ const slot = (name) => {
716
+ let s = summary.get(name)
717
+ if (!s) { s = { decls: 0, initRhs: undefined, uses: [] }; summary.set(name, s) }
718
+ return s
719
+ }
720
+ const use = (name, kind, extra) => slot(name).uses.push(extra ? { kind, ...extra } : { kind })
721
+
722
+ // Static string key of a `[]` index node, else null (computed).
723
+ const litKey = (k) => (Array.isArray(k) && k[0] === 'str' && typeof k[1] === 'string') ? k[1] : null
724
+
725
+ // A child sitting in a value position. A bare string there is a real use —
726
+ // `walk` alone silently drops non-array children, so every value-position
727
+ // child (let-rhs, assign-rhs, call/index args, closure body, …) must route
728
+ // through here or its use goes unrecorded (a latent miscompile: the binding
729
+ // looks unused and an optimization fires unsoundly).
730
+ const val = (child, inClosure) => {
731
+ if (typeof child === 'string') use(child, inClosure ? USE.CAPTURE : USE.BARE)
732
+ else walk(child, inClosure)
733
+ }
734
+
735
+ // Classify the target of an assignment-like node (`=`, compound, `++`, `--`).
736
+ const assignTarget = (t, compound) => {
737
+ if (typeof t === 'string') { use(t, USE.REASSIGN); return }
738
+ if (!Array.isArray(t)) return
739
+ const o = t[0]
740
+ if ((o === '.' || o === '?.') && typeof t[1] === 'string') {
741
+ use(t[1], USE.MEMBER_W, { key: typeof t[2] === 'string' ? t[2] : null, computed: false, compound })
742
+ return
758
743
  }
759
- if (callerArrParams?.has(expr)) {
760
- const v = callerArrParams.get(expr)
761
- if (v != null) return v
744
+ if (o === '[]' && typeof t[1] === 'string') {
745
+ const k = litKey(t[2])
746
+ use(t[1], USE.MEMBER_W, { key: k, computed: k == null, compound })
747
+ if (t[2] != null) val(t[2])
748
+ return
762
749
  }
763
- return null
764
- }
765
- if (Array.isArray(expr) && expr[0] === '()' && typeof expr[1] === 'string') {
766
- const f = ctx.func.map?.get(expr[1])
767
- if (f?.arrayElemSchema != null) return f.arrayElemSchema
750
+ walk(t) // some other LHS shape — generic
768
751
  }
769
- return null
770
- }
771
752
 
772
- /** Infer arg arr-elem-VAL. Mirrors inferArgArrElemSchema but tracks VAL.* element kind. */
773
- export function inferArgArrElemValType(expr, callerArrElemVals, callerArrValParams) {
774
- if (typeof expr === 'string') {
775
- if (callerArrElemVals?.has(expr)) {
776
- const v = callerArrElemVals.get(expr)
777
- if (v != null) return v
753
+ function walk(node, inClosure) {
754
+ if (!Array.isArray(node)) return
755
+ const op = node[0]
756
+ if (typeof op !== 'string') return // literal node `[null, value]`
757
+ if (op === 'str') return // string literal
758
+ if (op === '=>') { for (let i = 1; i < node.length; i++) val(node[i], true); return }
759
+
760
+ if (op === 'let' || op === 'const') {
761
+ for (let i = 1; i < node.length; i++) {
762
+ const d = node[i]
763
+ if (typeof d === 'string') { if (!inClosure) slot(d).decls++; continue }
764
+ if (Array.isArray(d) && d[0] === '=') {
765
+ const lhs = d[1], rhs = d[2]
766
+ if (typeof lhs === 'string') {
767
+ if (!inClosure) { const s = slot(lhs); s.decls++; if (s.initRhs === undefined) s.initRhs = rhs }
768
+ } else {
769
+ walk(lhs, inClosure) // pattern — computed keys/defaults are real uses
770
+ }
771
+ val(rhs, inClosure)
772
+ } else walk(d, inClosure)
773
+ }
774
+ return
775
+ }
776
+
777
+ if (inClosure) { // every mention here is a CAPTURE
778
+ for (let i = 1; i < node.length; i++) {
779
+ const c = node[i]
780
+ if (typeof c === 'string') use(c, USE.CAPTURE)
781
+ else walk(c, true)
782
+ }
783
+ return
778
784
  }
779
- if (callerArrValParams?.has(expr)) {
780
- const v = callerArrValParams.get(expr)
781
- if (v != null) return v
785
+
786
+ // === precise classification (outside any closure) ===
787
+ if (ASSIGN_OPS.has(op)) { assignTarget(node[1], op !== '='); val(node[2]); return }
788
+ if (op === '++' || op === '--') { assignTarget(node[1], true); return }
789
+ if (op === 'delete') {
790
+ const t = node[1]
791
+ if (Array.isArray(t) && (t[0] === '.' || t[0] === '?.' || t[0] === '[]') && typeof t[1] === 'string') {
792
+ use(t[1], USE.DELETE_MEMBER)
793
+ if (t[0] === '[]' && t[2] != null) val(t[2])
794
+ } else val(t)
795
+ return
796
+ }
797
+ if (op === '.' || op === '?.') {
798
+ const recv = node[1]
799
+ if (typeof recv === 'string')
800
+ use(recv, USE.MEMBER_R, { key: typeof node[2] === 'string' ? node[2] : null, optional: op === '?.', computed: false })
801
+ else walk(recv)
802
+ return // node[2] is the property name
803
+ }
804
+ if (op === '[]') {
805
+ const recv = node[1], k = litKey(node[2])
806
+ if (typeof recv === 'string') use(recv, USE.MEMBER_R, { key: k, optional: false, computed: k == null })
807
+ else walk(recv)
808
+ if (node[2] != null) val(node[2])
809
+ return
810
+ }
811
+ if (op === ':') { // object property `{k:v}` / labeled statement
812
+ if (Array.isArray(node[1])) walk(node[1]) // computed key `{[expr]:v}` — a real use
813
+ val(node[2]) // property value (or the labeled statement)
814
+ return // string node[1] = plain key / label — not a use
815
+ }
816
+ if (op === 'return') {
817
+ const e = node[1]
818
+ if (typeof e === 'string') use(e, USE.RETURN)
819
+ else walk(e)
820
+ return
821
+ }
822
+ if (op === '()') {
823
+ const callee = node[1]
824
+ if (typeof callee === 'string') use(callee, USE.CALL_CALLEE)
825
+ else walk(callee)
826
+ const argNode = node[2]
827
+ if (argNode != null) {
828
+ const args = (Array.isArray(argNode) && argNode[0] === ',') ? argNode.slice(1) : [argNode]
829
+ for (let ai = 0; ai < args.length; ai++) {
830
+ const a = args[ai]
831
+ if (Array.isArray(a) && a[0] === '...') { val(a[1]); continue }
832
+ if (typeof a === 'string') use(a, USE.CALL_ARG, { callee: typeof callee === 'string' ? callee : null, argIndex: ai })
833
+ else walk(a)
834
+ }
835
+ }
836
+ return
837
+ }
838
+ if (_CMP_OPS.has(op) && node.length === 3) {
839
+ for (let i = 1; i <= 2; i++) {
840
+ const side = node[i]
841
+ if (typeof side === 'string') use(side, USE.COMPARE, { nullCmp: _isNullishLit(node[3 - i]) })
842
+ else walk(side)
843
+ }
844
+ return
845
+ }
846
+ if (op === '+') {
847
+ for (let i = 1; i < node.length; i++) {
848
+ const c = node[i]
849
+ if (typeof c === 'string') use(c, USE.CONCAT)
850
+ else walk(c)
851
+ }
852
+ return
853
+ }
854
+ if (op === '!' || op === 'typeof' || op === 'void') {
855
+ const c = node[1]
856
+ if (typeof c === 'string') use(c, USE.BOOL_TEST)
857
+ else walk(c)
858
+ return
859
+ }
860
+ if (op === 'if' || op === 'while' || op === '?:') { // `prepare` normalizes `?` → `?:`
861
+ const c = node[1]
862
+ if (typeof c === 'string') use(c, USE.BOOL_TEST)
863
+ else walk(c)
864
+ for (let i = 2; i < node.length; i++) val(node[i])
865
+ return
866
+ }
867
+
868
+ // generic — every string child is a BARE value use
869
+ for (let i = 1; i < node.length; i++) {
870
+ const c = node[i]
871
+ if (typeof c === 'string') use(c, USE.BARE)
872
+ else walk(c)
782
873
  }
783
- return null
784
- }
785
- if (Array.isArray(expr) && expr[0] === '()' && typeof expr[1] === 'string') {
786
- const f = ctx.func.map?.get(expr[1])
787
- if (f?.arrayElemValType != null) return f.arrayElemValType
788
874
  }
789
- return null
875
+
876
+ walk(body, false)
877
+
878
+ for (const [name, s] of summary) if (s.decls === 0) summary.delete(name)
879
+ _bindingUsesCache.set(body, summary)
880
+ return summary
790
881
  }
791
882
 
792
- /** Infer typed-array ctor (`new.Float64Array` etc.) of an arg expression at a call site.
793
- * Sources: caller's body-local typedElems, caller's typed params, literal `new TypedArray(...)`,
794
- * calls to typed-narrowed user funcs. Returns null when the ctor can't be determined. */
795
- export function inferArgTypedCtor(expr, callerTypedElems, callerTypedParams) {
796
- if (typeof expr === 'string') {
797
- if (callerTypedElems?.has(expr)) return callerTypedElems.get(expr)
798
- if (callerTypedParams?.has(expr)) return callerTypedParams.get(expr)
799
- return null
800
- }
801
- const ctor = typedElemCtor(expr)
802
- if (ctor) return ctor
803
- if (Array.isArray(expr) && expr[0] === '()' && typeof expr[1] === 'string') {
804
- const f = ctx.func.map?.get(expr[1])
805
- if (f?.sig?.ptrKind === VAL.TYPED && f.sig.ptrAux != null) return ctorFromElemAux(f.sig.ptrAux)
883
+ /**
884
+ * SRoA eligibility scan which `let/const o = {staticLiteral}` bindings can
885
+ * have their fields dissolved into plain WASM locals (`flat` carrier): no heap
886
+ * alloc, no field load/store, `o.prop` becomes `local.get`.
887
+ *
888
+ * A binding is flat-eligible iff `o` appears ONLY as a literal-key `.`/`[]`
889
+ * READ of an in-schema prop, or the member LHS of a literal-key `.`/`[]` WRITE
890
+ * of an in-schema prop. Any other mention — bare ref, dynamic/numeric key,
891
+ * off-schema prop, `?.`, reassignment, compound assign, `++`/`--`, `delete`,
892
+ * closure capture, self-referential initializer, duplicate keys, or a second
893
+ * declaration — disqualifies it. A non-escaping object is never observed by
894
+ * any object walk (keys/values/entries/assign/spread/JSON/for-in/dyn), so the
895
+ * transform is additive and sound. Conservative: any doubt → not flat.
896
+ *
897
+ * A policy over `scanBindingUses`: the shared traversal classifies every
898
+ * mention; this scan keeps a binding only if its initializer is a self-
899
+ * contained static literal and every use is an in-schema literal-key access.
900
+ *
901
+ * Returns `Map<name, {names, values}>` — the literal's parallel prop arrays.
902
+ * Field `i` of binding `o` lives in WASM local `o#${i}` (`#` cannot occur in a
903
+ * jz identifier, so the name is collision-free).
904
+ */
905
+ function scanFlatObjects(body) {
906
+ const cand = new Map() // name → {names, values}
907
+
908
+ // A binding referenced as a value inside `node` (skips `:`/`.` property-name
909
+ // slots). Used only to reject a self-referential initializer — a literal
910
+ // whose own field values mention the binding is not a self-contained object.
911
+ const referencesName = (node, name) => {
912
+ if (typeof node === 'string') return node === name
913
+ if (!Array.isArray(node)) return false
914
+ const op = node[0]
915
+ if (op === 'str') return false
916
+ if (op === ':') return referencesName(node[2], name)
917
+ if (op === '.' || op === '?.') return referencesName(node[1], name)
918
+ for (let i = 1; i < node.length; i++) if (referencesName(node[i], name)) return true
919
+ return false
806
920
  }
807
- return null
921
+
922
+ for (const [name, s] of scanBindingUses(body)) {
923
+ // Candidate: exactly one `let/const name = {staticLiteral}` decl, unique
924
+ // keys, and an initializer that does not reference the binding itself.
925
+ if (s.decls !== 1 || !Array.isArray(s.initRhs) || s.initRhs[0] !== '{}') continue
926
+ const props = staticObjectProps(s.initRhs.slice(1))
927
+ if (!props || new Set(props.names).size !== props.names.length) continue
928
+ if (props.values.some(v => referencesName(v, name))) continue
929
+
930
+ // Schema = literal keys ∪ plain literal-key member writes. Such a write
931
+ // monotonically extends the static field universe (the new field reads
932
+ // `undefined` until the write runs, exactly as JS does); the schema stays
933
+ // closed because any computed/off-schema access disqualifies below.
934
+ const schema = new Set(props.names)
935
+ for (const u of s.uses)
936
+ if (u.kind === USE.MEMBER_W && !u.compound && !u.computed && u.key != null)
937
+ schema.add(u.key)
938
+
939
+ // Flat iff every mention is an in-schema literal-key `.`/`[]` READ, or an
940
+ // in-schema literal-key plain `.`/`[]` WRITE. Any other use kind — `?.`,
941
+ // computed/off-schema key, reassignment, compound or `delete` member write,
942
+ // `++`/`--`, call arg, closure capture, bare ref — leaves the object live.
943
+ const flat = s.uses.every(u =>
944
+ (u.kind === USE.MEMBER_R && !u.optional && !u.computed && schema.has(u.key)) ||
945
+ (u.kind === USE.MEMBER_W && !u.compound && !u.computed && schema.has(u.key)))
946
+ if (!flat) continue
947
+
948
+ // Materialize the parallel {names, values}: literal props first, then each
949
+ // extension field (value `undefined`), in first-write order.
950
+ const names = props.names.slice(), values = props.values.slice()
951
+ for (const k of schema)
952
+ if (!names.includes(k)) { names.push(k); values.push(undefined) }
953
+ cand.set(name, { names, values })
954
+ }
955
+ return cand
808
956
  }
809
957
 
810
- // Per-body memoization: analyzeBody is a pure function of `body` plus a small
811
- // set of ctx fields (func.locals, func.repByLocal, func.map[*][field]). compile.js
812
- // calls slices of it many times per function (scan-fixpoint, narrowing, final
813
- // lowering); the unified cache absorbs that traffic. Caller-mutation safety is
814
- // preserved by cloning every Map on read (entry value stored once, copies handed out).
815
- // Invalidation: emitFunc calls `invalidateLocalsCache` after seeding cross-call
816
- // param facts; compile.js' E2 pass calls `invalidateValTypesCache` after valResult
817
- // narrowing; narrowReturnArrayElems clears entries between fixpoint iters.
958
+ /**
959
+ * No-copy slice scan which `let/const t = s.slice(...)` bindings can be a
960
+ * VIEW (a SLICE_BIT pointer straight into `s`'s buffer) instead of a fresh
961
+ * byte copy.
962
+ *
963
+ * jz rewinds the bump arena only at function exit, so every string the
964
+ * function can observe stays alive until it returns. A view is therefore sound
965
+ * exactly when its binding does NOT escape the function: `t` must never be
966
+ * returned, passed as a call argument, stored into a heap object/array,
967
+ * captured by a closure, aliased to another binding, reassigned, or
968
+ * compound-assigned. The permitted uses — receiver of a `.`/`[]`, operand of a
969
+ * comparison or `+`, a boolean test — read `t` synchronously and never persist
970
+ * it past the function.
971
+ *
972
+ * Declared exactly once as `let/const`. The result is purely structural —
973
+ * whether the receiver is actually a string (so `.slice` lowers to the string
974
+ * view) is settled later, at emit time, when param types are known; emitDecl
975
+ * keeps the ordinary copying slice for any non-string receiver. Conservative:
976
+ * any unrecognised position disqualifies the binding.
977
+ *
978
+ * Returns `Set<name>` of view-eligible binding names.
979
+ */
980
+ // Permitted use-kinds for a slice view — the value is read synchronously and
981
+ // never persisted past the function. `MEMBER_R`/`MEMBER_W` cover any `.`/`[]`
982
+ // receiver; `COMPARE` any comparison; `CONCAT`/`BOOL_TEST` the copy / test
983
+ // positions. Any other kind (reassign, call arg, return, capture, bare alias)
984
+ // escapes and disqualifies the binding.
985
+ const _SLICE_VIEW_OK = new Set([USE.MEMBER_R, USE.MEMBER_W, USE.COMPARE, USE.CONCAT, USE.BOOL_TEST])
986
+
987
+ function scanSliceViews(body) {
988
+ const isSliceCall = (n) =>
989
+ Array.isArray(n) && n[0] === '()' && Array.isArray(n[1])
990
+ && n[1][0] === '.' && n[1][2] === 'slice'
991
+
992
+ const views = new Set()
993
+ for (const [name, s] of scanBindingUses(body)) {
994
+ if (s.decls !== 1 || !isSliceCall(s.initRhs)) continue
995
+ if (s.uses.every(u => _SLICE_VIEW_OK.has(u.kind))) views.add(name)
996
+ }
997
+ return views
998
+ }
818
999
 
819
1000
  /**
820
1001
  * Unified per-body analysis. Single AST traversal producing every per-binding
@@ -845,11 +1026,89 @@ export function inferArgTypedCtor(expr, callerTypedElems, callerTypedParams) {
845
1026
  */
846
1027
  const _bodyFactsCache = new WeakMap()
847
1028
 
1029
+ /**
1030
+ * Narrow uint32 accumulator locals to unsigned i32. A local qualifies when its
1031
+ * initializer is a non-negative integer literal in [0, 2^32), every
1032
+ * reassignment is `name = (…) >>> k` (so it always holds a canonical uint32),
1033
+ * and every read sits inside a `>>>` (ToUint32) sink reached only through
1034
+ * bit-faithful operators (`^ & | ~ << >> + - *`). Under those constraints the
1035
+ * raw i32 bit pattern reproduces JS semantics exactly — every observable use is
1036
+ * funnelled through ToUint32 — so the f64 round-trip on the hot path is pure
1037
+ * overhead. Names that escape (closures, bare `return`, signed-sensitive
1038
+ * operands) keep their wider type. Returns the qualifying set; callers retype
1039
+ * `locals` to 'i32' and tag `readVar` reads `.unsigned` for convert_i32_u.
1040
+ */
1041
+ function narrowUint32(body, locals) {
1042
+ const TRANSPARENT = new Set(['^', '&', '|', '~', '<<', '>>', '+', '-', '*'])
1043
+ const initLit = new Set() // names with a valid u32-literal initializer
1044
+ const disq = new Set() // names disqualified by an unsafe occurrence
1045
+ const seen = new Set()
1046
+ const isU32Lit = e => {
1047
+ const v = typeof e === 'number' ? e
1048
+ : Array.isArray(e) && e[0] == null && typeof e[1] === 'number' ? e[1] : NaN
1049
+ return Number.isInteger(v) && v >= 0 && v < 4294967296
1050
+ }
1051
+ const isAssignOp = op => op[op.length - 1] === '=' &&
1052
+ op !== '==' && op !== '===' && op !== '!=' && op !== '!==' && op !== '<=' && op !== '>='
1053
+ const banNames = n => {
1054
+ if (typeof n === 'string') disq.add(n)
1055
+ else if (Array.isArray(n)) for (let i = 1; i < n.length; i++) banNames(n[i])
1056
+ }
1057
+ const walk = (node, underShr, inClosure) => {
1058
+ if (typeof node === 'string') { if (inClosure) disq.add(node); return }
1059
+ if (!Array.isArray(node)) return
1060
+ const op = node[0]
1061
+ if (typeof op !== 'string') {
1062
+ for (let i = 1; i < node.length; i++) walk(node[i], false, inClosure)
1063
+ return
1064
+ }
1065
+ if (op === '=>') { for (let i = 1; i < node.length; i++) walk(node[i], false, true); return }
1066
+ if (op === 'let' || op === 'const') {
1067
+ for (let i = 1; i < node.length; i++) {
1068
+ const d = node[i]
1069
+ if (Array.isArray(d) && d[0] === '=' && typeof d[1] === 'string') {
1070
+ const nm = d[1]
1071
+ if (seen.has(nm) || inClosure || !isU32Lit(d[2])) disq.add(nm)
1072
+ else initLit.add(nm)
1073
+ seen.add(nm)
1074
+ walk(d[2], false, inClosure)
1075
+ } else if (typeof d === 'string') { disq.add(d); seen.add(d) }
1076
+ else if (Array.isArray(d) && d[0] === '=') { banNames(d[1]); walk(d[2], false, inClosure) }
1077
+ }
1078
+ return
1079
+ }
1080
+ if ((op === '++' || op === '--') && typeof node[1] === 'string') { disq.add(node[1]); return }
1081
+ if (isAssignOp(op)) {
1082
+ const lhs = node[1]
1083
+ if (typeof lhs === 'string') {
1084
+ if (op !== '=' || inClosure || !(Array.isArray(node[2]) && node[2][0] === '>>>')) disq.add(lhs)
1085
+ } else banNames(lhs)
1086
+ walk(node[2], false, inClosure)
1087
+ return
1088
+ }
1089
+ const childShr = op === '>>>' ? true : TRANSPARENT.has(op) ? underShr : false
1090
+ for (let i = 1; i < node.length; i++) {
1091
+ const c = node[i]
1092
+ if (typeof c === 'string') { if (inClosure || !childShr) disq.add(c) }
1093
+ else walk(c, childShr, inClosure)
1094
+ }
1095
+ }
1096
+ walk(body, false, false)
1097
+ const result = new Set()
1098
+ for (const nm of initLit) {
1099
+ if (disq.has(nm)) continue
1100
+ const t = locals.get(nm)
1101
+ if (t !== 'i32' && t !== 'f64') continue
1102
+ locals.set(nm, 'i32')
1103
+ result.add(nm)
1104
+ }
1105
+ return result
1106
+ }
1107
+
848
1108
  /**
849
1109
  * Returns the cached facts object directly — DO NOT MUTATE the returned maps.
850
- * Callers that need to extend (e.g. add params to locals) must clone explicitly.
851
- * `analyzeLocals` is the canonical clone-then-extend facade; everywhere else
852
- * reads slices via `analyzeBody(body).<slice>`.
1110
+ * Callers that need to extend (e.g. add params to locals) must clone explicitly
1111
+ * before mutating. Slice reads via `analyzeBody(body).<slice>`.
853
1112
  */
854
1113
  export function analyzeBody(body) {
855
1114
  // Non-object bodies (`() => 0`, `() => x`, missing) have nothing to observe
@@ -857,6 +1116,7 @@ export function analyzeBody(body) {
857
1116
  if (body === null || typeof body !== 'object') return {
858
1117
  locals: new Map(), valTypes: new Map(), arrElemSchemas: new Map(),
859
1118
  arrElemValTypes: new Map(), typedElems: new Map(), escapes: new Map(),
1119
+ flatObjects: new Map(),
860
1120
  }
861
1121
  const hit = _bodyFactsCache.get(body)
862
1122
  if (hit) return hit
@@ -901,14 +1161,14 @@ export function analyzeBody(body) {
901
1161
 
902
1162
  const elemValOf = (name) => {
903
1163
  if (typeof name !== 'string') return null
904
- const repVt = ctx.func.repByLocal?.get(name)?.arrayElemValType
1164
+ const repVt = ctx.func.localReps?.get(name)?.arrayElemValType
905
1165
  if (repVt) return repVt
906
1166
  return arrElemValTypes.get(name) || null
907
1167
  }
908
1168
 
909
1169
  const exprElemSourceVal = (expr) => {
910
1170
  if (typeof expr === 'string') {
911
- const repVt = ctx.func.repByLocal?.get(expr)?.val
1171
+ const repVt = ctx.func.localReps?.get(expr)?.val
912
1172
  if (repVt) return repVt
913
1173
  return ctx.scope.globalValTypes?.get(expr) || null
914
1174
  }
@@ -998,7 +1258,7 @@ export function analyzeBody(body) {
998
1258
  if (sid2 != null) observeArrSchema(name, sid2)
999
1259
  }
1000
1260
  if (typeof rhs === 'string') {
1001
- const repSid = ctx.func.repByLocal?.get(rhs)?.arrayElemSchema
1261
+ const repSid = ctx.func.localReps?.get(rhs)?.arrayElemSchema
1002
1262
  if (repSid != null) observeArrSchema(name, repSid)
1003
1263
  }
1004
1264
  }
@@ -1047,7 +1307,7 @@ export function analyzeBody(body) {
1047
1307
  const refs = ctx.func.refinements
1048
1308
  const hadParam = refs?.has(paramName)
1049
1309
  const prev = hadParam ? refs.get(paramName) : undefined
1050
- if (refs && recvVt) refs.set(paramName, recvVt)
1310
+ if (refs && recvVt) refs.set(paramName, { val: recvVt })
1051
1311
  let bodyVt = null
1052
1312
  try { bodyVt = valTypeOf(exprBody) }
1053
1313
  finally {
@@ -1109,10 +1369,10 @@ export function analyzeBody(body) {
1109
1369
  if (op === 'let' || op === 'const') {
1110
1370
  for (let i = 1; i < node.length; i++) {
1111
1371
  const a = node[i]
1112
- // analyzeLocals: bare-name decl
1372
+ // analyzeBody: bare-name decl
1113
1373
  if (typeof a === 'string') { if (!locals.has(a)) locals.set(a, 'f64'); continue }
1114
1374
  if (!Array.isArray(a) || a[0] !== '=') continue
1115
- // analyzeLocals: destructuring decl — set destructured names to f64, walk rhs only
1375
+ // analyzeBody: destructuring decl — set destructured names to f64, walk rhs only
1116
1376
  if (typeof a[1] !== 'string') {
1117
1377
  for (const n of collectParamNames([a[1]])) if (!locals.has(n)) locals.set(n, 'f64')
1118
1378
  walk(a[2])
@@ -1212,12 +1472,11 @@ export function analyzeBody(body) {
1212
1472
  const prevTypedOverlay = ctx.func.localTypedElemsOverlay
1213
1473
  ctx.func.localValTypesOverlay = valTypes
1214
1474
  ctx.func.localTypedElemsOverlay = typedElems
1215
- try { walk(body) } finally {
1216
- ctx.func.localValTypesOverlay = prevOverlay
1217
- ctx.func.localTypedElemsOverlay = prevTypedOverlay
1218
- }
1475
+ let unsignedLocals
1476
+ try {
1477
+ walk(body)
1219
1478
 
1220
- // Second pass: widen i32 locals compared against f64.
1479
+ // Second pass: widen i32 locals compared against f64.
1221
1480
  const CMP_OPS = new Set(['<', '>', '<=', '>=', '==', '!='])
1222
1481
  function widenPass(node) {
1223
1482
  if (!Array.isArray(node)) return
@@ -1237,6 +1496,10 @@ export function analyzeBody(body) {
1237
1496
  // was later re-typed to f64. Without this, integer-init locals shadowed
1238
1497
  // by f64-arithmetic RHSs end up with `i32.trunc_sat_f64_s` truncating the
1239
1498
  // fractional value (e.g. mandelbrot escape: `x2 = zx*zx` losing 3.515 → 3).
1499
+ // Also re-checks `=` and compound-assign reassignments — single-pass walk
1500
+ // visits each assign once with stale operand types, missing widens through
1501
+ // back-edges (`iy = 2.0 * ix * iy + 1.0` where `ix` widens later in the loop
1502
+ // body, demanding `iy` widen on the next iteration).
1240
1503
  // Monotonic: only widens i32 → f64. Bound by locals count so no infinite loop.
1241
1504
  let widened = true
1242
1505
  while (widened) {
@@ -1256,87 +1519,64 @@ export function analyzeBody(body) {
1256
1519
  }
1257
1520
  }
1258
1521
  }
1522
+ if (op === '=' && typeof node[1] === 'string') {
1523
+ const name = node[1], rhs = node[2]
1524
+ if (locals.get(name) === 'i32' && exprType(rhs, locals) === 'f64') {
1525
+ locals.set(name, 'f64'); widened = true
1526
+ }
1527
+ }
1528
+ if ((op === '+=' || op === '-=' || op === '*=' || op === '%=') && typeof node[1] === 'string') {
1529
+ const name = node[1]
1530
+ if (locals.get(name) === 'i32' && exprType([op[0], name, node[2]], locals) === 'f64') {
1531
+ locals.set(name, 'f64'); widened = true
1532
+ }
1533
+ }
1534
+ if (op === '/=' && typeof node[1] === 'string') {
1535
+ const name = node[1]
1536
+ if (locals.get(name) === 'i32') { locals.set(name, 'f64'); widened = true }
1537
+ }
1259
1538
  for (let i = 1; i < node.length; i++) recheck(node[i])
1260
1539
  }
1261
1540
  recheck(body)
1262
1541
  }
1263
1542
 
1264
- const result = { locals, valTypes, arrElemSchemas, arrElemValTypes, typedElems, escapes }
1543
+ // Narrow proven uint32 accumulator locals to unsigned i32. Runs post-widen so
1544
+ // a local already demoted to f64 above (e.g. compared against an f64) is
1545
+ // reconsidered with final types — and stays f64, since a relational compare
1546
+ // is a non-transparent read that disqualifies narrowing anyway.
1547
+ unsignedLocals = narrowUint32(body, locals)
1548
+ } finally {
1549
+ ctx.func.localValTypesOverlay = prevOverlay
1550
+ ctx.func.localTypedElemsOverlay = prevTypedOverlay
1551
+ }
1552
+
1553
+ // SRoA: dissolve non-escaping object-literal bindings into field locals.
1554
+ // The dead `o` local is dropped — every `o` reference is rewritten by the
1555
+ // codegen flat hooks, so a stray `local.get $o` becomes a loud wasm
1556
+ // validation error instead of a silent miscompile.
1557
+ const flatObjects = doSchemas ? scanFlatObjects(body) : new Map()
1558
+ for (const [name, props] of flatObjects) {
1559
+ for (let i = 0; i < props.names.length; i++) locals.set(`${name}#${i}`, 'f64')
1560
+ locals.delete(name)
1561
+ }
1562
+
1563
+ // No-copy slice views — `let t = s.slice(...)` bindings proven non-escaping.
1564
+ // Consumed by emitDecl, which lowers the initializer to a SLICE_BIT view.
1565
+ const sliceViews = doSchemas ? scanSliceViews(body) : new Set()
1566
+
1567
+ const result = { locals, valTypes, arrElemSchemas, arrElemValTypes, typedElems, escapes, flatObjects, sliceViews, unsignedLocals }
1265
1568
  _bodyFactsCache.set(body, result)
1266
1569
  return result
1267
1570
  }
1268
1571
 
1269
1572
  /** Drop the cached analyzeBody entry for this body. Used by emitFunc after
1270
1573
  * seeding cross-call param VAL facts so the next walk picks up fresh
1271
- * `ctx.func.repByLocal` (drives exprType receiver-type lookups).
1574
+ * `ctx.func.localReps` (drives exprType receiver-type lookups).
1272
1575
  * Same hook as `invalidateValTypesCache` — split names preserve caller intent. */
1273
1576
  export function invalidateLocalsCache(body) {
1274
1577
  if (body && typeof body === 'object') _bodyFactsCache.delete(body)
1275
1578
  }
1276
1579
 
1277
- // String-only methods: presence of `name.method(...)` is definite evidence that
1278
- // `name` is VAL.STRING. Excludes ambiguous methods (length, slice, indexOf, [],
1279
- // concat) that strings share with arrays/typed-arrays.
1280
- const STRING_ONLY_METHODS = new Set([
1281
- 'charCodeAt', 'charAt', 'codePointAt', 'startsWith', 'endsWith',
1282
- 'toUpperCase', 'toLowerCase', 'toLocaleLowerCase', 'normalize', 'localeCompare',
1283
- 'padStart', 'padEnd', 'repeat', 'trimStart', 'trimEnd', 'trim',
1284
- 'matchAll', 'match', 'replace', 'replaceAll', 'split',
1285
- ])
1286
- // Array-only methods: presence rules out STRING.
1287
- const ARRAY_ONLY_METHODS = new Set([
1288
- 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'fill', 'reverse',
1289
- 'flat', 'flatMap', 'copyWithin',
1290
- ])
1291
-
1292
- /** Infer VAL.STRING for `names` from method-call evidence in `body`.
1293
- * Descends into nested closures (captured names retain shape) but respects
1294
- * shadowing: a param of a nested `=>` removes that name from the inference
1295
- * scope inside that closure. Returns Map<name, VAL.STRING> for names that
1296
- * have at least one string-only method call and no array-only conflicts. */
1297
- export function inferStringParams(body, names) {
1298
- if (!names || names.length === 0) return new Map()
1299
- const evidence = new Map() // name → 'string' | 'conflict'
1300
- function walk(node, scope) {
1301
- if (!Array.isArray(node) || scope.size === 0) return
1302
- const op = node[0]
1303
- if (op === '=>') {
1304
- const shadowed = collectParamNames([node[1]])
1305
- let inner = scope
1306
- for (const s of shadowed) {
1307
- if (inner.has(s)) {
1308
- if (inner === scope) inner = new Set(scope)
1309
- inner.delete(s)
1310
- }
1311
- }
1312
- walk(node[2], inner)
1313
- return
1314
- }
1315
- // `name.method` — observe positive/negative evidence
1316
- if (op === '.' && typeof node[1] === 'string' && scope.has(node[1])) {
1317
- const m = node[2]
1318
- if (typeof m === 'string') {
1319
- if (STRING_ONLY_METHODS.has(m)) {
1320
- if (evidence.get(node[1]) !== 'conflict') evidence.set(node[1], 'string')
1321
- } else if (ARRAY_ONLY_METHODS.has(m)) {
1322
- evidence.set(node[1], 'conflict')
1323
- }
1324
- }
1325
- }
1326
- // Reassignment to an unambiguously non-string RHS poisons the inference
1327
- if (op === '=' && typeof node[1] === 'string' && scope.has(node[1])) {
1328
- const rhs = node[2]
1329
- const vt = valTypeOf(rhs)
1330
- if (vt && vt !== VAL.STRING) evidence.set(node[1], 'conflict')
1331
- }
1332
- for (let i = 1; i < node.length; i++) walk(node[i], scope)
1333
- }
1334
- walk(body, new Set(names))
1335
- const out = new Map()
1336
- for (const [n, ev] of evidence) if (ev === 'string') out.set(n, VAL.STRING)
1337
- return out
1338
- }
1339
-
1340
1580
  /** Drop the cached analyzeBody entry. Used after E2-phase valResult narrowing
1341
1581
  * so the next walk re-evaluates `valTypeOf(call)` with up-to-date `f.valResult`
1342
1582
  * — required for the D-pass paramReps val/arrayElemSchema re-fixpoint to see
@@ -1347,14 +1587,14 @@ export function invalidateValTypesCache(body) {
1347
1587
 
1348
1588
  /**
1349
1589
  * Analyze all local value types from declarations and assignments.
1350
- * Writes the per-name `val` field of `ctx.func.repByLocal` for method dispatch
1590
+ * Writes the per-name `val` field of `ctx.func.localReps` for method dispatch
1351
1591
  * and schema resolution.
1352
1592
  */
1353
1593
  export function analyzeValTypes(body) {
1354
1594
  const valPoison = new Set()
1355
1595
  const setVal = (name, vt) => {
1356
1596
  if (valPoison.has(name)) return
1357
- const prev = ctx.func.repByLocal?.get(name)?.val
1597
+ const prev = ctx.func.localReps?.get(name)?.val
1358
1598
  if (!vt) {
1359
1599
  if (prev) valPoison.add(name)
1360
1600
  updateRep(name, { val: undefined })
@@ -1367,7 +1607,7 @@ export function analyzeValTypes(body) {
1367
1607
  }
1368
1608
  updateRep(name, { val: vt })
1369
1609
  }
1370
- const getVal = name => ctx.func.repByLocal?.get(name)?.val
1610
+ const getVal = name => ctx.func.localReps?.get(name)?.val
1371
1611
  // Pre-walk: observe Array<schema> facts so `const p = arr[i]` can bind a schemaId
1372
1612
  // on `p`, unlocking schema slot reads + skipping str_key dispatch on `.prop` access.
1373
1613
  // Parallel arrElemValTypes walk records VAL.* element kinds into
@@ -1378,7 +1618,7 @@ export function analyzeValTypes(body) {
1378
1618
  for (const [name, vt] of facts.arrElemValTypes) {
1379
1619
  if (vt != null) updateRep(name, { arrayElemValType: vt })
1380
1620
  }
1381
- // Propagate body-observed array-elem schemas to repByLocal so analyzePtrUnboxable's
1621
+ // Propagate body-observed array-elem schemas to localReps so unboxablePtrs's
1382
1622
  // `let p = arr[i]` rule (which only consults rep) sees the schema and can unbox `p`
1383
1623
  // to an i32 offset. Without this, `arr.push({x,y,z})` followed by `arr[i].x` reads
1384
1624
  // pay an i64.reinterpret/i32.wrap on every slot access (no aliasing → CSE can't fold).
@@ -1389,7 +1629,7 @@ export function analyzeValTypes(body) {
1389
1629
  // paramReps[k].arrayElemSchema at emit start) over local body observations.
1390
1630
  const arrElemSchemaOf = (name) => {
1391
1631
  if (typeof name !== 'string') return null
1392
- const repSid = ctx.func.repByLocal?.get(name)?.arrayElemSchema
1632
+ const repSid = ctx.func.localReps?.get(name)?.arrayElemSchema
1393
1633
  if (repSid != null) return repSid
1394
1634
  const localSid = arrElems.get(name)
1395
1635
  return localSid != null ? localSid : null
@@ -1417,7 +1657,7 @@ export function analyzeValTypes(body) {
1417
1657
  const ctor = typedElemCtor(rhs)
1418
1658
  if (ctor) return setOrInvalidate(ctor)
1419
1659
  // TYPED-narrowed call result carries elem aux on f.sig.ptrAux — reverse-map it
1420
- // back to a canonical ctor string so analyzePtrUnboxable's typedElemAux lookup
1660
+ // back to a canonical ctor string so unboxablePtrs's typedElemAux lookup
1421
1661
  // (compile.js) restores the same aux on the unboxed local's rep.
1422
1662
  if (Array.isArray(rhs) && rhs[0] === '()' && typeof rhs[1] === 'string') {
1423
1663
  const f = ctx.func.map?.get(rhs[1])
@@ -1434,6 +1674,44 @@ export function analyzeValTypes(body) {
1434
1674
  const tc = ternaryCtorOfRhs(rhs)
1435
1675
  if (tc) setOrInvalidate(tc)
1436
1676
  }
1677
+ // Total write count for `name` across the whole body, recursing into nested
1678
+ // closures so a closure that reassigns the var is also counted. Capped at 2 —
1679
+ // callers only need the "exactly one write" verdict.
1680
+ function writeCount(node, name, n) {
1681
+ if (n > 1 || !Array.isArray(node)) return n
1682
+ const o = node[0]
1683
+ if ((ASSIGN_OPS.has(o) || o === '++' || o === '--') && node[1] === name) n++
1684
+ if (o === 'let' || o === 'const') {
1685
+ for (let i = 1; i < node.length && n <= 1; i++) {
1686
+ const d = node[i]
1687
+ if (Array.isArray(d) && d[0] === '=' && d[2] != null) n = writeCount(d[2], name, n)
1688
+ }
1689
+ return n
1690
+ }
1691
+ for (let i = 1; i < node.length && n <= 1; i++) n = writeCount(node[i], name, n)
1692
+ return n
1693
+ }
1694
+ // Bind an object-literal's schemaId onto its holding local's rep so that
1695
+ // `o.prop` / `o.method()` dispatch is precise instead of falling back to
1696
+ // structural subtyping (which mis-resolves when another in-scope object
1697
+ // shares a member at a different slot). `shapeOf` already covers plain-data
1698
+ // literals on a direct `let o = {…}` decl, but not literals with
1699
+ // function-valued props — and `var o = {…}` is rewritten by jzify into
1700
+ // `let o; o = {…}`, so the schemaId never reaches `o` either way.
1701
+ // `expectWrites` is the reassignment count that marks `o` single-assignment:
1702
+ // 1 for the jzify `=` form (the synthesized assignment IS the only write),
1703
+ // 0 for a direct `let`/`const` decl (the initializer is not counted as a
1704
+ // write). A polymorphically reassigned holder keeps dynamic dispatch.
1705
+ // A name already in `ctx.schema.vars` carries a prepare-phase schema
1706
+ // (Object.assign merge via `inferAssignSchema`, destructure tracking) that
1707
+ // supersedes the bare-literal one — binding here would shadow the merged
1708
+ // schema (rep schemaId wins over `ctx.schema.vars` in `idOf`).
1709
+ function bindObjSchema(name, rhs, expectWrites = 1) {
1710
+ if (ctx.func.current?.params?.some(p => p.name === name)) return
1711
+ if (ctx.schema.vars?.has(name)) return
1712
+ const sid = objLiteralSchemaId(rhs)
1713
+ if (sid != null && writeCount(body, name, 0) === expectWrites) updateRep(name, { schemaId: sid })
1714
+ }
1437
1715
  function walk(node) {
1438
1716
  if (!Array.isArray(node)) return
1439
1717
  const [op, ...args] = node
@@ -1471,26 +1749,30 @@ export function analyzeValTypes(body) {
1471
1749
  const sh = shapeOf(a[2])
1472
1750
  if (sh) {
1473
1751
  updateRep(a[1], { jsonShape: sh })
1474
- if (sh.vt === VAL.ARRAY && sh.elem?.vt) {
1475
- updateRep(a[1], { arrayElemValType: sh.elem.vt })
1752
+ if (sh.val === VAL.ARRAY && sh.elem?.val) {
1753
+ updateRep(a[1], { arrayElemValType: sh.elem.val })
1476
1754
  // Array of fixed-shape OBJECTs: register elem schema so `it = items[j]`
1477
1755
  // → `it.prop` lowers to slot read via the existing arr-elem-schema path.
1478
- if (sh.elem.vt === VAL.OBJECT && sh.elem.names && ctx.schema.register) {
1756
+ if (sh.elem.val === VAL.OBJECT && sh.elem.names && ctx.schema.register) {
1479
1757
  const elemSid = ctx.schema.register(sh.elem.names)
1480
1758
  updateRep(a[1], { arrayElemSchema: elemSid })
1481
1759
  }
1482
1760
  }
1483
- if (sh.vt === VAL.OBJECT && sh.names && ctx.schema.register) {
1761
+ if (sh.val === VAL.OBJECT && sh.names && ctx.schema.register) {
1484
1762
  const sid = ctx.schema.register(sh.names)
1485
1763
  updateRep(a[1], { schemaId: sid })
1486
1764
  ctx.schema.vars.set(a[1], sid)
1487
1765
  }
1488
1766
  }
1767
+ // `shapeOf` misses object literals with function-valued props; bind
1768
+ // their schemaId here so number-hint ToPrimitive (valueOf/toString slot
1769
+ // dispatch) resolves. expectWrites=0: a decl initializer is not a write.
1770
+ if (vt === VAL.OBJECT) bindObjSchema(a[1], a[2], 0)
1489
1771
  // Propagate schemaId from a narrowed call result so subsequent valTypeOf
1490
1772
  // calls in this function body see the precise schema. emitDecl rebinds
1491
1773
  // this at emission time too — analyze-time binding is what unlocks the
1492
1774
  // slotVT lookup chain in `analyzeValTypes`'s own walk + per-func emit
1493
- // dispatch reading repByLocal.
1775
+ // dispatch reading localReps.
1494
1776
  if (vt === VAL.OBJECT && Array.isArray(a[2]) && a[2][0] === '()' && typeof a[2][1] === 'string') {
1495
1777
  const f = ctx.func.map?.get(a[2][1])
1496
1778
  if (f?.sig?.ptrAux != null) updateRep(a[1], { schemaId: f.sig.ptrAux })
@@ -1515,6 +1797,7 @@ export function analyzeValTypes(body) {
1515
1797
  if (vt === VAL.REGEX) trackRegex(args[0], args[1])
1516
1798
  if (vt === VAL.TYPED || vt === VAL.BUFFER || isCondExpr(args[1])) trackTyped(args[0], args[1])
1517
1799
  propagateTyped(args[0], args[1])
1800
+ if (vt === VAL.OBJECT) bindObjSchema(args[0], args[1])
1518
1801
  return
1519
1802
  }
1520
1803
  // Track property assignments for auto-boxing: x.prop = val
@@ -1568,8 +1851,11 @@ const INT_MATH_FNS = new Set(['imul', 'clz32', 'floor', 'ceil', 'round', 'trunc'
1568
1851
  * `&= |= ^= <<= >>= >>>=` (always int by op result type);
1569
1852
  * `/=` `**=` poison.
1570
1853
  *
1571
- * Writes `intCertain: true` on `ctx.func.repByLocal[name]`. No emit impact —
1572
- * codegen extensions consume this in follow-up passes.
1854
+ * Writes `intCertain: true` on `ctx.func.localReps[name]`. Consumers:
1855
+ * `toNumF64` (src/ir.js) skips the `__to_num` wrapper since an intCertain
1856
+ * local never carries a NaN-boxed pointer.
1857
+ * • `Math.floor/ceil/trunc/round` (module/math.js) — short-circuits to the
1858
+ * identity, eliding the wasm rounding op on an already-integer operand.
1573
1859
  */
1574
1860
  export function analyzeIntCertain(body) {
1575
1861
  // Pass 1: collect every defining RHS per binding name. Compound assignments
@@ -1680,6 +1966,17 @@ export function analyzeIntCertain(body) {
1680
1966
  }
1681
1967
  }
1682
1968
 
1969
+ // A directly-uint32 expression: `x >>> 0` (zero-fill shift) or a call to a function
1970
+ // already proven `unsignedResult`. Such a value lives in i32 but ranges [0, 2^32),
1971
+ // so signed i32 ops on it are wrong — exprType widens its arithmetic to f64 to
1972
+ // match emit (which reboxes via `f64.convert_i32_u`). Unsignedness through a local
1973
+ // assignment is intentionally not tracked here — kept in lockstep with narrow.js's
1974
+ // `isUnsignedTail`, so emit and exprType agree (no trunc_sat saturation).
1975
+ const isUnsignedI32Expr = (e) => Array.isArray(e) && (
1976
+ e[0] === '>>>' ||
1977
+ (e[0] === '()' && typeof e[1] === 'string' && ctx.func.map?.get(e[1])?.sig?.unsignedResult === true)
1978
+ )
1979
+
1683
1980
  /**
1684
1981
  * Infer expression result type from AST (without emitting).
1685
1982
  * Used to determine local variable types before compilation.
@@ -1688,7 +1985,7 @@ export function analyzeIntCertain(body) {
1688
1985
  export function exprType(expr, locals) {
1689
1986
  if (expr == null) return 'f64'
1690
1987
  if (typeof expr === 'number')
1691
- return Number.isInteger(expr) && expr >= -2147483648 && expr <= 2147483647 && !Object.is(expr, -0) ? 'i32' : 'f64'
1988
+ return isI32(expr) ? 'i32' : 'f64'
1692
1989
  if (typeof expr === 'string') {
1693
1990
  if (locals?.has?.(expr)) return locals.get(expr)
1694
1991
  const paramType = ctx.func.current?.params?.find(p => p.name === expr)?.type
@@ -1729,7 +2026,7 @@ export function exprType(expr, locals) {
1729
2026
  return 'f64'
1730
2027
  }
1731
2028
  // `.length` on a known sized receiver returns i32 directly (__len/__str_byteLen
1732
- // both return i32). Letting it stay i32 lets analyzeLocals keep the counter
2029
+ // both return i32). Letting it stay i32 lets analyzeBody keep the counter
1733
2030
  // local i32 too, eliminating the per-iteration `f64.convert_i32_s` widen and
1734
2031
  // the matching `i32.trunc_sat_f64_s` truncs at every `arr[i]` / `i*k` site.
1735
2032
  // Only safe when receiver type is statically known to expose an integer length.
@@ -1751,10 +2048,32 @@ export function exprType(expr, locals) {
1751
2048
  // Always i32
1752
2049
  if (['>', '<', '>=', '<=', '==', '!=', '!', '&', '|', '^', '~', '<<', '>>', '>>>'].includes(op)) return 'i32'
1753
2050
  // Preserve i32 if both operands i32
1754
- if (['+', '-', '*', '%'].includes(op)) {
2051
+ if (op === '+' || op === '-' || op === '%') {
1755
2052
  const ta = exprType(args[0], locals)
1756
2053
  const tb = args[1] != null ? exprType(args[1], locals) : ta // unary: inherit
1757
- return ta === 'i32' && tb === 'i32' ? 'i32' : 'f64'
2054
+ if (ta !== 'i32' || tb !== 'i32') return 'f64'
2055
+ // A uint32 operand ([0, 2^32)) makes the result exceed signed i32 range, so
2056
+ // emit widens to f64 (see emit.js `+`/`-`/`%`). exprType must agree — else
2057
+ // narrowing the result back to i32 would trunc_sat-saturate the f64 to INT32_MAX.
2058
+ if (isUnsignedI32Expr(args[0]) || (args[1] != null && isUnsignedI32Expr(args[1]))) return 'f64'
2059
+ return 'i32'
2060
+ }
2061
+ // `*` — a JS multiply is an f64 operation; `i32.mul` reproduces it faithfully
2062
+ // only while the exact product is f64-exact. Stay i32 when both operands are
2063
+ // i32 *and* the product provably fits: a fully-static product checked
2064
+ // directly, otherwise a literal operand small enough that |literal|·2^31 ≤
2065
+ // 2^53 (mirrors emit.js `mulFitsI32` — keeps `i*4` i32, widens `h*16777619`).
2066
+ if (op === '*') {
2067
+ const ta = exprType(args[0], locals), tb = exprType(args[1], locals)
2068
+ if (ta !== 'i32' || tb !== 'i32') return 'f64'
2069
+ // uint32 operand: product can exceed i32; emit widens to f64 (see emit.js `*`).
2070
+ if (isUnsignedI32Expr(args[0]) || isUnsignedI32Expr(args[1])) return 'f64'
2071
+ if (sv !== NO_VALUE && typeof sv === 'number') return isI32(sv) ? 'i32' : 'f64'
2072
+ const small = e => {
2073
+ const v = staticValue(e)
2074
+ return v !== NO_VALUE && typeof v === 'number' && Math.abs(v) <= 0x400000
2075
+ }
2076
+ return small(args[0]) || small(args[1]) ? 'i32' : 'f64'
1758
2077
  }
1759
2078
  // Unary preserves type
1760
2079
  if (op === 'u-' || op === 'u+') return exprType(args[0], locals)
@@ -1771,12 +2090,13 @@ export function exprType(expr, locals) {
1771
2090
  // through, instead of widening the local to f64 because exprType defaulted.
1772
2091
  if (op === '()') {
1773
2092
  if (args[0] === 'math.imul' || args[0] === 'math.clz32') return 'i32'
1774
- // Method calls returning i32: charCodeAt byte (0..255). Lets tokenizer-shape
1775
- // hot loops keep `c` as i32 across `c >= 48 && c <= 57`, `c - 48`, etc.,
1776
- // skipping the f64.convert_i32_u widen at every char read.
1777
- if (Array.isArray(args[0]) && args[0][0] === '.' && args[0][2] === 'charCodeAt') return 'i32'
2093
+ // charCodeAt: i32 when the index is provably in `[0, recv.length)` (an
2094
+ // induction variable bounded by `recv.length` OOB impossible). Otherwise
2095
+ // f64: the JS-spec OOB result is NaN, which is not representable as i32.
2096
+ if (Array.isArray(args[0]) && args[0][0] === '.' && args[0][2] === 'charCodeAt'
2097
+ && inBoundsCharCodeAt(ctx).has(args[0])) return 'i32'
1778
2098
  // User-function call: consult the callee's narrowed result type. By the time
1779
- // analyzeLocals runs in emitFunc, narrowSignatures has set sig.results[0]='i32'
2099
+ // analyzeBody runs in emitFunc, narrowSignatures has set sig.results[0]='i32'
1780
2100
  // on every body-i32-only func. Propagating this lets `let h = userFn(...)`
1781
2101
  // (mix in callback bench: i32-FNV) keep h as an i32 local instead of widening
1782
2102
  // to f64 and round-tripping i32↔f64 every iteration.
@@ -1788,15 +2108,9 @@ export function exprType(expr, locals) {
1788
2108
  return 'f64'
1789
2109
  }
1790
2110
 
1791
- /**
1792
- * Analyze all local declarations and assignments to determine types.
1793
- * A local is i32 if ALL assignments produce i32. Any f64 widens to f64.
1794
- *
1795
- * Thin slice of `analyzeBody` (single unified walk).
1796
- */
1797
- export function analyzeLocals(body) {
1798
- return analyzeBody(body).locals
1799
- }
2111
+ // `analyzeBody` was inlined to `analyzeBody(body).locals` at its three real
2112
+ // call sites in src/compile.js and src/narrow.js the one-line facade existed
2113
+ // only as a historical surface and obscured the unified-walk relationship.
1800
2114
 
1801
2115
  /**
1802
2116
  * Identify locals that can be stored as an unboxed i32 pointer offset instead of
@@ -1813,18 +2127,16 @@ export function analyzeLocals(body) {
1813
2127
  * SET ← `new Set(...)`
1814
2128
  * MAP ← `new Map(...)`
1815
2129
  * CLOSURE← `=>` literal
1816
- * BUFFER ← `new ArrayBuffer(...)` / `new DataView(...)`
2130
+ * BUFFER ← `new ArrayBuffer(...)`
1817
2131
  * TYPED ← `new XxxArray(...)` / method returning typed array
2132
+ * (`new DataView(...)` is TYPED but stays boxed — no elem aux)
1818
2133
  * - not captured in boxed storage (boxed locals stay f64 for the heap slot)
1819
2134
  * - never compared to null/undefined (we lose the nullish NaN representation)
1820
2135
  *
1821
2136
  * Returns Map<name, VAL> of locals to unbox.
1822
2137
  */
1823
- export function analyzePtrUnboxable(body, locals, boxed) {
1824
- const candidates = new Set()
1825
- const disqualified = new Set()
1826
- const valOf = name => ctx.func.repByLocal?.get(name)?.val
1827
-
2138
+ export function unboxablePtrs(body, locals, boxed) {
2139
+ const valOf = name => ctx.func.localReps?.get(name)?.val
1828
2140
  const UNBOXABLE_KINDS = new Set([VAL.OBJECT, VAL.SET, VAL.MAP, VAL.BUFFER, VAL.TYPED, VAL.CLOSURE, VAL.DATE])
1829
2141
 
1830
2142
  // RHS must produce a fresh, non-null pointer of the declared VAL kind.
@@ -1851,7 +2163,7 @@ export function analyzePtrUnboxable(body, locals, boxed) {
1851
2163
  // subsequent `p.x` reads then become direct `f64.load offset=K (local.get $p)`
1852
2164
  // (since ptrOffsetIR sees ptrKind=OBJECT and skips the per-access wrap).
1853
2165
  if (expr[0] === '[]' && typeof expr[1] === 'string') {
1854
- const repSid = ctx.func.repByLocal?.get(expr[1])?.arrayElemSchema
2166
+ const repSid = ctx.func.localReps?.get(expr[1])?.arrayElemSchema
1855
2167
  return repSid != null
1856
2168
  }
1857
2169
  return false
@@ -1863,7 +2175,7 @@ export function analyzePtrUnboxable(body, locals, boxed) {
1863
2175
  if (kind === VAL.SET) return callee === 'new.Set'
1864
2176
  if (kind === VAL.MAP) return callee === 'new.Map'
1865
2177
  if (kind === VAL.DATE) return callee === 'new.Date'
1866
- if (kind === VAL.BUFFER) return callee === 'new.ArrayBuffer' || callee === 'new.DataView'
2178
+ if (kind === VAL.BUFFER) return callee === 'new.ArrayBuffer'
1867
2179
  if (kind === VAL.TYPED) return callee.endsWith('Array') && callee !== 'new.ArrayBuffer'
1868
2180
  }
1869
2181
  // Call to narrow-ABI'd helper of matching VAL kind.
@@ -1884,77 +2196,165 @@ export function analyzePtrUnboxable(body, locals, boxed) {
1884
2196
  }
1885
2197
  return false
1886
2198
  }
1887
- const isNullishLit = (expr) =>
1888
- expr === 'null' || expr === 'undefined' ||
1889
- (Array.isArray(expr) && expr[0] == null &&
1890
- (expr[1] === null || expr[1] === undefined))
2199
+ // A policy over `scanBindingUses`: an UNBOXABLE-kind `let/const` local with a
2200
+ // fresh-pointer initializer stays unboxable unless some use forbids it. The
2201
+ // only forbidding uses are a reassignment (`=`/compound/`++`/`--`) or a
2202
+ // null/undefined comparison (an unboxed pointer has no nullish NaN form).
2203
+ // Closure captures do not disqualify — a capture-*mutated* local is already
2204
+ // in `boxed`, and a capture-*read* leaves the pointer in its own slot.
2205
+ const result = new Map()
2206
+ for (const [name, s] of scanBindingUses(body)) {
2207
+ const vt = valOf(name)
2208
+ if (!UNBOXABLE_KINDS.has(vt)) continue
2209
+ if (locals.get(name) !== 'f64') continue
2210
+ if (boxed?.has(name)) continue
2211
+ if (!isFreshInit(s.initRhs, vt)) continue
2212
+ const ok = s.uses.every(u =>
2213
+ u.kind !== USE.REASSIGN && !(u.kind === USE.COMPARE && u.nullCmp))
2214
+ if (ok) result.set(name, vt)
2215
+ }
2216
+ return result
2217
+ }
2218
+
2219
+ /**
2220
+ * CSE-safe load bases — `let/const` pointer locals whose `(f64.load offset=K $X)`
2221
+ * reads `cseScalarLoad` (src/optimize.js) may scalar-replace without a store
2222
+ * clobbering them. `cseScalarLoad` is module-wide disabled because it scanned
2223
+ * *every* i32 local; a store through an i32 local legitimately aliasing the load
2224
+ * base returned stale bytes. This pass is the missing soundness gate: a
2225
+ * per-function whitelist, each entry proven non-aliasing — guarantee, not guess.
2226
+ *
2227
+ * `X` qualifies iff ALL hold:
2228
+ * (a) X is an unboxed pointer — `localReps.get(X).ptrKind` set, `locals[X]==='i32'`.
2229
+ * (b) X is bound exactly once (no re-decl, no `=`/`++`/`--`/compound reassign).
2230
+ * (c) Every occurrence of X is the receiver of a `.`/`?.`/`[]` *read* — never a
2231
+ * write target, never a bare value (alias / arg / return / stored element),
2232
+ * never captured by a closure. So X's pointer lives only in `$X`; nothing
2233
+ * else holds it, and no store names it.
2234
+ * (d) The allocation X's bytes live in is disjoint from every store target.
2235
+ * jz allocations carry one kind each and distinct kinds never share bytes,
2236
+ * so X is store-safe when every store's base has a determinable kind ≠ X's
2237
+ * source kind. Any indeterminable store target disqualifies the whole set
2238
+ * (a store through unknown memory could alias anything).
2239
+ *
2240
+ * (c)+(d): no store in the function can touch a cell reachable via `$X + K`, so
2241
+ * a load on `$X` is invariant between two control-flow boundaries — exactly
2242
+ * `cseScalarLoad`'s straight-line region model. Method-call mutations (`.push`,
2243
+ * …) need no accounting here: the pass already flushes its table on every call.
2244
+ *
2245
+ * Returns `Set<name>` — names only, no `$` prefix (the caller stamps it).
2246
+ */
2247
+ export function cseSafeLoadBases(body, locals, localReps) {
2248
+ if (body === null || typeof body !== 'object') return new Set()
1891
2249
 
1892
- function collect(node) {
2250
+ // Allocation kind a pointer name's bytes live in: ptrKind (unboxed) wins,
2251
+ // else value-kind, else an array-schema'd binding is an ARRAY, else unknown.
2252
+ const kindOf = (name) => {
2253
+ if (typeof name !== 'string') return null
2254
+ const r = localReps?.get(name)
2255
+ return r?.ptrKind || r?.val || (r?.arrayElemSchema != null ? VAL.ARRAY : null) ||
2256
+ ctx.scope.globalValTypes?.get(name) || null
2257
+ }
2258
+ // X's bytes live in: the array/object an element read drew it from
2259
+ // (`X = src[i]` / `X = src.f`), else a fresh `{}`/`new` (X's own kind).
2260
+ const srcKind = (rhs) =>
2261
+ Array.isArray(rhs) && (rhs[0] === '[]' || rhs[0] === '.' || rhs[0] === '?.') &&
2262
+ typeof rhs[1] === 'string' ? kindOf(rhs[1]) : valTypeOf(rhs)
2263
+
2264
+ // Pass 1 — bound-once unboxed-pointer candidates; record each source kind.
2265
+ const cand = new Map() // name → source allocation kind
2266
+ const declCount = new Map()
2267
+ const collect = (node) => {
1893
2268
  if (!Array.isArray(node)) return
1894
- const [op, ...args] = node
2269
+ const op = node[0]
1895
2270
  if (op === '=>') return
1896
2271
  if (op === 'let' || op === 'const') {
1897
- for (const a of args) {
1898
- if (!Array.isArray(a) || a[0] !== '=' || typeof a[1] !== 'string') continue
1899
- const name = a[1]
1900
- const vt = valOf(name)
1901
- if (!UNBOXABLE_KINDS.has(vt)) continue
1902
- if (locals.get(name) !== 'f64') continue
1903
- if (boxed?.has(name)) continue
1904
- if (!isFreshInit(a[2], vt)) continue
1905
- candidates.add(name)
2272
+ for (let i = 1; i < node.length; i++) {
2273
+ const a = node[i]
2274
+ if (typeof a === 'string') { declCount.set(a, (declCount.get(a) || 0) + 1); continue }
2275
+ if (Array.isArray(a) && a[0] === '=' && typeof a[1] === 'string') {
2276
+ const name = a[1]
2277
+ declCount.set(name, (declCount.get(name) || 0) + 1)
2278
+ if (localReps?.get(name)?.ptrKind != null && locals.get(name) === 'i32')
2279
+ cand.set(name, srcKind(a[2]))
2280
+ collect(a[2])
2281
+ } else collect(a)
1906
2282
  }
2283
+ return
1907
2284
  }
1908
- for (const a of args) collect(a)
2285
+ for (let i = 1; i < node.length; i++) collect(node[i])
1909
2286
  }
2287
+ collect(body)
2288
+ for (const [n, c] of declCount) if (c > 1) cand.delete(n)
2289
+ if (!cand.size) return new Set()
1910
2290
 
1911
- const NULL_CMP_OPS = new Set(['==', '!=', '===', '!=='])
1912
-
1913
- function check(node) {
2291
+ // Pass 2 every occurrence must be a `.`/`?.`/`[]` read receiver (c).
2292
+ const live = new Set(cand.keys())
2293
+ const walk = (node, inClosure) => {
1914
2294
  if (!Array.isArray(node)) return
1915
- const [op, ...args] = node
1916
- if (op === '=>') return
1917
- if (ASSIGN_OPS.has(op) && typeof args[0] === 'string' && candidates.has(args[0])) {
1918
- if (op !== '=') disqualified.add(args[0])
1919
- // Initial `let x = {…}` arrives here too as op='='; the `let` pass already vetted it.
1920
- // A later `x = …` in the same body is a re-assignment — disqualify unless it's the init.
1921
- // We detect by tracking count: if we see '=' twice for the same name, disqualify.
1922
- }
1923
- if ((op === '++' || op === '--') && typeof args[0] === 'string' && candidates.has(args[0]))
1924
- disqualified.add(args[0])
1925
- if (NULL_CMP_OPS.has(op)) {
1926
- for (let i = 0; i < 2; i++) {
1927
- const side = args[i], other = args[1 - i]
1928
- if (typeof side === 'string' && candidates.has(side) && isNullishLit(other)) disqualified.add(side)
2295
+ const op = node[0]
2296
+ if (op === 'str') return
2297
+ const closured = inClosure || op === '=>'
2298
+ if (op === 'let' || op === 'const') { // decl `=` — bound name is not a use
2299
+ for (let i = 1; i < node.length; i++) {
2300
+ const a = node[i]
2301
+ if (typeof a === 'string') continue
2302
+ if (Array.isArray(a) && a[0] === '=') {
2303
+ if (typeof a[1] !== 'string') walk(a[1], closured)
2304
+ walk(a[2], closured)
2305
+ } else walk(a, closured)
1929
2306
  }
2307
+ return
2308
+ }
2309
+ if (op === '.' || op === '?.' || op === '[]') { // member READ — receiver is safe
2310
+ const o = node[1]
2311
+ if (typeof o === 'string') { if (inClosure && cand.has(o)) live.delete(o) }
2312
+ else walk(o, closured)
2313
+ if (op === '[]' && node[2] != null) walk(node[2], closured)
2314
+ return
2315
+ }
2316
+ if (ASSIGN_OPS.has(op) || op === '++' || op === '--' || op === 'delete') {
2317
+ const t = node[1] // write target — X here disqualifies
2318
+ if (typeof t === 'string') { if (cand.has(t)) live.delete(t) }
2319
+ else if (Array.isArray(t) && (t[0] === '.' || t[0] === '?.' || t[0] === '[]') &&
2320
+ typeof t[1] === 'string' && cand.has(t[1])) live.delete(t[1])
2321
+ else walk(t, closured)
2322
+ for (let i = 2; i < node.length; i++) walk(node[i], closured)
2323
+ return
2324
+ }
2325
+ for (let i = 1; i < node.length; i++) { // any other position — bare X escapes
2326
+ const c = node[i]
2327
+ if (typeof c === 'string') { if (cand.has(c)) live.delete(c) }
2328
+ else walk(c, closured)
1930
2329
  }
1931
- for (const a of args) check(a)
1932
2330
  }
2331
+ walk(body, false)
2332
+ if (!live.size) return live
1933
2333
 
1934
- // Count bare `=` assignments per candidate. Init `let x = …` is NOT parsed as `['=', x, …]`
1935
- // at the statement level — it's inside `['let', ['=', x, …]]`. A standalone `['=', x, …]`
1936
- // at statement level IS a reassignment.
1937
- const assignCount = new Map()
1938
- function countAssigns(node, inLet) {
2334
+ // Pass 3 store-target disjointness (d). A store lands in `base`'s allocation.
2335
+ let unknownStore = false
2336
+ const storeKinds = new Set()
2337
+ const scanStores = (node) => {
1939
2338
  if (!Array.isArray(node)) return
1940
- const [op, ...args] = node
1941
- if (op === '=>') return
1942
- if (op === '=' && !inLet && typeof args[0] === 'string' && candidates.has(args[0])) {
1943
- assignCount.set(args[0], (assignCount.get(args[0]) || 0) + 1)
2339
+ const op = node[0]
2340
+ if ((ASSIGN_OPS.has(op) || op === '++' || op === '--') && Array.isArray(node[1]) &&
2341
+ (node[1][0] === '.' || node[1][0] === '?.' || node[1][0] === '[]') &&
2342
+ typeof node[1][1] === 'string') {
2343
+ const k = kindOf(node[1][1])
2344
+ if (k == null) unknownStore = true
2345
+ else storeKinds.add(k)
1944
2346
  }
1945
- const childInLet = op === 'let' || op === 'const'
1946
- for (const a of args) countAssigns(a, childInLet)
2347
+ for (let i = 1; i < node.length; i++) scanStores(node[i])
1947
2348
  }
2349
+ scanStores(body)
2350
+ if (unknownStore) return new Set()
1948
2351
 
1949
- collect(body)
1950
- check(body)
1951
- countAssigns(body, false)
1952
-
1953
- for (const [name, count] of assignCount) if (count > 0) disqualified.add(name)
1954
-
1955
- const result = new Map()
1956
- for (const name of candidates) if (!disqualified.has(name)) result.set(name, valOf(name))
1957
- return result
2352
+ const safe = new Set()
2353
+ for (const name of live) {
2354
+ const k = cand.get(name)
2355
+ if (k != null && !storeKinds.has(k)) safe.add(name)
2356
+ }
2357
+ return safe
1958
2358
  }
1959
2359
 
1960
2360
  // === Param / closure helpers ===
@@ -2038,6 +2438,17 @@ export function findFreeVars(node, bound, free, scope) {
2038
2438
  findFreeVars(args[1], innerBound, free, scope)
2039
2439
  return
2040
2440
  }
2441
+ if (op === 'catch') {
2442
+ // ['catch', tryBody, errName, handler] — errName is a binding occurrence,
2443
+ // not a reference, and is in scope only inside the handler. Recursing into
2444
+ // it as a plain string would mis-capture an outer var of the same name.
2445
+ findFreeVars(args[0], bound, free, scope)
2446
+ const errName = args[1]
2447
+ const handlerBound = typeof errName === 'string' && errName
2448
+ ? new Set(bound).add(errName) : bound
2449
+ findFreeVars(args[2], handlerBound, free, scope)
2450
+ return
2451
+ }
2041
2452
  if (op === 'let' || op === 'const') {
2042
2453
  collectParamNames(args, bound)
2043
2454
  if (scope) collectParamNames(args, scope)
@@ -2065,65 +2476,11 @@ export function findMutations(node, names, mutated) {
2065
2476
  for (const a of args) findMutations(a, names, mutated)
2066
2477
  }
2067
2478
 
2068
- /**
2069
- * Pre-scan AST for variables that need a `__dyn_props` shadow sidecar.
2070
- *
2071
- * The shadow exists so `obj[runtimeKey]` can read a value via `__dyn_get`,
2072
- * and `obj.prop = v` keeps the sidecar in sync. Most object literals are only
2073
- * accessed via `.prop` or `obj['lit']`, both of which resolve through the
2074
- * schema directly and bypass the shadow. Allocating + populating the sidecar
2075
- * for those literals is pure waste.
2076
- *
2077
- * Populates:
2078
- * - ctx.types.dynKeyVars: Set<string> — names accessed via runtime key
2079
- * - ctx.types.anyDynKey: boolean — any dynamic key access exists in program
2080
- * (used for escaping literals where no target var is known)
2081
- */
2082
- export function analyzeDynKeys(...roots) {
2083
- const dynVars = new Set()
2084
- let anyDyn = false
2085
-
2086
- function walk(node) {
2087
- if (!Array.isArray(node)) return
2088
- const [op, ...args] = node
2089
- if (op === '[]') {
2090
- const [obj, idx] = args
2091
- if (!isLiteralStr(idx)) {
2092
- anyDyn = true
2093
- if (typeof obj === 'string') dynVars.add(obj)
2094
- }
2095
- }
2096
- if (op === '=' && Array.isArray(args[0]) && args[0][0] === '[]') {
2097
- const [, obj, idx] = args[0]
2098
- if (!isLiteralStr(idx)) {
2099
- anyDyn = true
2100
- if (typeof obj === 'string') dynVars.add(obj)
2101
- }
2102
- }
2103
- // Runtime for-in (compile-time unroll didn't fire) → walks via shadow
2104
- if (op === 'for-in') {
2105
- anyDyn = true
2106
- if (typeof args[1] === 'string') dynVars.add(args[1])
2107
- }
2108
- for (const a of args) walk(a)
2109
- }
2110
- for (const r of roots) walk(r)
2111
- if (ctx.func.list) for (const f of ctx.func.list) if (f.body) walk(f.body)
2112
- const initFacts = ctx.module.initFacts
2113
- if (initFacts?.anyDyn) {
2114
- anyDyn = true
2115
- for (const v of initFacts.dynVars) dynVars.add(v)
2116
- }
2117
-
2118
- ctx.types.dynKeyVars = dynVars
2119
- ctx.types.anyDynKey = anyDyn
2120
- }
2121
-
2122
2479
  /**
2123
2480
  * Pre-scan function body for captured variables that are mutated.
2124
2481
  * Marks mutably-captured vars in ctx.func.boxed for cell-based capture.
2125
2482
  */
2126
- export function analyzeBoxedCaptures(body) {
2483
+ export function boxedCaptures(body) {
2127
2484
  const outerScope = new Set()
2128
2485
  ;(function collectDecls(node) {
2129
2486
  if (!Array.isArray(node)) return
@@ -2225,7 +2582,7 @@ export function narrowReturnArrayElems(field, paramReps, valueUsed) {
2225
2582
  for (const p of func.sig.params) if (!ctx.func.locals.has(p.name)) ctx.func.locals.set(p.name, p.type)
2226
2583
  const localElems = facts[sliceKey]
2227
2584
  ctx.func.locals = savedLocals
2228
- const paramElemMap = callerParamFactMap(paramReps, func, field) || new Map()
2585
+ const paramElemMap = paramFactsOf(paramReps, func, field) || new Map()
2229
2586
  const resolveExpr = (expr) => {
2230
2587
  if (typeof expr === 'string') {
2231
2588
  if (localElems.has(expr)) {
@@ -2258,6 +2615,452 @@ export function narrowReturnArrayElems(field, paramReps, valueUsed) {
2258
2615
  }
2259
2616
  }
2260
2617
 
2618
+ /**
2619
+ * Whole-program SRoA eligibility — decides which object schemas may back an
2620
+ * `Array<S>` with the `structInline` carrier (the K f64 schema fields inlined
2621
+ * per element, no per-row heap object). Writes `ctx.schema.inlineArray:
2622
+ * Set<sid>`, read by the array push / index / length codegen.
2623
+ *
2624
+ * Default-disqualify: a schema is inlinable only when *every* observed use of
2625
+ * every `Array<S>` binding — across all user functions and module inits — is
2626
+ * one the structInline codegen handles. A missed or unrecognized use poisons
2627
+ * the schema, so the worst outcome is a lost optimization, never a stride
2628
+ * mismatch (miscompile).
2629
+ *
2630
+ * Handled uses of an `Array<S>` binding `a`:
2631
+ * - decl/reassign from `[]` (empty), a call returning `Array<S>`, or an alias
2632
+ * - `a.push({S-literal})` — struct push (K-cell store)
2633
+ * - `a.length` — physical len / K
2634
+ * - `a[i]` consumed as `const p = a[i]` cursor, or directly `a[i].field`
2635
+ * - `a` passed where the callee param is `Array<S>` (paramReps agreement)
2636
+ * - `return a` when the enclosing function returns `Array<S>`
2637
+ * A cursor `p` (`const p = a[i]`) may only be read/written as `p.field`.
2638
+ * Anything else — bare ref, value escape, other array method, `a[i] = …`
2639
+ * element-replace — poisons S.
2640
+ *
2641
+ * Reads codegen truth: a binding is `Array<S>` iff its settled rep
2642
+ * (`funcFacts.get(func).localReps`) carries `arrayElemSchema = S` — the exact
2643
+ * map the emitter consults — so the analysis and the emitter never disagree on
2644
+ * which bindings are inline-carried.
2645
+ *
2646
+ * Conservative corners (sound, give up the optimization): closures and module
2647
+ * inits are not walked in detail — any schema reachable as a `.push({S})`
2648
+ * argument, an `Array<S>`-returning call, an `[{S}, …]` literal, or a captured
2649
+ * tracked array inside one is poisoned.
2650
+ */
2651
+ export function analyzeStructInline(funcFacts, programFacts) {
2652
+ const inlineArray = ctx.schema?.inlineArray
2653
+ if (!inlineArray || !ctx.schema?.list) return
2654
+ const { paramReps } = programFacts
2655
+ const cand = new Set() // sids observed as an `Array<S>` element schema
2656
+ const black = new Set() // sids disqualified by some use
2657
+
2658
+ const propsOf = (sid) => ctx.schema.list[sid] || []
2659
+ const inSchema = (sid, p) => typeof p === 'string' && propsOf(sid).includes(p)
2660
+ const isStrLit = (k) => Array.isArray(k) && k[0] === 'str' && typeof k[1] === 'string'
2661
+
2662
+ // Argument list of a `['()', callee, argNode]` call node.
2663
+ const argsOf = (node) => {
2664
+ const a = node[2]
2665
+ return a == null ? [] : (Array.isArray(a) && a[0] === ',') ? a.slice(1) : [a]
2666
+ }
2667
+
2668
+ // `name` referenced anywhere as a value (skips `:`/`.` property-name slots).
2669
+ const mentions = (node, name) => {
2670
+ if (typeof node === 'string') return node === name
2671
+ if (!Array.isArray(node)) return false
2672
+ const op = node[0]
2673
+ if (op === 'str') return false
2674
+ if (op === ':') return mentions(node[2], name)
2675
+ if (op === '.' || op === '?.') return mentions(node[1], name)
2676
+ for (let i = 1; i < node.length; i++) if (mentions(node[i], name)) return true
2677
+ return false
2678
+ }
2679
+
2680
+ // Poison every schema whose `Array<S>` could materialize inside an un-walked
2681
+ // subtree (closure body / module init): `.push({S})` args, `Array<S>`-returning
2682
+ // calls, `[{S}, …]` array literals. Standalone `{S}` objects are independent
2683
+ // of array layout and intentionally left alone.
2684
+ const poisonAll = (node) => {
2685
+ if (!Array.isArray(node)) return
2686
+ const op = node[0]
2687
+ if (op === '()') {
2688
+ const callee = node[1]
2689
+ if (typeof callee === 'string') {
2690
+ const sid = ctx.func.map?.get(callee)?.arrayElemSchema
2691
+ if (sid != null) black.add(sid)
2692
+ } else if (Array.isArray(callee) && callee[0] === '.' && callee[2] === 'push') {
2693
+ for (const a of argsOf(node)) {
2694
+ const sid = objLiteralSchemaId(a)
2695
+ if (sid != null) black.add(sid)
2696
+ }
2697
+ }
2698
+ } else if (op === '[' || op === '[]') {
2699
+ for (const el of staticArrayElems(node) || []) {
2700
+ const sid = objLiteralSchemaId(el)
2701
+ if (sid != null) black.add(sid)
2702
+ }
2703
+ }
2704
+ for (let i = 1; i < node.length; i++) poisonAll(node[i])
2705
+ }
2706
+
2707
+ for (const [func, facts] of funcFacts) {
2708
+ const body = func?.body
2709
+ const reps = facts?.localReps
2710
+ if (func?.raw || !reps || body == null || typeof body !== 'object') continue
2711
+
2712
+ // `Array<S>` bindings of this function (codegen truth) and their schemas.
2713
+ const arrName = new Map() // name → sid
2714
+ for (const [name, r] of reps) {
2715
+ const sid = r?.arrayElemSchema
2716
+ if (sid == null) continue
2717
+ if ((propsOf(sid).length || 0) < 1) continue // K=0 — not inlinable
2718
+ cand.add(sid)
2719
+ arrName.set(name, sid)
2720
+ }
2721
+ if (!arrName.size) continue
2722
+
2723
+ // A structInline `Array<S>` value is only ever born from an empty `[]`
2724
+ // grown by structInline `.push`. `expr` is such a producer of `Array<sid>`
2725
+ // iff it is: a tracked `Array<sid>` alias, an empty `[]` literal, or a call
2726
+ // to a user function (whose returned array is structInline whenever sid
2727
+ // survives this whole-program pass). Every other source — a non-empty
2728
+ // `[{S},…]` literal, a builtin call (`JSON.parse`, `Object.values`, `.map`,
2729
+ // `.slice`, a member access onto a parsed object) — yields a taggedLinear
2730
+ // array and must poison sid.
2731
+ const safeArrSource = (expr, sid) => {
2732
+ if (typeof expr === 'string') return arrName.get(expr) === sid
2733
+ if (!Array.isArray(expr)) return false
2734
+ const elems = staticArrayElems(expr)
2735
+ if (elems) return elems.length === 0
2736
+ return expr[0] === '()' && typeof expr[1] === 'string' && !!ctx.func.map?.has(expr[1])
2737
+ }
2738
+
2739
+ // Pass 1 — collect `const p = a[i]` cursors; drop on name clash / re-decl.
2740
+ const cursor = new Map() // name → sid
2741
+ const declSeen = new Set()
2742
+ const collectCursors = (node) => {
2743
+ if (!Array.isArray(node) || node[0] === '=>') return
2744
+ if (node[0] === 'let' || node[0] === 'const') {
2745
+ for (let i = 1; i < node.length; i++) {
2746
+ const d = node[i]
2747
+ if (!Array.isArray(d) || d[0] !== '=' || typeof d[1] !== 'string') continue
2748
+ const name = d[1], rhs = d[2]
2749
+ if (declSeen.has(name)) { const s = cursor.get(name); if (s != null) black.add(s) }
2750
+ declSeen.add(name)
2751
+ if (Array.isArray(rhs) && rhs[0] === '[]' && rhs.length === 3 &&
2752
+ typeof rhs[1] === 'string' && arrName.has(rhs[1]) && !isStrLit(rhs[2])) {
2753
+ const sid = arrName.get(rhs[1])
2754
+ if (cursor.has(name) || arrName.has(name)) black.add(sid)
2755
+ else cursor.set(name, sid)
2756
+ }
2757
+ }
2758
+ }
2759
+ for (let i = 1; i < node.length; i++) collectCursors(node[i])
2760
+ }
2761
+ collectCursors(body)
2762
+
2763
+ // A `['[]', arrName, idx]` element read of a tracked array → its sid.
2764
+ const elemArrSid = (n) =>
2765
+ Array.isArray(n) && n[0] === '[]' && n.length === 3 &&
2766
+ typeof n[1] === 'string' && arrName.has(n[1]) && !isStrLit(n[2])
2767
+ ? arrName.get(n[1]) : null
2768
+
2769
+ // Pass 2 — verify every occurrence is a structInline-handled use.
2770
+ const flag = (c) => {
2771
+ if (typeof c !== 'string') return false
2772
+ if (arrName.has(c)) { black.add(arrName.get(c)); return true }
2773
+ if (cursor.has(c)) { black.add(cursor.get(c)); return true }
2774
+ return false
2775
+ }
2776
+ const visitChild = (c) => { if (!flag(c)) verify(c) }
2777
+
2778
+ function verify(node) {
2779
+ if (!Array.isArray(node)) return
2780
+ const op = node[0]
2781
+ if (op === 'str') return
2782
+ if (op === '=>') { // closure — un-walked, poison
2783
+ for (const n of arrName.keys()) if (mentions(node, n)) black.add(arrName.get(n))
2784
+ for (const [n, s] of cursor) if (mentions(node, n)) black.add(s)
2785
+ poisonAll(node)
2786
+ return
2787
+ }
2788
+ if (op === ':') { visitChild(node[2]); return }
2789
+
2790
+ if (op === '.' || op === '?.') {
2791
+ const o = node[1], p = node[2]
2792
+ if (typeof o === 'string') {
2793
+ if (arrName.has(o)) { if (!(op === '.' && p === 'length')) black.add(arrName.get(o)) }
2794
+ else if (cursor.has(o)) { if (!(op === '.' && inSchema(cursor.get(o), p))) black.add(cursor.get(o)) }
2795
+ return
2796
+ }
2797
+ const esid = elemArrSid(o)
2798
+ if (esid != null) {
2799
+ if (!(op === '.' && inSchema(esid, p))) black.add(esid)
2800
+ visitChild(o[2])
2801
+ return
2802
+ }
2803
+ visitChild(o)
2804
+ return
2805
+ }
2806
+
2807
+ if (op === '[]') {
2808
+ const o = node[1], k = node[2]
2809
+ if (typeof o === 'string') {
2810
+ if (arrName.has(o)) black.add(arrName.get(o)) // element value escape
2811
+ else if (cursor.has(o)) { if (!(isStrLit(k) && inSchema(cursor.get(o), k[1]))) black.add(cursor.get(o)) }
2812
+ if (k != null) visitChild(k)
2813
+ return
2814
+ }
2815
+ const esid = elemArrSid(o)
2816
+ if (esid != null) {
2817
+ if (!(isStrLit(k) && inSchema(esid, k[1]))) black.add(esid)
2818
+ visitChild(o[2])
2819
+ } else if (o != null) visitChild(o)
2820
+ if (k != null) visitChild(k)
2821
+ return
2822
+ }
2823
+
2824
+ // Reassignment of the array binding — the rhs must be a structInline
2825
+ // `Array<S>` producer; an alias is left un-walked (flagging it would
2826
+ // self-poison), other producers are walked to verify their subtree.
2827
+ if (op === '=' && typeof node[1] === 'string' && arrName.has(node[1])) {
2828
+ const sid = arrName.get(node[1])
2829
+ if (!safeArrSource(node[2], sid)) black.add(sid)
2830
+ else if (typeof node[2] !== 'string') visitChild(node[2])
2831
+ return
2832
+ }
2833
+
2834
+ if (op === '()') {
2835
+ const callee = node[1]
2836
+ if (Array.isArray(callee) && callee[0] === '.') {
2837
+ const recv = callee[1], method = callee[2]
2838
+ if (typeof recv === 'string' && arrName.has(recv)) {
2839
+ const sid = arrName.get(recv)
2840
+ const args = argsOf(node)
2841
+ if (method !== 'push' || !args.length) black.add(sid)
2842
+ else for (const arg of args) {
2843
+ if (Array.isArray(arg) && arg[0] === '{}' && objLiteralSchemaId(arg) === sid) {
2844
+ for (let i = 1; i < arg.length; i++) {
2845
+ const pr = arg[i]
2846
+ visitChild(Array.isArray(pr) && pr[0] === ':' ? pr[2] : pr)
2847
+ }
2848
+ } else black.add(sid)
2849
+ }
2850
+ return
2851
+ }
2852
+ if (typeof recv === 'string' && cursor.has(recv)) { black.add(cursor.get(recv)); return }
2853
+ const esid = elemArrSid(recv)
2854
+ if (esid != null) { black.add(esid); visitChild(recv[2]) }
2855
+ else visitChild(recv)
2856
+ for (const a of argsOf(node)) visitChild(a)
2857
+ return
2858
+ }
2859
+ if (typeof callee === 'string') {
2860
+ const args = argsOf(node)
2861
+ const known = ctx.func.map?.has(callee)
2862
+ const cParams = paramReps?.get(callee)
2863
+ for (let k = 0; k < args.length; k++) {
2864
+ const arg = args[k]
2865
+ if (typeof arg === 'string' && arrName.has(arg)) {
2866
+ const sid = arrName.get(arg)
2867
+ if (!(known && cParams?.get(k)?.arrayElemSchema === sid)) black.add(sid)
2868
+ } else if (!flag(arg)) verify(arg)
2869
+ }
2870
+ return
2871
+ }
2872
+ visitChild(callee)
2873
+ for (const a of argsOf(node)) visitChild(a)
2874
+ return
2875
+ }
2876
+
2877
+ if (op === 'return') {
2878
+ const e = node[1]
2879
+ if (typeof e === 'string') {
2880
+ if (arrName.has(e)) { if (func.arrayElemSchema !== arrName.get(e)) black.add(arrName.get(e)) }
2881
+ else flag(e)
2882
+ return
2883
+ }
2884
+ // A function typed `Array<S>` must return a structInline producer —
2885
+ // a non-empty literal / builtin call here yields a taggedLinear array.
2886
+ if (func.arrayElemSchema != null && !safeArrSource(e, func.arrayElemSchema))
2887
+ black.add(func.arrayElemSchema)
2888
+ const esid = elemArrSid(e)
2889
+ if (esid != null) { black.add(esid); visitChild(e[2]); return }
2890
+ if (e != null) visitChild(e)
2891
+ return
2892
+ }
2893
+
2894
+ if (op === 'let' || op === 'const') {
2895
+ for (let i = 1; i < node.length; i++) {
2896
+ const d = node[i]
2897
+ if (!Array.isArray(d) || d[0] !== '=') { if (Array.isArray(d)) visitChild(d); continue }
2898
+ const name = d[1], rhs = d[2]
2899
+ if (typeof name === 'string' && cursor.has(name) &&
2900
+ Array.isArray(rhs) && rhs[0] === '[]') {
2901
+ if (rhs[2] != null) visitChild(rhs[2]) // cursor decl — verify index only
2902
+ continue
2903
+ }
2904
+ if (typeof name === 'string' && arrName.has(name)) {
2905
+ const sid = arrName.get(name)
2906
+ if (!safeArrSource(rhs, sid)) black.add(sid) // non-structInline producer
2907
+ else if (typeof rhs !== 'string') visitChild(rhs) // [] / user-call — verify subtree
2908
+ continue
2909
+ }
2910
+ if (typeof name !== 'string') visitChild(name)
2911
+ visitChild(rhs)
2912
+ }
2913
+ return
2914
+ }
2915
+
2916
+ for (let i = 1; i < node.length; i++) visitChild(node[i])
2917
+ }
2918
+ verify(body)
2919
+ }
2920
+
2921
+ // Module inits are not walked in detail — poison any schema whose array form
2922
+ // could appear there (struct-array consumed/built at module scope).
2923
+ if (ctx.module?.moduleInits) for (const mi of ctx.module.moduleInits) poisonAll(mi)
2924
+
2925
+ for (const sid of cand) if (!black.has(sid)) inlineArray.add(sid)
2926
+ }
2927
+
2928
+ /** Schema id when `name` is bound (codegen truth) to a structInline `Array<S>`,
2929
+ * else null. `ctx.func.localReps` is the per-function rep map the emitter
2930
+ * consults for element-schema facts; `ctx.schema.inlineArray` is the
2931
+ * whole-program eligibility set filled by `analyzeStructInline`. Read together
2932
+ * so the emitter never inline-carries a binding the analysis rejected. */
2933
+ export function inlineArraySid(name) {
2934
+ if (typeof name !== 'string') return null
2935
+ const sid = ctx.func.localReps?.get(name)?.arrayElemSchema
2936
+ return sid != null && ctx.schema.inlineArray?.has(sid) ? sid : null
2937
+ }
2938
+
2939
+ /**
2940
+ * Whole-program function-namespace SRoA analysis.
2941
+ *
2942
+ * A user function used as a property bag — `parse.space = …; parse.step()` —
2943
+ * is otherwise compiled as a dynamic object: each `f.prop` write becomes a
2944
+ * `__dyn_set` into a hash side-table keyed by the closure pointer, each read a
2945
+ * `__dyn_get`. But a function's property table can never be observed by the
2946
+ * host (the host gets only the callable; the table lives in jz linear memory),
2947
+ * so the property set is statically closed — jz sees every `f.PROP` site. When
2948
+ * `f` never escapes as a bare value, each property dissolves:
2949
+ * - written once, at module top level, to a known function → the property is
2950
+ * constant: every `f.PROP` site rewrites straight to that function name
2951
+ * (direct calls, no storage at all).
2952
+ * - otherwise → a mutable f64 module global (`global.get` / `global.set`).
2953
+ *
2954
+ * Returns `Map<funcName, { disq, props:Set, valRead:Set,
2955
+ * writes:Map<prop,[{rhs,atInit}]> }>` — `valRead` is the subset of props read
2956
+ * as a value (not merely called). `flattenFuncNamespaces` (plan.js) turns it
2957
+ * into the rewrite. A name carrying `disq` escapes / is reassigned / is
2958
+ * computed-indexed and must not be touched.
2959
+ */
2960
+ export function analyzeFuncNamespaces(ast) {
2961
+ const funcNames = ctx.func.names
2962
+ if (!funcNames || !funcNames.size) return new Map()
2963
+
2964
+ const ns = new Map()
2965
+ const rec = (name) => {
2966
+ let r = ns.get(name)
2967
+ if (!r) ns.set(name, r = { disq: false, props: new Set(), valRead: new Set(), writes: new Map() })
2968
+ return r
2969
+ }
2970
+ // `['.'|'?.', f, P]` member where f is a known function and P a string key.
2971
+ const memberOf = (n) =>
2972
+ Array.isArray(n) && (n[0] === '.' || n[0] === '?.') &&
2973
+ isFuncRef(n[1], funcNames) && typeof n[2] === 'string' ? n : null
2974
+
2975
+ // `atInit` — node is a direct top-level statement (constant-fold candidate);
2976
+ // read only at the `=` handler, never propagated into sub-expressions.
2977
+ function visit(node, atInit) {
2978
+ if (typeof node === 'string') {
2979
+ // Bare mention of a known function in value position — it escapes; an
2980
+ // alias could reach its property table. Disqualify.
2981
+ if (funcNames.has(node)) rec(node).disq = true
2982
+ return
2983
+ }
2984
+ if (!Array.isArray(node)) return
2985
+ const op = node[0]
2986
+
2987
+ if (op === 'let' || op === 'const') {
2988
+ for (let i = 1; i < node.length; i++) {
2989
+ const d = node[i]
2990
+ if (Array.isArray(d) && d[0] === '=') {
2991
+ if (!isFuncRef(d[1], funcNames)) visit(d[1], false) // skip f's own decl
2992
+ // `let f = f` is prepare's self-name placeholder for a lifted function —
2993
+ // skip it (a bare visit would falsely disqualify f). Any other funcRef
2994
+ // rhs (`let g = f`) is a real alias and must disqualify f.
2995
+ if (!(isFuncRef(d[2], funcNames) && d[2] === d[1])) visit(d[2], false)
2996
+ } else visit(d, false)
2997
+ }
2998
+ return
2999
+ }
3000
+
3001
+ if (op === 'export') {
3002
+ // Exporting the function value is safe — the host gets the callable,
3003
+ // never the linear-memory property table. Skip bare function children.
3004
+ for (let i = 1; i < node.length; i++)
3005
+ if (!isFuncRef(node[i], funcNames)) visit(node[i], false)
3006
+ return
3007
+ }
3008
+
3009
+ if (op === '=') {
3010
+ const m = memberOf(node[1])
3011
+ if (m) {
3012
+ const r = rec(m[1]); r.props.add(m[2])
3013
+ let w = r.writes.get(m[2]); if (!w) r.writes.set(m[2], w = [])
3014
+ w.push({ rhs: node[2], atInit })
3015
+ visit(node[2], false)
3016
+ return
3017
+ }
3018
+ if (isFuncRef(node[1], funcNames)) rec(node[1]).disq = true // reassignment
3019
+ else visit(node[1], false)
3020
+ visit(node[2], false)
3021
+ return
3022
+ }
3023
+
3024
+ if (op === '()') {
3025
+ const m = memberOf(node[1])
3026
+ if (m) rec(m[1]).props.add(m[2])
3027
+ else if (!isFuncRef(node[1], funcNames)) visit(node[1], false) // bare f(...) ok
3028
+ for (let i = 2; i < node.length; i++) visit(node[i], false)
3029
+ return
3030
+ }
3031
+
3032
+ // `f.PROP` / `f?.PROP` as a plain value (read) — not the callee of a call
3033
+ // (those are handled by the `()` branch above). A value-read means the
3034
+ // property's stored value must stay retrievable; devirt cannot drop it.
3035
+ const m = memberOf(node)
3036
+ if (m) { const r = rec(m[1]); r.props.add(m[2]); r.valRead.add(m[2]); return }
3037
+
3038
+ // Computed `f[k]` — the key set is no longer static.
3039
+ if (op === '[]' && isFuncRef(node[1], funcNames)) {
3040
+ rec(node[1]).disq = true
3041
+ for (let i = 2; i < node.length; i++) visit(node[i], false)
3042
+ return
3043
+ }
3044
+
3045
+ for (let i = 1; i < node.length; i++) visit(node[i], false)
3046
+ }
3047
+
3048
+ const visitTop = (n) => {
3049
+ if (Array.isArray(n) && n[0] === ';')
3050
+ for (let i = 1; i < n.length; i++) visit(n[i], true)
3051
+ else visit(n, true)
3052
+ }
3053
+ visitTop(ast)
3054
+ // Bundled multi-module programs keep each module's top-level statements in
3055
+ // moduleInits, not `ast` — the `f.prop = …` writes that define a namespace
3056
+ // live there. Walk them at init scope so writes are recorded and an escape
3057
+ // inside init code still disqualifies.
3058
+ for (const mi of ctx.module.moduleInits || []) visitTop(mi)
3059
+ for (const fn of ctx.func.list) if (fn.body && !fn.raw) visit(fn.body, false)
3060
+
3061
+ return ns
3062
+ }
3063
+
2261
3064
  /**
2262
3065
  * Phase: program-fact collection.
2263
3066
  *
@@ -2372,7 +3175,16 @@ export function collectProgramFacts(ast) {
2372
3175
  // through valTypeOf → lookupValType. Skips into closures — they're observed via
2373
3176
  // their own func.list entry. The overlay is the per-function analyzeBody.valTypes
2374
3177
  // map (already populated with the same overlay-aware walk).
2375
- if (doSchema && f.hasSchemaLiterals) observeProgramSlots(ast)
3178
+ if (doSchema && f.hasSchemaLiterals) {
3179
+ observeProgramSlots(ast)
3180
+ // Per-slot intCertain mirror of the per-binding lattice. Runs after slot
3181
+ // type observation (which it does not depend on) — same trigger gate so
3182
+ // programs without schema literals skip both. Re-runnable: subsequent
3183
+ // collectProgramFacts invocations (E2 phase) overwrite the same map; the
3184
+ // analysis is monotone-down so re-running can only widen poisoning, never
3185
+ // un-poison — safe.
3186
+ analyzeSchemaSlotIntCertain(ast)
3187
+ }
2376
3188
 
2377
3189
  return {
2378
3190
  dynVars: f.dynVars, anyDyn: f.anyDyn, propMap, valueUsed, callSites,
@@ -2431,3 +3243,576 @@ export function observeProgramSlots(ast) {
2431
3243
  }
2432
3244
  ctx.func.localValTypesOverlay = prevOverlay
2433
3245
  }
3246
+
3247
+ /** Whole-program slot intCertain observation.
3248
+ *
3249
+ * A schema slot `(sid, idx)` is `intCertain` iff every write to it across the
3250
+ * program is integer-shaped (literal int, bitwise op, intCertain local read,
3251
+ * …). Mirrors `analyzeIntCertain`'s `isIntExpr` rules but works at module
3252
+ * scope: each body gets a local `intCertain` fixpoint over its own bindings,
3253
+ * then schema writes within that body are reduced against the fixpoint.
3254
+ *
3255
+ * Global poison semantics: any non-int write to a slot — in any body —
3256
+ * permanently flips it false. Slots never observed stay `undefined`.
3257
+ *
3258
+ * Cross-function flow (slot written from a call's return value) is **not**
3259
+ * tracked — those writes count as non-int and poison the slot. Conservative:
3260
+ * produces only false negatives, never false positives. */
3261
+ export function analyzeSchemaSlotIntCertain(ast) {
3262
+ if (!ctx.schema?.register) return
3263
+ const slotIntCertain = ctx.schema.slotIntCertain
3264
+ const poisonSlot = (sid, idx) => {
3265
+ let arr = slotIntCertain.get(sid)
3266
+ if (!arr) { arr = []; slotIntCertain.set(sid, arr) }
3267
+ while (arr.length <= idx) arr.push(undefined)
3268
+ arr[idx] = false
3269
+ }
3270
+ const observeSlot = (sid, idx, isInt) => {
3271
+ let arr = slotIntCertain.get(sid)
3272
+ if (!arr) { arr = []; slotIntCertain.set(sid, arr) }
3273
+ while (arr.length <= idx) arr.push(undefined)
3274
+ if (arr[idx] === false) return
3275
+ if (!isInt) { arr[idx] = false; return }
3276
+ if (arr[idx] === undefined) arr[idx] = true
3277
+ }
3278
+
3279
+ // Per-body fixpoint: replicates analyzeIntCertain's two-pass collect/iterate
3280
+ // but stays local to the body so it can run during collectProgramFacts
3281
+ // (before emit-time inferLocals sets per-function intCertain reps).
3282
+ const analyzeBodyLocally = (body) => {
3283
+ const defs = new Map()
3284
+ const pushDef = (name, rhs) => {
3285
+ let list = defs.get(name)
3286
+ if (!list) { list = []; defs.set(name, list) }
3287
+ list.push(rhs)
3288
+ }
3289
+ const collect = (node) => {
3290
+ if (!Array.isArray(node)) return
3291
+ const [op, ...args] = node
3292
+ if (op === '=>') return
3293
+ if (op === 'let' || op === 'const') {
3294
+ for (const a of args)
3295
+ if (Array.isArray(a) && a[0] === '=' && typeof a[1] === 'string') pushDef(a[1], a[2])
3296
+ } else if (op === '=' && typeof args[0] === 'string') {
3297
+ pushDef(args[0], args[1])
3298
+ } else if (typeof op === 'string' && op.length > 1 && op.endsWith('=') &&
3299
+ !INT_CMP_OPS.has(op) && op !== '=>' && typeof args[0] === 'string') {
3300
+ pushDef(args[0], [op.slice(0, -1), args[0], args[1]])
3301
+ } else if ((op === '++' || op === '--') && typeof args[0] === 'string') {
3302
+ pushDef(args[0], [op === '++' ? '+' : '-', args[0], [null, 1]])
3303
+ }
3304
+ for (const a of args) collect(a)
3305
+ }
3306
+ collect(body)
3307
+ const intCertain = new Map()
3308
+ for (const name of defs.keys()) intCertain.set(name, true)
3309
+ const isIntExpr = (expr) => {
3310
+ if (typeof expr === 'number') return Number.isInteger(expr) && !Object.is(expr, -0)
3311
+ if (typeof expr === 'boolean') return true
3312
+ if (typeof expr === 'string') return intCertain.get(expr) === true
3313
+ if (!Array.isArray(expr)) return false
3314
+ const sv = staticValue(expr)
3315
+ if (sv !== NO_VALUE && typeof sv === 'number' && Object.is(sv, -0)) return false
3316
+ const [op, ...args] = expr
3317
+ if (op == null) {
3318
+ const v = args[0]
3319
+ if (typeof v === 'number') return Number.isInteger(v) && !Object.is(v, -0)
3320
+ if (typeof v === 'boolean') return true
3321
+ return false
3322
+ }
3323
+ if (INT_BIT_OPS.has(op) || INT_CMP_OPS.has(op)) return true
3324
+ if (op === '.') {
3325
+ if ((args[1] === 'length' || args[1] === 'byteLength') && typeof args[0] === 'string') {
3326
+ const vt = lookupValType(args[0])
3327
+ return vt === VAL.TYPED || vt === VAL.ARRAY || vt === VAL.STRING || vt === VAL.BUFFER
3328
+ }
3329
+ if (args[1] === 'size' && typeof args[0] === 'string') {
3330
+ const vt = lookupValType(args[0])
3331
+ return vt === VAL.SET || vt === VAL.MAP
3332
+ }
3333
+ return false
3334
+ }
3335
+ if (INT_CLOSED_OPS.has(op)) {
3336
+ const a = isIntExpr(args[0])
3337
+ const b = args[1] != null ? isIntExpr(args[1]) : a
3338
+ return a && b
3339
+ }
3340
+ if (op === 'u-' || op === 'u+') return isIntExpr(args[0])
3341
+ if (op === '?:') return isIntExpr(args[1]) && isIntExpr(args[2])
3342
+ if (op === '&&' || op === '||') return isIntExpr(args[0]) && isIntExpr(args[1])
3343
+ if (op === '()') {
3344
+ const c = args[0]
3345
+ if (typeof c === 'string' && c.startsWith('math.') && INT_MATH_FNS.has(c.slice(5))) return true
3346
+ if (Array.isArray(c) && c[0] === '.' && c[1] === 'Math' && INT_MATH_FNS.has(c[2])) return true
3347
+ }
3348
+ return false
3349
+ }
3350
+ let changed = true
3351
+ while (changed) {
3352
+ changed = false
3353
+ for (const [name, rhsList] of defs) {
3354
+ if (!intCertain.get(name)) continue
3355
+ if (!rhsList.every(isIntExpr)) { intCertain.set(name, false); changed = true }
3356
+ }
3357
+ }
3358
+ return isIntExpr
3359
+ }
3360
+
3361
+ // Body walker: for each `{}` literal observe per-slot intCertain; for each
3362
+ // `obj.prop = expr` write, poison-or-confirm the slot resolved via the
3363
+ // schema attached to `obj` (ValueRep `schemaId` or `ctx.schema.vars`).
3364
+ const visit = (node, isInt) => {
3365
+ if (!Array.isArray(node)) return
3366
+ const op = node[0]
3367
+ if (op === '=>') return
3368
+ if (op === '{}') {
3369
+ const parsed = staticObjectProps(node.slice(1))
3370
+ if (parsed) {
3371
+ const sid = ctx.schema.register(parsed.names)
3372
+ for (let i = 0; i < parsed.values.length; i++) observeSlot(sid, i, isInt(parsed.values[i]))
3373
+ }
3374
+ } else if (op === '=' && Array.isArray(node[1]) && node[1][0] === '.') {
3375
+ const [, obj, prop] = node[1]
3376
+ if (typeof obj === 'string') {
3377
+ // Same precise-path resolution as ctx.schema.slotVT — no structural
3378
+ // fallback (slot index could differ across schemas with the same prop).
3379
+ const sid = repOf(obj)?.schemaId ?? ctx.schema.vars.get(obj)
3380
+ if (sid != null) {
3381
+ const idx = ctx.schema.list[sid]?.indexOf(prop)
3382
+ if (idx >= 0) observeSlot(sid, idx, isInt(node[2]))
3383
+ else if (idx < 0) {/* off-schema write — irrelevant to existing slots */}
3384
+ }
3385
+ }
3386
+ }
3387
+ for (let i = 1; i < node.length; i++) visit(node[i], isInt)
3388
+ }
3389
+
3390
+ if (ast) visit(ast, analyzeBodyLocally(ast))
3391
+ for (const func of ctx.func.list) {
3392
+ if (!func.body || func.raw) continue
3393
+ visit(func.body, analyzeBodyLocally(func.body))
3394
+ }
3395
+ if (ctx.module.initFacts?.hasSchemaLiterals && ctx.module.moduleInits) {
3396
+ for (const mi of ctx.module.moduleInits) visit(mi, analyzeBodyLocally(mi))
3397
+ }
3398
+ }
3399
+
3400
+ // =============================================================================
3401
+ // AST predicate helpers — pure functions over jz AST arrays
3402
+ // =============================================================================
3403
+ // AST nodes are strings (identifiers), numbers (literals), or arrays where [0]
3404
+ // is the operator tag (e.g. ['+', a, b], ['=>', params, body]). [null, value]
3405
+ // denotes a parenthesized/boxed literal.
3406
+
3407
+ export const MAX_SMALL_FOR_UNROLL = 8
3408
+ export const MAX_NESTED_FOR_UNROLL = 64
3409
+
3410
+ /** Detect whether `name` is written to (=, +=, ++, --, etc.) anywhere within `body`.
3411
+ * Conservative over-reject: if unsure, treat as written.
3412
+ * `let`/`const` declarations are NOT reassignments — only the initializer expressions
3413
+ * inside them are scanned. */
3414
+ export function isReassigned(body, name) {
3415
+ if (!Array.isArray(body)) return false
3416
+ const op = body[0]
3417
+ if (ASSIGN_OPS.has(op) && body[1] === name) return true
3418
+ if ((op === '++' || op === '--') && body[1] === name) return true
3419
+ if (op === 'let' || op === 'const') {
3420
+ for (let i = 1; i < body.length; i++) {
3421
+ const d = body[i]
3422
+ if (Array.isArray(d) && d[0] === '=' && d[2] != null && isReassigned(d[2], name)) return true
3423
+ }
3424
+ return false
3425
+ }
3426
+ for (let i = 1; i < body.length; i++) if (isReassigned(body[i], name)) return true
3427
+ return false
3428
+ }
3429
+
3430
+ /** Does `body` contain a `continue` that targets THIS loop?
3431
+ * A `continue` inside a nested `for`/`while`/`do` targets the inner loop, so we don't count it. */
3432
+ export function hasOwnContinue(body) {
3433
+ if (!Array.isArray(body)) return false
3434
+ const op = body[0]
3435
+ if (op === 'continue') return true
3436
+ if (op === 'for' || op === 'while' || op === 'do') return false
3437
+ for (let i = 1; i < body.length; i++) if (hasOwnContinue(body[i])) return true
3438
+ return false
3439
+ }
3440
+
3441
+ export function hasOwnBreakOrContinue(body) {
3442
+ if (!Array.isArray(body)) return false
3443
+ const op = body[0]
3444
+ if (op === 'break' || op === 'continue') return true
3445
+ if (op === 'for' || op === 'while' || op === 'do' || op === '=>') return false
3446
+ for (let i = 1; i < body.length; i++) if (hasOwnBreakOrContinue(body[i])) return true
3447
+ return false
3448
+ }
3449
+
3450
+ export function containsNestedClosure(body) {
3451
+ if (!Array.isArray(body)) return false
3452
+ if (body[0] === '=>') return true
3453
+ for (let i = 1; i < body.length; i++) if (containsNestedClosure(body[i])) return true
3454
+ return false
3455
+ }
3456
+
3457
+ export function containsNestedLoop(body) {
3458
+ if (!Array.isArray(body)) return false
3459
+ const op = body[0]
3460
+ if (op === 'for' || op === 'while' || op === 'do') return true
3461
+ if (op === '=>') return false
3462
+ for (let i = 1; i < body.length; i++) if (containsNestedLoop(body[i])) return true
3463
+ return false
3464
+ }
3465
+
3466
+ /** Recursive loop size estimator — product of trip counts for nested `for (let i=0; i<N; i++)` loops. */
3467
+ export function nestedSmallLoopBudget(body) {
3468
+ if (!Array.isArray(body)) return 1
3469
+ if (body[0] === '=>') return 1
3470
+ if (body[0] === 'for') {
3471
+ const [, init, cond, step, loopBody] = body
3472
+ const n = smallConstForTripCount(init, cond, step)
3473
+ return n == null ? MAX_NESTED_FOR_UNROLL + 1 : n * nestedSmallLoopBudget(loopBody)
3474
+ }
3475
+ let max = 1
3476
+ for (let i = 1; i < body.length; i++) max = Math.max(max, nestedSmallLoopBudget(body[i]))
3477
+ return max
3478
+ }
3479
+
3480
+ export function containsDeclOf(body, name) {
3481
+ if (!Array.isArray(body)) return false
3482
+ const op = body[0]
3483
+ if (op === '=>') return false
3484
+ if (op === 'let' || op === 'const') {
3485
+ for (let i = 1; i < body.length; i++) {
3486
+ const d = body[i]
3487
+ if (d === name) return true
3488
+ if (Array.isArray(d) && d[0] === '=' && d[1] === name) return true
3489
+ }
3490
+ }
3491
+ for (let i = 1; i < body.length; i++) if (containsDeclOf(body[i], name)) return true
3492
+ return false
3493
+ }
3494
+
3495
+ /** Clone AST node, substituting bare-name matches with [null, value]. Skips into closures. */
3496
+ export function cloneWithSubst(node, name, value) {
3497
+ if (node === name) return [null, value]
3498
+ if (!Array.isArray(node)) return node
3499
+ if (node[0] === '=>') return node
3500
+ return node.map(x => cloneWithSubst(x, name, value))
3501
+ }
3502
+
3503
+ /** Does `body` access a typed-array element by string name known to the type system? */
3504
+ export function containsKnownTypedArrayIndex(body) {
3505
+ if (!Array.isArray(body)) return false
3506
+ if (body[0] === '=>') return false
3507
+ if (body[0] === '[]' && typeof body[1] === 'string' && ctx.types.typedElem?.has(body[1])) return true
3508
+ for (let i = 1; i < body.length; i++) if (containsKnownTypedArrayIndex(body[i])) return true
3509
+ return false
3510
+ }
3511
+
3512
+ /** Analyze `for (let i=0; i<N; i++)` trip count. Returns N if structurally matches, else null. */
3513
+ export function smallConstForTripCount(init, cond, step) {
3514
+ if (!Array.isArray(init) || init[0] !== 'let' || init.length !== 2) return null
3515
+ const decl = init[1]
3516
+ if (!Array.isArray(decl) || decl[0] !== '=' || typeof decl[1] !== 'string') return null
3517
+ const name = decl[1]
3518
+ const start = intLiteralValue(decl[2])
3519
+ if (start !== 0) return null
3520
+
3521
+ if (!Array.isArray(cond) || cond[0] !== '<' || cond[1] !== name) return null
3522
+ const end = intLiteralValue(cond[2])
3523
+ if (end == null || end < 0 || end > MAX_SMALL_FOR_UNROLL) return null
3524
+
3525
+ const stepOk = Array.isArray(step) && (
3526
+ (step[0] === '++' && step[1] === name) ||
3527
+ (step[0] === '-' && Array.isArray(step[1]) && step[1][0] === '++' && step[1][1] === name && intLiteralValue(step[2]) === 1)
3528
+ )
3529
+ return stepOk ? end : null
3530
+ }
3531
+
3532
+ // =============================================================================
3533
+ // charCodeAt in-bounds proof
3534
+ // =============================================================================
3535
+ // `String.prototype.charCodeAt` returns NaN for an out-of-range index, so the
3536
+ // generic codegen contract is an f64 result (see module/string.js). When the
3537
+ // index is the induction variable of a `for (let i = C; i < recv.length; i++)`
3538
+ // loop, every `recv.charCodeAt(i)` in the loop body is statically inside
3539
+ // `[0, recv.length)` — OOB is impossible — so the call may use the cheaper i32
3540
+ // (raw-byte) contract instead. This is a static guarantee, not a guess.
3541
+
3542
+ /** Step expression of a `for` that increments `name` by exactly 1. */
3543
+ function isUnitIncrement(step, name) {
3544
+ if (!Array.isArray(step)) return false
3545
+ if (step[0] === '++' && step[1] === name) return true
3546
+ // postfix `i++` in value position lowers to `(++i) - 1`
3547
+ if (step[0] === '-' && Array.isArray(step[1]) && step[1][0] === '++'
3548
+ && step[1][1] === name && intLiteralValue(step[2]) === 1) return true
3549
+ return false
3550
+ }
3551
+
3552
+ /** `let`/`const` re-declaration of `name` within `node` — does not cross `=>`
3553
+ * (a closure has its own scope; collection already stops at closure boundaries). */
3554
+ function redeclaresName(node, name) {
3555
+ if (!Array.isArray(node) || node[0] === '=>') return false
3556
+ if (node[0] === 'let' || node[0] === 'const') {
3557
+ for (let k = 1; k < node.length; k++) {
3558
+ const d = node[k]
3559
+ if (d === name) return true
3560
+ if (Array.isArray(d) && d[0] === '=' && d[1] === name) return true
3561
+ }
3562
+ }
3563
+ for (let k = 1; k < node.length; k++) if (redeclaresName(node[k], name)) return true
3564
+ return false
3565
+ }
3566
+
3567
+ /** Collect `recv.charCodeAt(idxVar)` callee nodes within `node`. Stops at `=>`:
3568
+ * a closure may run after the loop, when `idxVar` has reached `recv.length`. */
3569
+ function collectBoundedCC(node, recv, idxVar, set) {
3570
+ if (!Array.isArray(node) || node[0] === '=>') return
3571
+ if (node[0] === '()' && node.length === 3 && node[2] === idxVar
3572
+ && Array.isArray(node[1]) && node[1][0] === '.'
3573
+ && node[1][1] === recv && node[1][2] === 'charCodeAt')
3574
+ set.add(node[1])
3575
+ for (let k = 1; k < node.length; k++) collectBoundedCC(node[k], recv, idxVar, set)
3576
+ }
3577
+
3578
+ /** Receiver of a `.length` expression, possibly wrapped in `(… | 0)` — the
3579
+ * shape `prepare` produces when it hoists a for-cond bound. */
3580
+ function lengthRecv(expr) {
3581
+ if (Array.isArray(expr) && expr[0] === '|' && intLiteralValue(expr[2]) === 0) expr = expr[1]
3582
+ if (Array.isArray(expr) && expr[0] === '.' && expr[2] === 'length'
3583
+ && typeof expr[1] === 'string') return expr[1]
3584
+ return null
3585
+ }
3586
+
3587
+ /** Flatten `let`/`const` declarations (incl. `;`-joined groups) into `out`,
3588
+ * mapping each declared name to its initializer expression. */
3589
+ function collectDecls(node, out) {
3590
+ if (!Array.isArray(node)) return
3591
+ if (node[0] === ';') { for (let k = 1; k < node.length; k++) collectDecls(node[k], out); return }
3592
+ if (node[0] === 'let' || node[0] === 'const') {
3593
+ for (let k = 1; k < node.length; k++) {
3594
+ const d = node[k]
3595
+ if (Array.isArray(d) && d[0] === '=' && typeof d[1] === 'string') out.set(d[1], d[2])
3596
+ }
3597
+ }
3598
+ }
3599
+
3600
+ /** Walk `node`, recording in `set` the `charCodeAt` callee nodes proven in-bounds
3601
+ * by an enclosing canonical induction loop `for (let i = C; i < recv.length; i++)`.
3602
+ * Matches the post-`prepare` shape, where the `.length` bound is hoisted into a
3603
+ * temp (`cond` becomes `i < lenTmp`, `lenTmp` declared in `init`). */
3604
+ export function scanBoundedLoops(node, set) {
3605
+ if (!Array.isArray(node)) return
3606
+ if (node[0] === 'for' && node.length === 5) {
3607
+ const [, init, cond, step, body] = node
3608
+ let idx = null, recv = null, boundVar = null
3609
+ if (Array.isArray(cond) && cond[0] === '<' && typeof cond[1] === 'string') {
3610
+ const decls = new Map()
3611
+ collectDecls(init, decls)
3612
+ idx = cond[1]
3613
+ // index must be declared in `init` as `let i = C`, C an integer literal ≥ 0
3614
+ const start = decls.has(idx) ? intLiteralValue(decls.get(idx)) : null
3615
+ if (start == null || start < 0) idx = null
3616
+ // bound is `recv.length`, directly or via a hoisted temp declared in `init`
3617
+ let bound = cond[2]
3618
+ if (typeof bound === 'string') { boundVar = bound; bound = decls.get(bound) }
3619
+ recv = lengthRecv(bound)
3620
+ }
3621
+ // step `i++`; body never writes `i`/`recv`/the bound temp (incl. via
3622
+ // closures) and never re-declares `i`. Then every bare `i` in the body
3623
+ // satisfies `0 ≤ C ≤ i < recv.length`.
3624
+ if (idx && recv && idx !== recv && isUnitIncrement(step, idx)
3625
+ && !isReassigned(body, idx) && !isReassigned(body, recv)
3626
+ && (boundVar == null || !isReassigned(body, boundVar))
3627
+ && !redeclaresName(body, idx))
3628
+ collectBoundedCC(body, recv, idx, set)
3629
+ }
3630
+ for (let k = 1; k < node.length; k++) scanBoundedLoops(node[k], set)
3631
+ }
3632
+
3633
+ const NO_BOUNDED_CC = new Set() // shared immutable empty result
3634
+
3635
+ /** Set of `['.', recv, 'charCodeAt']` callee nodes in the current function whose
3636
+ * index argument is provably within `[0, recv.length)`. Memoised per body. */
3637
+ export function inBoundsCharCodeAt(ctx) {
3638
+ const body = ctx.func?.body
3639
+ if (!Array.isArray(body)) return NO_BOUNDED_CC
3640
+ if (ctx.func._ccBody === body) return ctx.func.ccInBounds
3641
+ const set = new Set()
3642
+ scanBoundedLoops(body, set)
3643
+ ctx.func.ccInBounds = set
3644
+ ctx.func._ccBody = body
3645
+ return set
3646
+ }
3647
+
3648
+ /** Does `body` always exit the enclosing scope (return / throw / break / continue)? */
3649
+ export function isTerminator(body) {
3650
+ if (!Array.isArray(body)) return false
3651
+ const op = body[0]
3652
+ if (op === 'return' || op === 'throw' || op === 'break' || op === 'continue') return true
3653
+ if (op === '{}' || op === ';') {
3654
+ for (let i = body.length - 1; i >= 1; i--) {
3655
+ const s = body[i]
3656
+ if (s == null) continue
3657
+ return isTerminator(s)
3658
+ }
3659
+ return false
3660
+ }
3661
+ return false
3662
+ }
3663
+
3664
+ // =============================================================================
3665
+ // JSON-shape inference
3666
+ // =============================================================================
3667
+ // What a binding looks like when its provenance is a compile-time-known
3668
+ // `JSON.parse(stringConst)`. Building this tree at compile time lets
3669
+ // `.prop` and `[i]` reads on the result recover their VAL kind without a
3670
+ // runtime probe.
3671
+
3672
+ /** Resolve a string-constant source for an expression: literal forms, or a
3673
+ * binding the scope tracker has recorded as effectively-const. Module/json's
3674
+ * static-fold path keeps a constStrs-only resolver to avoid folding `let`-bound
3675
+ * initializers; shape inference is sound on the broader shapeStrs because an
3676
+ * effectively-const literal's value is invariant. */
3677
+ export function jsonConstString(expr) {
3678
+ if (Array.isArray(expr) && expr[0] === 'str' && typeof expr[1] === 'string') return expr[1]
3679
+ if (Array.isArray(expr) && expr[0] == null && typeof expr[1] === 'string') return expr[1]
3680
+ if (typeof expr === 'string') {
3681
+ return ctx.scope.shapeStrs?.get(expr) ?? ctx.scope.constStrs?.get(expr) ?? null
3682
+ }
3683
+ return null
3684
+ }
3685
+
3686
+ function jsonShapeStrings(expr) {
3687
+ const single = jsonConstString(expr)
3688
+ if (single != null) return [single]
3689
+ if (Array.isArray(expr) && expr[0] === '[]' && typeof expr[1] === 'string') return ctx.scope.shapeStrArrays?.get(expr[1]) ?? null
3690
+ return null
3691
+ }
3692
+
3693
+ /** Build a structural shape tree from a parsed JSON value. Each node is
3694
+ * `{ val, props?, elem? }` — `val` is the inferred VAL kind (matches
3695
+ * rep.val in localReps entries). Lets `valTypeOf` propagate VAL kinds
3696
+ * through `.prop` chains and `[i]` reads on bindings sourced from
3697
+ * `JSON.parse` of a compile-time-known string. Polymorphic arrays drop
3698
+ * their `elem`. */
3699
+ function shapeOfJsonValue(v) {
3700
+ if (v === null || v === undefined) return null
3701
+ if (typeof v === 'number') return { val: VAL.NUMBER }
3702
+ if (typeof v === 'string') return { val: VAL.STRING }
3703
+ if (typeof v === 'boolean') return { val: VAL.NUMBER }
3704
+ if (Array.isArray(v)) {
3705
+ let elem = null
3706
+ for (const x of v) {
3707
+ const s = shapeOfJsonValue(x)
3708
+ if (!s) { elem = null; break }
3709
+ if (!elem) elem = s
3710
+ else if (!shapeUnifies(elem, s)) { elem = null; break }
3711
+ }
3712
+ return { val: VAL.ARRAY, elem }
3713
+ }
3714
+ if (typeof v === 'object') {
3715
+ const props = Object.create(null)
3716
+ const names = Object.keys(v)
3717
+ for (const k of names) {
3718
+ const s = shapeOfJsonValue(v[k])
3719
+ if (s) props[k] = s
3720
+ }
3721
+ return { val: VAL.OBJECT, props, names }
3722
+ }
3723
+ return null
3724
+ }
3725
+
3726
+ function shapeUnifies(a, b) {
3727
+ if (!a || !b || a.val !== b.val) return false
3728
+ if (a.val === VAL.OBJECT || a.val === VAL.HASH) {
3729
+ const ak = Object.keys(a.props), bk = Object.keys(b.props)
3730
+ if (ak.length !== bk.length) return false
3731
+ for (const k of ak) {
3732
+ if (!b.props[k] || !shapeUnifies(a.props[k], b.props[k])) return false
3733
+ }
3734
+ }
3735
+ if (a.val === VAL.ARRAY) {
3736
+ if ((a.elem == null) !== (b.elem == null)) return false
3737
+ if (a.elem && !shapeUnifies(a.elem, b.elem)) return false
3738
+ }
3739
+ return true
3740
+ }
3741
+
3742
+ function shapeLayoutUnifies(a, b) {
3743
+ if (!shapeUnifies(a, b)) return false
3744
+ if (a.val === VAL.OBJECT || a.val === VAL.HASH) {
3745
+ if (a.names?.length !== b.names?.length) return false
3746
+ for (let i = 0; i < a.names.length; i++) if (a.names[i] !== b.names[i]) return false
3747
+ }
3748
+ if (a.val === VAL.ARRAY && a.elem) return shapeLayoutUnifies(a.elem, b.elem)
3749
+ return true
3750
+ }
3751
+
3752
+ function parseJsonShape(src) {
3753
+ if (typeof src !== 'string') return null
3754
+ let parsed
3755
+ try { parsed = JSON.parse(src) } catch { return null }
3756
+ return shapeOfJsonValue(parsed)
3757
+ }
3758
+
3759
+ function parseUnifiedJsonShape(srcs) {
3760
+ if (!srcs?.length) return null
3761
+ let out = null
3762
+ for (const src of srcs) {
3763
+ const sh = parseJsonShape(src)
3764
+ if (!sh) return null
3765
+ if (!out) out = sh
3766
+ else if (!shapeLayoutUnifies(out, sh)) return null
3767
+ }
3768
+ return out
3769
+ }
3770
+
3771
+ /** Resolve the json shape for an expression by walking name → rep.jsonShape and
3772
+ * `.prop` / `[i]` indirection. Returns null when shape is unknown at this site. */
3773
+ export function shapeOf(expr) {
3774
+ if (typeof expr === 'string')
3775
+ return ctx.func.localReps?.get(expr)?.jsonShape
3776
+ ?? ctx.scope.globalReps?.get(expr)?.jsonShape
3777
+ ?? null
3778
+ if (!Array.isArray(expr)) return null
3779
+ const [op, ...args] = expr
3780
+ if (op === '()' && args[0] === 'JSON.parse') {
3781
+ const srcs = jsonShapeStrings(args[1])
3782
+ if (srcs) return parseUnifiedJsonShape(srcs)
3783
+ }
3784
+ if (op === '.' && typeof args[1] === 'string') {
3785
+ const parent = shapeOf(args[0])
3786
+ if (parent?.val === VAL.OBJECT || parent?.val === VAL.HASH) return parent.props[args[1]] || null
3787
+ }
3788
+ if (op === '[]' && args.length === 2) {
3789
+ const parent = shapeOf(args[0])
3790
+ if (parent?.val === VAL.ARRAY) return parent.elem || null
3791
+ }
3792
+ return null
3793
+ }
3794
+
3795
+ /** Build a structural shape from a `{}` AST node — recursive for nested
3796
+ * object/array literals + propagating shapes through identifier references
3797
+ * (so `let G = {…}; let H = {x: G}` carries G's shape under H.x). Returns
3798
+ * null when any property breaks the static-shape contract (computed key,
3799
+ * spread, non-shape value). Only called from `recordGlobalRep` — local
3800
+ * bindings keep relying on `shapeOf` whose narrower contract (JSON.parse /
3801
+ * traversal only) lets `Object.assign(a, …)` extend `a`'s schema without
3802
+ * locking a static jsonShape onto it. */
3803
+ export function shapeOfObjectLiteralAst(expr) {
3804
+ if (typeof expr === 'string') return shapeOf(expr)
3805
+ if (!Array.isArray(expr) || expr[0] !== '{}') return shapeOf(expr)
3806
+ const raw = expr.length === 2 && Array.isArray(expr[1]) && expr[1][0] === ','
3807
+ ? expr[1].slice(1)
3808
+ : expr.slice(1)
3809
+ const props = Object.create(null)
3810
+ const names = []
3811
+ for (const p of raw) {
3812
+ if (!Array.isArray(p) || p[0] !== ':' || typeof p[1] !== 'string') return null
3813
+ names.push(p[1])
3814
+ const child = shapeOfObjectLiteralAst(p[2])
3815
+ if (child) props[p[1]] = child
3816
+ }
3817
+ return names.length ? { val: VAL.OBJECT, props, names } : null
3818
+ }