jz 0.3.1 → 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/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])
@@ -536,7 +750,7 @@ export function emitDecl(...inits) {
536
750
  ctx.func.locals.set(innerName, 'f64')
537
751
  result.push(
538
752
  ['local.set', `$${innerName}`, ['local.get', `$${name}`]],
539
- ['local.set', `$${bt}`, ['call', '$__alloc_hdr', ['i32.const', 0], ['i32.const', Math.max(1, schema.length)], ['i32.const', 8]]],
753
+ ['local.set', `$${bt}`, ['call', '$__alloc_hdr', ['i32.const', 0], ['i32.const', Math.max(1, schema.length)]]],
540
754
  ['f64.store', ['local.get', `$${bt}`], ['local.get', `$${name}`]],
541
755
  ...schema.slice(1).map((_, j) =>
542
756
  ['f64.store', ['i32.add', ['local.get', `$${bt}`], ['i32.const', (j + 1) * 8]], ['f64.const', 0]]),
@@ -547,6 +761,51 @@ 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
+ return ['block', `$break${loopId}`, ['loop', `$loop${loopId}`,
793
+ ['br_if', `$break${loopId}`, ['i32.ge_s', ['local.get', `$${sidx}`], ['local.get', `$${srcLenLocal}`]]],
794
+ ['f64.store', destAddr(['i32.add', ['local.get', `$${posLocal}`], ['local.get', `$${sidx}`]]), elem],
795
+ ['local.set', `$${sidx}`, ['i32.add', ['local.get', `$${sidx}`], ['i32.const', 1]]],
796
+ ['br', `$loop${loopId}`]]]
797
+ }
798
+ const advance = ['local.set', `$${posLocal}`,
799
+ ['i32.add', ['local.get', `$${posLocal}`], ['local.get', `$${srcLenLocal}`]]]
800
+ if (staticVT === VAL.ARRAY) return [arrCopy(), advance]
801
+ if (staticVT === VAL.STRING || staticVT === VAL.TYPED) return [scalarLoop(), advance]
802
+ inc('__ptr_type')
803
+ return [['if',
804
+ ['i32.eq', ['call', '$__ptr_type', srcI64()], ['i32.const', PTR.ARRAY]],
805
+ ['then', arrCopy()],
806
+ ['else', scalarLoop()]], advance]
807
+ }
808
+
550
809
  /**
551
810
  * Build an array from items, handling ['__spread', expr] markers.
552
811
  * Split into sections (normal arrays and spreads), then copy all into result.
@@ -581,9 +840,12 @@ export function buildArrayWithSpreads(items) {
581
840
  sections.push({ type: 'array', items: currentArray })
582
841
  }
583
842
 
584
- if (sections.length === 1) {
585
- const sec = sections[0]
586
- return emit(sec.type === 'array' ? ['[', ...sec.items] : sec.expr)
843
+ // A single all-normal section is a plain literal — defer to the `[` emitter.
844
+ // A single *spread* section is NOT shortcut to `emit(sec.expr)`: that would
845
+ // alias the source, but `[...x]` must yield a fresh array. It falls through
846
+ // to the alloc + emitSpreadCopy path below, which copies.
847
+ if (sections.length === 1 && sections[0].type === 'array') {
848
+ return emit(['[', ...sections[0].items])
587
849
  }
588
850
 
589
851
  const len = tempI32('len')
@@ -591,35 +853,39 @@ export function buildArrayWithSpreads(items) {
591
853
  const out = allocPtr({ type: 1, len: ['local.get', `$${len}`], tag: 'arr' })
592
854
  const result = out.local
593
855
 
594
- const ir = [
595
- ['local.set', `$${len}`, ['i32.const', 0]],
596
- ]
856
+ const ir = []
857
+ inc('__len')
597
858
 
598
- inc('__len', '__ptr_offset')
859
+ // Pass 1 — evaluate every section IN SOURCE ORDER into temps. JS spread keeps
860
+ // strict left-to-right order: a later spread whose source mutates an earlier
861
+ // element's input must still observe the pre-mutation value. Array items
862
+ // become per-item f64 temps; spreads become a ptr temp + a cached __len.
599
863
  for (const sec of sections) {
600
- if (sec.type === 'spread') {
864
+ if (sec.type === 'array') {
865
+ sec.itemLocals = []
866
+ for (let i = 0; i < sec.items.length; i++) {
867
+ const it = `${T}ai${ctx.func.uniq++}`
868
+ ctx.func.locals.set(it, 'f64')
869
+ sec.itemLocals.push(it)
870
+ ir.push(['local.set', `$${it}`, asF64(emit(sec.items[i]))])
871
+ }
872
+ } else {
601
873
  sec.local = `${T}sp${ctx.func.uniq++}`
602
874
  ctx.func.locals.set(sec.local, 'f64')
603
875
  sec.lenLocal = `${T}spl${ctx.func.uniq++}`
604
876
  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
- }
877
+ // A materialized multi-value is not a statically-typed pointer — let
878
+ // emitSpreadCopy resolve its kind at runtime via its one-time __ptr_type branch.
879
+ sec.val = multiCount(sec.expr) ? undefined : valTypeOf(sec.expr)
611
880
  const n = multiCount(sec.expr)
612
881
  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
- }
882
+ // Cache __len once per spread; reused below for total-len sum and the copy.
883
+ ir.push(['local.set', `$${sec.lenLocal}`, ['call', '$__len', ['i64.reinterpret_f64', ['local.get', `$${sec.local}`]]]])
620
884
  }
621
885
  }
622
886
 
887
+ // Pass 2 — total length (array sections statically sized, spreads cached above).
888
+ ir.push(['local.set', `$${len}`, ['i32.const', 0]])
623
889
  for (const sec of sections) {
624
890
  if (sec.type === 'array') {
625
891
  ir.push(['local.set', `$${len}`, ['i32.add', ['local.get', `$${len}`], ['i32.const', sec.items.length]]])
@@ -628,41 +894,20 @@ export function buildArrayWithSpreads(items) {
628
894
  }
629
895
  }
630
896
 
897
+ // Pass 3 — allocate exact, then store the pre-evaluated temps.
631
898
  ir.push(out.init, ['local.set', `$${pos}`, ['i32.const', 0]])
632
-
633
899
  for (const sec of sections) {
634
900
  if (sec.type === 'array') {
635
- for (let i = 0; i < sec.items.length; i++) {
901
+ for (const it of sec.itemLocals) {
636
902
  ir.push(
637
903
  ['f64.store',
638
904
  ['i32.add', ['local.get', `$${result}`], ['i32.shl', ['local.get', `$${pos}`], ['i32.const', 3]]],
639
- asF64(emit(sec.items[i]))],
905
+ ['local.get', `$${it}`]],
640
906
  ['local.set', `$${pos}`, ['i32.add', ['local.get', `$${pos}`], ['i32.const', 1]]]
641
907
  )
642
908
  }
643
909
  } 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
- )
910
+ ir.push(...emitSpreadCopy(result, pos, sec.local, sec.lenLocal, sec.val))
666
911
  }
667
912
  }
668
913
 
@@ -698,13 +943,17 @@ export function emitBody(node) {
698
943
  // Skip names that are reassigned later — refinement would be unsound past the assignment.
699
944
  if (Array.isArray(s) && s[0] === 'if' && s[3] == null && isTerminator(s[2])) {
700
945
  const refs = extractRefinements(s[1], new Map(), false)
701
- for (const [name, val] of refs) {
946
+ for (const [name, fact] of refs) {
702
947
  let reassigned = false
703
948
  for (let j = i + 1; j < stmts.length; j++)
704
949
  if (isReassigned(stmts[j], name)) { reassigned = true; break }
705
950
  if (reassigned) continue
706
- accumulated.push([name, ctx.func.refinements.get(name)])
707
- ctx.func.refinements.set(name, val)
951
+ const cur = ctx.func.refinements.get(name)
952
+ accumulated.push([name, cur])
953
+ // Merge so sibling early-returns layering on the same name compose
954
+ // (e.g. `if (typeof x === 'string') return; if (Array.isArray(x)) return;`
955
+ // leaves both `notString: true` and would-be array exclusion stacked).
956
+ ctx.func.refinements.set(name, cur ? { ...cur, ...fact } : fact)
708
957
  }
709
958
  }
710
959
  }
@@ -716,23 +965,86 @@ export function emitBody(node) {
716
965
  return out
717
966
  }
718
967
 
968
+ // A VAL.BOOL value rides the cheap 0/1 numeric carrier, and `ToNumber(bool)` is
969
+ // exactly that carrier — so for relational / loose-equality coercion a boolean
970
+ // behaves identically to a number. Normalize it before the type-directed compare
971
+ // dispatch (the BOOL fact still drives typeof / String / boundary boxing; only
972
+ // these arithmetic-shaped operators read it as numeric).
973
+ const numericVal = vt => vt === VAL.BOOL ? VAL.NUMBER : vt
974
+
975
+ // Primitive value-type classes for strict-equality type-mismatch folding. Two
976
+ // operands of different known classes — when at least one is a primitive — can
977
+ // never be `===` (number/boolean/string/bigint don't cross-coerce under `===`).
978
+ // Two *reference* kinds (array vs object, …) fall through to the shared ref-eq
979
+ // path instead, which already resolves distinct pointers to `false`.
980
+ const STRICT_PRIM = new Set([VAL.NUMBER, VAL.BOOL, VAL.STRING, VAL.BIGINT])
981
+
982
+ /**
983
+ * Strict `===`/`!==`. Unlike loose `==`, no coercion: a statically-known type
984
+ * mismatch folds to a constant (`true === 1` → false, `"1" === 1` → false). When
985
+ * the types match — or one side is statically unknown — the result is bit-for-bit
986
+ * identical to loose `==` on same-type operands, so we delegate to it.
987
+ *
988
+ * Two carrier-level limitations remain (documented gaps, not regressions):
989
+ * • booleans and numbers share the 0/1 carrier, so `1 === trueDynamic` can only
990
+ * be told apart when the boolean's type is statically known;
991
+ * • jz unifies `null` and `undefined` into one NaN-boxed sentinel, so
992
+ * `null === undefined` is `true` (same as `==`).
993
+ */
994
+ function emitStrictEq(a, b, negate) {
995
+ // `typeof x === 'type'` (prepare rewrote the literal to a numeric code) — typeof
996
+ // always yields a string, so strict and loose agree; reuse the loose lowering.
997
+ const tc = emitTypeofCmp(a, b, negate ? 'ne' : 'eq'); if (tc) return tc
998
+ // Known, differing primitive classes can never be strictly equal.
999
+ const rawA = resolveValType(a, valTypeOf, lookupValType)
1000
+ const rawB = resolveValType(b, valTypeOf, lookupValType)
1001
+ if (rawA && rawB && rawA !== rawB && (STRICT_PRIM.has(rawA) || STRICT_PRIM.has(rawB)))
1002
+ return emitNum(negate ? 1 : 0)
1003
+ // Same type (or dynamic-unknown): identical bits to loose `==`/`!=`.
1004
+ return emitter[negate ? '!=' : '=='](a, b)
1005
+ }
1006
+
719
1007
  /** Comparison op factory with constant folding. */
