jz 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/emit.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * AST → WASM IR emission.
3
3
  *
4
4
  * # Stage contract
5
- * IN: prepared AST node + ctx state (func.locals, func.repByLocal, types.typedElem, etc.)
5
+ * IN: prepared AST node + ctx state (func.locals, func.localReps, types.typedElem, etc.)
6
6
  * OUT: IR node (array) with `.type` ('i32' | 'f64' | 'void'). For statements, a flat
7
7
  * list of WASM instructions (no type tag).
8
8
  * NO-MUTATE: emit does not rewrite the AST. Side effects go to ctx.runtime.*,
@@ -23,32 +23,50 @@
23
23
  */
24
24
 
25
25
  import { ctx, err, inc, PTR } from './ctx.js'
26
- import { T, VAL, nonNegIntLiteral, valTypeOf, lookupValType, extractParams, classifyParam, findFreeVars, STMT_OPS, repOf, updateRep, repOfGlobal, staticPropertyKey } from './analyze.js'
27
26
  import {
27
+ T, VAL, nonNegIntLiteral, valTypeOf, lookupValType, extractParams, classifyParam, findFreeVars, STMT_OPS, repOf, updateRep, repOfGlobal, staticPropertyKey,
28
+ // AST predicates (formerly in src/ast.js)
28
29
  isReassigned, hasOwnContinue, hasOwnBreakOrContinue, containsNestedClosure,
29
30
  containsNestedLoop, nestedSmallLoopBudget, containsDeclOf, cloneWithSubst,
30
31
  containsKnownTypedArrayIndex, smallConstForTripCount, isTerminator,
32
+ inBoundsCharCodeAt,
31
33
  MAX_SMALL_FOR_UNROLL, MAX_NESTED_FOR_UNROLL,
32
- } from './ast.js'
34
+ } from './analyze.js'
33
35
  import {
34
36
  typed, asF64, asI32, asI64, asPtrOffset, asParamType, toI32, fromI64,
35
37
  NULL_IR, nullExpr, undefExpr, MAX_CLOSURE_ARITY,
36
38
  WASM_OPS, SPREAD_MUTATORS, BOXED_MUTATORS,
37
39
  mkPtrIR, ptrOffsetIR, ptrTypeIR,
38
- isLit, litVal, isNullishLit, isPureIR, emitNum, f64rem, toNumF64,
40
+ isLit, litVal, isNullishLit, isPureIR, emitNum, f64rem, toNumF64, toStrI64,
39
41
  truthyIR, toBoolFromEmitted, isPostfix,
40
42
  isGlobal, isConst, keyValType, usesDynProps, needsDynShadow,
41
43
  temp, tempI32, tempI64, allocPtr,
42
- boxedAddr, readVar, writeVar, isNullish,
44
+ boxedAddr, readVar, writeVar, isNullish, isBoolAtom,
43
45
  isLiteralStr, resolveValType, isFuncRef,
44
46
  multiCount, loopTop, flat,
45
- reconstructArgsWithSpreads,
47
+ reconstructArgsWithSpreads, tcoTailRewrite,
46
48
  } from './ir.js'
49
+ import { typeofPredicate } from './infer.js'
47
50
 
48
51
  // Current emission "expect" mode ('void' or null); set by emit(), read by compound-assignment emitters
49
52
  // to decide whether to emit a value-returning or side-effect-only form.
50
53
  let _expect = null
51
54
 