720
1008
  const cmpOp = (i32op, f64op, fn) => (a, b) => {
721
1009
  const va = emit(a), vb = emit(b)
722
- if (isLit(va) && isLit(vb)) return emitNum(fn(litVal(va), litVal(vb)) ? 1 : 0)
1010
+ // Skip the const-fold for `.unsigned` operands: `litVal` is the signed bit pattern
1011
+ // (-1, not 4294967295), so folding the order would be wrong. Fall through to the
1012
+ // f64 widen path below, which converts each operand by its own signedness.
1013
+ if (isLit(va) && isLit(vb) && !va.unsigned && !vb.unsigned) return emitNum(fn(litVal(va), litVal(vb)) ? 1 : 0)
723
1014
  // String compare: NaN-boxed string pointers compare as NaN under f64.lt/gt
724
1015
  // (always false), so without this the spec-correct `"a" < "b"` returns 0.
725
1016
  // Route both-STRING operands through __str_cmp's three-way result, then apply
726
1017
  // 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)
1018
+ const vta = numericVal(resolveValType(a, valTypeOf, lookupValType))
1019
+ const vtb = numericVal(resolveValType(b, valTypeOf, lookupValType))
729
1020
  if (vta === VAL.BIGINT || vtb === VAL.BIGINT) {
730
1021
  const op = bigintUnsignedBound(a) || bigintUnsignedBound(b) ? i32op.replace('_s', '_u') : i32op
731
1022
  return typed([`i64.${op}`, asI64(va), asI64(vb)], 'i32')
732
1023
  }
733
1024
  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')
1025
+ return typed([`i32.${i32op}`, ctx.abi.string.ops.cmp(asF64(va), asF64(vb), ctx), ['i32.const', 0]], 'i32')
1026
+ }
1027
+ // Exactly one operand is a known string; the other has no static type, so it
1028
+ // may hold a string pointer at runtime (e.g. `c >= '0'` where `c` came from
1029
+ // `s[i]` on an untyped receiver). JS relational compare is lexicographic only
1030
+ // when *both* sides are strings, else it ToNumbers both. The f64 path below
1031
+ // would compare the unknown side's NaN-boxed string bits as a float (NaN ⇒
1032
+ // always false), so dispatch at runtime on the unknown side: string → __str_cmp
1033
+ // three-way; else ToNumber both. Mirrors `+`'s __is_str_key string dispatch.
1034
+ // Gated on a *known-string* counterpart, so numeric loops (`i < n`) never pay
1035
+ // the check — comparing against a string literal signals string intent.
1036
+ if (((vta === VAL.STRING && vtb == null) || (vtb === VAL.STRING && vta == null)) && ctx.abi.string?.ops?.cmp) {
1037
+ const unkIsA = vta == null
1038
+ const ta = temp('cmp'), tb = temp('cmp')
1039
+ inc('__is_str_key')
1040
+ const getA = typed(['local.get', `$${ta}`], 'f64'), getB = typed(['local.get', `$${tb}`], 'f64')
1041
+ const check = ['call', '$__is_str_key', ['i64.reinterpret_f64', ['local.get', `$${unkIsA ? ta : tb}`]]]
1042
+ const strCmp = [`i32.${i32op}`, ctx.abi.string.ops.cmp(getA, getB, ctx), ['i32.const', 0]]
1043
+ const numCmp = [`f64.${f64op}`, toNumF64(a, getA), toNumF64(b, getB)]
1044
+ return typed(['block', ['result', 'i32'],
1045
+ ['local.set', `$${ta}`, asF64(va)],
1046
+ ['local.set', `$${tb}`, asF64(vb)],
1047
+ ['if', ['result', 'i32'], check, ['then', strCmp], ['else', numCmp]]], 'i32')
736
1048
  }
737
1049
  if (vta === VAL.DATE || vtb === VAL.DATE) {
738
1050
  const dateNum = (node, v, vt) => {
@@ -748,11 +1060,17 @@ const cmpOp = (i32op, f64op, fn) => (a, b) => {
748
1060
  return typed([`f64.${f64op}`, toNumF64(a, va), asF64(vb)], 'i32')
749
1061
  if (vta === VAL.NUMBER && needsRelationalToNumber(b, vtb))
750
1062
  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')
1063
+ // An `.unsigned` i32 operand ([0, 2^32)) can't share a signed i32 compare with a
1064
+ // possibly-signed one: mixed sign inverts the order (3 < 0xFFFFFFFF unsigned, but
1065
+ // 3 > -1 signed). Widen to f64, where asF64 converts each operand by its own
1066
+ // signedness (convert_i32_u for unsigned, _s otherwise) to its true numeric value.
1067
+ if (!va.unsigned && !vb.unsigned) {
1068
+ const ai = intConstValue(a), bi = intConstValue(b)
1069
+ if (va.type === 'i32' && bi != null) return typed([`i32.${i32op}`, va, ['i32.const', bi]], 'i32')
1070
+ if (vb.type === 'i32' && ai != null) return typed([`i32.${i32op}`, ['i32.const', ai], vb], 'i32')
1071
+ if (va.type === 'i32' && vb.type === 'i32') return typed([`i32.${i32op}`, va, vb], 'i32')
1072
+ }
1073
+ return typed([`f64.${f64op}`, asF64(va), asF64(vb)], 'i32')
756
1074
  }
757
1075
 
758
1076
  function needsRelationalToNumber(expr, vt) {
@@ -885,7 +1203,7 @@ export const emitter = {
885
1203
  },
886
1204
 
887
1205
  'throw': expr => {
888
- ctx.runtime.throws = true
1206
+ ctx.runtime.throws = ctx.runtime.userThrows = true
889
1207
  const thrown = temp()
890
1208
  return typed(['block',
891
1209
  ['local.set', `$${thrown}`, asF64(emit(expr))],
@@ -896,7 +1214,7 @@ export const emitter = {
896
1214
  'catch': (body, errName, handler) => {
897
1215
  if (!canThrow(body)) return emitFlat(body)
898
1216
 
899
- ctx.runtime.throws = true
1217
+ ctx.runtime.throws = ctx.runtime.userThrows = true
900
1218
  const id = ctx.func.uniq++
901
1219
  ctx.func.locals.set(errName, 'f64')
902
1220
  const prev = ctx.func.inTry; ctx.func.inTry = true
@@ -922,7 +1240,7 @@ export const emitter = {
922
1240
  return [...bodyIR, ...cleanupIR]
923
1241
  }
924
1242
 
925
- ctx.runtime.throws = true
1243
+ ctx.runtime.throws = ctx.runtime.userThrows = true
926
1244
  const id = ctx.func.uniq++
927
1245
  const errLocal = temp('err')
928
1246
  const parentStack = ctx.func.finallyStack || []
@@ -966,19 +1284,20 @@ export const emitter = {
966
1284
  const rt = ctx.func.current?.results[0] || 'f64'
967
1285
  const pk = ctx.func.current?.ptrKind
968
1286
  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')
1287
+ const ty = pk != null ? 'i32' : rt
1288
+ const tcoed = tcoTailRewrite(ir, ty)
1289
+ if (Array.isArray(tcoed) && tcoed[0] === 'return_call' && finalizers.length === 0) {
1290
+ return typed(tcoed, 'void')
1291
+ }
972
1292
  if (finalizers.length > 0) {
973
- const ty = pk != null ? 'i32' : rt
974
1293
  const name = ty === 'i32' ? tempI32('ret') : ty === 'i64' ? tempI64('ret') : temp('ret')
975
1294
  return [
976
- ['local.set', `$${name}`, ir],
1295
+ ['local.set', `$${name}`, tcoed],
977
1296
  ...finalizerBlock(),
978
1297
  typed(['return', ['local.get', `$${name}`]], 'void'),
979
1298
  ]
980
1299
  }
981
- return typed(['return', ir], 'void')
1300
+ return typed(['return', tcoed], 'void')
982
1301
  },
983
1302
 
984
1303
  // === Assignment ===
@@ -990,7 +1309,15 @@ export const emitter = {
990
1309
  if (Array.isArray(name) && name[0] === '[]') {
991
1310
  const [, arr, idx] = name
992
1311
  const keyType = keyValType(idx)
993
- const useRuntimeKeyDispatch = keyType == null || (typeof idx === 'string' && keyType !== VAL.STRING)
1312
+ // A provably-numeric index name an int-certain loop counter or a
1313
+ // NUMBER-typed local — can never be a string key, so the runtime
1314
+ // `__is_str_key` → `__dyn_set` dispatch is dead. Mirrors the index *read*
1315
+ // path (`intIndexIR`), closing the read/write asymmetry on `arr[i] = …`
1316
+ // inside refined-array loops (e.g. watr's recursive AST walkers).
1317
+ const idxNumericName = typeof idx === 'string' &&
1318
+ (repOf(idx)?.intCertain === true || repOf(idx)?.val === VAL.NUMBER)
1319
+ const useRuntimeKeyDispatch = !idxNumericName &&
1320
+ (keyType == null || (typeof idx === 'string' && keyType !== VAL.STRING))
994
1321
  const keyExpr = asF64(emit(idx))
995
1322
  const valueExpr = asF64(emit(val))
996
1323
  const storeArrayValue = (arrExpr, idxNode, persist) => {
@@ -1031,15 +1358,25 @@ export const emitter = {
1031
1358
  const litKey = isLiteralStr(idx) ? idx[1]
1032
1359
  : typeof arr === 'string' && lookupValType(arr) === VAL.OBJECT ? staticPropertyKey(idx)
1033
1360
  : null
1361
+ // SRoA flat object: `o['k'] = x` → `local.set $o#i` (no heap store).
1362
+ if (litKey != null && typeof arr === 'string' && ctx.func.flatObjects?.has(arr)) {
1363
+ const fo = ctx.func.flatObjects.get(arr)
1364
+ const fi = fo.names.indexOf(litKey)
1365
+ if (fi >= 0) {
1366
+ const t = temp()
1367
+ return typed(['block', ['result', 'f64'],
1368
+ ['local.set', `$${t}`, valueExpr],
1369
+ ['local.set', `$${arr}#${fi}`, ['local.get', `$${t}`]],
1370
+ ['local.get', `$${t}`]], 'f64')
1371
+ }
1372
+ }
1034
1373
  if (litKey != null && typeof arr === 'string' && ctx.schema.find) {
1035
1374
  const slot = ctx.schema.find(arr, litKey)
1036
1375
  if (slot >= 0) {
1037
1376
  const t = temp()
1038
1377
  return typed(['block', ['result', 'f64'],
1039
1378
  ['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}`]],
1379
+ ctx.abi.object.ops.store(ptrOffsetIR(asF64(emit(arr)), lookupValType(arr) || VAL.OBJECT), slot, ['local.get', `$${t}`]),
1043
1380
  ['local.get', `$${t}`]], 'f64')
1044
1381
  }
1045
1382
  }
@@ -1188,6 +1525,18 @@ export const emitter = {
1188
1525
  // Object property assignment: obj.prop = x
1189
1526
  if (Array.isArray(name) && name[0] === '.') {
1190
1527
  const [, obj, prop] = name
1528
+ // SRoA flat object: `o.prop = x` → `local.set $o#i` (no heap store).
1529
+ const flatW = typeof obj === 'string' ? ctx.func.flatObjects?.get(obj) : null
1530
+ if (flatW) {
1531
+ const fi = flatW.names.indexOf(prop)
1532
+ if (fi >= 0) {
1533
+ const t = temp()
1534
+ return typed(['block', ['result', 'f64'],
1535
+ ['local.set', `$${t}`, asF64(emit(val))],
1536
+ ['local.set', `$${obj}#${fi}`, ['local.get', `$${t}`]],
1537
+ ['local.get', `$${t}`]], 'f64')
1538
+ }
1539
+ }
1191
1540
  // Schema-based object → f64.store at fixed offset.
1192
1541
  if (typeof obj === 'string' && ctx.schema.find) {
1193
1542
  const idx = ctx.schema.find(obj, prop)
@@ -1197,7 +1546,7 @@ export const emitter = {
1197
1546
  if (shadow) inc('__dyn_set')
1198
1547
  const stmts = [
1199
1548
  ['local.set', `$${t}`, vv],
1200
- ['f64.store', ['i32.add', ptrOffsetIR(asF64(va), lookupValType(obj) || VAL.OBJECT), ['i32.const', idx * 8]], ['local.get', `$${t}`]],
1549
+ ctx.abi.object.ops.store(ptrOffsetIR(asF64(va), lookupValType(obj) || VAL.OBJECT), idx, ['local.get', `$${t}`]),
1201
1550
  ]
1202
1551
  if (shadow)
1203
1552
  stmts.push(['drop', ['call', '$__dyn_set', asI64(va), asI64(emit(['str', prop])), ['i64.reinterpret_f64', ['local.get', `$${t}`]]]])
@@ -1350,16 +1699,21 @@ export const emitter = {
1350
1699
  inc('__str_append_byte', '__char_at')
1351
1700
  return typed(['call', '$__str_append_byte',
1352
1701
  asI64(emit(a)),
1353
- ['call', '$__char_at', asI64(emit(b[1])), asI32(emit(b[2]))],
1702
+ ctx.abi.string.ops.charCodeAt(asF64(emit(b[1])), asI32(emit(b[2])), ctx),
1354
1703
  ], 'f64')
1355
1704
  }
1356
1705
  }
1357
- inc('__str_concat_raw')
1358
- return typed(['call', '$__str_concat_raw', asI64(emit(a)), asI64(emit(b))], 'f64')
1706
+ return typed(ctx.abi.string.ops.concatRaw(asF64(emit(a)), asF64(emit(b)), ctx), 'f64')
1359
1707
  }
1360
1708
  if (vtA === VAL.STRING || vtB === VAL.STRING) {
1361
- inc('__str_concat')
1362
- return typed(['call', '$__str_concat', asI64(emit(a)), asI64(emit(b))], 'f64')
1709
+ // An OBJECT operand coerces via ToPrimitive(string) at compile time —
1710
+ // __str_concat's runtime __to_str cannot invoke a user-defined toString.
1711
+ // A BOOL operand renders "true"/"false" rather than its 0/1 carrier.
1712
+ const strOperand = (vt, n) => vt === VAL.OBJECT ? typed(['f64.reinterpret_i64', toStrI64(n, emit(n))], 'f64')
1713
+ : vt === VAL.BOOL ? emitBoolStr(n) : asF64(emit(n))
1714
+ const ea = strOperand(vtA, a)
1715
+ const eb = strOperand(vtB, b)
1716
+ return typed(ctx.abi.string.ops.concat(ea, eb, ctx), 'f64')
1363
1717
  }
1364
1718
  if (vtA === VAL.BIGINT || vtB === VAL.BIGINT)
1365
1719
  return fromI64(['i64.add', asI64(emit(a)), asI64(emit(b))])
@@ -1386,10 +1740,15 @@ export const emitter = {
1386
1740
  }
1387
1741
  const va = emit(a), vb = emit(b), _f = foldConst(va, vb, (a, b) => a + b)
1388
1742
  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')
1743
+ // Neither side is a string here (string paths handled above), but either may
1744
+ // still be null/undefined/pointer numeric `+` performs ToNumber like `-`/`*`.
1745
+ if (isLit(vb) && litVal(vb) === 0) return toNumF64(a, va)
1746
+ if (isLit(va) && litVal(va) === 0) return toNumF64(b, vb)
1747
+ // An `.unsigned` operand is a uint32 (range [0, 2^32)); JS `+` is a float
1748
+ // op whose result can exceed i32, so `i32.add` would wrap (4294967295+1→0).
1749
+ // Widen to f64 — never wrap — matching spec. Only `>>>0`/`|0`/imul wrap.
1750
+ if (isI32Num(va) && isI32Num(vb) && !widensUnsigned(va) && !widensUnsigned(vb)) return typed(['i32.add', va, vb], 'i32')
1751
+ return typed(['f64.add', toNumF64(a, va), toNumF64(b, vb)], 'f64')
1393
1752
  },
1394
1753
  '-': (a, b) => {
1395
1754
  if (_expect === 'void' && isPostfix(a, '++', b)) return emit(a, 'void')
@@ -1401,7 +1760,9 @@ export const emitter = {
1401
1760
  const va = emit(a), vb = emit(b), _f = foldConst(va, vb, (a, b) => a - b)
1402
1761
  if (_f) return _f
1403
1762
  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')
1763
+ // Unsigned uint32 operand: JS `-` is float (can go negative / exceed i32),
1764
+ // so avoid the wrapping i32.sub fast-path. See `+` above.
1765
+ if (isI32Num(va) && isI32Num(vb) && !widensUnsigned(va) && !widensUnsigned(vb)) return typed(['i32.sub', va, vb], 'i32')
1405
1766
  return typed(['f64.sub', toNumF64(a, va), toNumF64(b, vb)], 'f64')
1406
1767
  },
1407
1768
  'u+': a => {
@@ -1423,7 +1784,9 @@ export const emitter = {
1423
1784
  if (isLit(va) && litVal(va) === 1) return toNumF64(b, vb)
1424
1785
  if (isLit(vb) && litVal(vb) === 0) return isLit(va) ? vb : typed(['block', ['result', vb.type], va, 'drop', vb], vb.type)
1425
1786
  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')
1787
+ // `.unsigned` operand is a uint32 ([0, 2^32)); its product can exceed i32, so
1788
+ // `i32.mul` would wrap ((2^32-1)*2 → -2). Widen to f64 — see `+` above.
1789
+ if (isI32Num(va) && isI32Num(vb) && !widensUnsigned(va) && !widensUnsigned(vb) && mulFitsI32(va, vb)) return typed(['i32.mul', va, vb], 'i32')
1427
1790
  return typed(['f64.mul', toNumF64(a, va), toNumF64(b, vb)], 'f64')
1428
1791
  },
1429
1792
  '/': (a, b) => {
@@ -1439,7 +1802,12 @@ export const emitter = {
1439
1802
  return fromI64(['i64.rem_s', asI64(emit(a)), asI64(emit(b))])
1440
1803
  const va = emit(a), vb = emit(b), _f = foldConst(va, vb, (a, b) => a % b, b => b !== 0)
1441
1804
  if (_f) return _f
1442
- if (va.type === 'i32' && vb.type === 'i32') return typed(['i32.rem_s', va, vb], 'i32')
1805
+ // ES remainder by zero is NaN; only the f64 path yields that (a - trunc(a/0)*0).
1806
+ // The i32.rem_s fast path traps on a zero divisor, so divert a literal-zero divisor.
1807
+ if (isLit(vb) && litVal(vb) === 0) return emitNum(NaN)
1808
+ // `.unsigned` operand: `i32.rem_s` reads the uint32 as a negative signed value
1809
+ // ((2^32-1)%7 → rem_s(-1,7) = -1, not 3). Widen to f64 — see `+` above.
1810
+ if (isI32Num(va) && isI32Num(vb) && !va.unsigned && !vb.unsigned) return typed(['i32.rem_s', va, vb], 'i32')
1443
1811
  return f64rem(toNumF64(a, va), toNumF64(b, vb))
1444
1812
  },
1445
1813
  // === Comparisons (always i32 result) ===
@@ -1447,6 +1815,8 @@ export const emitter = {
1447
1815
  '==': (a, b) => {
1448
1816
  const charCmp = emitSingleCharIndexCmp(a, b)
1449
1817
  if (charCmp) return charCmp
1818
+ const subCmp = emitSubstringEqCmp(a, b)
1819
+ if (subCmp) return subCmp
1450
1820
  // JS loose nullish equality: x == null / x == undefined.
1451
1821
  // If the non-literal side has a known non-null VAL type, fold to 0.
1452
1822
  if (isNullishLit(a)) {
@@ -1465,8 +1835,8 @@ export const emitter = {
1465
1835
  // of the other side: jz's `==` is strict (prepare.js:868), and every NaN-boxed pointer
1466
1836
  // reinterprets to a quiet NaN (0x7FF8… prefix) so f64.eq with any normal float is false.
1467
1837
  // 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)
1838
+ const vta = numericVal(resolveValType(a, valTypeOf, lookupValType))
1839
+ const vtb = numericVal(resolveValType(b, valTypeOf, lookupValType))
1470
1840
  if (vta === VAL.NUMBER && needsLooseEqualityToNumber(b, vtb)) return looseNumberEq(va, b, vb)
1471
1841
  if (vtb === VAL.NUMBER && needsLooseEqualityToNumber(a, vta)) return looseNumberEq(vb, a, va)
1472
1842
  if (vta === VAL.NUMBER || vtb === VAL.NUMBER) return typed(['f64.eq', asF64(va), asF64(vb)], 'i32')
@@ -1483,6 +1853,8 @@ export const emitter = {
1483
1853
  '!=': (a, b) => {
1484
1854
  const charCmp = emitSingleCharIndexCmp(a, b, true)
1485
1855
  if (charCmp) return charCmp
1856
+ const subCmp = emitSubstringEqCmp(a, b, true)
1857
+ if (subCmp) return subCmp
1486
1858
  if (isNullishLit(a)) {
1487
1859
  if (valTypeOf(b)) return emitNum(1)
1488
1860
  return typed(['i32.eqz', isNullish(asF64(emit(b)))], 'i32')
@@ -1494,8 +1866,8 @@ export const emitter = {
1494
1866
  const tc = emitTypeofCmp(a, b, 'ne'); if (tc) return tc
1495
1867
  const va = emit(a), vb = emit(b)
1496
1868
  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)
1869
+ const vta = numericVal(resolveValType(a, valTypeOf, lookupValType))
1870
+ const vtb = numericVal(resolveValType(b, valTypeOf, lookupValType))
1499
1871
  if (vta === VAL.NUMBER && needsLooseEqualityToNumber(b, vtb)) return looseNumberEq(va, b, vb, true)
1500
1872
  if (vtb === VAL.NUMBER && needsLooseEqualityToNumber(a, vta)) return looseNumberEq(vb, a, va, true)
1501
1873
  if (vta === VAL.NUMBER || vtb === VAL.NUMBER) return typed(['f64.ne', asF64(va), asF64(vb)], 'i32')
@@ -1505,6 +1877,8 @@ export const emitter = {
1505
1877
  inc('__eq')
1506
1878
  return typed(['i32.eqz', ['call', '$__eq', asI64(va), asI64(vb)]], 'i32')
1507
1879
  },
1880
+ '===': (a, b) => emitStrictEq(a, b, false),
1881
+ '!==': (a, b) => emitStrictEq(a, b, true),
1508
1882
  '<': cmpOp('lt_s', 'lt', (a, b) => a < b),
1509
1883
  '>': cmpOp('gt_s', 'gt', (a, b) => a > b),
1510
1884
  '<=': cmpOp('le_s', 'le', (a, b) => a <= b),
@@ -1519,7 +1893,9 @@ export const emitter = {
1519
1893
  if (v.ptrKind != null) return typed(['i32.eqz', v], 'i32')
1520
1894
  // Known pointer-kinded operand: `!x` is just `x is nullish` (null/undefined).
1521
1895
  // Excludes STRING — empty string '' is a valid (non-null) pointer but is falsy.
1522
- const vt = resolveValType(a, valTypeOf, lookupValType)
1896
+ // VAL.BOOL rides the 0/1 numeric carrier (not a pointer), so normalize it to
1897
+ // NUMBER and let it fall to the truthy path — `!false` must be `true`.
1898
+ const vt = numericVal(resolveValType(a, valTypeOf, lookupValType))
1523
1899
  if (vt && vt !== VAL.NUMBER && vt !== VAL.BIGINT && vt !== VAL.STRING) {
1524
1900
  return isNullish(asF64(v))
1525
1901
  }
@@ -1586,11 +1962,23 @@ export const emitter = {
1586
1962
 
1587
1963
  '&&': (a, b) => {
1588
1964
  const va = emit(a)
1589
- if (isLit(va)) { const v = litVal(va); return (v !== 0 && v === v) ? emit(b) : va }
1965
+ // Constant-folded literal: pre-bind under truthy refinements (b runs only when a was truthy).
1966
+ if (isLit(va)) {
1967
+ const v = litVal(va)
1968
+ if (v !== 0 && v === v) {
1969
+ const refs = extractRefinements(a, new Map(), true)
1970
+ return withRefinements(refs, b, () => emit(b))
1971
+ }
1972
+ return va
1973
+ }
1974
+ // a is truthy in the right-arm — narrow b accordingly. Matches `?:`'s then-arm threading
1975
+ // (`Array.isArray(x) && x[0]` → x[0] sees x as ARRAY, eliding union-rep fallbacks).
1976
+ const rightRefs = extractRefinements(a, new Map(), true)
1977
+ const emitRight = () => withRefinements(rightRefs, b, () => emit(b))
1590
1978
  // i32 fast path: use i32 tee as cond directly (nonzero=truthy in wasm `if`),
1591
1979
  // skip f64 round-trip and __is_truthy call entirely.
1592
1980
  if (va.type === 'i32') {
1593
- const vb = emit(b)
1981
+ const vb = emitRight()
1594
1982
  const t = tempI32()
1595
1983
  if (vb.type === 'i32') {
1596
1984
  return typed(['if', ['result', 'i32'],
@@ -1607,15 +1995,25 @@ export const emitter = {
1607
1995
  const teed = typed(['local.tee', `$${t}`, asF64(va)], 'f64')
1608
1996
  return typed(['if', ['result', 'f64'],
1609
1997
  toBoolFromEmitted(teed),
1610
- ['then', asF64(emit(b))],
1998
+ ['then', asF64(emitRight())],
1611
1999
  ['else', ['local.get', `$${t}`]]], 'f64')
1612
2000
  },
1613
2001
 
1614
2002
  '||': (a, b) => {
1615
2003
  const va = emit(a)
1616
- if (isLit(va)) { const v = litVal(va); return (v !== 0 && v === v) ? va : emit(b) }
2004
+ // Constant-folded literal: pre-bind under falsy refinements (b runs only when a was falsy).
2005
+ if (isLit(va)) {
2006
+ const v = litVal(va)
2007
+ if (v !== 0 && v === v) return va
2008
+ const refs = extractRefinements(a, new Map(), false)
2009
+ return withRefinements(refs, b, () => emit(b))
2010
+ }
2011
+ // a is falsy in the right-arm — `x == null || ...` proves x is null/undefined in b;
2012
+ // De Morgan'd via the sense=false branch of extractRefinements (mirrors the ?: else-arm).
2013
+ const rightRefs = extractRefinements(a, new Map(), false)
2014
+ const emitRight = () => withRefinements(rightRefs, b, () => emit(b))
1617
2015
  if (va.type === 'i32') {
1618
- const vb = emit(b)
2016
+ const vb = emitRight()
1619
2017
  const t = tempI32()
1620
2018
  if (vb.type === 'i32') {
1621
2019
  return typed(['if', ['result', 'i32'],
@@ -1633,7 +2031,7 @@ export const emitter = {
1633
2031
  return typed(['if', ['result', 'f64'],
1634
2032
  toBoolFromEmitted(teed),
1635
2033
  ['then', ['local.get', `$${t}`]],
1636
- ['else', asF64(emit(b))]], 'f64')
2034
+ ['else', asF64(emitRight())]], 'f64')
1637
2035
  },
1638
2036
 
1639
2037
  // a ?? b: returns b only if a is nullish
@@ -1669,7 +2067,7 @@ export const emitter = {
1669
2067
  // i32 / lit values are already numeric — the toNumF64 wrap is skipped to keep
1670
2068
  // the numeric fast path at one wasm instruction. Non-numeric (NaN-boxed string,
1671
2069
  // 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') },
2070
+ '~': a => { 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') },
1673
2071
  ...Object.fromEntries([
1674
2072
  ['&', 'and'], ['|', 'or'], ['^', 'xor'], ['<<', 'shl'], ['>>', 'shr_s'],
1675
2073
  ].map(([op, fn]) => [op, (a, b) => {
@@ -1682,19 +2080,28 @@ export const emitter = {
1682
2080
  if (op === '^') return emitNum(la ^ lb); if (op === '<<') return emitNum(la << lb)
1683
2081
  if (op === '>>') return emitNum(la >> lb)
1684
2082
  }
1685
- const ca = va.type === 'i32' || isLit(va) ? va : toNumF64(a, va)
1686
- const cb = vb.type === 'i32' || isLit(vb) ? vb : toNumF64(b, vb)
2083
+ const ca = isI32Num(va) || isLit(va) ? va : toNumF64(a, va)
2084
+ const cb = isI32Num(vb) || isLit(vb) ? vb : toNumF64(b, vb)
1687
2085
  return typed([`i32.${fn}`, toI32(ca), toI32(cb)], 'i32')
1688
2086
  }])),
1689
2087
  '>>>': (a, b) => {
1690
- const va = emit(a), vb = emit(b), _f = foldConst(va, vb, (a, b) => a >>> b)
1691
- if (_f) return _f
2088
+ const va = emit(a), vb = emit(b)
2089
+ if (isLit(va) && isLit(vb)) {
2090
+ const r = litVal(va) >>> litVal(vb) // JS uint32 result ∈ [0, 2^32)
2091
+ // ≥ 2^31 doesn't fit signed i32: materialize the wrapped bits as an i32 const
2092
+ // tagged `.unsigned` so `asF64` lifts via `convert_i32_u`. Emitting `f64.const r`
2093
+ // here (the old foldConst path) would `trunc_sat_f64_s`-saturate to INT32_MAX
2094
+ // when the enclosing function narrows to an i32 result. Values < 2^31 fold to a
2095
+ // plain i32 const (signed == unsigned, stays foldable downstream).
2096
+ if (r >= 0x80000000) { const node = typed(['i32.const', r | 0], 'i32'); node.unsigned = true; return node }
2097
+ return emitNum(r)
2098
+ }
1692
2099
  // F: Mark unsigned so `asF64` lifts via `f64.convert_i32_u` (preserving the
1693
2100
  // [0, 2^32) value range). Without this, `(s >>> 0) / 4294967296` would convert
1694
2101
  // signed for negative-high-bit s values, flipping sign and breaking the
1695
2102
  // 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)
2103
+ const ca = isI32Num(va) || isLit(va) ? va : toNumF64(a, va)
2104
+ const cb = isI32Num(vb) || isLit(vb) ? vb : toNumF64(b, vb)
1698
2105
  const node = typed(['i32.shr_u', toI32(ca), toI32(cb)], 'i32')
1699
2106
  node.unsigned = true
1700
2107
  return node
@@ -1773,8 +2180,24 @@ export const emitter = {
1773
2180
  },
1774
2181
 
1775
2182
  'while': (cond, body) => emitter['for'](null, cond, null, body),
1776
- 'break': () => [...emitFinalizers(), ['br', loopTop().brk]],
1777
- 'continue': () => [...emitFinalizers(), ['br', loopTop().loop]],
2183
+ 'label': (name, body) => {
2184
+ const brk = `$label${ctx.func.uniq++}`
2185
+ ctx.func.stack.push({ label: name, brk })
2186
+ const result = ['block', brk, ...emitFlat(body)]
2187
+ ctx.func.stack.pop()
2188
+ return result
2189
+ },
2190
+ 'break': (label) => {
2191
+ const target = label == null
2192
+ ? loopTop().brk
2193
+ : ctx.func.stack.findLast(frame => frame.label === label)?.brk
2194
+ if (!target) err(`break label '${label}' is not in scope`)
2195
+ return [...emitFinalizers(), ['br', target]]
2196
+ },
2197
+ 'continue': (label) => {
2198
+ if (label != null) err(`continue label '${label}' is not supported`)
2199
+ return [...emitFinalizers(), ['br', loopTop().loop]]
2200
+ },
1778
2201
 
1779
2202
  // === Call ===
1780
2203
 
@@ -1823,8 +2246,6 @@ export const emitter = {
1823
2246
  let argList = Array.isArray(callArgs)
1824
2247
  ? (callArgs[0] === ',' ? callArgs.slice(1) : [callArgs])
1825
2248
  : 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
2249
 
1829
2250
  // Helper: expand spread arguments into flat list of normal arguments + spread markers
1830
2251
  // Returns { normal: [...], spreads: [(pos, expr), ...] }
@@ -1844,6 +2265,13 @@ export const emitter = {
1844
2265
 
1845
2266
  const parsed = parseArgs(argList)
1846
2267
 
2268
+ // Closure devirtualization: a callee that is a module global proven (by
2269
+ // plan.js) to hold one statically-known function for the whole post-init
2270
+ // program rewrites to that function — the known-top-level-function branch
2271
+ // below then emits a direct `call`, dropping the indirect/trampoline path.
2272
+ if (typeof callee === 'string' && ctx.func.globalDevirt?.has(callee))
2273
+ callee = ctx.func.globalDevirt.get(callee)
2274
+
1847
2275
  // Optional method call: obj?.method(args) — null if obj is nullish, else
1848
2276
  // obj.method(args). The parser shapes this as ['()', ['?.', obj, method], args],
1849
2277
  // distinct from the regular method call's ['.', obj, method] callee. Receiver
@@ -1868,8 +2296,27 @@ export const emitter = {
1868
2296
  if (Array.isArray(callee) && callee[0] === '.') {
1869
2297
  const [, obj, method] = callee
1870
2298
 
1871
- // Function property call: fn.prop(args) direct call to fn$prop
1872
- if (typeof obj === 'string' && ctx.func.names.has(obj)) {
2299
+ // charCodeAt with a statically in-bounds index emit the i32
2300
+ // (OOB-impossible) contract directly; the generic path keeps the
2301
+ // f64/NaN JS-spec result. See analyze.js inBoundsCharCodeAt.
2302
+ if (method === 'charCodeAt' && !parsed.hasSpread && parsed.normal.length === 1
2303
+ && ctx.abi.string?.ops?.charCodeAt && inBoundsCharCodeAt(ctx).has(callee)) {
2304
+ const recv = emit(obj)
2305
+ // jsstring carrier: receiver is an externref boundary param. Route to
2306
+ // `wasm:js-string.charCodeAt` directly — the in-bounds proof rules out
2307
+ // the OOB trap the builtin would otherwise raise.
2308
+ if (recv?.type === 'externref') {
2309
+ ctx.core.jsstring.add('charCodeAt')
2310
+ return typed(['call', '$__jss_charCodeAt', recv, asI32(emit(parsed.normal[0]))], 'i32')
2311
+ }
2312
+ return typed(ctx.abi.string.ops.charCodeAt(
2313
+ asF64(recv), asI32(emit(parsed.normal[0])), ctx, false), 'i32')
2314
+ }
2315
+
2316
+ // Function property call: fn.prop(args) → direct call to fn$prop.
2317
+ // Skipped when the property was reassigned (wrapper composition) — then
2318
+ // it is a mutable slot and must be read dynamically before the call.
2319
+ if (typeof obj === 'string' && ctx.func.names.has(obj) && !ctx.func.multiProp.has(`${obj}.${method}`)) {
1873
2320
  const fname = `${obj}$${method}`
1874
2321
  if (ctx.func.names.has(fname)) {
1875
2322
  const func = ctx.func.map.get(fname)
@@ -1879,14 +2326,25 @@ export const emitter = {
1879
2326
  const callIR = typed(['call', `$${fname}`, ...emittedArgs], func.sig.results[0])
1880
2327
  if (func.sig.ptrKind != null) callIR.ptrKind = func.sig.ptrKind
1881
2328
  if (func.sig.ptrAux != null) callIR.ptrAux = func.sig.ptrAux
2329
+ if (func.sig.unsignedResult) callIR.unsigned = true
1882
2330
  return callIR
1883
2331
  }
1884
2332
  }
1885
2333
 
1886
2334
  let vt = keyValType(obj)
2335
+ // A reassigned slice/concat receiver may carry a stale `vt` — a reassignment
2336
+ // inside a nested closure escapes analyzeValTypes' poisoning (its walk stops
2337
+ // at `=>`). Drop to runtime dispatch, but only for guessy types: STRING/ARRAY
2338
+ // dispatch correctly either way, and BUFFER/TYPED are construction proofs
2339
+ // (`new ArrayBuffer`/`new XxxArray`) — the runtime String/Array fallback has
2340
+ // no branch for them, so nulling `vt` would miscompile `ab.slice()` into an
2341
+ // f64-array copy. jzify also splits every `var x = init` into `let x; x = init`,
2342
+ // marking single-assignment vars "reassigned"; keeping definite BUFFER/TYPED
2343
+ // is what keeps `var`-declared buffers correct.
1887
2344
  if (typeof obj === 'string' && isReassigned(ctx.func.body, obj)
1888
2345
  && (method === 'slice' || method === 'concat')
1889
- && vt !== VAL.STRING && vt !== VAL.ARRAY) vt = null
2346
+ && vt !== VAL.STRING && vt !== VAL.ARRAY
2347
+ && vt !== VAL.BUFFER && vt !== VAL.TYPED) vt = null
1890
2348
 
1891
2349
  // Helper to call method with arguments (handles spread expansion)
1892
2350
  const callMethod = (objArg, methodEmitter) => {
@@ -1914,22 +2372,14 @@ export const emitter = {
1914
2372
  ctx.func.locals.set(si, 'i32'); ctx.func.locals.set(base, 'i32')
1915
2373
 
1916
2374
  const objIsArr = lookupValType(objArg) === VAL.ARRAY
1917
- // Spread source: if statically known ARRAY, inline len/load via hoisted srcBase
1918
- // (skip per-iteration __arr_idx call + dispatch).
1919
- const srcVT = valTypeOf(spreadExpr)
1920
- const srcIsArr = !multiCount(spreadExpr) && srcVT === VAL.ARRAY
1921
- const srcBase = srcIsArr ? `${T}psb${ctx.func.uniq++}` : null
1922
- if (srcIsArr) ctx.func.locals.set(srcBase, 'i32')
2375
+ // A materialized multi-value is not a statically-typed pointer let
2376
+ // emitSpreadCopy resolve its kind once at runtime.
2377
+ const srcVT = multiCount(spreadExpr) ? undefined : valTypeOf(spreadExpr)
1923
2378
  const n = multiCount(spreadExpr)
1924
2379
  const ir = []
1925
2380
  ir.push(['local.set', `$${o}`, asF64(emit(objArg))])
1926
2381
  ir.push(['local.set', `$${sa}`, n ? materializeMulti(spreadExpr) : asF64(emit(spreadExpr))])
1927
- if (srcIsArr) {
1928
- ir.push(['local.set', `$${srcBase}`, ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['local.get', `$${sa}`]]]])
1929
- ir.push(['local.set', `$${sl}`, ['i32.load', ['i32.sub', ['local.get', `$${srcBase}`], ['i32.const', 8]]]])
1930
- } else {
1931
- ir.push(['local.set', `$${sl}`, ['call', '$__len', ['i64.reinterpret_f64', ['local.get', `$${sa}`]]]])
1932
- }
2382
+ ir.push(['local.set', `$${sl}`, ['call', '$__len', ['i64.reinterpret_f64', ['local.get', `$${sa}`]]]])
1933
2383
  // Old length: inline as `i32.load (off-8)` if obj is known ARRAY (matches .push handler).
1934
2384
  if (objIsArr) {
1935
2385
  ir.push(['local.set', `$${ol}`,
@@ -1942,20 +2392,9 @@ export const emitter = {
1942
2392
  ['i32.add', ['local.get', `$${ol}`], ['local.get', `$${sl}`]]]])
1943
2393
  // base captured AFTER grow (grow may relocate the array).
1944
2394
  ir.push(['local.set', `$${base}`, ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['local.get', `$${o}`]]]])
1945
- // Tight store loop.
1946
- ir.push(['local.set', `$${si}`, ['i32.const', 0]])
1947
- const loopId = ctx.func.uniq++
1948
- const srcLoad = srcIsArr
1949
- ? ['f64.load', ['i32.add', ['local.get', `$${srcBase}`], ['i32.shl', ['local.get', `$${si}`], ['i32.const', 3]]]]
1950
- : asF64(emit(['[]', sa, si]))
1951
- ir.push(['block', `$break${loopId}`, ['loop', `$continue${loopId}`,
1952
- ['br_if', `$break${loopId}`, ['i32.ge_u', ['local.get', `$${si}`], ['local.get', `$${sl}`]]],
1953
- ['f64.store',
1954
- ['i32.add', ['local.get', `$${base}`],
1955
- ['i32.shl', ['i32.add', ['local.get', `$${ol}`], ['local.get', `$${si}`]], ['i32.const', 3]]],
1956
- srcLoad],
1957
- ['local.set', `$${si}`, ['i32.add', ['local.get', `$${si}`], ['i32.const', 1]]],
1958
- ['br', `$continue${loopId}`]]])
2395
+ // Bulk-copy the spread: an ARRAY source is a contiguous f64 block → memory.copy.
2396
+ ir.push(['local.set', `$${si}`, ['local.get', `$${ol}`]])
2397
+ ir.push(...emitSpreadCopy(base, si, sa, sl, srcVT))
1959
2398
  // Single set_len for the full spread.
1960
2399
  ir.push(['call', '$__set_len', ['i64.reinterpret_f64', ['local.get', `$${o}`]],
1961
2400
  ['i32.add', ['local.get', `$${ol}`], ['local.get', `$${sl}`]]])
@@ -1977,6 +2416,9 @@ export const emitter = {
1977
2416
  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++}`
1978
2417
  ctx.func.locals.set(acc, 'f64'); ctx.func.locals.set(arr, 'f64')
1979
2418
  ctx.func.locals.set(len, 'i32'); ctx.func.locals.set(idx, 'i32')
2419
+ // Emit-time rep seeding for a fresh spread-staging local (no prior reader).
2420
+ // Without this, the loop body's `[]` read on `arr` falls back to polymorphic
2421
+ // dispatch — VAL.* on the rep elides STRING gate for ARRAY/TYPED spreads.
1980
2422
  const spreadVT = valTypeOf(spreadExpr)
1981
2423
  if (spreadVT) updateRep(arr, { val: spreadVT })
1982
2424
 
@@ -2041,6 +2483,7 @@ export const emitter = {
2041
2483
  const spreadExpr = item[1]
2042
2484
  const arrL = `${T}sp${ctx.func.uniq++}`, lenL = `${T}splen${ctx.func.uniq++}`, idxL = `${T}spidx${ctx.func.uniq++}`
2043
2485
  ctx.func.locals.set(arrL, 'f64'); ctx.func.locals.set(lenL, 'i32'); ctx.func.locals.set(idxL, 'i32')
2486
+ // Emit-time rep seeding for fresh spread-staging local (see arr-spread comment above).
2044
2487
  const spreadVT = valTypeOf(spreadExpr)
2045
2488
  if (spreadVT) updateRep(arrL, { val: spreadVT })
2046
2489
  const n = multiCount(spreadExpr)
@@ -2116,11 +2559,11 @@ export const emitter = {
2116
2559
  // Boxed handle is OBJECT-kind, never ARRAY — skip forwarding.
2117
2560
  const loadInner = [
2118
2561
  ['local.set', `$${boxBase}`, ptrOffsetIR(asF64(emit(obj)), lookupValType(obj) || VAL.OBJECT)],
2119
- ['local.set', `$${innerName}`, ['f64.load', ['local.get', `$${boxBase}`]]]]
2562
+ ['local.set', `$${innerName}`, ctx.abi.object.ops.load(['local.get', `$${boxBase}`], 0)]]
2120
2563
  const result = callMethod(innerName, emitter)
2121
2564
  // Mutating methods may reallocate; writeback inner value to boxed slot
2122
2565
  if (BOXED_MUTATORS.has(method)) {
2123
- const wb = ['f64.store', ['local.get', `$${boxBase}`], ['local.get', `$${innerName}`]]
2566
+ const wb = ctx.abi.object.ops.store(['local.get', `$${boxBase}`], 0, ['local.get', `$${innerName}`])
2124
2567
  return typed(['block', ['result', 'f64'], ...loadInner, asF64(result), wb], 'f64')
2125
2568
  }
2126
2569
  // Non-mutating: just load inner and call
@@ -2157,7 +2600,7 @@ export const emitter = {
2157
2600
  if (typeof obj === 'string' && ctx.schema.find && ctx.closure.call) {
2158
2601
  const idx = ctx.schema.find(obj, method)
2159
2602
  if (idx >= 0 && !ctx.schema.isBoxed?.(obj)) {
2160
- const propRead = typed(['f64.load', ['i32.add', ptrOffsetIR(asF64(emit(obj)), lookupValType(obj) || VAL.OBJECT), ['i32.const', idx * 8]]], 'f64')
2603
+ const propRead = typed(ctx.abi.object.ops.load(ptrOffsetIR(asF64(emit(obj)), lookupValType(obj) || VAL.OBJECT), idx), 'f64')
2161
2604
  if (parsed.hasSpread) {
2162
2605
  const combined = reconstructArgsWithSpreads(parsed.normal, parsed.spreads)
2163
2606
  return ctx.closure.call(propRead, [buildArrayWithSpreads(combined)], true)
@@ -2170,13 +2613,21 @@ export const emitter = {
2170
2613
  if (typeof obj === 'string' && ctx.schema.find && ctx.closure.call && ctx.schema.isBoxed?.(obj)) {
2171
2614
  const idx = ctx.schema.find(obj, method)
2172
2615
  if (idx >= 0) {
2173
- const propRead = typed(['f64.load', ['i32.add', ptrOffsetIR(asF64(emit(obj)), lookupValType(obj) || VAL.OBJECT), ['i32.const', idx * 8]]], 'f64')
2616
+ const propRead = typed(ctx.abi.object.ops.load(ptrOffsetIR(asF64(emit(obj)), lookupValType(obj) || VAL.OBJECT), idx), 'f64')
2174
2617
  return ctx.closure.call(propRead, parsed.normal)
2175
2618
  }
2176
2619
  }
2177
2620
 
2178
- // Generic only
2179
- if (ctx.core.emit[genKey]) {
2621
+ // Generic only — but a collection emitter (`.get`/`.set`/`.has`/`.add`/
2622
+ // `.delete`) assumes a Map/Set receiver: a proven collection already
2623
+ // dispatched via `.${vt}:${method}` above, so reaching here means the
2624
+ // receiver is not a proven collection. A zero-arg call then cannot be the
2625
+ // collection op (each needs ≥1 key/value arg) — it is a user/closure
2626
+ // method (e.g. `new C().get()`). Skip the collection emitter so it falls
2627
+ // through to closure/dynamic dispatch instead of crashing on `emit(key)`.
2628
+ const collectionMisfit = COLLECTION_METHODS.has(method) &&
2629
+ !parsed.hasSpread && parsed.normal.length === 0
2630
+ if (ctx.core.emit[genKey] && !collectionMisfit) {
2180
2631
  return callMethod(obj, ctx.core.emit[genKey])
2181
2632
  }
2182
2633
 
@@ -2232,6 +2683,10 @@ export const emitter = {
2232
2683
  // Unknown callee - assume external method
2233
2684
  if (ctx.transform.strict)
2234
2685
  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 }.`)
2686
+ // Under wasi there is no host `__ext_call` — the call lowers to a
2687
+ // no-op returning `undefined`. This is by-design so polymorphic code
2688
+ // can target js and wasi from one source; users who want fail-fast
2689
+ // pass `strict: true` (handled above).
2235
2690
  if (ctx.transform.host === 'wasi') return undefExpr()
2236
2691
  inc('__ext_call')
2237
2692
  ctx.features.external = true
@@ -2243,7 +2698,7 @@ export const emitter = {
2243
2698
  ['i64.reinterpret_f64', arrayIR]]], 'f64');
2244
2699
  }
2245
2700
 
2246
- if (ctx.core.emit[callee]) {
2701
+ if (typeof callee === 'string' && ctx.core.emit[callee] && !isBoundName(callee) && !isUserFunc(callee)) {
2247
2702
  // Pass spread args through to emitter (e.g. Math.max(...arr))
2248
2703
  if (parsed.hasSpread) {
2249
2704
  const allArgs = []
@@ -2259,7 +2714,7 @@ export const emitter = {
2259
2714
  }
2260
2715
 
2261
2716
  // Direct call if callee is a known top-level function
2262
- if (typeof callee === 'string' && ctx.func.names.has(callee) && !ctx.func.locals?.has(callee)) {
2717
+ if (typeof callee === 'string' && ctx.func.names.has(callee) && !isBoundName(callee)) {
2263
2718
  const func = ctx.func.map.get(callee)
2264
2719
 
2265
2720
  // Rest param case: collect all args (including expanded spreads) into array
@@ -2282,6 +2737,7 @@ export const emitter = {
2282
2737
  arrayIR], func.sig.results[0])
2283
2738
  if (func.sig.ptrKind != null) callIR.ptrKind = func.sig.ptrKind
2284
2739
  if (func.sig.ptrAux != null) callIR.ptrAux = func.sig.ptrAux
2740
+ if (func.sig.unsignedResult) callIR.unsigned = true
2285
2741
  return callIR
2286
2742
  }
2287
2743
 
@@ -2302,6 +2758,9 @@ export const emitter = {
2302
2758
  const callIR = typed(['call', `$${callee}`, ...args], func?.sig.results[0] || 'f64')
2303
2759
  if (func?.sig.ptrKind != null) callIR.ptrKind = func.sig.ptrKind
2304
2760
  if (func?.sig.ptrAux != null) callIR.ptrAux = func.sig.ptrAux
2761
+ // Unsigned-uint32 result (every tail was `>>>`): consumer's asF64 must use
2762
+ // `f64.convert_i32_u` instead of `_s`, preserving the [0, 2^32) range.
2763
+ if (func?.sig.unsignedResult) callIR.unsigned = true
2305
2764
  return callIR
2306
2765
  }
2307
2766
 
@@ -2424,15 +2883,19 @@ export function emit(node, expect) {
2424
2883
  if (node.loc != null) ctx.error.loc = node.loc
2425
2884
  }
2426
2885
  if (node == null) return null
2427
- if (node === true) return typed(['i32.const', 1], 'i32')
2428
- if (node === false) return typed(['i32.const', 0], 'i32')
2886
+ // Boolean literals carry VAL.BOOL for type observation (valTypeOf reads the
2887
+ // AST), but their working representation is the plain number 0/1 — identical
2888
+ // codegen to the pre-carrier `[, 1]`/`[, 0]` folding, so no perf is paid.
2889
+ if (node === true) return emitNum(1)
2890
+ if (node === false) return emitNum(0)
2429
2891
  if (typeof node === 'symbol') // JZ_NULL sentinel → null NaN
2430
2892
  return nullExpr()
2431
2893
  if (typeof node === 'bigint') {
2432
- // Wrap to unsigned i64 range emit as positive hex so downstream BigInt() parsers
2433
- // (e.g. watr's optimize.js getConst) don't choke on "-0x..." strings.
2434
- const n = node & 0xFFFFFFFFFFFFFFFFn
2435
- return typed(['f64.reinterpret_i64', ['i64.const', '0x' + n.toString(16)]], 'f64')
2894
+ // Truncate to 64 bits`BigInt.asUintN(64, …)` semantics, same as the
2895
+ // explicit mask `node & 0xFFFFFFFFFFFFFFFFn`. Decimal form (vs. the prior
2896
+ // unsigned-hex dance) is enough now that watr's optimize.js getConst
2897
+ // handles signed strings correctly (4.6.8 W5 fix).
2898
+ return typed(['f64.reinterpret_i64', ['i64.const', BigInt.asUintN(64, node).toString()]], 'f64')
2436
2899
  }
2437
2900
  if (typeof node === 'number') return emitNum(node)
2438
2901
  if (typeof node === 'string') {
@@ -2468,7 +2931,15 @@ export function emit(node, expect) {
2468
2931
  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})))`
2469
2932
  inc(trampolineName, '__alloc', '__mkptr')
2470
2933
  } else {
2471
- ctx.core.stdlib[trampolineName] = `(func $${trampolineName} ${paramDecls.join(' ')} (result f64) (call $${node} ${fwd}))`
2934
+ // Convert i32/i64 results back to f64 uniform closure ABI returns f64.
2935
+ const resType = func?.sig.results[0]
2936
+ const callExpr = `(call $${node} ${fwd})`
2937
+ const wrapped = resType === 'i32'
2938
+ ? (func.sig.unsignedResult ? `(f64.convert_i32_u ${callExpr})` : `(f64.convert_i32_s ${callExpr})`)
2939
+ : resType === 'i64'
2940
+ ? `(f64.reinterpret_i64 ${callExpr})`
2941
+ : callExpr
2942
+ ctx.core.stdlib[trampolineName] = `(func $${trampolineName} ${paramDecls.join(' ')} (result f64) ${wrapped})`
2472
2943
  inc(trampolineName)
2473
2944
  }
2474
2945
  }