55
+ // A genuine i32 *number* — safe for the i32 fast path in arithmetic/bitwise
56
+ // operators. An unboxed pointer (object/array/string/closure local kept as a
57
+ // raw i32 handle) is *also* i32-typed but carries `.ptrKind`; treating it as a
58
+ // number would compute on raw pointer bits. A ptrKind-carrying operand must
59
+ // instead route through ToNumber (`toNumF64`), which performs ToPrimitive.
60
+ const isI32Num = (v) => v.type === 'i32' && v.ptrKind == null
61
+
62
+ // An operand whose uint32 value can be *observed as a JS number* — a `>>>`
63
+ // result, an `unsignedResult` call, or an unsigned i32.const. Its magnitude can
64
+ // exceed signed-i32 range, so wrapping i32 arithmetic would corrupt it; widen to
65
+ // f64 instead. A `.wrapSafe` operand is also unsigned but is a `narrowUint32`
66
+ // accumulator read proven to be re-truncated by a `>>>` (ToUint32) sink at every
67
+ // use — wrapping is exactly its intended semantics, so it stays on the i32 path.
68
+ const widensUnsigned = (v) => v.unsigned && !v.wrapSafe
69
+
52
70
  const FIRST_CLASS_UNARY_MATH = {
53
71
  'math.abs': 'f64.abs',
54
72
  'math.sqrt': 'f64.sqrt',
@@ -80,14 +98,30 @@ function builtinFunctionValue(name) {
80
98
  const emitNeg = (a) => {
81
99
  if (valTypeOf(a) === VAL.BIGINT) return fromI64(['i64.sub', ['i64.const', 0], asI64(emit(a))])
82
100
  const v = emit(a)
83
- return isLit(v) ? emitNum(-litVal(v)) : v.type === 'i32'
101
+ return isLit(v) ? emitNum(-litVal(v)) : isI32Num(v)
84
102
  ? typed(['i32.sub', typed(['i32.const', 0], 'i32'), v], 'i32')
85
103
  : typed(['f64.neg', toNumF64(a, v)], 'f64')
86
104
  }
87
105
 
88
106
  /** Try constant-folding binary arith: returns emitNum(result) or null. */
107
+ // `.unsigned` literals carry a uint32 value whose i32 `litVal` is its *signed* bit
108
+ // pattern (e.g. `-1` for 4294967295), so folding them through `fn` numerically would
109
+ // be wrong. Bail to the runtime path — the arithmetic handlers widen unsigned operands
110
+ // to f64 (convert_i32_u), reproducing the JS-spec result.
89
111
  const foldConst = (va, vb, fn, guard) =>
90
- isLit(va) && isLit(vb) && (!guard || guard(litVal(vb))) ? emitNum(fn(litVal(va), litVal(vb))) : null
112
+ isLit(va) && isLit(vb) && !va.unsigned && !vb.unsigned && (!guard || guard(litVal(vb)))
113
+ ? emitNum(fn(litVal(va), litVal(vb))) : null
114
+
115
+ // JS `*` is an f64 multiply; `i32.mul` yields only the exact product mod 2^32.
116
+ // Those agree under a ToInt32/ToUint32 sink (and as plain numbers) while the
117
+ // exact product stays f64-exact, i.e. |product| <= 2^53. Two i32 operands can
118
+ // reach 2^62, so `i32.mul` is sound only when one side is a literal small
119
+ // enough that, against the full i32 range (2^31) of the other, the product
120
+ // holds within 2^53 — i.e. |literal| <= 2^22. Keeps index arithmetic (`i*4`,
121
+ // `row*16`) on `i32.mul` while routing hash-mix-scale products to `f64.mul`.
122
+ const mulFitsI32 = (va, vb) =>
123
+ (isLit(va) && Math.abs(litVal(va)) <= 0x400000) ||
124
+ (isLit(vb) && Math.abs(litVal(vb)) <= 0x400000)
91
125
 
92
126
  /** Emit typeof comparison: typeof x == typeCode → type-aware check. */
93
127
  export function emitTypeofCmp(a, b, cmpOp) {
@@ -102,6 +136,10 @@ export function emitTypeofCmp(a, b, cmpOp) {
102
136
  const eq = cmpOp === 'eq'
103
137
 
104
138
  if (code === -1) {
139
+ // A VAL.BOOL value rides in the f64 0/1 carrier, so it would pass the
140
+ // `v===v` number test — but it is typeof "boolean", not "number".
141
+ if (resolveValType(typeofExpr, valTypeOf, lookupValType) === VAL.BOOL)
142
+ return typed(['i32.const', eq ? 0 : 1], 'i32')
105
143
  return typed(eq
106
144
  ? ['f64.eq', ['local.tee', `$${t}`, va], ['local.get', `$${t}`]]
107
145
  : ['f64.ne', ['local.tee', `$${t}`, va], ['local.get', `$${t}`]], 'i32')
@@ -118,7 +156,12 @@ export function emitTypeofCmp(a, b, cmpOp) {
118
156
  return typed(eq ? check : ['i32.eqz', check], 'i32')
119
157
  }
120
158
  if (code === -4) {
121
- return typed(['i32.const', eq ? 0 : 1], 'i32')
159
+ // boolean. Statically VAL.BOOL true; any other known type → false.
160
+ const vt = resolveValType(typeofExpr, valTypeOf, lookupValType)
161
+ if (vt) return typed(['i32.const', (vt === VAL.BOOL) === eq ? 1 : 0], 'i32')
162
+ // Unknown static type: boolean iff the carrier is a boxed-boolean atom.
163
+ const isBool = isBoolAtom(['local.tee', `$${t}`, va])
164
+ return typed(eq ? isBool : ['i32.eqz', isBool], 'i32')
122
165
  }
123
166
  if (code === -5) {
124
167
  inc('__ptr_type')
@@ -157,9 +200,22 @@ export function emitTypeofCmp(a, b, cmpOp) {
157
200
  return null
158
201
  }
159
202
 
203
+ /** Stringify a VAL.BOOL operand to "true"/"false" (f64 string pointer). The
204
+ * boolean rides the cheap 0/1 carrier, so we runtime-select between the two
205
+ * interned literals; a constant operand folds to a single literal downstream. */
206
+ export const emitBoolStr = (node) =>
207
+ typed(['select', asF64(emit(['str', 'true'])), asF64(emit(['str', 'false'])), truthyIR(emit(node))], 'f64')
208
+
160
209
  const CMP_SET = new Set(['>', '<', '>=', '<=', '==', '!=', '!'])
161
210
  const isCmp = n => Array.isArray(n) && CMP_SET.has(n[0])
162
211
 
212
+ // Map/Set methods whose generic (`.${method}`) emitter assumes a collection
213
+ // receiver and dereferences a key/value argument. Every one needs ≥1 argument
214
+ // (`.get(k)` / `.has(v)` / `.add(v)` / `.delete(v)` / `.set(k[,v])`), so a
215
+ // zero-arg call on a not-proven-collection receiver cannot be the collection
216
+ // op — it is a user/closure method and must not reach the collection emitter.
217
+ const COLLECTION_METHODS = new Set(['get', 'set', 'has', 'add', 'delete'])
218
+
163
219
  // Pointer kinds for which JS `==` / `!=` is pure reference equality — i.e. i64 bit
164
220
  // compare of the NaN-box is equivalent to __eq. Excludes STRING (content compare for
165
221
  // heap strings) and BIGINT (content compare).
@@ -174,6 +230,23 @@ function stringLiteral(node) {
174
230
  return null
175
231
  }
176
232
 
233
+ // Index expressions where peepholing `s[k] === 'X'` to char-byte compare is
234
+ // semantics-preserving: must produce a non-negative *integer* at run time so
235
+ // `__str_byteLen u> k` bounds-checks the same range JS would. Out-of-range
236
+ // (negative or ≥ len) falls into the `else 0` arm — matches `undefined === 'X'`.
237
+ function intIndexIR(key) {
238
+ const lit = nonNegIntLiteral(key)
239
+ if (lit != null) return ['i32.const', lit]
240
+ // intCertain name: forward-prop says every defining RHS is integer-shaped.
241
+ // Captures loop variables (`for(let i=0;;i++)`), `let k = j + 1`, etc.
242
+ if (typeof key === 'string' && repOf(key)?.intCertain) return asI32(emit(key))
243
+ // intCertain schema slot read `o.x`: every observed write is integer-shaped,
244
+ // so the loaded f64 represents an int — fold into the byte-compare fast path.
245
+ if (Array.isArray(key) && key[0] === '.' && typeof key[1] === 'string' && typeof key[2] === 'string' &&
246
+ ctx.schema.slotIntCertainAt?.(key[1], key[2]) === true) return asI32(emit(key))
247
+ return null
248
+ }
249
+
177
250
  function emitSingleCharIndexCmp(a, b, negate = false) {
178
251
  const leftLit = stringLiteral(a)
179
252
  const rightLit = stringLiteral(b)
@@ -188,8 +261,8 @@ function emitSingleCharIndexCmp(a, b, negate = false) {
188
261
  if ([...lit].some(c => c.charCodeAt(0) > 0x7F)) return null
189
262
 
190
263
  const [, obj, key] = indexed
191
- const idx = nonNegIntLiteral(key)
192
- if (idx == null) return null
264
+ const idxIR = intIndexIR(key)
265
+ if (idxIR == null) return null
193
266
 
194
267
  const vt = typeof obj === 'string' ? lookupValType(obj) : valTypeOf(obj)
195
268
  if (vt && vt !== VAL.STRING) return null
@@ -202,30 +275,102 @@ function emitSingleCharIndexCmp(a, b, negate = false) {
202
275
  // Single-char literal: compare byte directly, skipping __str_idx allocation.
203
276
  if (lit.length !== 1 || !ctx.core.stdlib['__char_at'] || !ctx.core.stdlib['__str_byteLen']) return null
204
277
 
205
- const ptr = temp('sc'), idxIR = ['i32.const', idx]
278
+ // Stash the index in a local when it isn't a constant — bounds + load both reference it.
279
+ const isConstIdx = Array.isArray(idxIR) && idxIR[0] === 'i32.const'
280
+ let idxRefIR = idxIR, idxBindIR = null
281
+ if (!isConstIdx) {
282
+ const idxTmp = tempI32('si')
283
+ idxBindIR = ['local.set', `$${idxTmp}`, idxIR]
284
+ idxRefIR = ['local.get', `$${idxTmp}`]
285
+ }
286
+
287
+ const ptr = temp('sc')
206
288
  inc('__str_byteLen', '__char_at')
207
289
  const charEq = ['if', ['result', 'i32'],
208
- ['i32.gt_u', ['call', '$__str_byteLen', ['i64.reinterpret_f64', ['local.get', `$${ptr}`]]], idxIR],
209
- ['then', ['i32.eq', ['call', '$__char_at', ['i64.reinterpret_f64', ['local.get', `$${ptr}`]], idxIR], ['i32.const', lit.charCodeAt(0)]]],
290
+ ['i32.gt_u', ['call', '$__str_byteLen', ['i64.reinterpret_f64', ['local.get', `$${ptr}`]]], idxRefIR],
291
+ ['then', ['i32.eq', ['call', '$__char_at', ['i64.reinterpret_f64', ['local.get', `$${ptr}`]], idxRefIR], ['i32.const', lit.charCodeAt(0)]]],
210
292
  ['else', ['i32.const', 0]]]
211
293
 
294
+ const prelude = idxBindIR ? [['local.set', `$${ptr}`, asF64(emit(obj))], idxBindIR] : [['local.set', `$${ptr}`, asF64(emit(obj))]]
295
+
212
296
  if (vt === VAL.STRING) {
213
- return typed(['block', ['result', 'i32'],
214
- ['local.set', `$${ptr}`, asF64(emit(obj))],
215
- finish(charEq)], 'i32')
297
+ return typed(['block', ['result', 'i32'], ...prelude, finish(charEq)], 'i32')
216
298
  }
217
299
 
218
300
  inc('__ptr_type', '__typed_idx', '__eq')
219
301
  const genericEq = ['call', '$__eq',
220
- ['i64.reinterpret_f64', ['call', '$__typed_idx', ['i64.reinterpret_f64', ['local.get', `$${ptr}`]], idxIR]],
302
+ ['i64.reinterpret_f64', ['call', '$__typed_idx', ['i64.reinterpret_f64', ['local.get', `$${ptr}`]], idxRefIR]],
221
303
  asI64(emit(['str', lit]))]
222
304
  const cmp = ['if', ['result', 'i32'],
223
305
  ['i32.eq', ['call', '$__ptr_type', ['i64.reinterpret_f64', ['local.get', `$${ptr}`]]], ['i32.const', PTR.STRING]],
224
306
  ['then', charEq],
225
307
  ['else', genericEq]]
308
+ return typed(['block', ['result', 'i32'], ...prelude, finish(cmp)], 'i32')
309
+ }
310
+
311
+ // `<str>.{substr,substring,slice}(...) === <other>` whose substring is consumed
312
+ // only by the equality: materialising it (an __alloc + byte copy) is pure waste.
313
+ // Fuse to __str_{substring,slice}_eq, which clamp the range like the method then
314
+ // byte-compare it against `other` in place. Sibling to emitSingleCharIndexCmp,
315
+ // tried at the same `==`/`!=` sites. Motivating hot path: the parser keyword
316
+ // scan, `cur.substr(i,l) === keyword`.
317
+ function emitSubstringEqCmp(a, b, negate = false) {
318
+ // Post-prepare a multi-arg call keeps its args as one comma list; a single
319
+ // arg sits bare. Normalise either (and a flat tail, defensively) to a list.
320
+ const callInfo = node => {
321
+ if (!Array.isArray(node) || node[0] !== '()') return null
322
+ const callee = node[1]
323
+ if (!Array.isArray(callee) || callee[0] !== '.') return null
324
+ const method = callee[2]
325
+ if (method !== 'substr' && method !== 'substring' && method !== 'slice') return null
326
+ let args = node.slice(2)
327
+ if (args.length === 1 && Array.isArray(args[0]) && args[0][0] === ',') args = args[0].slice(1)
328
+ while (args.length && args[args.length - 1] == null) args = args.slice(0, -1)
329
+ return { recv: callee[1], method, args }
330
+ }
331
+
332
+ let info = callInfo(a), other = b, callIsLeft = true
333
+ if (!info) { info = callInfo(b); other = a; callIsLeft = false }
334
+ if (!info) return null
335
+ const { recv, method, args } = info
336
+ if (args.length > 2) return null
337
+ if (!ctx.core.stdlib['__char_at'] || !ctx.core.stdlib['__str_byteLen']) return null
338
+
339
+ // The receiver must be a string. `substr`/`substring` name string-only methods,
340
+ // so an unknown receiver is safe — the normal `.substr`/`.substring` emitter
341
+ // assumes a string too. `slice` is also Array.prototype.slice — require a
342
+ // statically-known STRING there. A known non-string receiver bails always.
343
+ const vt = resolveValType(recv, valTypeOf, lookupValType)
344
+ if (vt && vt !== VAL.STRING) return null
345
+ if (method === 'slice' && vt !== VAL.STRING) return null
346
+
347
+ const helper = method === 'slice' ? '__str_slice_eq' : '__str_substring_eq'
348
+ inc(helper)
349
+
350
+ // Absent end → byteLen: pass i32 max — every clamp arm floors it to the length.
351
+ const TO_END = ['i32.const', 0x7FFFFFFF]
352
+ let startIR, endIR
353
+ if (method === 'substr' && args[1] != null) {
354
+ // substr's 2nd arg is a length: end = start + length, so start reads twice.
355
+ const s = tempI32('subS')
356
+ startIR = ['local.tee', `$${s}`, args[0] == null ? ['i32.const', 0] : asI32(emit(args[0]))]
357
+ endIR = ['i32.add', ['local.get', `$${s}`], asI32(emit(args[1]))]
358
+ } else {
359
+ startIR = args[0] == null ? ['i32.const', 0] : asI32(emit(args[0]))
360
+ endIR = args[1] == null ? TO_END : asI32(emit(args[1]))
361
+ }
362
+
363
+ const finish = expr => negate ? ['i32.eqz', expr] : expr
364
+
365
+ if (callIsLeft)
366
+ return typed(finish(['call', `$${helper}`, asI64(emit(recv)), startIR, endIR, asI64(emit(other))]), 'i32')
367
+
368
+ // `other` is the source-left operand — evaluate it first to preserve order.
369
+ const o = temp('subO')
226
370
  return typed(['block', ['result', 'i32'],
227
- ['local.set', `$${ptr}`, asF64(emit(obj))],
228
- finish(cmp)], 'i32')
371
+ ['local.set', `$${o}`, asF64(emit(other))],
372
+ finish(['call', `$${helper}`, asI64(emit(recv)), startIR, endIR,
373
+ ['i64.reinterpret_f64', ['local.get', `$${o}`]]])], 'i32')
229
374
  }
230
375
 
231
376
  // === Flow-sensitive type refinement ===
@@ -235,7 +380,7 @@ const TYPEOF_CODE_TO_VAL = { [-1]: VAL.NUMBER, [-2]: VAL.STRING, [-6]: VAL.CLOSU
235
380
 
236
381
  /** Extract refinements from a boolean condition AST.
237
382
  * `sense`: true = refine for then-branch, false = refine for else-branch (i.e. cond inverted).
238
- * Returns a Map<name, VAL>. Walks && / || / ! accordingly. */
383
+ * Returns a Map<name, {val?: VAL, notString?: true}>. Walks && / || / ! accordingly. */
239
384
  function extractRefinements(cond, out, sense = true) {
240
385
  if (!Array.isArray(cond)) return out
241
386
  const op = cond[0]
@@ -247,29 +392,51 @@ function extractRefinements(cond, out, sense = true) {
247
392
  if (op === '||' && !sense) { extractRefinements(cond[1], out, false); extractRefinements(cond[2], out, false); return out }
248
393
  // typeof x == 'number' | 'string' | 'function' — sense must be positive for "==", negative for "!="
249
394
  if ((op === '==' || op === '===' || op === '!=' || op === '!==')) {
250
- const eq = (op === '==' || op === '===')
251
- const wantPositive = eq ? sense : !sense
252
- if (!wantPositive) return out
253
- const a = cond[1], b = cond[2]
254
- const pair = Array.isArray(a) && a[0] === 'typeof' ? [a[1], b]
255
- : Array.isArray(b) && b[0] === 'typeof' ? [b[1], a] : null
256
- if (pair && typeof pair[0] === 'string' && Array.isArray(pair[1]) && pair[1][0] == null) {
257
- const val = TYPEOF_CODE_TO_VAL[pair[1][1]]
258
- if (val) out.set(pair[0], val)
395
+ const tp = typeofPredicate(cond)
396
+ if (tp) {
397
+ const wantPositive = tp.eq ? sense : !sense
398
+ if (wantPositive) {
399
+ const val = TYPEOF_CODE_TO_VAL[tp.code]
400
+ if (val) mergeRefinement(out, tp.name, { val })
401
+ } else if (tp.code === 'string' || tp.code === -2) {
402
+ // Negative branch of typeof-string guard (e.g. post `if (typeof x === 'string') return`)
403
+ // proves the binding is not a primitive string in the suffix scope — feeds B4's
404
+ // length / subscript dispatch elision the same way write-shape evidence does.
405
+ mergeRefinement(out, tp.name, { notString: true })
406
+ }
259
407
  }
260
408
  return out
261
409
  }
262
- // Array.isArray(x)only refines under positive sense.
263
- // Callee may be the flattened string 'Array.isArray' or the raw ['.', 'Array', 'isArray'] pair.
410
+ // Type-predicate calls under positive sense refine by the asserted VAL.
411
+ // Callee may be the flattened string 'Array.isArray' or the raw ['.', 'Array',
412
+ // 'isArray'] pair; __is_map / __is_set / __is_typed come from jzify's
413
+ // instanceof lowering as a bare string callee.
264
414
  if (op === '()' && sense && typeof cond[2] === 'string') {
265
415
  const callee = cond[1]
266
- const isArr = callee === 'Array.isArray'
267
- || (Array.isArray(callee) && callee[0] === '.' && callee[1] === 'Array' && callee[2] === 'isArray')
268
- if (isArr) { out.set(cond[2], VAL.ARRAY); return out }
416
+ const val = predicateRefinement(callee)
417
+ if (val != null) { mergeRefinement(out, cond[2], { val }); return out }
269
418
  }
270
419
  return out
271
420
  }
272
421
 
422
+ /** Map a call-callee shape to the VAL kind it asserts under positive sense, or null. */
423
+ function predicateRefinement(callee) {
424
+ if (callee === 'Array.isArray') return VAL.ARRAY
425
+ if (Array.isArray(callee) && callee[0] === '.' && callee[1] === 'Array' && callee[2] === 'isArray')
426
+ return VAL.ARRAY
427
+ if (callee === '__is_map') return VAL.MAP
428
+ if (callee === '__is_set') return VAL.SET
429
+ if (callee === '__is_typed') return VAL.TYPED
430
+ return null
431
+ }
432
+
433
+ /** Merge a refinement fact into the per-name slot. Later facts override; non-overlapping
434
+ * fields union. Keeps the call-side simple (always assign through this). */
435
+ function mergeRefinement(out, name, fact) {
436
+ const cur = out.get(name)
437
+ out.set(name, cur ? { ...cur, ...fact } : fact)
438
+ }
439
+
273
440
  function unrollSmallConstFor(init, cond, step, body) {
274
441
  const end = smallConstForTripCount(init, cond, step)
275
442
  if (end == null) return null
@@ -294,19 +461,31 @@ function canThrow(body, seen = new Set()) {
294
461
  if (op === '=>') return false
295
462
  if (op === '()') {
296
463
  const callee = body[1]
297
- if (typeof callee === 'string') {
298
- const bodyName = ctx.func.directClosures?.get(callee)
299
- const f = ctx.func.map?.get(bodyName || callee)
300
- if (f?.body && !f.raw && !seen.has(f.name)) {
301
- seen.add(f.name)
302
- if (canThrow(f.body, seen)) return true
303
- }
464
+ // A call can throw unless we can see the whole callee and prove it can't:
465
+ // only direct calls into a resolvable, non-raw function body are traceable.
466
+ // Indirect/method/builtin calls (callee not a plain name, or a name we can't
467
+ // resolve) are conservatively throwing — a user `try` must wrap them.
468
+ if (typeof callee !== 'string') return true
469
+ const bodyName = ctx.func.directClosures?.get(callee)
470
+ const f = ctx.func.map?.get(bodyName || callee)
471
+ if (!f?.body || f.raw) return true
472
+ if (!seen.has(f.name)) {
473
+ seen.add(f.name)
474
+ if (canThrow(f.body, seen)) return true
304
475
  }
305
476
  }
306
477
  for (let i = 1; i < body.length; i++) if (canThrow(body[i], seen)) return true
307
478
  return false
308
479
  }
309
480
 
481
+ const isBoundName = name =>
482
+ ctx.func.locals?.has(name) || ctx.func.current?.params?.some(p => p.name === name)
483
+
484
+ // A source-defined function (carries a body) — as opposed to an imported name,
485
+ // which `ctx.func.names` also holds but which has no body and may legitimately
486
+ // share a name with a built-in emitter (e.g. an imported `parseInt`).
487
+ const isUserFunc = name => !!ctx.func.map.get(name)?.body
488
+
310
489
  /** Emit pending `finally` cleanups for an abrupt control-flow exit.
311
490
  * Inner cleanups run before outer cleanups. While emitting each cleanup, remove
312
491
  * it from the active stack so `return` inside `finally` does not re-enter it. */
@@ -423,6 +602,19 @@ export function emitDecl(...inits) {
423
602
  const [, name, init] = i
424
603
  if (typeof name !== 'string' || init == null) continue
425
604
 
605
+ // SRoA flat object: `let o = {a:1, b:2}` — dissolve fields into `o#i`
606
+ // locals, no heap alloc. Each field local ← asF64(value). Reads/writes are
607
+ // rewritten by the `.`/`[]` flat hooks. See scanFlatObjects (analyze.js).
608
+ // Monotonic-extension fields (`o.newProp = …`) carry no literal value —
609
+ // they init to undefined so a read before the write matches JS.
610
+ const flatDecl = ctx.func.flatObjects?.get(name)
611
+ if (flatDecl && Array.isArray(init) && init[0] === '{}') {
612
+ for (let j = 0; j < flatDecl.names.length; j++)
613
+ result.push(['local.set', `$${name}#${j}`,
614
+ flatDecl.values[j] === undefined ? undefExpr() : asF64(emit(flatDecl.values[j]))])
615
+ continue
616
+ }
617
+
426
618
  // Multi-value ephemeral destructuring — skip heap alloc when temp is
427
619
  // assigned from a multi-value call then immediately destructured element-by-element.
428
620
  if (name.startsWith(T) && Array.isArray(init) && init[0] === '()' && typeof init[1] === 'string'
@@ -457,9 +649,28 @@ export function emitDecl(...inits) {
457
649
  }
458
650
  }
459
651
  }
652
+ // No-copy slice view: `let t = s.slice(...)` whose result scanSliceViews
653
+ // proved never escapes — lower the initializer to a SLICE_BIT view instead
654
+ // of a copying slice. Everything downstream treats `t` as an ordinary
655
+ // string. Gated here (not in the analysis) on a statically-known STRING
656
+ // receiver — param types are settled only by emit time — and on plain-local
657
+ // carriers (boxed/global escape); any miss falls back to the copying slice.
658
+ let viewInit = null
659
+ if (ctx.func.sliceViews?.has(name) && !ctx.func.boxed.has(name) && !isGlobal(name)
660
+ && Array.isArray(init) && init[0] === '()'
661
+ && Array.isArray(init[1]) && init[1][0] === '.' && init[1][2] === 'slice') {
662
+ const recv = init[1][1]
663
+ const recvVt = typeof recv === 'string' ? keyValType(recv) : valTypeOf(recv)
664
+ if (recvVt === VAL.STRING) {
665
+ const raw = init[2]
666
+ const sa = raw == null ? [] : Array.isArray(raw) && raw[0] === ',' ? raw.slice(1) : [raw]
667
+ viewInit = ctx.core.emit['.string:slice#view'](recv, sa[0], sa[1])
668
+ }
669
+ }
670
+
460
671
  const isObjLit = Array.isArray(init) && init[0] === '{}'
461
672
  if (isObjLit) ctx.schema.targetStack.push({ name, active: true })
462
- const val = emit(init)
673
+ const val = viewInit || emit(init)
463
674
  if (isObjLit) ctx.schema.targetStack.pop()
464
675
  // Direct-call dispatch for const-bound, non-escaping local closures: skip call_indirect.
465
676
  // Gate: not boxed (no mutable cross-fn capture), not global, not reassigned in this body.
@@ -492,10 +703,12 @@ export function emitDecl(...inits) {
492
703
  }
493
704
  const localType = ctx.func.locals.get(name) || 'f64'
494
705
  let ptrKind = repOf(name)?.ptrKind
706
+ // Emit-time rep mutation (lifecycle: analysis → emit transition).
495
707
  // Inherit ptrKind from a pointer-ABI RHS: destructure temps (`__d0 = v`) and other
496
708
  // fresh let-bindings whose init is already an unboxed pointer. Without this, readVar
497
709
  // returns an untyped i32 local.get and later `asF64` emits a numeric convert instead
498
- // of a ptr-rebox. Safe because emitDecl runs once per let/const binding.
710
+ // of a ptr-rebox. Safe because emitDecl runs once per let/const binding — no prior
711
+ // emit-time read could have observed the unset rep.
499
712
  if (ptrKind == null && val.ptrKind != null && localType === 'i32' && !ctx.func.boxed?.has(name)) {
500
713
  updateRep(name, { ptrKind: val.ptrKind })
501
714
  ptrKind = val.ptrKind
@@ -515,12 +728,13 @@ export function emitDecl(...inits) {
515
728
  // Unboxed pointer local — extract i32 offset from NaN-boxed f64 via reinterpret, not numeric trunc.
516
729
  // CLOSURE init carries funcIdx in val.closureFuncIdx; persist it on the rep so a later
517
730
  // asF64 (escape: store, return, indirect-call rebox) reconstructs the correct table slot.
731
+ // Emit-time mutation — analyzeValTypes never sees closureFuncIdx.
518
732
  if (ptrKind === VAL.CLOSURE && val.closureFuncIdx != null && repOf(name)?.ptrAux == null)
519
733
  updateRep(name, { ptrAux: val.closureFuncIdx })
520
734
  coerced = val.ptrKind === ptrKind ? val
521
735
  : typed(['i32.wrap_i64', ['i64.reinterpret_f64', asF64(val)]], 'i32')
522
736
  } else {
523
- coerced = localType === 'f64' ? asF64(val) : asI32(val)
737
+ coerced = localType === 'f64' ? asF64(val) : val.type === 'i32' ? val : toI32(val)
524
738
  }
525
739
  if (!(isLit(coerced) && coerced[1] === 0 && !Object.is(coerced[1], -0) && !ctx.func.stack.length))
526
740
  result.push(['local.set', `$${name}`, coerced])
@@ -547,6 +761,56 @@ export function emitDecl(...inits) {
547
761
  return result.length === 0 ? null : result.length === 1 ? result[0] : result
548
762
  }
549
763
 
764
+ /**
765
+ * Copy a spread source's elements into a destination array.
766
+ *
767
+ * `dest` is the destination data-base i32 local; `posLocal` the element index to
768
+ * start writing at — advanced by the source length on exit. An ARRAY source is a
769
+ * contiguous block of f64 NaN-boxes, so it copies with a single `memory.copy`; a
770
+ * string/typed source needs a per-element decode. The source's *type* is
771
+ * loop-invariant — it cannot change while the spread runs — so when it is not
772
+ * statically known it is resolved exactly once (one `__ptr_type`) and branched,
773
+ * never re-checked per element. Returns a list of IR instructions.
774
+ */
775
+ function emitSpreadCopy(dest, posLocal, srcLocal, srcLenLocal, staticVT) {
776
+ const srcI64 = () => ['i64.reinterpret_f64', ['local.get', `$${srcLocal}`]]
777
+ const destAddr = idx => ['i32.add', ['local.get', `$${dest}`], ['i32.shl', idx, ['i32.const', 3]]]
778
+ const arrCopy = () => (inc('__ptr_offset'),
779
+ ['memory.copy', destAddr(['local.get', `$${posLocal}`]),
780
+ ['call', '$__ptr_offset', srcI64()],
781
+ ['i32.shl', ['local.get', `$${srcLenLocal}`], ['i32.const', 3]]])
782
+ const scalarLoop = () => {
783
+ const sidx = `${T}sidx${ctx.func.uniq++}`
784
+ ctx.func.locals.set(sidx, 'i32')
785
+ const loopId = ctx.func.uniq++
786
+ const elem = ctx.module.modules['string']
787
+ ? ['if', ['result', 'f64'],
788
+ ['i32.eq', ['call', '$__ptr_type', srcI64()], ['i32.const', PTR.STRING]],
789
+ ['then', (inc('__str_idx'), ['call', '$__str_idx', srcI64(), ['local.get', `$${sidx}`]])],
790
+ ['else', (inc('__typed_idx'), ['call', '$__typed_idx', srcI64(), ['local.get', `$${sidx}`]])]]
791
+ : (inc('__typed_idx'), ['call', '$__typed_idx', srcI64(), ['local.get', `$${sidx}`]])
792
+ // Reset the counter on each entry — WASM zeroes locals once at function
793
+ // entry, but this loop re-executes when the spread sits inside a JS loop;
794
+ // a stale `sidx` (= prior srcLen) would skip the copy entirely.
795
+ return ['block', `$break${loopId}`,
796
+ ['local.set', `$${sidx}`, ['i32.const', 0]],
797
+ ['loop', `$loop${loopId}`,
798
+ ['br_if', `$break${loopId}`, ['i32.ge_s', ['local.get', `$${sidx}`], ['local.get', `$${srcLenLocal}`]]],
799
+ ['f64.store', destAddr(['i32.add', ['local.get', `$${posLocal}`], ['local.get', `$${sidx}`]]), elem],
800
+ ['local.set', `$${sidx}`, ['i32.add', ['local.get', `$${sidx}`], ['i32.const', 1]]],
801
+ ['br', `$loop${loopId}`]]]
802
+ }
803
+ const advance = ['local.set', `$${posLocal}`,
804
+ ['i32.add', ['local.get', `$${posLocal}`], ['local.get', `$${srcLenLocal}`]]]
805
+ if (staticVT === VAL.ARRAY) return [arrCopy(), advance]
806
+ if (staticVT === VAL.STRING || staticVT === VAL.TYPED) return [scalarLoop(), advance]
807
+ inc('__ptr_type')
808
+ return [['if',
809
+ ['i32.eq', ['call', '$__ptr_type', srcI64()], ['i32.const', PTR.ARRAY]],
810
+ ['then', arrCopy()],
811
+ ['else', scalarLoop()]], advance]
812
+ }
813
+
550
814
  /**
551
815
  * Build an array from items, handling ['__spread', expr] markers.
552
816
  * Split into sections (normal arrays and spreads), then copy all into result.
@@ -581,9 +845,12 @@ export function buildArrayWithSpreads(items) {
581
845
  sections.push({ type: 'array', items: currentArray })
582
846
  }
583
847
 
584
- if (sections.length === 1) {
585
- const sec = sections[0]
586
- return emit(sec.type === 'array' ? ['[', ...sec.items] : sec.expr)
848
+ // A single all-normal section is a plain literal — defer to the `[` emitter.
849
+ // A single *spread* section is NOT shortcut to `emit(sec.expr)`: that would
850
+ // alias the source, but `[...x]` must yield a fresh array. It falls through
851
+ // to the alloc + emitSpreadCopy path below, which copies.
852
+ if (sections.length === 1 && sections[0].type === 'array') {
853
+ return emit(['[', ...sections[0].items])
587
854
  }
588
855
 
589
856
  const len = tempI32('len')
@@ -591,35 +858,39 @@ export function buildArrayWithSpreads(items) {
591
858
  const out = allocPtr({ type: 1, len: ['local.get', `$${len}`], tag: 'arr' })
592
859
  const result = out.local
593
860
 
594
- const ir = [
595
- ['local.set', `$${len}`, ['i32.const', 0]],
596
- ]
861
+ const ir = []
862
+ inc('__len')
597
863
 
598
- inc('__len', '__ptr_offset')
864
+ // Pass 1 — evaluate every section IN SOURCE ORDER into temps. JS spread keeps
865
+ // strict left-to-right order: a later spread whose source mutates an earlier
866
+ // element's input must still observe the pre-mutation value. Array items
867
+ // become per-item f64 temps; spreads become a ptr temp + a cached __len.
599
868
  for (const sec of sections) {
600
- if (sec.type === 'spread') {
869
+ if (sec.type === 'array') {
870
+ sec.itemLocals = []
871
+ for (let i = 0; i < sec.items.length; i++) {
872
+ const it = `${T}ai${ctx.func.uniq++}`
873
+ ctx.func.locals.set(it, 'f64')
874
+ sec.itemLocals.push(it)
875
+ ir.push(['local.set', `$${it}`, asF64(emit(sec.items[i]))])
876
+ }
877
+ } else {
601
878
  sec.local = `${T}sp${ctx.func.uniq++}`
602
879
  ctx.func.locals.set(sec.local, 'f64')
603
880
  sec.lenLocal = `${T}spl${ctx.func.uniq++}`
604
881
  ctx.func.locals.set(sec.lenLocal, 'i32')
605
- sec.vt = valTypeOf(sec.expr)
606
- // ARRAY-known source: hoist data base and inline len/load (skip per-iter dispatch).
607
- if (sec.vt === VAL.ARRAY && !multiCount(sec.expr)) {
608
- sec.baseLocal = `${T}spb${ctx.func.uniq++}`
609
- ctx.func.locals.set(sec.baseLocal, 'i32')
610
- }
882
+ // A materialized multi-value is not a statically-typed pointer — let
883
+ // emitSpreadCopy resolve its kind at runtime via its one-time __ptr_type branch.
884
+ sec.val = multiCount(sec.expr) ? undefined : valTypeOf(sec.expr)
611
885
  const n = multiCount(sec.expr)
612
886
  ir.push(['local.set', `$${sec.local}`, n ? materializeMulti(sec.expr) : asF64(emit(sec.expr))])
613
- if (sec.baseLocal) {
614
- ir.push(['local.set', `$${sec.baseLocal}`, ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['local.get', `$${sec.local}`]]]])
615
- ir.push(['local.set', `$${sec.lenLocal}`, ['i32.load', ['i32.sub', ['local.get', `$${sec.baseLocal}`], ['i32.const', 8]]]])
616
- } else {
617
- // Cache __len once per spread; reused below for total-len sum and inner copy bound.
618
- ir.push(['local.set', `$${sec.lenLocal}`, ['call', '$__len', ['i64.reinterpret_f64', ['local.get', `$${sec.local}`]]]])
619
- }
887
+ // Cache __len once per spread; reused below for total-len sum and the copy.
888
+ ir.push(['local.set', `$${sec.lenLocal}`, ['call', '$__len', ['i64.reinterpret_f64', ['local.get', `$${sec.local}`]]]])
620
889
  }
621
890
  }
622
891
 
892
+ // Pass 2 — total length (array sections statically sized, spreads cached above).
893
+ ir.push(['local.set', `$${len}`, ['i32.const', 0]])
623
894
  for (const sec of sections) {
624
895
  if (sec.type === 'array') {
625
896
  ir.push(['local.set', `$${len}`, ['i32.add', ['local.get', `$${len}`], ['i32.const', sec.items.length]]])
@@ -628,41 +899,20 @@ export function buildArrayWithSpreads(items) {
628
899
  }
629
900
  }
630
901
 
902
+ // Pass 3 — allocate exact, then store the pre-evaluated temps.
631
903
  ir.push(out.init, ['local.set', `$${pos}`, ['i32.const', 0]])
632
-
633
904
  for (const sec of sections) {
634
905
  if (sec.type === 'array') {
635
- for (let i = 0; i < sec.items.length; i++) {
906
+ for (const it of sec.itemLocals) {
636
907
  ir.push(
637
908
  ['f64.store',
638
909
  ['i32.add', ['local.get', `$${result}`], ['i32.shl', ['local.get', `$${pos}`], ['i32.const', 3]]],
639
- asF64(emit(sec.items[i]))],
910
+ ['local.get', `$${it}`]],
640
911
  ['local.set', `$${pos}`, ['i32.add', ['local.get', `$${pos}`], ['i32.const', 1]]]
641
912
  )
642
913
  }
643
914
  } else {
644
- const slen = sec.lenLocal, sidx = `${T}sidx${ctx.func.uniq++}`
645
- ctx.func.locals.set(sidx, 'i32')
646
- const loopId = ctx.func.uniq++
647
- const elemLoad = sec.baseLocal
648
- ? ['f64.load', ['i32.add', ['local.get', `$${sec.baseLocal}`], ['i32.shl', ['local.get', `$${sidx}`], ['i32.const', 3]]]]
649
- : ctx.module.modules['string']
650
- ? ['if', ['result', 'f64'],
651
- ['i32.eq', ['call', '$__ptr_type', ['i64.reinterpret_f64', ['local.get', `$${sec.local}`]]], ['i32.const', PTR.STRING]],
652
- ['then', (inc('__str_idx'), ['call', '$__str_idx', ['i64.reinterpret_f64', ['local.get', `$${sec.local}`]], ['local.get', `$${sidx}`]])],
653
- ['else', (inc('__typed_idx'), ['call', '$__typed_idx', ['i64.reinterpret_f64', ['local.get', `$${sec.local}`]], ['local.get', `$${sidx}`]])]]
654
- : (inc('__typed_idx'), ['call', '$__typed_idx', ['i64.reinterpret_f64', ['local.get', `$${sec.local}`]], ['local.get', `$${sidx}`]])
655
- ir.push(
656
- ['local.set', `$${sidx}`, ['i32.const', 0]],
657
- ['block', `$break${loopId}`, ['loop', `$loop${loopId}`,
658
- ['br_if', `$break${loopId}`, ['i32.ge_s', ['local.get', `$${sidx}`], ['local.get', `$${slen}`]]],
659
- ['f64.store',
660
- ['i32.add', ['local.get', `$${result}`], ['i32.shl', ['local.get', `$${pos}`], ['i32.const', 3]]],
661
- elemLoad],
662
- ['local.set', `$${pos}`, ['i32.add', ['local.get', `$${pos}`], ['i32.const', 1]]],
663
- ['local.set', `$${sidx}`, ['i32.add', ['local.get', `$${sidx}`], ['i32.const', 1]]],
664
- ['br', `$loop${loopId}`]]]
665
- )
915
+ ir.push(...emitSpreadCopy(result, pos, sec.local, sec.lenLocal, sec.val))
666
916
  }
667
917
  }
668
918
 
@@ -698,13 +948,17 @@ export function emitBody(node) {
698
948
  // Skip names that are reassigned later — refinement would be unsound past the assignment.
699
949
  if (Array.isArray(s) && s[0] === 'if' && s[3] == null && isTerminator(s[2])) {
700
950
  const refs = extractRefinements(s[1], new Map(), false)
701
- for (const [name, val] of refs) {
951
+ for (const [name, fact] of refs) {
702
952
  let reassigned = false
703
953
  for (let j = i + 1; j < stmts.length; j++)
704
954
  if (isReassigned(stmts[j], name)) { reassigned = true; break }
705
955
  if (reassigned) continue
706
- accumulated.push([name, ctx.func.refinements.get(name)])
707
- ctx.func.refinements.set(name, val)
956
+ const cur = ctx.func.refinements.get(name)
957
+ accumulated.push([name, cur])
958
+ // Merge so sibling early-returns layering on the same name compose
959
+ // (e.g. `if (typeof x === 'string') return; if (Array.isArray(x)) return;`
960
+ // leaves both `notString: true` and would-be array exclusion stacked).
961
+ ctx.func.refinements.set(name, cur ? { ...cur, ...fact } : fact)
708
962
  }
709
963
  }
710
964
  }
@@ -716,23 +970,86 @@ export function emitBody(node) {
716
970
  return out
717
971
  }
718
972
 
973
+ // A VAL.BOOL value rides the cheap 0/1 numeric carrier, and `ToNumber(bool)` is
974
+ // exactly that carrier — so for relational / loose-equality coercion a boolean
975
+ // behaves identically to a number. Normalize it before the type-directed compare
976
+ // dispatch (the BOOL fact still drives typeof / String / boundary boxing; only
977
+ // these arithmetic-shaped operators read it as numeric).
978
+ const numericVal = vt => vt === VAL.BOOL ? VAL.NUMBER : vt
979
+
980
+ // Primitive value-type classes for strict-equality type-mismatch folding. Two
981
+ // operands of different known classes — when at least one is a primitive — can
982
+ // never be `===` (number/boolean/string/bigint don't cross-coerce under `===`).
983
+ // Two *reference* kinds (array vs object, …) fall through to the shared ref-eq
984
+ // path instead, which already resolves distinct pointers to `false`.
985
+ const STRICT_PRIM = new Set([VAL.NUMBER, VAL.BOOL, VAL.STRING, VAL.BIGINT])
986
+
987
+ /**
988
+ * Strict `===`/`!==`. Unlike loose `==`, no coercion: a statically-known type
989
+ * mismatch folds to a constant (`true === 1` → false, `"1" === 1` → false). When
990
+ * the types match — or one side is statically unknown — the result is bit-for-bit
991
+ * identical to loose `==` on same-type operands, so we delegate to it.
992
+ *
993
+ * Two carrier-level limitations remain (documented gaps, not regressions):
994
+ * • booleans and numbers share the 0/1 carrier, so `1 === trueDynamic` can only
995
+ * be told apart when the boolean's type is statically known;
996
+ * • jz unifies `null` and `undefined` into one NaN-boxed sentinel, so
997
+ * `null === undefined` is `true` (same as `==`).
998
+ */
999
+ function emitStrictEq(a, b, negate) {
1000
+ // `typeof x === 'type'` (prepare rewrote the literal to a numeric code) — typeof
1001
+ // always yields a string, so strict and loose agree; reuse the loose lowering.
1002
+ const tc = emitTypeofCmp(a, b, negate ? 'ne' : 'eq'); if (tc) return tc
1003
+ // Known, differing primitive classes can never be strictly equal.
1004
+ const rawA = resolveValType(a, valTypeOf, lookupValType)
1005
+ const rawB = resolveValType(b, valTypeOf, lookupValType)
1006
+ if (rawA && rawB && rawA !== rawB && (STRICT_PRIM.has(rawA) || STRICT_PRIM.has(rawB)))
1007
+ return emitNum(negate ? 1 : 0)
1008
+ // Same type (or dynamic-unknown): identical bits to loose `==`/`!=`.
1009
+ return emitter[negate ? '!=' : '=='](a, b)
1010
+ }
1011
+
719
1012
  /** Comparison op factory with constant folding. */
720
1013
  const cmpOp = (i32op, f64op, fn) => (a, b) => {
721
1014
  const va = emit(a), vb = emit(b)
722
- if (isLit(va) && isLit(vb)) return emitNum(fn(litVal(va), litVal(vb)) ? 1 : 0)
1015
+ // Skip the const-fold for `.unsigned` operands: `litVal` is the signed bit pattern
1016
+ // (-1, not 4294967295), so folding the order would be wrong. Fall through to the
1017
+ // f64 widen path below, which converts each operand by its own signedness.
1018
+ if (isLit(va) && isLit(vb) && !va.unsigned && !vb.unsigned) return emitNum(fn(litVal(va), litVal(vb)) ? 1 : 0)
723
1019
  // String compare: NaN-boxed string pointers compare as NaN under f64.lt/gt
724
1020
  // (always false), so without this the spec-correct `"a" < "b"` returns 0.
725
1021
  // Route both-STRING operands through __str_cmp's three-way result, then apply
726
1022
  // the same i32 sign op as numeric (lt_s/gt_s/le_s/ge_s vs 0).
727
- const vta = resolveValType(a, valTypeOf, lookupValType)
728
- const vtb = resolveValType(b, valTypeOf, lookupValType)
1023
+ const vta = numericVal(resolveValType(a, valTypeOf, lookupValType))
1024
+ const vtb = numericVal(resolveValType(b, valTypeOf, lookupValType))
729
1025
  if (vta === VAL.BIGINT || vtb === VAL.BIGINT) {
730
1026
  const op = bigintUnsignedBound(a) || bigintUnsignedBound(b) ? i32op.replace('_s', '_u') : i32op
731
1027
  return typed([`i64.${op}`, asI64(va), asI64(vb)], 'i32')
732
1028
  }
733
1029
  if (vta === VAL.STRING && vtb === VAL.STRING) {
734
- inc('__str_cmp')
735
- return typed([`i32.${i32op}`, ['call', '$__str_cmp', asI64(va), asI64(vb)], ['i32.const', 0]], 'i32')
1030
+ return typed([`i32.${i32op}`, ctx.abi.string.ops.cmp(asF64(va), asF64(vb), ctx), ['i32.const', 0]], 'i32')
1031
+ }
1032
+ // Exactly one operand is a known string; the other has no static type, so it
1033
+ // may hold a string pointer at runtime (e.g. `c >= '0'` where `c` came from
1034
+ // `s[i]` on an untyped receiver). JS relational compare is lexicographic only
1035
+ // when *both* sides are strings, else it ToNumbers both. The f64 path below
1036
+ // would compare the unknown side's NaN-boxed string bits as a float (NaN ⇒
1037
+ // always false), so dispatch at runtime on the unknown side: string → __str_cmp
1038
+ // three-way; else ToNumber both. Mirrors `+`'s __is_str_key string dispatch.
1039
+ // Gated on a *known-string* counterpart, so numeric loops (`i < n`) never pay
1040
+ // the check — comparing against a string literal signals string intent.
1041
+ if (((vta === VAL.STRING && vtb == null) || (vtb === VAL.STRING && vta == null)) && ctx.abi.string?.ops?.cmp) {
1042
+ const unkIsA = vta == null
1043
+ const ta = temp('cmp'), tb = temp('cmp')
1044
+ inc('__is_str_key')
1045
+ const getA = typed(['local.get', `$${ta}`], 'f64'), getB = typed(['local.get', `$${tb}`], 'f64')
1046
+ const check = ['call', '$__is_str_key', ['i64.reinterpret_f64', ['local.get', `$${unkIsA ? ta : tb}`]]]
1047
+ const strCmp = [`i32.${i32op}`, ctx.abi.string.ops.cmp(getA, getB, ctx), ['i32.const', 0]]
1048
+ const numCmp = [`f64.${f64op}`, toNumF64(a, getA), toNumF64(b, getB)]
1049
+ return typed(['block', ['result', 'i32'],
1050
+ ['local.set', `$${ta}`, asF64(va)],
1051
+ ['local.set', `$${tb}`, asF64(vb)],
1052
+ ['if', ['result', 'i32'], check, ['then', strCmp], ['else', numCmp]]], 'i32')
736
1053
  }
737
1054
  if (vta === VAL.DATE || vtb === VAL.DATE) {
738
1055
  const dateNum = (node, v, vt) => {
@@ -748,11 +1065,17 @@ const cmpOp = (i32op, f64op, fn) => (a, b) => {
748
1065
  return typed([`f64.${f64op}`, toNumF64(a, va), asF64(vb)], 'i32')
749
1066
  if (vta === VAL.NUMBER && needsRelationalToNumber(b, vtb))
750
1067
  return typed([`f64.${f64op}`, asF64(va), toNumF64(b, vb)], 'i32')
751
- const ai = intConstValue(a), bi = intConstValue(b)
752
- if (va.type === 'i32' && bi != null) return typed([`i32.${i32op}`, va, ['i32.const', bi]], 'i32')
753
- if (vb.type === 'i32' && ai != null) return typed([`i32.${i32op}`, ['i32.const', ai], vb], 'i32')
754
- return va.type === 'i32' && vb.type === 'i32'
755
- ? typed([`i32.${i32op}`, va, vb], 'i32') : typed([`f64.${f64op}`, asF64(va), asF64(vb)], 'i32')
1068
+ // An `.unsigned` i32 operand ([0, 2^32)) can't share a signed i32 compare with a
1069
+ // possibly-signed one: mixed sign inverts the order (3 < 0xFFFFFFFF unsigned, but
1070
+ // 3 > -1 signed). Widen to f64, where asF64 converts each operand by its own
1071
+ // signedness (convert_i32_u for unsigned, _s otherwise) to its true numeric value.
1072
+ if (!va.unsigned && !vb.unsigned) {
1073
+ const ai = intConstValue(a), bi = intConstValue(b)
1074
+ if (va.type === 'i32' && bi != null) return typed([`i32.${i32op}`, va, ['i32.const', bi]], 'i32')
1075
+ if (vb.type === 'i32' && ai != null) return typed([`i32.${i32op}`, ['i32.const', ai], vb], 'i32')
1076
+ if (va.type === 'i32' && vb.type === 'i32') return typed([`i32.${i32op}`, va, vb], 'i32')
1077
+ }
1078
+ return typed([`f64.${f64op}`, asF64(va), asF64(vb)], 'i32')
756
1079
  }
757
1080
 
758
1081
  function needsRelationalToNumber(expr, vt) {
@@ -885,7 +1208,7 @@ export const emitter = {
885
1208
  },
886
1209
 
887
1210
  'throw': expr => {
888
- ctx.runtime.throws = true
1211
+ ctx.runtime.throws = ctx.runtime.userThrows = true
889
1212
  const thrown = temp()
890
1213
  return typed(['block',
891
1214
  ['local.set', `$${thrown}`, asF64(emit(expr))],
@@ -896,7 +1219,7 @@ export const emitter = {
896
1219
  'catch': (body, errName, handler) => {
897
1220
  if (!canThrow(body)) return emitFlat(body)
898
1221
 
899
- ctx.runtime.throws = true
1222
+ ctx.runtime.throws = ctx.runtime.userThrows = true
900
1223
  const id = ctx.func.uniq++
901
1224
  ctx.func.locals.set(errName, 'f64')
902
1225
  const prev = ctx.func.inTry; ctx.func.inTry = true
@@ -922,7 +1245,7 @@ export const emitter = {
922
1245
  return [...bodyIR, ...cleanupIR]
923
1246
  }
924
1247
 
925
- ctx.runtime.throws = true
1248
+ ctx.runtime.throws = ctx.runtime.userThrows = true
926
1249
  const id = ctx.func.uniq++
927
1250
  const errLocal = temp('err')
928
1251
  const parentStack = ctx.func.finallyStack || []
@@ -966,19 +1289,20 @@ export const emitter = {
966
1289
  const rt = ctx.func.current?.results[0] || 'f64'
967
1290
  const pk = ctx.func.current?.ptrKind
968
1291
  const ir = pk != null ? asPtrOffset(emit(expr), pk) : asParamType(emit(expr), rt)
969
- if (!ctx.func.inTry && !ctx.transform.noTailCall &&
970
- Array.isArray(ir) && ir[0] === 'call' && typeof ir[1] === 'string')
971
- return typed(['return_call', ...ir.slice(1)], 'void')
1292
+ const ty = pk != null ? 'i32' : rt
1293
+ const tcoed = tcoTailRewrite(ir, ty)
1294
+ if (Array.isArray(tcoed) && tcoed[0] === 'return_call' && finalizers.length === 0) {
1295
+ return typed(tcoed, 'void')
1296
+ }
972
1297
  if (finalizers.length > 0) {
973
- const ty = pk != null ? 'i32' : rt
974
1298
  const name = ty === 'i32' ? tempI32('ret') : ty === 'i64' ? tempI64('ret') : temp('ret')
975
1299
  return [
976
- ['local.set', `$${name}`, ir],
1300
+ ['local.set', `$${name}`, tcoed],
977
1301
  ...finalizerBlock(),
978
1302
  typed(['return', ['local.get', `$${name}`]], 'void'),
979
1303
  ]
980
1304
  }
981
- return typed(['return', ir], 'void')
1305
+ return typed(['return', tcoed], 'void')
982
1306
  },
983
1307
 
984
1308
  // === Assignment ===
@@ -990,7 +1314,15 @@ export const emitter = {
990
1314
  if (Array.isArray(name) && name[0] === '[]') {
991
1315
  const [, arr, idx] = name
992
1316
  const keyType = keyValType(idx)
993
- const useRuntimeKeyDispatch = keyType == null || (typeof idx === 'string' && keyType !== VAL.STRING)
1317
+ // A provably-numeric index name an int-certain loop counter or a
1318
+ // NUMBER-typed local — can never be a string key, so the runtime
1319
+ // `__is_str_key` → `__dyn_set` dispatch is dead. Mirrors the index *read*
1320
+ // path (`intIndexIR`), closing the read/write asymmetry on `arr[i] = …`
1321
+ // inside refined-array loops (e.g. watr's recursive AST walkers).
1322
+ const idxNumericName = typeof idx === 'string' &&
1323
+ (repOf(idx)?.intCertain === true || repOf(idx)?.val === VAL.NUMBER)
1324
+ const useRuntimeKeyDispatch = !idxNumericName &&
1325
+ (keyType == null || (typeof idx === 'string' && keyType !== VAL.STRING))
994
1326
  const keyExpr = asF64(emit(idx))
995
1327
  const valueExpr = asF64(emit(val))
996
1328
  const storeArrayValue = (arrExpr, idxNode, persist) => {
@@ -1031,15 +1363,25 @@ export const emitter = {
1031
1363
  const litKey = isLiteralStr(idx) ? idx[1]
1032
1364
  : typeof arr === 'string' && lookupValType(arr) === VAL.OBJECT ? staticPropertyKey(idx)
1033
1365
  : null
1366
+ // SRoA flat object: `o['k'] = x` → `local.set $o#i` (no heap store).
1367
+ if (litKey != null && typeof arr === 'string' && ctx.func.flatObjects?.has(arr)) {
1368
+ const fo = ctx.func.flatObjects.get(arr)
1369
+ const fi = fo.names.indexOf(litKey)
1370
+ if (fi >= 0) {
1371
+ const t = temp()
1372
+ return typed(['block', ['result', 'f64'],
1373
+ ['local.set', `$${t}`, valueExpr],
1374
+ ['local.set', `$${arr}#${fi}`, ['local.get', `$${t}`]],
1375
+ ['local.get', `$${t}`]], 'f64')
1376
+ }
1377
+ }
1034
1378
  if (litKey != null && typeof arr === 'string' && ctx.schema.find) {
1035
1379
  const slot = ctx.schema.find(arr, litKey)
1036
1380
  if (slot >= 0) {
1037
1381
  const t = temp()
1038
1382
  return typed(['block', ['result', 'f64'],
1039
1383
  ['local.set', `$${t}`, valueExpr],
1040
- ['f64.store',
1041
- ['i32.add', ptrOffsetIR(asF64(emit(arr)), lookupValType(arr) || VAL.OBJECT), ['i32.const', slot * 8]],
1042
- ['local.get', `$${t}`]],
1384
+ ctx.abi.object.ops.store(ptrOffsetIR(asF64(emit(arr)), lookupValType(arr) || VAL.OBJECT), slot, ['local.get', `$${t}`]),
1043
1385
  ['local.get', `$${t}`]], 'f64')
1044
1386
  }
1045
1387
  }
@@ -1188,6 +1530,18 @@ export const emitter = {
1188
1530
  // Object property assignment: obj.prop = x
1189
1531
  if (Array.isArray(name) && name[0] === '.') {
1190
1532
  const [, obj, prop] = name
1533
+ // SRoA flat object: `o.prop = x` → `local.set $o#i` (no heap store).
1534
+ const flatW = typeof obj === 'string' ? ctx.func.flatObjects?.get(obj) : null
1535
+ if (flatW) {
1536
+ const fi = flatW.names.indexOf(prop)
1537
+ if (fi >= 0) {
1538
+ const t = temp()
1539
+ return typed(['block', ['result', 'f64'],
1540
+ ['local.set', `$${t}`, asF64(emit(val))],
1541
+ ['local.set', `$${obj}#${fi}`, ['local.get', `$${t}`]],
1542
+ ['local.get', `$${t}`]], 'f64')
1543
+ }
1544
+ }
1191
1545
  // Schema-based object → f64.store at fixed offset.
1192
1546
  if (typeof obj === 'string' && ctx.schema.find) {
1193
1547
  const idx = ctx.schema.find(obj, prop)
@@ -1197,7 +1551,7 @@ export const emitter = {
1197
1551
  if (shadow) inc('__dyn_set')
1198
1552
  const stmts = [
1199
1553
  ['local.set', `$${t}`, vv],
1200
- ['f64.store', ['i32.add', ptrOffsetIR(asF64(va), lookupValType(obj) || VAL.OBJECT), ['i32.const', idx * 8]], ['local.get', `$${t}`]],
1554
+ ctx.abi.object.ops.store(ptrOffsetIR(asF64(va), lookupValType(obj) || VAL.OBJECT), idx, ['local.get', `$${t}`]),
1201
1555
  ]
1202
1556
  if (shadow)
1203
1557
  stmts.push(['drop', ['call', '$__dyn_set', asI64(va), asI64(emit(['str', prop])), ['i64.reinterpret_f64', ['local.get', `$${t}`]]]])
@@ -1350,16 +1704,21 @@ export const emitter = {
1350
1704
  inc('__str_append_byte', '__char_at')
1351
1705
  return typed(['call', '$__str_append_byte',
1352
1706
  asI64(emit(a)),
1353
- ['call', '$__char_at', asI64(emit(b[1])), asI32(emit(b[2]))],
1707
+ ctx.abi.string.ops.charCodeAt(asF64(emit(b[1])), asI32(emit(b[2])), ctx),
1354
1708
  ], 'f64')
1355
1709
  }
1356
1710
  }
1357
- inc('__str_concat_raw')
1358
- return typed(['call', '$__str_concat_raw', asI64(emit(a)), asI64(emit(b))], 'f64')
1711
+ return typed(ctx.abi.string.ops.concatRaw(asF64(emit(a)), asF64(emit(b)), ctx), 'f64')
1359
1712
  }
1360
1713
  if (vtA === VAL.STRING || vtB === VAL.STRING) {
1361
- inc('__str_concat')
1362
- return typed(['call', '$__str_concat', asI64(emit(a)), asI64(emit(b))], 'f64')
1714
+ // An OBJECT operand coerces via ToPrimitive(string) at compile time —
1715
+ // __str_concat's runtime __to_str cannot invoke a user-defined toString.
1716
+ // A BOOL operand renders "true"/"false" rather than its 0/1 carrier.
1717
+ const strOperand = (vt, n) => vt === VAL.OBJECT ? typed(['f64.reinterpret_i64', toStrI64(n, emit(n))], 'f64')
1718
+ : vt === VAL.BOOL ? emitBoolStr(n) : asF64(emit(n))
1719
+ const ea = strOperand(vtA, a)
1720
+ const eb = strOperand(vtB, b)
1721
+ return typed(ctx.abi.string.ops.concat(ea, eb, ctx), 'f64')
1363
1722
  }
1364
1723
  if (vtA === VAL.BIGINT || vtB === VAL.BIGINT)
1365
1724
  return fromI64(['i64.add', asI64(emit(a)), asI64(emit(b))])
@@ -1386,10 +1745,15 @@ export const emitter = {
1386
1745
  }
1387
1746
  const va = emit(a), vb = emit(b), _f = foldConst(va, vb, (a, b) => a + b)
1388
1747
  if (_f) return _f
1389
- if (isLit(vb) && litVal(vb) === 0) return va
1390
- if (isLit(va) && litVal(va) === 0) return vb
1391
- if (va.type === 'i32' && vb.type === 'i32') return typed(['i32.add', va, vb], 'i32')
1392
- return typed(['f64.add', asF64(va), asF64(vb)], 'f64')
1748
+ // Neither side is a string here (string paths handled above), but either may
1749
+ // still be null/undefined/pointer numeric `+` performs ToNumber like `-`/`*`.
1750
+ if (isLit(vb) && litVal(vb) === 0) return toNumF64(a, va)
1751
+ if (isLit(va) && litVal(va) === 0) return toNumF64(b, vb)
1752
+ // An `.unsigned` operand is a uint32 (range [0, 2^32)); JS `+` is a float
1753
+ // op whose result can exceed i32, so `i32.add` would wrap (4294967295+1→0).
1754
+ // Widen to f64 — never wrap — matching spec. Only `>>>0`/`|0`/imul wrap.
1755
+ if (isI32Num(va) && isI32Num(vb) && !widensUnsigned(va) && !widensUnsigned(vb)) return typed(['i32.add', va, vb], 'i32')
1756
+ return typed(['f64.add', toNumF64(a, va), toNumF64(b, vb)], 'f64')
1393
1757
  },
1394
1758
  '-': (a, b) => {
1395
1759
  if (_expect === 'void' && isPostfix(a, '++', b)) return emit(a, 'void')
@@ -1401,7 +1765,9 @@ export const emitter = {
1401
1765
  const va = emit(a), vb = emit(b), _f = foldConst(va, vb, (a, b) => a - b)
1402
1766
  if (_f) return _f
1403
1767
  if (isLit(vb) && litVal(vb) === 0) return toNumF64(a, va)
1404
- if (va.type === 'i32' && vb.type === 'i32') return typed(['i32.sub', va, vb], 'i32')
1768
+ // Unsigned uint32 operand: JS `-` is float (can go negative / exceed i32),
1769
+ // so avoid the wrapping i32.sub fast-path. See `+` above.
1770
+ if (isI32Num(va) && isI32Num(vb) && !widensUnsigned(va) && !widensUnsigned(vb)) return typed(['i32.sub', va, vb], 'i32')
1405
1771
  return typed(['f64.sub', toNumF64(a, va), toNumF64(b, vb)], 'f64')
1406
1772
  },
1407
1773
  'u+': a => {
@@ -1423,7 +1789,9 @@ export const emitter = {
1423
1789
  if (isLit(va) && litVal(va) === 1) return toNumF64(b, vb)
1424
1790
  if (isLit(vb) && litVal(vb) === 0) return isLit(va) ? vb : typed(['block', ['result', vb.type], va, 'drop', vb], vb.type)
1425
1791
  if (isLit(va) && litVal(va) === 0) return isLit(vb) ? va : typed(['block', ['result', va.type], vb, 'drop', va], va.type)
1426
- if (va.type === 'i32' && vb.type === 'i32') return typed(['i32.mul', va, vb], 'i32')
1792
+ // `.unsigned` operand is a uint32 ([0, 2^32)); its product can exceed i32, so
1793
+ // `i32.mul` would wrap ((2^32-1)*2 → -2). Widen to f64 — see `+` above.
1794
+ if (isI32Num(va) && isI32Num(vb) && !widensUnsigned(va) && !widensUnsigned(vb) && mulFitsI32(va, vb)) return typed(['i32.mul', va, vb], 'i32')
1427
1795
  return typed(['f64.mul', toNumF64(a, va), toNumF64(b, vb)], 'f64')
1428
1796
  },
1429
1797
  '/': (a, b) => {
@@ -1439,7 +1807,12 @@ export const emitter = {
1439
1807
  return fromI64(['i64.rem_s', asI64(emit(a)), asI64(emit(b))])
1440
1808
  const va = emit(a), vb = emit(b), _f = foldConst(va, vb, (a, b) => a % b, b => b !== 0)
1441
1809
  if (_f) return _f
1442
- if (va.type === 'i32' && vb.type === 'i32') return typed(['i32.rem_s', va, vb], 'i32')
1810
+ // ES remainder by zero is NaN; only the f64 path yields that (a - trunc(a/0)*0).
1811
+ // The i32.rem_s fast path traps on a zero divisor, so divert a literal-zero divisor.
1812
+ if (isLit(vb) && litVal(vb) === 0) return emitNum(NaN)
1813
+ // `.unsigned` operand: `i32.rem_s` reads the uint32 as a negative signed value
1814
+ // ((2^32-1)%7 → rem_s(-1,7) = -1, not 3). Widen to f64 — see `+` above.
1815
+ if (isI32Num(va) && isI32Num(vb) && !va.unsigned && !vb.unsigned) return typed(['i32.rem_s', va, vb], 'i32')
1443
1816
  return f64rem(toNumF64(a, va), toNumF64(b, vb))
1444
1817
  },
1445
1818
  // === Comparisons (always i32 result) ===
@@ -1447,6 +1820,8 @@ export const emitter = {
1447
1820
  '==': (a, b) => {
1448
1821
  const charCmp = emitSingleCharIndexCmp(a, b)
1449
1822
  if (charCmp) return charCmp
1823
+ const subCmp = emitSubstringEqCmp(a, b)
1824
+ if (subCmp) return subCmp
1450
1825
  // JS loose nullish equality: x == null / x == undefined.
1451
1826
  // If the non-literal side has a known non-null VAL type, fold to 0.
1452
1827
  if (isNullishLit(a)) {
@@ -1465,8 +1840,8 @@ export const emitter = {
1465
1840
  // of the other side: jz's `==` is strict (prepare.js:868), and every NaN-boxed pointer
1466
1841
  // reinterprets to a quiet NaN (0x7FF8… prefix) so f64.eq with any normal float is false.
1467
1842
  // Catches `closureVar === 34` in jzified hot loops where the unknown side has no VAL.
1468
- const vta = resolveValType(a, valTypeOf, lookupValType)
1469
- const vtb = resolveValType(b, valTypeOf, lookupValType)
1843
+ const vta = numericVal(resolveValType(a, valTypeOf, lookupValType))
1844
+ const vtb = numericVal(resolveValType(b, valTypeOf, lookupValType))
1470
1845
  if (vta === VAL.NUMBER && needsLooseEqualityToNumber(b, vtb)) return looseNumberEq(va, b, vb)
1471
1846
  if (vtb === VAL.NUMBER && needsLooseEqualityToNumber(a, vta)) return looseNumberEq(vb, a, va)
1472
1847
  if (vta === VAL.NUMBER || vtb === VAL.NUMBER) return typed(['f64.eq', asF64(va), asF64(vb)], 'i32')
@@ -1483,6 +1858,8 @@ export const emitter = {
1483
1858
  '!=': (a, b) => {
1484
1859
  const charCmp = emitSingleCharIndexCmp(a, b, true)
1485
1860
  if (charCmp) return charCmp
1861
+ const subCmp = emitSubstringEqCmp(a, b, true)
1862
+ if (subCmp) return subCmp
1486
1863
  if (isNullishLit(a)) {
1487
1864
  if (valTypeOf(b)) return emitNum(1)
1488
1865
  return typed(['i32.eqz', isNullish(asF64(emit(b)))], 'i32')
@@ -1494,8 +1871,8 @@ export const emitter = {
1494
1871
  const tc = emitTypeofCmp(a, b, 'ne'); if (tc) return tc
1495
1872
  const va = emit(a), vb = emit(b)
1496
1873
  if (va.type === 'i32' && vb.type === 'i32') return typed(['i32.ne', va, vb], 'i32')
1497
- const vta = resolveValType(a, valTypeOf, lookupValType)
1498
- const vtb = resolveValType(b, valTypeOf, lookupValType)
1874
+ const vta = numericVal(resolveValType(a, valTypeOf, lookupValType))
1875
+ const vtb = numericVal(resolveValType(b, valTypeOf, lookupValType))
1499
1876
  if (vta === VAL.NUMBER && needsLooseEqualityToNumber(b, vtb)) return looseNumberEq(va, b, vb, true)
1500
1877
  if (vtb === VAL.NUMBER && needsLooseEqualityToNumber(a, vta)) return looseNumberEq(vb, a, va, true)
1501
1878
  if (vta === VAL.NUMBER || vtb === VAL.NUMBER) return typed(['f64.ne', asF64(va), asF64(vb)], 'i32')
@@ -1505,6 +1882,8 @@ export const emitter = {
1505
1882
  inc('__eq')
1506
1883
  return typed(['i32.eqz', ['call', '$__eq', asI64(va), asI64(vb)]], 'i32')
1507
1884
  },
1885
+ '===': (a, b) => emitStrictEq(a, b, false),
1886
+ '!==': (a, b) => emitStrictEq(a, b, true),
1508
1887
  '<': cmpOp('lt_s', 'lt', (a, b) => a < b),
1509
1888
  '>': cmpOp('gt_s', 'gt', (a, b) => a > b),
1510
1889
  '<=': cmpOp('le_s', 'le', (a, b) => a <= b),
@@ -1519,7 +1898,9 @@ export const emitter = {
1519
1898
  if (v.ptrKind != null) return typed(['i32.eqz', v], 'i32')
1520
1899
  // Known pointer-kinded operand: `!x` is just `x is nullish` (null/undefined).
1521
1900
  // Excludes STRING — empty string '' is a valid (non-null) pointer but is falsy.
1522
- const vt = resolveValType(a, valTypeOf, lookupValType)
1901
+ // VAL.BOOL rides the 0/1 numeric carrier (not a pointer), so normalize it to
1902
+ // NUMBER and let it fall to the truthy path — `!false` must be `true`.
1903
+ const vt = numericVal(resolveValType(a, valTypeOf, lookupValType))
1523
1904
  if (vt && vt !== VAL.NUMBER && vt !== VAL.BIGINT && vt !== VAL.STRING) {
1524
1905
  return isNullish(asF64(v))
1525
1906
  }
@@ -1586,11 +1967,23 @@ export const emitter = {
1586
1967
 
1587
1968
  '&&': (a, b) => {
1588
1969
  const va = emit(a)
1589
- if (isLit(va)) { const v = litVal(va); return (v !== 0 && v === v) ? emit(b) : va }
1970
+ // Constant-folded literal: pre-bind under truthy refinements (b runs only when a was truthy).
1971
+ if (isLit(va)) {
1972
+ const v = litVal(va)
1973
+ if (v !== 0 && v === v) {
1974
+ const refs = extractRefinements(a, new Map(), true)
1975
+ return withRefinements(refs, b, () => emit(b))
1976
+ }
1977
+ return va
1978
+ }
1979
+ // a is truthy in the right-arm — narrow b accordingly. Matches `?:`'s then-arm threading
1980
+ // (`Array.isArray(x) && x[0]` → x[0] sees x as ARRAY, eliding union-rep fallbacks).
1981
+ const rightRefs = extractRefinements(a, new Map(), true)
1982
+ const emitRight = () => withRefinements(rightRefs, b, () => emit(b))
1590
1983
  // i32 fast path: use i32 tee as cond directly (nonzero=truthy in wasm `if`),
1591
1984
  // skip f64 round-trip and __is_truthy call entirely.
1592
1985
  if (va.type === 'i32') {
1593
- const vb = emit(b)
1986
+ const vb = emitRight()
1594
1987
  const t = tempI32()
1595
1988
  if (vb.type === 'i32') {
1596
1989
  return typed(['if', ['result', 'i32'],
@@ -1607,15 +2000,25 @@ export const emitter = {
1607
2000
  const teed = typed(['local.tee', `$${t}`, asF64(va)], 'f64')
1608
2001
  return typed(['if', ['result', 'f64'],
1609
2002
  toBoolFromEmitted(teed),
1610
- ['then', asF64(emit(b))],
2003
+ ['then', asF64(emitRight())],
1611
2004
  ['else', ['local.get', `$${t}`]]], 'f64')
1612
2005
  },
1613
2006
 
1614
2007
  '||': (a, b) => {
1615
2008
  const va = emit(a)
1616
- if (isLit(va)) { const v = litVal(va); return (v !== 0 && v === v) ? va : emit(b) }
2009
+ // Constant-folded literal: pre-bind under falsy refinements (b runs only when a was falsy).
2010
+ if (isLit(va)) {
2011
+ const v = litVal(va)
2012
+ if (v !== 0 && v === v) return va
2013
+ const refs = extractRefinements(a, new Map(), false)
2014
+ return withRefinements(refs, b, () => emit(b))
2015
+ }
2016
+ // a is falsy in the right-arm — `x == null || ...` proves x is null/undefined in b;
2017
+ // De Morgan'd via the sense=false branch of extractRefinements (mirrors the ?: else-arm).
2018
+ const rightRefs = extractRefinements(a, new Map(), false)
2019
+ const emitRight = () => withRefinements(rightRefs, b, () => emit(b))
1617
2020
  if (va.type === 'i32') {
1618
- const vb = emit(b)
2021
+ const vb = emitRight()
1619
2022
  const t = tempI32()
1620
2023
  if (vb.type === 'i32') {
1621
2024
  return typed(['if', ['result', 'i32'],
@@ -1633,7 +2036,7 @@ export const emitter = {
1633
2036
  return typed(['if', ['result', 'f64'],
1634
2037
  toBoolFromEmitted(teed),
1635
2038
  ['then', ['local.get', `$${t}`]],
1636
- ['else', asF64(emit(b))]], 'f64')
2039
+ ['else', asF64(emitRight())]], 'f64')
1637
2040
  },
1638
2041
 
1639
2042
  // a ?? b: returns b only if a is nullish
@@ -1669,7 +2072,16 @@ export const emitter = {
1669
2072
  // i32 / lit values are already numeric — the toNumF64 wrap is skipped to keep
1670
2073
  // the numeric fast path at one wasm instruction. Non-numeric (NaN-boxed string,
1671
2074
  // unknown type) routes through __to_num so "2026" | 0 === 2026.
1672
- '~': a => { const v = emit(a); return isLit(v) ? emitNum(~litVal(v)) : typed(['i32.xor', toI32(v.type === 'i32' ? v : toNumF64(a, v)), typed(['i32.const', -1], 'i32')], 'i32') },
2075
+ // `~~x` is the idiomatic int32 truncation: the two xor-with-(-1) cancel, leaving
2076
+ // a single toI32 (whose NaN/Infinity guard runs once, unchanged). Fold it here so
2077
+ // DSP/bytebeat `~~` doesn't emit a dead double-xor watr won't remove.
2078
+ '~': a => {
2079
+ if (Array.isArray(a) && a[0] === '~') {
2080
+ const inner = a[1], iv = emit(inner)
2081
+ return isLit(iv) ? emitNum(~~litVal(iv)) : typed(toI32(isI32Num(iv) ? iv : toNumF64(inner, iv)), 'i32')
2082
+ }
2083
+ const v = emit(a); return isLit(v) ? emitNum(~litVal(v)) : typed(['i32.xor', toI32(isI32Num(v) ? v : toNumF64(a, v)), typed(['i32.const', -1], 'i32')], 'i32')
2084
+ },
1673
2085
  ...Object.fromEntries([
1674
2086
  ['&', 'and'], ['|', 'or'], ['^', 'xor'], ['<<', 'shl'], ['>>', 'shr_s'],
1675
2087
  ].map(([op, fn]) => [op, (a, b) => {
@@ -1682,19 +2094,28 @@ export const emitter = {
1682
2094
  if (op === '^') return emitNum(la ^ lb); if (op === '<<') return emitNum(la << lb)
1683
2095
  if (op === '>>') return emitNum(la >> lb)
1684
2096
  }
1685
- const ca = va.type === 'i32' || isLit(va) ? va : toNumF64(a, va)
1686
- const cb = vb.type === 'i32' || isLit(vb) ? vb : toNumF64(b, vb)
2097
+ const ca = isI32Num(va) || isLit(va) ? va : toNumF64(a, va)
2098
+ const cb = isI32Num(vb) || isLit(vb) ? vb : toNumF64(b, vb)
1687
2099
  return typed([`i32.${fn}`, toI32(ca), toI32(cb)], 'i32')
1688
2100
  }])),
1689
2101
  '>>>': (a, b) => {
1690
- const va = emit(a), vb = emit(b), _f = foldConst(va, vb, (a, b) => a >>> b)
1691
- if (_f) return _f
2102
+ const va = emit(a), vb = emit(b)
2103
+ if (isLit(va) && isLit(vb)) {
2104
+ const r = litVal(va) >>> litVal(vb) // JS uint32 result ∈ [0, 2^32)
2105
+ // ≥ 2^31 doesn't fit signed i32: materialize the wrapped bits as an i32 const
2106
+ // tagged `.unsigned` so `asF64` lifts via `convert_i32_u`. Emitting `f64.const r`
2107
+ // here (the old foldConst path) would `trunc_sat_f64_s`-saturate to INT32_MAX
2108
+ // when the enclosing function narrows to an i32 result. Values < 2^31 fold to a
2109
+ // plain i32 const (signed == unsigned, stays foldable downstream).
2110
+ if (r >= 0x80000000) { const node = typed(['i32.const', r | 0], 'i32'); node.unsigned = true; return node }
2111
+ return emitNum(r)
2112
+ }
1692
2113
  // F: Mark unsigned so `asF64` lifts via `f64.convert_i32_u` (preserving the
1693
2114
  // [0, 2^32) value range). Without this, `(s >>> 0) / 4294967296` would convert
1694
2115
  // signed for negative-high-bit s values, flipping sign and breaking the
1695
2116
  // canonical "uint32 → f64" idiom used in PRNGs and bit-manipulation code.
1696
- const ca = va.type === 'i32' || isLit(va) ? va : toNumF64(a, va)
1697
- const cb = vb.type === 'i32' || isLit(vb) ? vb : toNumF64(b, vb)
2117
+ const ca = isI32Num(va) || isLit(va) ? va : toNumF64(a, va)
2118
+ const cb = isI32Num(vb) || isLit(vb) ? vb : toNumF64(b, vb)
1698
2119
  const node = typed(['i32.shr_u', toI32(ca), toI32(cb)], 'i32')
1699
2120
  node.unsigned = true
1700
2121
  return node
@@ -1773,8 +2194,24 @@ export const emitter = {
1773
2194
  },
1774
2195
 
1775
2196
  'while': (cond, body) => emitter['for'](null, cond, null, body),
1776
- 'break': () => [...emitFinalizers(), ['br', loopTop().brk]],
1777
- 'continue': () => [...emitFinalizers(), ['br', loopTop().loop]],
2197
+ 'label': (name, body) => {
2198
+ const brk = `$label${ctx.func.uniq++}`
2199
+ ctx.func.stack.push({ label: name, brk })
2200
+ const result = ['block', brk, ...emitFlat(body)]
2201
+ ctx.func.stack.pop()
2202
+ return result
2203
+ },
2204
+ 'break': (label) => {
2205
+ const target = label == null
2206
+ ? loopTop().brk
2207
+ : ctx.func.stack.findLast(frame => frame.label === label)?.brk
2208
+ if (!target) err(`break label '${label}' is not in scope`)
2209
+ return [...emitFinalizers(), ['br', target]]
2210
+ },
2211
+ 'continue': (label) => {
2212
+ if (label != null) err(`continue label '${label}' is not supported`)
2213
+ return [...emitFinalizers(), ['br', loopTop().loop]]
2214
+ },
1778
2215
 
1779
2216
  // === Call ===
1780
2217
 
@@ -1823,8 +2260,6 @@ export const emitter = {
1823
2260
  let argList = Array.isArray(callArgs)
1824
2261
  ? (callArgs[0] === ',' ? callArgs.slice(1) : [callArgs])
1825
2262
  : callArgs ? [callArgs] : []
1826
- // Trailing comma in call: parser emits a trailing null sentinel — drop it
1827
- while (argList.length && argList[argList.length - 1] == null) argList.pop()
1828
2263
 
1829
2264
  // Helper: expand spread arguments into flat list of normal arguments + spread markers
1830
2265
  // Returns { normal: [...], spreads: [(pos, expr), ...] }
@@ -1844,6 +2279,13 @@ export const emitter = {
1844
2279
 
1845
2280
  const parsed = parseArgs(argList)
1846
2281
 
2282
+ // Closure devirtualization: a callee that is a module global proven (by
2283
+ // plan.js) to hold one statically-known function for the whole post-init
2284
+ // program rewrites to that function — the known-top-level-function branch
2285
+ // below then emits a direct `call`, dropping the indirect/trampoline path.
2286
+ if (typeof callee === 'string' && ctx.func.globalDevirt?.has(callee))
2287
+ callee = ctx.func.globalDevirt.get(callee)
2288
+
1847
2289
  // Optional method call: obj?.method(args) — null if obj is nullish, else
1848
2290
  // obj.method(args). The parser shapes this as ['()', ['?.', obj, method], args],
1849
2291
  // distinct from the regular method call's ['.', obj, method] callee. Receiver
@@ -1868,8 +2310,27 @@ export const emitter = {
1868
2310
  if (Array.isArray(callee) && callee[0] === '.') {
1869
2311
  const [, obj, method] = callee
1870
2312
 
1871
- // Function property call: fn.prop(args) direct call to fn$prop
1872
- if (typeof obj === 'string' && ctx.func.names.has(obj)) {
2313
+ // charCodeAt with a statically in-bounds index emit the i32
2314
+ // (OOB-impossible) contract directly; the generic path keeps the
2315
+ // f64/NaN JS-spec result. See analyze.js inBoundsCharCodeAt.
2316
+ if (method === 'charCodeAt' && !parsed.hasSpread && parsed.normal.length === 1
2317
+ && ctx.abi.string?.ops?.charCodeAt && inBoundsCharCodeAt(ctx).has(callee)) {
2318
+ const recv = emit(obj)
2319
+ // jsstring carrier: receiver is an externref boundary param. Route to
2320
+ // `wasm:js-string.charCodeAt` directly — the in-bounds proof rules out
2321
+ // the OOB trap the builtin would otherwise raise.
2322
+ if (recv?.type === 'externref') {
2323
+ ctx.core.jsstring.add('charCodeAt')
2324
+ return typed(['call', '$__jss_charCodeAt', recv, asI32(emit(parsed.normal[0]))], 'i32')
2325
+ }
2326
+ return typed(ctx.abi.string.ops.charCodeAt(
2327
+ asF64(recv), asI32(emit(parsed.normal[0])), ctx, false), 'i32')
2328
+ }
2329
+
2330
+ // Function property call: fn.prop(args) → direct call to fn$prop.
2331
+ // Skipped when the property was reassigned (wrapper composition) — then
2332
+ // it is a mutable slot and must be read dynamically before the call.
2333
+ if (typeof obj === 'string' && ctx.func.names.has(obj) && !ctx.func.multiProp.has(`${obj}.${method}`)) {
1873
2334
  const fname = `${obj}$${method}`
1874
2335
  if (ctx.func.names.has(fname)) {
1875
2336
  const func = ctx.func.map.get(fname)
@@ -1885,9 +2346,19 @@ export const emitter = {
1885
2346
  }
1886
2347
 
1887
2348
  let vt = keyValType(obj)
2349
+ // A reassigned slice/concat receiver may carry a stale `vt` — a reassignment
2350
+ // inside a nested closure escapes analyzeValTypes' poisoning (its walk stops
2351
+ // at `=>`). Drop to runtime dispatch, but only for guessy types: STRING/ARRAY
2352
+ // dispatch correctly either way, and BUFFER/TYPED are construction proofs
2353
+ // (`new ArrayBuffer`/`new XxxArray`) — the runtime String/Array fallback has
2354
+ // no branch for them, so nulling `vt` would miscompile `ab.slice()` into an
2355
+ // f64-array copy. jzify also splits every `var x = init` into `let x; x = init`,
2356
+ // marking single-assignment vars "reassigned"; keeping definite BUFFER/TYPED
2357
+ // is what keeps `var`-declared buffers correct.
1888
2358
  if (typeof obj === 'string' && isReassigned(ctx.func.body, obj)
1889
2359
  && (method === 'slice' || method === 'concat')
1890
- && vt !== VAL.STRING && vt !== VAL.ARRAY) vt = null
2360
+ && vt !== VAL.STRING && vt !== VAL.ARRAY
2361
+ && vt !== VAL.BUFFER && vt !== VAL.TYPED) vt = null
1891
2362
 
1892
2363
  // Helper to call method with arguments (handles spread expansion)
1893
2364
  const callMethod = (objArg, methodEmitter) => {
@@ -1915,22 +2386,14 @@ export const emitter = {
1915
2386
  ctx.func.locals.set(si, 'i32'); ctx.func.locals.set(base, 'i32')
1916
2387
 
1917
2388
  const objIsArr = lookupValType(objArg) === VAL.ARRAY
1918
- // Spread source: if statically known ARRAY, inline len/load via hoisted srcBase
1919
- // (skip per-iteration __arr_idx call + dispatch).
1920
- const srcVT = valTypeOf(spreadExpr)
1921
- const srcIsArr = !multiCount(spreadExpr) && srcVT === VAL.ARRAY
1922
- const srcBase = srcIsArr ? `${T}psb${ctx.func.uniq++}` : null
1923
- if (srcIsArr) ctx.func.locals.set(srcBase, 'i32')
2389
+ // A materialized multi-value is not a statically-typed pointer let
2390
+ // emitSpreadCopy resolve its kind once at runtime.
2391
+ const srcVT = multiCount(spreadExpr) ? undefined : valTypeOf(spreadExpr)
1924
2392
  const n = multiCount(spreadExpr)
1925
2393
  const ir = []
1926
2394
  ir.push(['local.set', `$${o}`, asF64(emit(objArg))])
1927
2395
  ir.push(['local.set', `$${sa}`, n ? materializeMulti(spreadExpr) : asF64(emit(spreadExpr))])
1928
- if (srcIsArr) {
1929
- ir.push(['local.set', `$${srcBase}`, ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['local.get', `$${sa}`]]]])
1930
- ir.push(['local.set', `$${sl}`, ['i32.load', ['i32.sub', ['local.get', `$${srcBase}`], ['i32.const', 8]]]])
1931
- } else {
1932
- ir.push(['local.set', `$${sl}`, ['call', '$__len', ['i64.reinterpret_f64', ['local.get', `$${sa}`]]]])
1933
- }
2396
+ ir.push(['local.set', `$${sl}`, ['call', '$__len', ['i64.reinterpret_f64', ['local.get', `$${sa}`]]]])
1934
2397
  // Old length: inline as `i32.load (off-8)` if obj is known ARRAY (matches .push handler).
1935
2398
  if (objIsArr) {
1936
2399
  ir.push(['local.set', `$${ol}`,
@@ -1943,20 +2406,9 @@ export const emitter = {
1943
2406
  ['i32.add', ['local.get', `$${ol}`], ['local.get', `$${sl}`]]]])
1944
2407
  // base captured AFTER grow (grow may relocate the array).
1945
2408
  ir.push(['local.set', `$${base}`, ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['local.get', `$${o}`]]]])
1946
- // Tight store loop.
1947
- ir.push(['local.set', `$${si}`, ['i32.const', 0]])
1948
- const loopId = ctx.func.uniq++
1949
- const srcLoad = srcIsArr
1950
- ? ['f64.load', ['i32.add', ['local.get', `$${srcBase}`], ['i32.shl', ['local.get', `$${si}`], ['i32.const', 3]]]]
1951
- : asF64(emit(['[]', sa, si]))
1952
- ir.push(['block', `$break${loopId}`, ['loop', `$continue${loopId}`,
1953
- ['br_if', `$break${loopId}`, ['i32.ge_u', ['local.get', `$${si}`], ['local.get', `$${sl}`]]],
1954
- ['f64.store',
1955
- ['i32.add', ['local.get', `$${base}`],
1956
- ['i32.shl', ['i32.add', ['local.get', `$${ol}`], ['local.get', `$${si}`]], ['i32.const', 3]]],
1957
- srcLoad],
1958
- ['local.set', `$${si}`, ['i32.add', ['local.get', `$${si}`], ['i32.const', 1]]],
1959
- ['br', `$continue${loopId}`]]])
2409
+ // Bulk-copy the spread: an ARRAY source is a contiguous f64 block → memory.copy.
2410
+ ir.push(['local.set', `$${si}`, ['local.get', `$${ol}`]])
2411
+ ir.push(...emitSpreadCopy(base, si, sa, sl, srcVT))
1960
2412
  // Single set_len for the full spread.
1961
2413
  ir.push(['call', '$__set_len', ['i64.reinterpret_f64', ['local.get', `$${o}`]],
1962
2414
  ['i32.add', ['local.get', `$${ol}`], ['local.get', `$${sl}`]]])
@@ -1978,6 +2430,9 @@ export const emitter = {
1978
2430
  const acc = `${T}acc${ctx.func.uniq++}`, arr = `${T}sp${ctx.func.uniq++}`, len = `${T}splen${ctx.func.uniq++}`, idx = `${T}spidx${ctx.func.uniq++}`
1979
2431
  ctx.func.locals.set(acc, 'f64'); ctx.func.locals.set(arr, 'f64')
1980
2432
  ctx.func.locals.set(len, 'i32'); ctx.func.locals.set(idx, 'i32')
2433
+ // Emit-time rep seeding for a fresh spread-staging local (no prior reader).
2434
+ // Without this, the loop body's `[]` read on `arr` falls back to polymorphic
2435
+ // dispatch — VAL.* on the rep elides STRING gate for ARRAY/TYPED spreads.
1981
2436
  const spreadVT = valTypeOf(spreadExpr)
1982
2437
  if (spreadVT) updateRep(arr, { val: spreadVT })
1983
2438
 
@@ -2042,6 +2497,7 @@ export const emitter = {
2042
2497
  const spreadExpr = item[1]
2043
2498
  const arrL = `${T}sp${ctx.func.uniq++}`, lenL = `${T}splen${ctx.func.uniq++}`, idxL = `${T}spidx${ctx.func.uniq++}`
2044
2499
  ctx.func.locals.set(arrL, 'f64'); ctx.func.locals.set(lenL, 'i32'); ctx.func.locals.set(idxL, 'i32')
2500
+ // Emit-time rep seeding for fresh spread-staging local (see arr-spread comment above).
2045
2501
  const spreadVT = valTypeOf(spreadExpr)
2046
2502
  if (spreadVT) updateRep(arrL, { val: spreadVT })
2047
2503
  const n = multiCount(spreadExpr)
@@ -2117,11 +2573,11 @@ export const emitter = {
2117
2573
  // Boxed handle is OBJECT-kind, never ARRAY — skip forwarding.
2118
2574
  const loadInner = [
2119
2575
  ['local.set', `$${boxBase}`, ptrOffsetIR(asF64(emit(obj)), lookupValType(obj) || VAL.OBJECT)],
2120
- ['local.set', `$${innerName}`, ['f64.load', ['local.get', `$${boxBase}`]]]]
2576
+ ['local.set', `$${innerName}`, ctx.abi.object.ops.load(['local.get', `$${boxBase}`], 0)]]
2121
2577
  const result = callMethod(innerName, emitter)
2122
2578
  // Mutating methods may reallocate; writeback inner value to boxed slot
2123
2579
  if (BOXED_MUTATORS.has(method)) {
2124
- const wb = ['f64.store', ['local.get', `$${boxBase}`], ['local.get', `$${innerName}`]]
2580
+ const wb = ctx.abi.object.ops.store(['local.get', `$${boxBase}`], 0, ['local.get', `$${innerName}`])
2125
2581
  return typed(['block', ['result', 'f64'], ...loadInner, asF64(result), wb], 'f64')
2126
2582
  }
2127
2583
  // Non-mutating: just load inner and call
@@ -2129,6 +2585,35 @@ export const emitter = {
2129
2585
  }
2130
2586
  }
2131
2587
 
2588
+ // valueOf/toString are ToPrimitive hooks (ES2024 7.1.1) that an own data
2589
+ // property shadows. An assigned `obj.valueOf`/`obj.toString` must win over
2590
+ // the builtin emitter for any receiver that can carry a dynamic-prop
2591
+ // sidecar — a sidecar-bearing static type (array/typed/object) OR a
2592
+ // statically-unknown receiver (e.g. an array-element read `arr[0]`, whose
2593
+ // type is only known at runtime). Probe the sidecar and call it when it
2594
+ // holds a closure, else fall back to the builtin (generic when untyped:
2595
+ // `.valueOf` returns the receiver, `.toString` runs type-aware __to_str).
2596
+ // Parallels the member-READ check in module/core.js emitPropAccess (which
2597
+ // stays scoped to known sidecar types). (watr's `str()` attaches
2598
+ // `bytes.valueOf = () => s`, recovered via `.valueOf()`.)
2599
+ if ((method === 'valueOf' || method === 'toString') && ctx.closure.call
2600
+ && !parsed.hasSpread && parsed.normal.length === 0
2601
+ && (vt === VAL.ARRAY || vt === VAL.TYPED || vt === VAL.OBJECT || !vt)) {
2602
+ const builtin = (vt && ctx.core.emit[`.${vt}:${method}`]) || ctx.core.emit[`.${method}`]
2603
+ if (builtin) {
2604
+ const objTmp = temp('vobj'), propTmp = temp('vprop')
2605
+ inc('__dyn_get_expr', '__ptr_type')
2606
+ return typed(['block', ['result', 'f64'],
2607
+ ['local.set', `$${objTmp}`, asF64(emit(obj))],
2608
+ ['local.set', `$${propTmp}`, ['f64.reinterpret_i64',
2609
+ ['call', '$__dyn_get_expr', ['i64.reinterpret_f64', ['local.get', `$${objTmp}`]], asI64(emit(['str', method]))]]],
2610
+ ['if', ['result', 'f64'],
2611
+ ['i32.eq', ['call', '$__ptr_type', ['i64.reinterpret_f64', ['local.get', `$${propTmp}`]]], ['i32.const', PTR.CLOSURE]],
2612
+ ['then', ctx.closure.call(typed(['local.get', `$${propTmp}`], 'f64'), [])],
2613
+ ['else', asF64(callMethod(objTmp, builtin))]]], 'f64')
2614
+ }
2615
+ }
2616
+
2132
2617
  // Known type → static dispatch
2133
2618
  if (vt && ctx.core.emit[`.${vt}:${method}`]) {
2134
2619
  return callMethod(obj, ctx.core.emit[`.${vt}:${method}`])
@@ -2158,7 +2643,7 @@ export const emitter = {
2158
2643
  if (typeof obj === 'string' && ctx.schema.find && ctx.closure.call) {
2159
2644
  const idx = ctx.schema.find(obj, method)
2160
2645
  if (idx >= 0 && !ctx.schema.isBoxed?.(obj)) {
2161
- const propRead = typed(['f64.load', ['i32.add', ptrOffsetIR(asF64(emit(obj)), lookupValType(obj) || VAL.OBJECT), ['i32.const', idx * 8]]], 'f64')
2646
+ const propRead = typed(ctx.abi.object.ops.load(ptrOffsetIR(asF64(emit(obj)), lookupValType(obj) || VAL.OBJECT), idx), 'f64')
2162
2647
  if (parsed.hasSpread) {
2163
2648
  const combined = reconstructArgsWithSpreads(parsed.normal, parsed.spreads)
2164
2649
  return ctx.closure.call(propRead, [buildArrayWithSpreads(combined)], true)
@@ -2171,13 +2656,21 @@ export const emitter = {
2171
2656
  if (typeof obj === 'string' && ctx.schema.find && ctx.closure.call && ctx.schema.isBoxed?.(obj)) {
2172
2657
  const idx = ctx.schema.find(obj, method)
2173
2658
  if (idx >= 0) {
2174
- const propRead = typed(['f64.load', ['i32.add', ptrOffsetIR(asF64(emit(obj)), lookupValType(obj) || VAL.OBJECT), ['i32.const', idx * 8]]], 'f64')
2659
+ const propRead = typed(ctx.abi.object.ops.load(ptrOffsetIR(asF64(emit(obj)), lookupValType(obj) || VAL.OBJECT), idx), 'f64')
2175
2660
  return ctx.closure.call(propRead, parsed.normal)
2176
2661
  }
2177
2662
  }
2178
2663
 
2179
- // Generic only
2180
- if (ctx.core.emit[genKey]) {
2664
+ // Generic only — but a collection emitter (`.get`/`.set`/`.has`/`.add`/
2665
+ // `.delete`) assumes a Map/Set receiver: a proven collection already
2666
+ // dispatched via `.${vt}:${method}` above, so reaching here means the
2667
+ // receiver is not a proven collection. A zero-arg call then cannot be the
2668
+ // collection op (each needs ≥1 key/value arg) — it is a user/closure
2669
+ // method (e.g. `new C().get()`). Skip the collection emitter so it falls
2670
+ // through to closure/dynamic dispatch instead of crashing on `emit(key)`.
2671
+ const collectionMisfit = COLLECTION_METHODS.has(method) &&
2672
+ !parsed.hasSpread && parsed.normal.length === 0
2673
+ if (ctx.core.emit[genKey] && !collectionMisfit) {
2181
2674
  return callMethod(obj, ctx.core.emit[genKey])
2182
2675
  }
2183
2676
 
@@ -2233,6 +2726,10 @@ export const emitter = {
2233
2726
  // Unknown callee - assume external method
2234
2727
  if (ctx.transform.strict)
2235
2728
  err(`strict mode: method call \`${typeof obj === 'string' ? obj : '<expr>'}.${method}(...)\` on a value of unknown type falls through to host \`__ext_call\`. Annotate the receiver type or pass { strict: false }.`)
2729
+ // Under wasi there is no host `__ext_call` — the call lowers to a
2730
+ // no-op returning `undefined`. This is by-design so polymorphic code
2731
+ // can target js and wasi from one source; users who want fail-fast
2732
+ // pass `strict: true` (handled above).
2236
2733
  if (ctx.transform.host === 'wasi') return undefExpr()
2237
2734
  inc('__ext_call')
2238
2735
  ctx.features.external = true
@@ -2244,7 +2741,7 @@ export const emitter = {
2244
2741
  ['i64.reinterpret_f64', arrayIR]]], 'f64');
2245
2742
  }
2246
2743
 
2247
- if (ctx.core.emit[callee]) {
2744
+ if (typeof callee === 'string' && ctx.core.emit[callee] && !isBoundName(callee) && !isUserFunc(callee)) {
2248
2745
  // Pass spread args through to emitter (e.g. Math.max(...arr))
2249
2746
  if (parsed.hasSpread) {
2250
2747
  const allArgs = []
@@ -2260,7 +2757,7 @@ export const emitter = {
2260
2757
  }
2261
2758
 
2262
2759
  // Direct call if callee is a known top-level function
2263
- if (typeof callee === 'string' && ctx.func.names.has(callee) && !ctx.func.locals?.has(callee)) {
2760
+ if (typeof callee === 'string' && ctx.func.names.has(callee) && !isBoundName(callee)) {
2264
2761
  const func = ctx.func.map.get(callee)
2265
2762
 
2266
2763
  // Rest param case: collect all args (including expanded spreads) into array
@@ -2429,15 +2926,19 @@ export function emit(node, expect) {
2429
2926
  if (node.loc != null) ctx.error.loc = node.loc
2430
2927
  }
2431
2928
  if (node == null) return null
2432
- if (node === true) return typed(['i32.const', 1], 'i32')
2433
- if (node === false) return typed(['i32.const', 0], 'i32')
2929
+ // Boolean literals carry VAL.BOOL for type observation (valTypeOf reads the
2930
+ // AST), but their working representation is the plain number 0/1 — identical
2931
+ // codegen to the pre-carrier `[, 1]`/`[, 0]` folding, so no perf is paid.
2932
+ if (node === true) return emitNum(1)
2933
+ if (node === false) return emitNum(0)
2434
2934
  if (typeof node === 'symbol') // JZ_NULL sentinel → null NaN
2435
2935
  return nullExpr()
2436
2936
  if (typeof node === 'bigint') {
2437
- // Wrap to unsigned i64 range emit as positive hex so downstream BigInt() parsers
2438
- // (e.g. watr's optimize.js getConst) don't choke on "-0x..." strings.
2439
- const n = node & 0xFFFFFFFFFFFFFFFFn
2440
- return typed(['f64.reinterpret_i64', ['i64.const', '0x' + n.toString(16)]], 'f64')
2937
+ // Truncate to 64 bits`BigInt.asUintN(64, …)` semantics, same as the
2938
+ // explicit mask `node & 0xFFFFFFFFFFFFFFFFn`. Decimal form (vs. the prior
2939
+ // unsigned-hex dance) is enough now that watr's optimize.js getConst
2940
+ // handles signed strings correctly (4.6.8 W5 fix).
2941
+ return typed(['f64.reinterpret_i64', ['i64.const', BigInt.asUintN(64, node).toString()]], 'f64')
2441
2942
  }
2442
2943
  if (typeof node === 'number') return emitNum(node)
2443
2944
  if (typeof node === 'string') {
@@ -2473,7 +2974,15 @@ export function emit(node, expect) {
2473
2974
  ctx.core.stdlib[trampolineName] = `(func $${trampolineName} ${paramDecls.join(' ')} (result f64) (local $${arr} i32) ${tempLocals} (call $${node} ${fwd}) ${capture} (local.set $${arr} (call $__alloc (i32.const ${n * 8 + 8}))) (i32.store (local.get $${arr}) (i32.const ${n})) (i32.store (i32.add (local.get $${arr}) (i32.const 4)) (i32.const ${n})) (local.set $${arr} (i32.add (local.get $${arr}) (i32.const 8))) ${stores} (call $__mkptr (i32.const 1) (i32.const 0) (local.get $${arr})))`
2474
2975
  inc(trampolineName, '__alloc', '__mkptr')
2475
2976
  } else {
2476
- ctx.core.stdlib[trampolineName] = `(func $${trampolineName} ${paramDecls.join(' ')} (result f64) (call $${node} ${fwd}))`
2977
+ // Convert i32/i64 results back to f64 uniform closure ABI returns f64.
2978
+ const resType = func?.sig.results[0]
2979
+ const callExpr = `(call $${node} ${fwd})`
2980
+ const wrapped = resType === 'i32'
2981
+ ? (func.sig.unsignedResult ? `(f64.convert_i32_u ${callExpr})` : `(f64.convert_i32_s ${callExpr})`)
2982
+ : resType === 'i64'
2983
+ ? `(f64.reinterpret_i64 ${callExpr})`
2984
+ : callExpr
2985
+ ctx.core.stdlib[trampolineName] = `(func $${trampolineName} ${paramDecls.join(' ')} (result f64) ${wrapped})`
2477
2986
  inc(trampolineName)
2478
2987
  }
2479
2988
  }