jz 0.1.1 → 0.2.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
@@ -23,7 +23,7 @@
23
23
  */
24
24
 
25
25
  import { ctx, err, inc, PTR } from './ctx.js'
26
- import { T, VAL, valTypeOf, lookupValType, extractParams, classifyParam, findFreeVars, STMT_OPS, repOf, updateRep, repOfGlobal } from './analyze.js'
26
+ import { T, VAL, valTypeOf, lookupValType, extractParams, classifyParam, findFreeVars, STMT_OPS, repOf, updateRep, repOfGlobal, staticPropertyKey } from './analyze.js'
27
27
  import {
28
28
  typed, asF64, asI32, asI64, asPtrOffset, asParamType, toI32, fromI64,
29
29
  NULL_IR, nullExpr, undefExpr, MAX_CLOSURE_ARITY,
@@ -34,6 +34,7 @@ import {
34
34
  isGlobal, isConst, keyValType, usesDynProps, needsDynShadow,
35
35
  temp, tempI32, tempI64, allocPtr,
36
36
  boxedAddr, readVar, writeVar, isNullish,
37
+ isLiteralStr, resolveValType, isFuncRef,
37
38
  multiCount, loopTop, flat,
38
39
  reconstructArgsWithSpreads,
39
40
  } from './ir.js'
@@ -42,6 +43,46 @@ import {
42
43
  // to decide whether to emit a value-returning or side-effect-only form.
43
44
  let _expect = null
44
45
 
46
+ const FIRST_CLASS_UNARY_MATH = {
47
+ 'math.abs': 'f64.abs',
48
+ 'math.sqrt': 'f64.sqrt',
49
+ 'math.ceil': 'f64.ceil',
50
+ 'math.floor': 'f64.floor',
51
+ 'math.trunc': 'f64.trunc',
52
+ }
53
+
54
+ function builtinFunctionValue(name) {
55
+ const op = FIRST_CLASS_UNARY_MATH[name]
56
+ if (!op) err(`Builtin function '${name}' cannot be used as a first-class value`)
57
+ if (!ctx.closure.table) err(`Builtin function '${name}' used as value requires closure support`)
58
+ const fn = `${T}builtin_${name.replace(/\W/g, '_')}`
59
+ if (!ctx.core.stdlib[fn]) {
60
+ const width = ctx.closure.width ?? MAX_CLOSURE_ARITY
61
+ const params = ['(param $__env f64)', '(param $__argc i32)']
62
+ for (let i = 0; i < width; i++) params.push(`(param $__a${i} f64)`)
63
+ ctx.core.stdlib[fn] = `(func $${fn} ${params.join(' ')} (result f64) (${op} (local.get $__a0)))`
64
+ inc(fn)
65
+ }
66
+ let idx = ctx.closure.table.indexOf(fn)
67
+ if (idx < 0) { idx = ctx.closure.table.length; ctx.closure.table.push(fn) }
68
+ const ir = mkPtrIR(PTR.CLOSURE, idx, 0)
69
+ ir.closureFuncIdx = idx
70
+ return ir
71
+ }
72
+
73
+ /** Emit unary negation: constant-fold, or i32 sub from 0 / f64.neg. */
74
+ const emitNeg = (a) => {
75
+ if (valTypeOf(a) === VAL.BIGINT) return fromI64(['i64.sub', ['i64.const', 0], asI64(emit(a))])
76
+ const v = emit(a)
77
+ return isLit(v) ? emitNum(-litVal(v)) : v.type === 'i32'
78
+ ? typed(['i32.sub', typed(['i32.const', 0], 'i32'), v], 'i32')
79
+ : typed(['f64.neg', toNumF64(a, v)], 'f64')
80
+ }
81
+
82
+ /** Try constant-folding binary arith: returns emitNum(result) or null. */
83
+ const foldConst = (va, vb, fn, guard) =>
84
+ isLit(va) && isLit(vb) && (!guard || guard(litVal(vb))) ? emitNum(fn(litVal(va), litVal(vb))) : null
85
+
45
86
  /** Emit typeof comparison: typeof x == typeCode → type-aware check. */
46
87
  export function emitTypeofCmp(a, b, cmpOp) {
47
88
  let typeofExpr, code
@@ -62,10 +103,7 @@ export function emitTypeofCmp(a, b, cmpOp) {
62
103
  if (code === -2) {
63
104
  inc('__ptr_type')
64
105
  const isPtr = ['f64.ne', ['local.tee', `$${t}`, va], ['local.get', `$${t}`]]
65
- const tt = `${T}${ctx.func.uniq++}`; ctx.func.locals.set(tt, 'i32')
66
- const isStr = ['i32.or',
67
- ['i32.eq', ['local.tee', `$${tt}`, ['call', '$__ptr_type', ['local.get', `$${t}`]]], ['i32.const', PTR.STRING]],
68
- ['i32.eq', ['local.get', `$${tt}`], ['i32.const', PTR.SSO]]]
106
+ const isStr = ['i32.eq', ['call', '$__ptr_type', ['i64.reinterpret_f64', ['local.get', `$${t}`]]], ['i32.const', PTR.STRING]]
69
107
  return typed(eq ? ['i32.and', isPtr, isStr]
70
108
  : ['i32.or', ['i32.eqz', isPtr], ['i32.eqz', isStr]], 'i32')
71
109
  }
@@ -81,9 +119,7 @@ export function emitTypeofCmp(a, b, cmpOp) {
81
119
  const isPtr = ['f64.ne', ['local.tee', `$${t}`, va], ['local.get', `$${t}`]]
82
120
  const tt = `${T}${ctx.func.uniq++}`; ctx.func.locals.set(tt, 'i32')
83
121
  const notStrFn = ['i32.and',
84
- ['i32.and',
85
- ['i32.ne', ['local.tee', `$${tt}`, ['call', '$__ptr_type', ['local.get', `$${t}`]]], ['i32.const', PTR.STRING]],
86
- ['i32.ne', ['local.get', `$${tt}`], ['i32.const', PTR.SSO]]],
122
+ ['i32.ne', ['local.tee', `$${tt}`, ['call', '$__ptr_type', ['i64.reinterpret_f64', ['local.get', `$${t}`]]]], ['i32.const', PTR.STRING]],
87
123
  ['i32.ne', ['local.get', `$${tt}`], ['i32.const', PTR.CLOSURE]]]
88
124
  const notNullish = ['i32.eqz', isNullish(['local.get', `$${t}`])]
89
125
  const check = ['i32.and', ['i32.and', isPtr, notStrFn], notNullish]
@@ -92,13 +128,24 @@ export function emitTypeofCmp(a, b, cmpOp) {
92
128
  if (code === -6) {
93
129
  inc('__ptr_type')
94
130
  const isPtr = ['f64.ne', ['local.tee', `$${t}`, va], ['local.get', `$${t}`]]
95
- const isFn = ['i32.eq', ['call', '$__ptr_type', ['local.get', `$${t}`]], ['i32.const', PTR.CLOSURE]]
131
+ const isFn = ['i32.eq', ['call', '$__ptr_type', ['i64.reinterpret_f64', ['local.get', `$${t}`]]], ['i32.const', PTR.CLOSURE]]
96
132
  return typed(eq ? ['i32.and', isPtr, isFn] : ['i32.or', ['i32.eqz', isPtr], ['i32.eqz', isFn]], 'i32')
97
133
  }
134
+ if (code === -7) {
135
+ const vt = resolveValType(typeofExpr, valTypeOf, lookupValType)
136
+ if (vt) return typed(['i32.const', (vt === VAL.BIGINT) === eq ? 1 : 0], 'i32')
137
+ const n = ['local.tee', `$${t}`, va]
138
+ const isBigInt = ['i32.and',
139
+ ['f64.eq', n, ['local.get', `$${t}`]],
140
+ ['i32.and',
141
+ ['f64.ne', ['local.get', `$${t}`], ['f64.const', 0]],
142
+ ['f64.lt', ['f64.abs', ['local.get', `$${t}`]], ['f64.const', 2.2250738585072014e-308]]]]
143
+ return typed(eq ? isBigInt : ['i32.eqz', isBigInt], 'i32')
144
+ }
98
145
  if (code >= 0) {
99
146
  inc('__ptr_type')
100
147
  const isPtr = ['f64.ne', ['local.tee', `$${t}`, va], ['local.get', `$${t}`]]
101
- const check = ['i32.eq', ['call', '$__ptr_type', ['local.get', `$${t}`]], ['i32.const', code]]
148
+ const check = ['i32.eq', ['call', '$__ptr_type', ['i64.reinterpret_f64', ['local.get', `$${t}`]]], ['i32.const', code]]
102
149
  return typed(eq ? ['i32.and', isPtr, check] : ['i32.or', ['i32.eqz', isPtr], ['i32.eqz', check]], 'i32')
103
150
  }
104
151
  return null
@@ -112,9 +159,74 @@ const isCmp = n => Array.isArray(n) && CMP_SET.has(n[0])
112
159
  // heap strings) and BIGINT (content compare).
113
160
  const REF_EQ_KINDS = new Set([
114
161
  VAL.ARRAY, VAL.OBJECT, VAL.SET, VAL.MAP,
115
- VAL.BUFFER, VAL.TYPED, VAL.CLOSURE, VAL.REGEX,
162
+ VAL.BUFFER, VAL.TYPED, VAL.CLOSURE, VAL.REGEX, VAL.DATE,
116
163
  ])
117
164
 
165
+ function stringLiteral(node) {
166
+ if (Array.isArray(node) && node[0] === 'str' && typeof node[1] === 'string') return node[1]
167
+ if (Array.isArray(node) && node[0] == null && typeof node[1] === 'string') return node[1]
168
+ return null
169
+ }
170
+
171
+ function nonNegIntLiteral(node) {
172
+ const n = intLiteralValue(node)
173
+ return n != null && n >= 0 ? n : null
174
+ }
175
+
176
+ function emitSingleCharIndexCmp(a, b, negate = false) {
177
+ const leftLit = stringLiteral(a)
178
+ const rightLit = stringLiteral(b)
179
+ const aIdx = Array.isArray(a) && a[0] === '[]'
180
+ const bIdx = Array.isArray(b) && b[0] === '[]'
181
+ let indexed, lit
182
+ if (bIdx && leftLit != null) { indexed = b; lit = leftLit }
183
+ else if (aIdx && rightLit != null) { indexed = a; lit = rightLit }
184
+ else return null
185
+
186
+ if (lit.length === 0) return null
187
+ if ([...lit].some(c => c.charCodeAt(0) > 0x7F)) return null
188
+
189
+ const [, obj, key] = indexed
190
+ const idx = nonNegIntLiteral(key)
191
+ if (idx == null) return null
192
+
193
+ const vt = typeof obj === 'string' ? lookupValType(obj) : valTypeOf(obj)
194
+ if (vt && vt !== VAL.STRING) return null
195
+
196
+ const finish = expr => negate ? ['i32.eqz', expr] : expr
197
+
198
+ // Known STRING: s[i] always returns 1-char SSO. Multi-char literal → always false.
199
+ if (vt === VAL.STRING && lit.length > 1) return emitNum(negate ? 1 : 0)
200
+
201
+ // Single-char literal: compare byte directly, skipping __str_idx allocation.
202
+ if (lit.length !== 1 || !ctx.core.stdlib['__char_at'] || !ctx.core.stdlib['__str_byteLen']) return null
203
+
204
+ const ptr = temp('sc'), idxIR = ['i32.const', idx]
205
+ inc('__str_byteLen', '__char_at')
206
+ const charEq = ['if', ['result', 'i32'],
207
+ ['i32.gt_u', ['call', '$__str_byteLen', ['i64.reinterpret_f64', ['local.get', `$${ptr}`]]], idxIR],
208
+ ['then', ['i32.eq', ['call', '$__char_at', ['i64.reinterpret_f64', ['local.get', `$${ptr}`]], idxIR], ['i32.const', lit.charCodeAt(0)]]],
209
+ ['else', ['i32.const', 0]]]
210
+
211
+ if (vt === VAL.STRING) {
212
+ return typed(['block', ['result', 'i32'],
213
+ ['local.set', `$${ptr}`, asF64(emit(obj))],
214
+ finish(charEq)], 'i32')
215
+ }
216
+
217
+ inc('__ptr_type', '__typed_idx', '__eq')
218
+ const genericEq = ['call', '$__eq',
219
+ ['i64.reinterpret_f64', ['call', '$__typed_idx', ['i64.reinterpret_f64', ['local.get', `$${ptr}`]], idxIR]],
220
+ asI64(emit(['str', lit]))]
221
+ const cmp = ['if', ['result', 'i32'],
222
+ ['i32.eq', ['call', '$__ptr_type', ['i64.reinterpret_f64', ['local.get', `$${ptr}`]]], ['i32.const', PTR.STRING]],
223
+ ['then', charEq],
224
+ ['else', genericEq]]
225
+ return typed(['block', ['result', 'i32'],
226
+ ['local.set', `$${ptr}`, asF64(emit(obj))],
227
+ finish(cmp)], 'i32')
228
+ }
229
+
118
230
  // === Flow-sensitive type refinement ===
119
231
  // Map typeof code (from resolveTypeof in prepare.js) → VAL kind. Undef/boolean/object have no
120
232
  // single VAL refinement, so they're excluded. String/number/function do.
@@ -219,6 +331,19 @@ function containsNestedLoop(body) {
219
331
  return false
220
332
  }
221
333
 
334
+ function nestedSmallLoopBudget(body) {
335
+ if (!Array.isArray(body)) return 1
336
+ if (body[0] === '=>') return 1
337
+ if (body[0] === 'for') {
338
+ const [, init, cond, step, loopBody] = body
339
+ const n = smallConstForTripCount(init, cond, step)
340
+ return n == null ? MAX_NESTED_FOR_UNROLL + 1 : n * nestedSmallLoopBudget(loopBody)
341
+ }
342
+ let max = 1
343
+ for (let i = 1; i < body.length; i++) max = Math.max(max, nestedSmallLoopBudget(body[i]))
344
+ return max
345
+ }
346
+
222
347
  function containsDeclOf(body, name) {
223
348
  if (!Array.isArray(body)) return false
224
349
  const op = body[0]
@@ -251,8 +376,17 @@ function cloneWithSubst(node, name, value) {
251
376
  }
252
377
 
253
378
  const MAX_SMALL_FOR_UNROLL = 8
379
+ const MAX_NESTED_FOR_UNROLL = 64
254
380
 
255
- function unrollSmallConstFor(init, cond, step, body) {
381
+ function containsKnownTypedArrayIndex(body) {
382
+ if (!Array.isArray(body)) return false
383
+ if (body[0] === '=>') return false
384
+ if (body[0] === '[]' && typeof body[1] === 'string' && ctx.types.typedElem?.has(body[1])) return true
385
+ for (let i = 1; i < body.length; i++) if (containsKnownTypedArrayIndex(body[i])) return true
386
+ return false
387
+ }
388
+
389
+ function smallConstForTripCount(init, cond, step) {
256
390
  if (!Array.isArray(init) || init[0] !== 'let' || init.length !== 2) return null
257
391
  const decl = init[1]
258
392
  if (!Array.isArray(decl) || decl[0] !== '=' || typeof decl[1] !== 'string') return null
@@ -268,8 +402,19 @@ function unrollSmallConstFor(init, cond, step, body) {
268
402
  (step[0] === '++' && step[1] === name) ||
269
403
  (step[0] === '-' && Array.isArray(step[1]) && step[1][0] === '++' && step[1][1] === name && intLiteralValue(step[2]) === 1)
270
404
  )
271
- if (!stepOk) return null
272
- if (hasOwnBreakOrContinue(body) || containsNestedClosure(body) || containsNestedLoop(body) || containsDeclOf(body, name)) return null
405
+ return stepOk ? end : null
406
+ }
407
+
408
+ function unrollSmallConstFor(init, cond, step, body) {
409
+ const end = smallConstForTripCount(init, cond, step)
410
+ if (end == null) return null
411
+ const name = init[1][1]
412
+ if (containsNestedLoop(body)) {
413
+ const nestedMode = ctx.transform.optimize?.nestedSmallConstForUnroll
414
+ if (nestedMode !== true && (nestedMode !== 'auto' || !containsKnownTypedArrayIndex(body))) return null
415
+ if (end * nestedSmallLoopBudget(body) > MAX_NESTED_FOR_UNROLL) return null
416
+ }
417
+ if (hasOwnBreakOrContinue(body) || containsNestedClosure(body) || containsDeclOf(body, name)) return null
273
418
  if (isReassigned(body, name)) return null
274
419
 
275
420
  const out = []
@@ -374,7 +519,7 @@ export function materializeMulti(callNode) {
374
519
  ? rawArgs[0].slice(1) : rawArgs
375
520
  const emittedArgs = argList.map((a, k) => emitArgForParam(emit(a), func.sig.params[k]))
376
521
  while (emittedArgs.length < func.sig.params.length)
377
- emittedArgs.push(func.sig.params[emittedArgs.length].type === 'i32' ? typed(['i32.const', 0], 'i32') : nullExpr())
522
+ emittedArgs.push(func.sig.params[emittedArgs.length].type === 'i32' ? typed(['i32.const', 0], 'i32') : undefExpr())
378
523
  const temps = Array.from({ length: n }, () => temp())
379
524
  const out = allocPtr({ type: 1, len: n, tag: 'marr' })
380
525
  const ir = [out.init, ['call', `$${name}`, ...emittedArgs]]
@@ -391,13 +536,13 @@ export function emitDecl(...inits) {
391
536
  for (let ii = 0; ii < inits.length; ii++) {
392
537
  const i = inits[ii]
393
538
  if (typeof i === 'string') {
394
- const undef = nullExpr()
539
+ const undef = undefExpr()
395
540
  if (ctx.func.boxed.has(i)) {
396
541
  const cell = ctx.func.boxed.get(i)
397
542
  ctx.func.locals.set(cell, 'i32')
398
- result.push(
399
- ['local.set', `$${cell}`, ['call', '$__alloc', ['i32.const', 8]]],
400
- ['f64.store', ['local.get', `$${cell}`], undef])
543
+ if (!ctx.func.preboxed?.has(i))
544
+ result.push(['local.set', `$${cell}`, ['call', '$__alloc', ['i32.const', 8]]])
545
+ result.push(['f64.store', ['local.get', `$${cell}`], undef])
401
546
  continue
402
547
  }
403
548
  if (isGlobal(i)) {
@@ -436,7 +581,7 @@ export function emitDecl(...inits) {
436
581
  ? rawArgs[0].slice(1) : rawArgs
437
582
  const emittedArgs = argList.map((a, k) => emitArgForParam(emit(a), func.sig.params[k]))
438
583
  while (emittedArgs.length < func.sig.params.length)
439
- emittedArgs.push(func.sig.params[emittedArgs.length].type === 'i32' ? typed(['i32.const', 0], 'i32') : nullExpr())
584
+ emittedArgs.push(func.sig.params[emittedArgs.length].type === 'i32' ? typed(['i32.const', 0], 'i32') : undefExpr())
440
585
  result.push(['call', `$${init[1]}`, ...emittedArgs])
441
586
  for (let k = n - 1; k >= 0; k--)
442
587
  result.push(['local.set', `$${targets[k]}`])
@@ -446,14 +591,14 @@ export function emitDecl(...inits) {
446
591
  }
447
592
  }
448
593
  const isObjLit = Array.isArray(init) && init[0] === '{}'
449
- if (isObjLit) ctx.schema.targetStack.push(name)
594
+ if (isObjLit) ctx.schema.targetStack.push({ name, active: true })
450
595
  const val = emit(init)
451
596
  if (isObjLit) ctx.schema.targetStack.pop()
452
597
  // Direct-call dispatch for const-bound, non-escaping local closures: skip call_indirect.
453
598
  // Gate: not boxed (no mutable cross-fn capture), not global, not reassigned in this body.
454
599
  // isReassigned is conservative across nested arrow shadows — we miss the optimization
455
600
  // rather than emit a wrong direct call.
456
- if (val?.closureBodyName && !ctx.func.boxed.has(name) && !isGlobal(name)
601
+ if (Array.isArray(init) && init[0] === '=>' && val?.closureBodyName && !ctx.func.boxed.has(name) && !isGlobal(name)
457
602
  && ctx.func.body && !isReassigned(ctx.func.body, name)) {
458
603
  if (!ctx.func.directClosures) ctx.func.directClosures = new Map()
459
604
  ctx.func.directClosures.set(name, val.closureBodyName)
@@ -461,9 +606,9 @@ export function emitDecl(...inits) {
461
606
  if (ctx.func.boxed.has(name)) {
462
607
  const cell = ctx.func.boxed.get(name)
463
608
  ctx.func.locals.set(cell, 'i32')
464
- result.push(
465
- ['local.set', `$${cell}`, ['call', '$__alloc', ['i32.const', 8]]],
466
- ['f64.store', ['local.get', `$${cell}`], asF64(val)])
609
+ if (!ctx.func.preboxed?.has(name))
610
+ result.push(['local.set', `$${cell}`, ['call', '$__alloc', ['i32.const', 8]]])
611
+ result.push(['f64.store', ['local.get', `$${cell}`], asF64(val)])
467
612
  continue
468
613
  }
469
614
  if (isGlobal(name)) {
@@ -510,21 +655,21 @@ export function emitDecl(...inits) {
510
655
  } else {
511
656
  coerced = localType === 'f64' ? asF64(val) : asI32(val)
512
657
  }
513
- if (!(isLit(coerced) && coerced[1] === 0 && !ctx.func.stack.length))
658
+ if (!(isLit(coerced) && coerced[1] === 0 && !Object.is(coerced[1], -0) && !ctx.func.stack.length))
514
659
  result.push(['local.set', `$${name}`, coerced])
515
660
 
516
661
  const schemaId = ctx.schema.idOf?.(name)
517
662
  if (ctx.func.localProps?.has(name) && schemaId != null) {
518
663
  const schema = ctx.schema.resolve(name)
519
664
  if (schema?.[0] === '__inner__') {
520
- inc('__alloc', '__mkptr')
665
+ inc('__alloc_hdr', '__mkptr')
521
666
  const bt = `${T}bx${ctx.func.uniq++}`
522
667
  ctx.func.locals.set(bt, 'i32')
523
668
  const innerName = `${name}${T}inner`
524
669
  ctx.func.locals.set(innerName, 'f64')
525
670
  result.push(
526
671
  ['local.set', `$${innerName}`, ['local.get', `$${name}`]],
527
- ['local.set', `$${bt}`, ['call', '$__alloc', ['i32.const', schema.length * 8]]],
672
+ ['local.set', `$${bt}`, ['call', '$__alloc_hdr', ['i32.const', 0], ['i32.const', Math.max(1, schema.length)], ['i32.const', 8]]],
528
673
  ['f64.store', ['local.get', `$${bt}`], ['local.get', `$${name}`]],
529
674
  ...schema.slice(1).map((_, j) =>
530
675
  ['f64.store', ['i32.add', ['local.get', `$${bt}`], ['i32.const', (j + 1) * 8]], ['f64.const', 0]]),
@@ -599,11 +744,11 @@ export function buildArrayWithSpreads(items) {
599
744
  const n = multiCount(sec.expr)
600
745
  ir.push(['local.set', `$${sec.local}`, n ? materializeMulti(sec.expr) : asF64(emit(sec.expr))])
601
746
  if (sec.baseLocal) {
602
- ir.push(['local.set', `$${sec.baseLocal}`, ['call', '$__ptr_offset', ['local.get', `$${sec.local}`]]])
747
+ ir.push(['local.set', `$${sec.baseLocal}`, ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['local.get', `$${sec.local}`]]]])
603
748
  ir.push(['local.set', `$${sec.lenLocal}`, ['i32.load', ['i32.sub', ['local.get', `$${sec.baseLocal}`], ['i32.const', 8]]]])
604
749
  } else {
605
750
  // Cache __len once per spread; reused below for total-len sum and inner copy bound.
606
- ir.push(['local.set', `$${sec.lenLocal}`, ['call', '$__len', ['local.get', `$${sec.local}`]]])
751
+ ir.push(['local.set', `$${sec.lenLocal}`, ['call', '$__len', ['i64.reinterpret_f64', ['local.get', `$${sec.local}`]]]])
607
752
  }
608
753
  }
609
754
  }
@@ -636,12 +781,10 @@ export function buildArrayWithSpreads(items) {
636
781
  ? ['f64.load', ['i32.add', ['local.get', `$${sec.baseLocal}`], ['i32.shl', ['local.get', `$${sidx}`], ['i32.const', 3]]]]
637
782
  : ctx.module.modules['string']
638
783
  ? ['if', ['result', 'f64'],
639
- ['i32.or',
640
- ['i32.eq', ['call', '$__ptr_type', ['local.get', `$${sec.local}`]], ['i32.const', PTR.STRING]],
641
- ['i32.eq', ['call', '$__ptr_type', ['local.get', `$${sec.local}`]], ['i32.const', PTR.SSO]]],
642
- ['then', (inc('__str_idx'), ['call', '$__str_idx', ['local.get', `$${sec.local}`], ['local.get', `$${sidx}`]])],
643
- ['else', (inc('__typed_idx'), ['call', '$__typed_idx', ['local.get', `$${sec.local}`], ['local.get', `$${sidx}`]])]]
644
- : (inc('__typed_idx'), ['call', '$__typed_idx', ['local.get', `$${sec.local}`], ['local.get', `$${sidx}`]])
784
+ ['i32.eq', ['call', '$__ptr_type', ['i64.reinterpret_f64', ['local.get', `$${sec.local}`]]], ['i32.const', PTR.STRING]],
785
+ ['then', (inc('__str_idx'), ['call', '$__str_idx', ['i64.reinterpret_f64', ['local.get', `$${sec.local}`]], ['local.get', `$${sidx}`]])],
786
+ ['else', (inc('__typed_idx'), ['call', '$__typed_idx', ['i64.reinterpret_f64', ['local.get', `$${sec.local}`]], ['local.get', `$${sidx}`]])]]
787
+ : (inc('__typed_idx'), ['call', '$__typed_idx', ['i64.reinterpret_f64', ['local.get', `$${sec.local}`]], ['local.get', `$${sidx}`]])
645
788
  ir.push(
646
789
  ['local.set', `$${sidx}`, ['i32.const', 0]],
647
790
  ['block', `$break${loopId}`, ['loop', `$loop${loopId}`,
@@ -710,6 +853,20 @@ export function emitBody(node) {
710
853
  const cmpOp = (i32op, f64op, fn) => (a, b) => {
711
854
  const va = emit(a), vb = emit(b)
712
855
  if (isLit(va) && isLit(vb)) return emitNum(fn(litVal(va), litVal(vb)) ? 1 : 0)
856
+ // String compare: NaN-boxed string pointers compare as NaN under f64.lt/gt
857
+ // (always false), so without this the spec-correct `"a" < "b"` returns 0.
858
+ // Route both-STRING operands through __str_cmp's three-way result, then apply
859
+ // the same i32 sign op as numeric (lt_s/gt_s/le_s/ge_s vs 0).
860
+ const vta = resolveValType(a, valTypeOf, lookupValType)
861
+ const vtb = resolveValType(b, valTypeOf, lookupValType)
862
+ if (vta === VAL.BIGINT || vtb === VAL.BIGINT) {
863
+ const op = bigintUnsignedBound(a) || bigintUnsignedBound(b) ? i32op.replace('_s', '_u') : i32op
864
+ return typed([`i64.${op}`, asI64(va), asI64(vb)], 'i32')
865
+ }
866
+ if (vta === VAL.STRING && vtb === VAL.STRING) {
867
+ inc('__str_cmp')
868
+ return typed([`i32.${i32op}`, ['call', '$__str_cmp', asI64(va), asI64(vb)], ['i32.const', 0]], 'i32')
869
+ }
713
870
  const ai = intConstValue(a), bi = intConstValue(b)
714
871
  if (va.type === 'i32' && bi != null) return typed([`i32.${i32op}`, va, ['i32.const', bi]], 'i32')
715
872
  if (vb.type === 'i32' && ai != null) return typed([`i32.${i32op}`, ['i32.const', ai], vb], 'i32')
@@ -727,13 +884,45 @@ function intConstValue(expr) {
727
884
  return null
728
885
  }
729
886
 
887
+ function bigintUnsignedBound(expr) {
888
+ const n = bigintConstValue(expr)
889
+ return n != null && n > 0x7fffffffffffffffn && n <= 0xffffffffffffffffn
890
+ }
891
+
892
+ function bigintConstValue(expr) {
893
+ if (typeof expr === 'bigint') return expr
894
+ if (!Array.isArray(expr)) return null
895
+ if (expr[0] == null && typeof expr[1] === 'bigint') return expr[1]
896
+ if (expr[0] === 'u-') {
897
+ const n = bigintConstValue(expr[1])
898
+ return n == null ? null : -n
899
+ }
900
+ return null
901
+ }
902
+
903
+ function arrayIndexKey(key) {
904
+ const n = Number(key)
905
+ const u = n >>> 0
906
+ return String(u) === key && u !== 0xffffffff ? u : null
907
+ }
908
+
730
909
  /** Compound assignment: read → op → write back (via readVar/writeVar). */
731
910
  function compoundAssign(name, val, f64op, i32op) {
732
911
  if (typeof name === 'string' && isConst(name)) err(`Assignment to const '${name}'`)
733
912
  const void_ = _expect === 'void'
734
913
  const va = readVar(name), vb = emit(val)
735
- if (i32op && va.type === 'i32' && vb.type === 'i32')
736
- return writeVar(name, i32op(va, vb), void_)
914
+ // Peel f64.convert_i32_s/u when va is i32 typed-array integer reads wrap their
915
+ // i32.load in convert_i32_* by default, but the i32 arithmetic path can use the
916
+ // raw i32 directly (eliminates per-iter widen + saturating-trunc roundtrip on
917
+ // hot accumulator loops like `let s = 0; for (...) s += i32arr[i]`).
918
+ let vbi = vb
919
+ if (i32op && va.type === 'i32' && vb.type !== 'i32' &&
920
+ Array.isArray(vb) && (vb[0] === 'f64.convert_i32_s' || vb[0] === 'f64.convert_i32_u')) {
921
+ const inner = vb[1]
922
+ vbi = Array.isArray(inner) ? typed(inner, 'i32') : inner
923
+ }
924
+ if (i32op && va.type === 'i32' && vbi.type === 'i32')
925
+ return writeVar(name, i32op(va, vbi), void_)
737
926
  return writeVar(name, f64op(asF64(va), asF64(vb)), void_)
738
927
  }
739
928
 
@@ -880,6 +1069,7 @@ export const emitter = {
880
1069
 
881
1070
  '=': (name, val) => {
882
1071
  if (typeof name === 'string' && isConst(name)) err(`Assignment to const '${name}'`)
1072
+ const void_ = _expect === 'void'
883
1073
  // Array index assignment: arr[i] = x
884
1074
  if (Array.isArray(name) && name[0] === '[]') {
885
1075
  const [, arr, idx] = name
@@ -899,28 +1089,34 @@ export const emitter = {
899
1089
  ['local.set', `$${arrTmp}`, arrExpr],
900
1090
  ['local.set', `$${idxTmp}`, asI32(typed(idxNode, 'f64'))],
901
1091
  ['local.set', `$${valTmp}`, valueExpr],
902
- ['local.set', `$${arrTmp}`, ['call', '$__arr_set_idx_ptr', ['local.get', `$${arrTmp}`], ['local.get', `$${idxTmp}`], ['local.get', `$${valTmp}`]]],
1092
+ ['local.set', `$${arrTmp}`, ['call', '$__arr_set_idx_ptr', ['i64.reinterpret_f64', ['local.get', `$${arrTmp}`]], ['local.get', `$${idxTmp}`], ['local.get', `$${valTmp}`]]],
903
1093
  ]
904
1094
  if (persist) body.push(persist(['local.get', `$${arrTmp}`]))
905
1095
  body.push(['local.get', `$${valTmp}`])
906
1096
  return typed(['block', ['result', 'f64'], ...body], 'f64')
907
1097
  }
908
1098
  const setDyn = () => {
1099
+ if (ctx.transform.strict)
1100
+ err(`strict mode: dynamic property assignment \`${typeof arr === 'string' ? arr : '<expr>'}[<expr>] = ...\` falls back to __dyn_set. Use a literal key or known array/typed-array numeric index, or pass { strict: false }.`)
909
1101
  inc('__dyn_set')
910
- return typed(['call', '$__dyn_set', asF64(emit(arr)), keyExpr, valueExpr], 'f64')
1102
+ return typed(['f64.reinterpret_i64', ['call', '$__dyn_set', asI64(emit(arr)), asI64(keyExpr), asI64(valueExpr)]], 'f64')
911
1103
  }
912
1104
  const dispatchKey = (numericIR) => {
1105
+ if (ctx.transform.strict)
1106
+ err(`strict mode: dynamic property assignment \`${typeof arr === 'string' ? arr : '<expr>'}[<expr>] = ...\` falls back to __dyn_set. Use a literal key or known array/typed-array numeric index, or pass { strict: false }.`)
913
1107
  const keyTmp = temp()
914
1108
  return typed(['block', ['result', 'f64'],
915
1109
  ['local.set', `$${keyTmp}`, keyExpr],
916
- ['if', ['result', 'f64'], ['call', '$__is_str_key', ['local.get', `$${keyTmp}`]],
917
- ['then', ['call', '$__dyn_set', asF64(emit(arr)), ['local.get', `$${keyTmp}`], valueExpr]],
1110
+ ['if', ['result', 'f64'], ['call', '$__is_str_key', ['i64.reinterpret_f64', ['local.get', `$${keyTmp}`]]],
1111
+ ['then', ['f64.reinterpret_i64', ['call', '$__dyn_set', asI64(emit(arr)), ['i64.reinterpret_f64', ['local.get', `$${keyTmp}`]], asI64(valueExpr)]]],
918
1112
  ['else', numericIR(['local.get', `$${keyTmp}`])]]], 'f64')
919
1113
  }
920
1114
  // Literal string key on schema-known object → direct payload slot write (skip __dyn_set)
921
- const litKey = Array.isArray(idx) && idx[0] === 'str' && typeof idx[1] === 'string' ? idx[1] : null
1115
+ const litKey = isLiteralStr(idx) ? idx[1]
1116
+ : typeof arr === 'string' && lookupValType(arr) === VAL.OBJECT ? staticPropertyKey(idx)
1117
+ : null
922
1118
  if (litKey != null && typeof arr === 'string' && ctx.schema.find) {
923
- const slot = ctx.schema.find(arr, litKey, true)
1119
+ const slot = ctx.schema.find(arr, litKey)
924
1120
  if (slot >= 0) {
925
1121
  const t = temp()
926
1122
  return typed(['block', ['result', 'f64'],
@@ -931,11 +1127,25 @@ export const emitter = {
931
1127
  ['local.get', `$${t}`]], 'f64')
932
1128
  }
933
1129
  }
1130
+ const arrIndex = litKey != null ? arrayIndexKey(litKey) : null
1131
+ if (arrIndex != null && typeof arr === 'string' && keyValType(arr) === VAL.ARRAY) {
1132
+ const persist = ptr => {
1133
+ if (ctx.func.boxed?.has(arr)) return ['f64.store', boxedAddr(arr), ptr]
1134
+ if (isGlobal(arr)) return ['global.set', `$${arr}`, ptr]
1135
+ return ['local.set', `$${arr}`, ptr]
1136
+ }
1137
+ return storeArrayValue(asF64(emit(arr)), typed(['f64.const', arrIndex], 'f64'), persist)
1138
+ }
934
1139
  if (keyType === VAL.STRING) return setDyn()
935
1140
  if (typeof arr === 'string' && ctx.core.emit['.typed:[]='] &&
936
1141
  lookupValType(arr) === 'typed') {
937
- const r = ctx.core.emit['.typed:[]=']?.(arr, idx, val)
1142
+ const r = ctx.core.emit['.typed:[]=']?.(arr, idx, val, void_)
938
1143
  if (r) return r
1144
+ // Element ctor unknown — runtime aux-byte dispatch. __typed_set_idx
1145
+ // returns the stored value as f64, used directly as the expr result.
1146
+ inc('__typed_set_idx')
1147
+ return typed(['call', '$__typed_set_idx',
1148
+ asI64(emit(arr)), asI32(emit(idx)), valueExpr], 'f64')
939
1149
  }
940
1150
  if (typeof arr === 'string' && ctx.schema.isBoxed?.(arr)) {
941
1151
  const inner = ctx.schema.emitInner(arr)
@@ -962,9 +1172,34 @@ export const emitter = {
962
1172
  return storeArrayValue(asF64(va), keyExpr, persist)
963
1173
  }
964
1174
  // arr is non-ARRAY here (VAL.ARRAY branch was taken above); safe to skip forwarding.
965
- const arrVT = (typeof arr === 'string' ? lookupValType(arr) : null) || VAL.OBJECT
1175
+ const knownArrVT = typeof arr === 'string' ? lookupValType(arr) : null
1176
+ const arrVT = knownArrVT || VAL.OBJECT
966
1177
  if (useRuntimeKeyDispatch) {
967
1178
  inc('__dyn_set', '__is_str_key')
1179
+ // When arr type is unknown (could be TypedArray) and __typed_set_idx is
1180
+ // available, dispatch the numeric branch through __ptr_type so TypedArray
1181
+ // writes go by element type. Without this, ternary-typed arrays (e.g.
1182
+ // `num === 4 ? new Uint32Array(4) : new Uint8Array(16)`) would silently
1183
+ // f64.store boxed bytes regardless of element width.
1184
+ const hasTypedSet = !!ctx.core.stdlib['__typed_set_idx']
1185
+ if (knownArrVT == null && hasTypedSet) {
1186
+ const objTmp = temp('asu')
1187
+ const idxTmp = tempI32('asi')
1188
+ inc('__ptr_type', '__typed_set_idx')
1189
+ return dispatchKey(keyNode => {
1190
+ const keyI32 = asI32(typed(keyNode, 'f64'))
1191
+ return ['block', ['result', 'f64'],
1192
+ ['local.set', `$${objTmp}`, asF64(va)],
1193
+ ['local.set', `$${idxTmp}`, keyI32],
1194
+ ['local.set', `$${t}`, vv],
1195
+ ['if', ['result', 'f64'],
1196
+ ['i32.eq', ['call', '$__ptr_type', ['i64.reinterpret_f64', ['local.get', `$${objTmp}`]]], ['i32.const', PTR.TYPED]],
1197
+ ['then', ['call', '$__typed_set_idx', ['i64.reinterpret_f64', ['local.get', `$${objTmp}`]], ['local.get', `$${idxTmp}`], ['local.get', `$${t}`]]],
1198
+ ['else', ['block', ['result', 'f64'],
1199
+ ['f64.store', ['i32.add', ptrOffsetIR(['local.get', `$${objTmp}`], arrVT), ['i32.shl', ['local.get', `$${idxTmp}`], ['i32.const', 3]]], ['local.get', `$${t}`]],
1200
+ ['local.get', `$${t}`]]]]]
1201
+ })
1202
+ }
968
1203
  return dispatchKey(keyNode => {
969
1204
  const keyI32 = asI32(typed(keyNode, 'f64'))
970
1205
  return ['block', ['result', 'f64'],
@@ -973,6 +1208,62 @@ export const emitter = {
973
1208
  ['local.get', `$${t}`]]
974
1209
  })
975
1210
  }
1211
+ if (typeof arr !== 'string') {
1212
+ const objTmp = temp('asu')
1213
+ const idxTmp = tempI32('asi')
1214
+ const ptrTmp = temp('asp')
1215
+ const hasTypedSet = !!ctx.core.stdlib['__typed_set_idx']
1216
+ inc('__ptr_type', '__arr_set_idx_ptr')
1217
+ if (hasTypedSet) inc('__typed_set_idx')
1218
+ return typed(['block', ['result', 'f64'],
1219
+ ['local.set', `$${objTmp}`, asF64(va)],
1220
+ ['local.set', `$${idxTmp}`, vi],
1221
+ ['local.set', `$${t}`, vv],
1222
+ ['if', ['result', 'f64'],
1223
+ ['i32.eq', ['call', '$__ptr_type', ['i64.reinterpret_f64', ['local.get', `$${objTmp}`]]], ['i32.const', PTR.ARRAY]],
1224
+ ['then', ['block', ['result', 'f64'],
1225
+ ['local.set', `$${ptrTmp}`, ['call', '$__arr_set_idx_ptr', ['i64.reinterpret_f64', ['local.get', `$${objTmp}`]], ['local.get', `$${idxTmp}`], ['local.get', `$${t}`]]],
1226
+ ['local.get', `$${t}`]]],
1227
+ ['else', hasTypedSet ? ['if', ['result', 'f64'],
1228
+ ['i32.eq', ['call', '$__ptr_type', ['i64.reinterpret_f64', ['local.get', `$${objTmp}`]]], ['i32.const', PTR.TYPED]],
1229
+ ['then', ['call', '$__typed_set_idx', ['i64.reinterpret_f64', ['local.get', `$${objTmp}`]], ['local.get', `$${idxTmp}`], ['local.get', `$${t}`]]],
1230
+ ['else', ['block', ['result', 'f64'],
1231
+ ['f64.store', ['i32.add', ptrOffsetIR(['local.get', `$${objTmp}`], arrVT), ['i32.shl', ['local.get', `$${idxTmp}`], ['i32.const', 3]]], ['local.get', `$${t}`]],
1232
+ ['local.get', `$${t}`]]]] : ['block', ['result', 'f64'],
1233
+ ['f64.store', ['i32.add', ptrOffsetIR(['local.get', `$${objTmp}`], arrVT), ['i32.shl', ['local.get', `$${idxTmp}`], ['i32.const', 3]]], ['local.get', `$${t}`]],
1234
+ ['local.get', `$${t}`]]]]], 'f64')
1235
+ }
1236
+ if (typeof arr === 'string' && knownArrVT == null) {
1237
+ const objTmp = temp('asu')
1238
+ const idxTmp = tempI32('asi')
1239
+ const ptrTmp = temp('asp')
1240
+ const hasTypedSet = !!ctx.core.stdlib['__typed_set_idx']
1241
+ inc('__ptr_type', '__arr_set_idx_ptr')
1242
+ if (hasTypedSet) inc('__typed_set_idx')
1243
+ const persist = ptr => {
1244
+ if (ctx.func.boxed?.has(arr)) return ['f64.store', boxedAddr(arr), ptr]
1245
+ if (isGlobal(arr)) return ['global.set', `$${arr}`, ptr]
1246
+ return ['local.set', `$${arr}`, ptr]
1247
+ }
1248
+ return typed(['block', ['result', 'f64'],
1249
+ ['local.set', `$${objTmp}`, asF64(va)],
1250
+ ['local.set', `$${idxTmp}`, vi],
1251
+ ['local.set', `$${t}`, vv],
1252
+ ['if', ['result', 'f64'],
1253
+ ['i32.eq', ['call', '$__ptr_type', ['i64.reinterpret_f64', ['local.get', `$${objTmp}`]]], ['i32.const', PTR.ARRAY]],
1254
+ ['then', ['block', ['result', 'f64'],
1255
+ ['local.set', `$${ptrTmp}`, ['call', '$__arr_set_idx_ptr', ['i64.reinterpret_f64', ['local.get', `$${objTmp}`]], ['local.get', `$${idxTmp}`], ['local.get', `$${t}`]]],
1256
+ persist(['local.get', `$${ptrTmp}`]),
1257
+ ['local.get', `$${t}`]]],
1258
+ ['else', hasTypedSet ? ['if', ['result', 'f64'],
1259
+ ['i32.eq', ['call', '$__ptr_type', ['i64.reinterpret_f64', ['local.get', `$${objTmp}`]]], ['i32.const', PTR.TYPED]],
1260
+ ['then', ['call', '$__typed_set_idx', ['i64.reinterpret_f64', ['local.get', `$${objTmp}`]], ['local.get', `$${idxTmp}`], ['local.get', `$${t}`]]],
1261
+ ['else', ['block', ['result', 'f64'],
1262
+ ['f64.store', ['i32.add', ptrOffsetIR(['local.get', `$${objTmp}`], arrVT), ['i32.shl', ['local.get', `$${idxTmp}`], ['i32.const', 3]]], ['local.get', `$${t}`]],
1263
+ ['local.get', `$${t}`]]]] : ['block', ['result', 'f64'],
1264
+ ['f64.store', ['i32.add', ptrOffsetIR(['local.get', `$${objTmp}`], arrVT), ['i32.shl', ['local.get', `$${idxTmp}`], ['i32.const', 3]]], ['local.get', `$${t}`]],
1265
+ ['local.get', `$${t}`]]]]], 'f64')
1266
+ }
976
1267
  return typed(['block', ['result', 'f64'],
977
1268
  ['local.set', `$${t}`, vv],
978
1269
  ['f64.store', ['i32.add', ptrOffsetIR(asF64(va), arrVT), ['i32.shl', vi, ['i32.const', 3]]], ['local.get', `$${t}`]],
@@ -982,10 +1273,8 @@ export const emitter = {
982
1273
  if (Array.isArray(name) && name[0] === '.') {
983
1274
  const [, obj, prop] = name
984
1275
  // Schema-based object → f64.store at fixed offset.
985
- // safe=true: skip structural subtyping when variable's type is unknown,
986
- // otherwise a slot write could clobber an array/string's payload.
987
1276
  if (typeof obj === 'string' && ctx.schema.find) {
988
- const idx = ctx.schema.find(obj, prop, true)
1277
+ const idx = ctx.schema.find(obj, prop)
989
1278
  if (idx >= 0) {
990
1279
  const va = emit(obj), vv = asF64(emit(val)), t = temp()
991
1280
  const shadow = needsDynShadow(obj)
@@ -995,39 +1284,60 @@ export const emitter = {
995
1284
  ['f64.store', ['i32.add', ptrOffsetIR(asF64(va), lookupValType(obj) || VAL.OBJECT), ['i32.const', idx * 8]], ['local.get', `$${t}`]],
996
1285
  ]
997
1286
  if (shadow)
998
- stmts.push(['drop', ['call', '$__dyn_set', asF64(va), asF64(emit(['str', prop])), ['local.get', `$${t}`]]])
1287
+ stmts.push(['drop', ['call', '$__dyn_set', asI64(va), asI64(emit(['str', prop])), ['i64.reinterpret_f64', ['local.get', `$${t}`]]]])
999
1288
  stmts.push(['local.get', `$${t}`])
1000
1289
  return typed(['block', ['result', 'f64'], ...stmts], 'f64')
1001
1290
  }
1002
1291
  }
1003
1292
  if (typeof obj === 'string') {
1004
1293
  const objType = keyValType(obj)
1005
- if (usesDynProps(objType)) {
1294
+ // OBJECT receivers (incl. JSON.parse-derived bindings) with off-schema
1295
+ // properties go through __dyn_set, which writes to the per-OBJECT
1296
+ // propsPtr at off-16 — same path as object-literal dyn shadow writes
1297
+ // (module/object.js). __hash_set assumes HASH bucket layout and would
1298
+ // corrupt OBJECT memory.
1299
+ if (usesDynProps(objType) || objType === VAL.OBJECT) {
1006
1300
  inc('__dyn_set')
1007
- return typed(['call', '$__dyn_set', asF64(emit(obj)), asF64(emit(['str', prop])), asF64(emit(val))], 'f64')
1301
+ return typed(['f64.reinterpret_i64', ['call', '$__dyn_set', asI64(emit(obj)), asI64(emit(['str', prop])), asI64(emit(val))]], 'f64')
1302
+ }
1303
+ if (ctx.func.names.has(obj) && !ctx.func.locals?.has(obj) && !ctx.func.current?.params?.some(p => p.name === obj)) {
1304
+ inc('__dyn_set')
1305
+ return typed(['f64.reinterpret_i64', ['call', '$__dyn_set', asI64(emit(obj)), asI64(emit(['str', prop])), asI64(emit(val))]], 'f64')
1306
+ }
1307
+ if (objType == null && ctx.transform.host !== 'wasi') {
1308
+ ctx.features.external = true
1008
1309
  }
1009
- if (objType == null) ctx.features.external = true
1010
1310
  inc('__hash_set')
1011
- const setCall = typed(['call', '$__hash_set', asF64(emit(obj)), asF64(emit(['str', prop])), asF64(emit(val))], 'f64')
1311
+ const setCall = typed(['f64.reinterpret_i64', ['call', '$__hash_set', asI64(emit(obj)), asI64(emit(['str', prop])), asI64(emit(val))]], 'f64')
1012
1312
  if (isGlobal(obj)) return typed(['block', ['result', 'f64'],
1013
1313
  ['global.set', `$${obj}`, setCall], ['global.get', `$${obj}`]], 'f64')
1314
+ // Closure-captured (boxed) locals store the value at the cell address — local.tee
1315
+ // would write to the i32 cell pointer, not the f64 value. Route through writeVar.
1316
+ if (ctx.func.boxed?.has(obj)) return writeVar(obj, setCall, false)
1014
1317
  return typed(['local.tee', `$${obj}`, setCall], 'f64')
1015
1318
  }
1016
- ctx.features.external = true
1319
+ if (ctx.transform.host !== 'wasi') ctx.features.external = true
1017
1320
  inc('__dyn_set')
1018
- return typed(['call', '$__dyn_set', asF64(emit(obj)), asF64(emit(['str', prop])), asF64(emit(val))], 'f64')
1321
+ return typed(['f64.reinterpret_i64', ['call', '$__dyn_set', asI64(emit(obj)), asI64(emit(['str', prop])), asI64(emit(val))]], 'f64')
1019
1322
  }
1020
1323
  if (typeof name !== 'string') err(`Assignment to non-variable: ${JSON.stringify(name)}`)
1021
- const void_ = _expect === 'void'
1324
+ if (Array.isArray(val) && val[0] === 'u+' && val[1] === name) {
1325
+ inc('__to_num')
1326
+ return writeVar(name, typed(['call', '$__to_num', asI64(emit(name))], 'f64'), void_)
1327
+ }
1022
1328
  return writeVar(name, emit(val), void_)
1023
1329
  },
1024
1330
 
1025
1331
  // Compound assignments: read-modify-write with type coercion
1026
1332
  '+=': (name, val) => {
1027
- // String concatenation: desugar to name = name + val (+ handler knows about strings)
1333
+ // String concatenation: desugar to name = name + val (+ handler knows about strings).
1334
+ // Also desugar when either side has unknown type — the `+` operator picks runtime
1335
+ // string/numeric dispatch (`__is_str_key`); compoundAssign would force f64.add and
1336
+ // silently corrupt string concatenations through unknown-typed values.
1028
1337
  const vt = typeof name === 'string' ? keyValType(name) : null
1029
1338
  const vtB = keyValType(val)
1030
1339
  if (vt === VAL.STRING || vtB === VAL.STRING) return emit(['=', name, ['+', name, val]])
1340
+ if ((vt == null || vtB == null) && ctx.core.stdlib['__str_concat']) return emit(['=', name, ['+', name, val]])
1031
1341
  return compoundAssign(name, val, (a, b) => typed(['f64.add', a, b], 'f64'), (a, b) => typed(['i32.add', a, b], 'i32'))
1032
1342
  },
1033
1343
  ...Object.fromEntries([
@@ -1068,11 +1378,8 @@ export const emitter = {
1068
1378
  const t = temp()
1069
1379
  const va = readVar(name)
1070
1380
  // Condition: ||= → truthy check, &&= → truthy check, ??= → nullish check
1071
- const cond = op === '??='
1072
- ? isNullish(['local.tee', `$${t}`, asF64(va)])
1073
- : ['i32.and',
1074
- ['f64.eq', ['local.tee', `$${t}`, asF64(va)], ['local.get', `$${t}`]],
1075
- ['f64.ne', ['local.get', `$${t}`], ['f64.const', 0]]]
1381
+ const lhs = typed(['local.tee', `$${t}`, asF64(va)], 'f64')
1382
+ const cond = op === '??=' ? isNullish(lhs) : truthyIR(lhs)
1076
1383
  // &&= and ??= assign when cond is true (truthy / nullish); ||= assigns when cond is false
1077
1384
  const [thenExpr, elseExpr] = op === '||='
1078
1385
  ? [['local.get', `$${t}`], asF64(emit(val))]
@@ -1109,12 +1416,25 @@ export const emitter = {
1109
1416
  const vtA = keyValType(a)
1110
1417
  const vtB = keyValType(b)
1111
1418
  if (vtA === VAL.STRING && vtB === VAL.STRING) {
1419
+ // Fused append-byte: `buf += s[i]` skips 1-char SSO construction +
1420
+ // generic concat dispatch when rhs is a string-index. The byte flows
1421
+ // straight from __char_at into memory, and the bump-extend path elides
1422
+ // the alloc+copy when lhs is the heap-top STRING.
1423
+ if (Array.isArray(b) && b[0] === '[]' && ctx.core.stdlib['__str_append_byte'] && ctx.core.stdlib['__char_at']) {
1424
+ if (keyValType(b[1]) === VAL.STRING) {
1425
+ inc('__str_append_byte', '__char_at')
1426
+ return typed(['call', '$__str_append_byte',
1427
+ asI64(emit(a)),
1428
+ ['call', '$__char_at', asI64(emit(b[1])), asI32(emit(b[2]))],
1429
+ ], 'f64')
1430
+ }
1431
+ }
1112
1432
  inc('__str_concat_raw')
1113
- return typed(['call', '$__str_concat_raw', asF64(emit(a)), asF64(emit(b))], 'f64')
1433
+ return typed(['call', '$__str_concat_raw', asI64(emit(a)), asI64(emit(b))], 'f64')
1114
1434
  }
1115
1435
  if (vtA === VAL.STRING || vtB === VAL.STRING) {
1116
1436
  inc('__str_concat')
1117
- return typed(['call', '$__str_concat', asF64(emit(a)), asF64(emit(b))], 'f64')
1437
+ return typed(['call', '$__str_concat', asI64(emit(a)), asI64(emit(b))], 'f64')
1118
1438
  }
1119
1439
  if (vtA === VAL.BIGINT || vtB === VAL.BIGINT)
1120
1440
  return fromI64(['i64.add', asI64(emit(a)), asI64(emit(b))])
@@ -1125,9 +1445,9 @@ export const emitter = {
1125
1445
  if ((vtA == null || vtB == null) && ctx.core.stdlib['__str_concat']) {
1126
1446
  const tA = temp('add'), tB = temp('add')
1127
1447
  inc('__str_concat', '__is_str_key')
1128
- const checkA = vtA == null ? ['call', '$__is_str_key', ['local.tee', `$${tA}`, asF64(emit(a))]] : null
1129
- const checkB = vtB == null ? ['call', '$__is_str_key', ['local.tee', `$${tB}`, asF64(emit(b))]] : null
1130
- const concat = ['call', '$__str_concat', ['local.get', `$${tA}`], ['local.get', `$${tB}`]]
1448
+ const checkA = vtA == null ? ['call', '$__is_str_key', ['i64.reinterpret_f64', ['local.tee', `$${tA}`, asF64(emit(a))]]] : null
1449
+ const checkB = vtB == null ? ['call', '$__is_str_key', ['i64.reinterpret_f64', ['local.tee', `$${tB}`, asF64(emit(b))]]] : null
1450
+ const concat = ['call', '$__str_concat', ['i64.reinterpret_f64', ['local.get', `$${tA}`]], ['i64.reinterpret_f64', ['local.get', `$${tB}`]]]
1131
1451
  const add = ['f64.add', ['local.get', `$${tA}`], ['local.get', `$${tB}`]]
1132
1452
  if (checkA && checkB) {
1133
1453
  return typed(['if', ['result', 'f64'], ['i32.or', checkA, checkB], ['then', concat], ['else', add]], 'f64')
@@ -1139,8 +1459,8 @@ export const emitter = {
1139
1459
  ['if', ['result', 'f64'], checkA ?? checkB, ['then', concat], ['else', add]]
1140
1460
  ], 'f64')
1141
1461
  }
1142
- const va = emit(a), vb = emit(b)
1143
- if (isLit(va) && isLit(vb)) return emitNum(litVal(va) + litVal(vb))
1462
+ const va = emit(a), vb = emit(b), _f = foldConst(va, vb, (a, b) => a + b)
1463
+ if (_f) return _f
1144
1464
  if (isLit(vb) && litVal(vb) === 0) return va
1145
1465
  if (isLit(va) && litVal(va) === 0) return vb
1146
1466
  if (va.type === 'i32' && vb.type === 'i32') return typed(['i32.add', va, vb], 'i32')
@@ -1152,9 +1472,9 @@ export const emitter = {
1152
1472
  return b === undefined
1153
1473
  ? fromI64(['i64.sub', ['i64.const', 0], asI64(emit(a))])
1154
1474
  : fromI64(['i64.sub', asI64(emit(a)), asI64(emit(b))])
1155
- if (b === undefined) { const v = emit(a); return isLit(v) ? emitNum(-litVal(v)) : v.type === 'i32' ? typed(['i32.sub', typed(['i32.const', 0], 'i32'), v], 'i32') : typed(['f64.neg', toNumF64(a, v)], 'f64') }
1156
- const va = emit(a), vb = emit(b)
1157
- if (isLit(va) && isLit(vb)) return emitNum(litVal(va) - litVal(vb))
1475
+ if (b === undefined) return emitNeg(a)
1476
+ const va = emit(a), vb = emit(b), _f = foldConst(va, vb, (a, b) => a - b)
1477
+ if (_f) return _f
1158
1478
  if (isLit(vb) && litVal(vb) === 0) return toNumF64(a, va)
1159
1479
  if (va.type === 'i32' && vb.type === 'i32') return typed(['i32.sub', va, vb], 'i32')
1160
1480
  return typed(['f64.sub', toNumF64(a, va), toNumF64(b, vb)], 'f64')
@@ -1162,18 +1482,18 @@ export const emitter = {
1162
1482
  'u+': a => {
1163
1483
  if (valTypeOf(a) === VAL.BIGINT)
1164
1484
  return typed(['f64.convert_i64_s', asI64(emit(a))], 'f64')
1485
+ const v = emit(a)
1486
+ if (v.type === 'i32') return asF64(v)
1487
+ if (valTypeOf(a) === VAL.NUMBER) return toNumF64(a, v)
1165
1488
  inc('__to_num')
1166
- return typed(['call', '$__to_num', asF64(emit(a))], 'f64')
1167
- },
1168
- 'u-': a => {
1169
- if (valTypeOf(a) === VAL.BIGINT) return fromI64(['i64.sub', ['i64.const', 0], asI64(emit(a))])
1170
- const v = emit(a); return isLit(v) ? emitNum(-litVal(v)) : v.type === 'i32' ? typed(['i32.sub', typed(['i32.const', 0], 'i32'), v], 'i32') : typed(['f64.neg', toNumF64(a, v)], 'f64')
1489
+ return typed(['call', '$__to_num', asI64(v)], 'f64')
1171
1490
  },
1491
+ 'u-': a => emitNeg(a),
1172
1492
  '*': (a, b) => {
1173
1493
  if (valTypeOf(a) === VAL.BIGINT || valTypeOf(b) === VAL.BIGINT)
1174
1494
  return fromI64(['i64.mul', asI64(emit(a)), asI64(emit(b))])
1175
- const va = emit(a), vb = emit(b)
1176
- if (isLit(va) && isLit(vb)) return emitNum(litVal(va) * litVal(vb))
1495
+ const va = emit(a), vb = emit(b), _f = foldConst(va, vb, (a, b) => a * b)
1496
+ if (_f) return _f
1177
1497
  if (isLit(vb) && litVal(vb) === 1) return toNumF64(a, va)
1178
1498
  if (isLit(va) && litVal(va) === 1) return toNumF64(b, vb)
1179
1499
  if (isLit(vb) && litVal(vb) === 0) return isLit(va) ? vb : typed(['block', ['result', vb.type], va, 'drop', vb], vb.type)
@@ -1184,23 +1504,24 @@ export const emitter = {
1184
1504
  '/': (a, b) => {
1185
1505
  if (valTypeOf(a) === VAL.BIGINT || valTypeOf(b) === VAL.BIGINT)
1186
1506
  return fromI64(['i64.div_s', asI64(emit(a)), asI64(emit(b))])
1187
- const va = emit(a), vb = emit(b)
1188
- if (isLit(va) && isLit(vb) && litVal(vb) !== 0) return emitNum(litVal(va) / litVal(vb))
1507
+ const va = emit(a), vb = emit(b), _f = foldConst(va, vb, (a, b) => a / b, b => b !== 0)
1508
+ if (_f) return _f
1189
1509
  if (isLit(vb) && litVal(vb) === 1) return toNumF64(a, va)
1190
1510
  return typed(['f64.div', toNumF64(a, va), toNumF64(b, vb)], 'f64')
1191
1511
  },
1192
1512
  '%': (a, b) => {
1193
1513
  if (valTypeOf(a) === VAL.BIGINT || valTypeOf(b) === VAL.BIGINT)
1194
1514
  return fromI64(['i64.rem_s', asI64(emit(a)), asI64(emit(b))])
1195
- const va = emit(a), vb = emit(b)
1196
- if (isLit(va) && isLit(vb) && litVal(vb) !== 0) return emitNum(litVal(va) % litVal(vb))
1515
+ const va = emit(a), vb = emit(b), _f = foldConst(va, vb, (a, b) => a % b, b => b !== 0)
1516
+ if (_f) return _f
1197
1517
  if (va.type === 'i32' && vb.type === 'i32') return typed(['i32.rem_s', va, vb], 'i32')
1198
1518
  return f64rem(toNumF64(a, va), toNumF64(b, vb))
1199
1519
  },
1200
-
1201
1520
  // === Comparisons (always i32 result) ===
1202
1521
 
1203
1522
  '==': (a, b) => {
1523
+ const charCmp = emitSingleCharIndexCmp(a, b)
1524
+ if (charCmp) return charCmp
1204
1525
  // JS loose nullish equality: x == null / x == undefined.
1205
1526
  // If the non-literal side has a known non-null VAL type, fold to 0.
1206
1527
  if (isNullishLit(a)) {
@@ -1215,11 +1536,13 @@ export const emitter = {
1215
1536
  const tc = emitTypeofCmp(a, b, 'eq'); if (tc) return tc
1216
1537
  const va = emit(a), vb = emit(b)
1217
1538
  if (va.type === 'i32' && vb.type === 'i32') return typed(['i32.eq', va, vb], 'i32')
1218
- // Both sides known-pure NUMBER → f64.eq (skip __eq's pointer-identity/string path).
1219
- // valTypeOf handles literals/arithmetic exprs; lookupValType covers typed locals/params.
1220
- const vta = valTypeOf(a) ?? (typeof a === 'string' ? lookupValType(a) : null)
1221
- const vtb = valTypeOf(b) ?? (typeof b === 'string' ? lookupValType(b) : null)
1222
- if (vta === VAL.NUMBER && vtb === VAL.NUMBER) return typed(['f64.eq', asF64(va), asF64(vb)], 'i32')
1539
+ // Either side known-pure NUMBER (literal or typed) → f64.eq is correct regardless
1540
+ // of the other side: jz's `==` is strict (prepare.js:868), and every NaN-boxed pointer
1541
+ // reinterprets to a quiet NaN (0x7FF8… prefix) so f64.eq with any normal float is false.
1542
+ // Catches `closureVar === 34` in jzified hot loops where the unknown side has no VAL.
1543
+ const vta = resolveValType(a, valTypeOf, lookupValType)
1544
+ const vtb = resolveValType(b, valTypeOf, lookupValType)
1545
+ if (vta === VAL.NUMBER || vtb === VAL.NUMBER) return typed(['f64.eq', asF64(va), asF64(vb)], 'i32')
1223
1546
  // Reference-equal pointer kinds (same kind, non-STRING, non-BIGINT): i64 bit equality.
1224
1547
  // JS `==` on objects/arrays/sets/maps/etc. is pure reference equality — no content path.
1225
1548
  // STRING needs __eq (heap strings can be equal by content but different pointers).
@@ -1228,9 +1551,11 @@ export const emitter = {
1228
1551
  return typed(['i64.eq', ['i64.reinterpret_f64', asF64(va)], ['i64.reinterpret_f64', asF64(vb)]], 'i32')
1229
1552
  }
1230
1553
  inc('__eq')
1231
- return typed(['call', '$__eq', asF64(va), asF64(vb)], 'i32')
1554
+ return typed(['call', '$__eq', asI64(va), asI64(vb)], 'i32')
1232
1555
  },
1233
1556
  '!=': (a, b) => {
1557
+ const charCmp = emitSingleCharIndexCmp(a, b, true)
1558
+ if (charCmp) return charCmp
1234
1559
  if (isNullishLit(a)) {
1235
1560
  if (valTypeOf(b)) return emitNum(1)
1236
1561
  return typed(['i32.eqz', isNullish(asF64(emit(b)))], 'i32')
@@ -1242,14 +1567,14 @@ export const emitter = {
1242
1567
  const tc = emitTypeofCmp(a, b, 'ne'); if (tc) return tc
1243
1568
  const va = emit(a), vb = emit(b)
1244
1569
  if (va.type === 'i32' && vb.type === 'i32') return typed(['i32.ne', va, vb], 'i32')
1245
- const vta = valTypeOf(a) ?? (typeof a === 'string' ? lookupValType(a) : null)
1246
- const vtb = valTypeOf(b) ?? (typeof b === 'string' ? lookupValType(b) : null)
1247
- if (vta === VAL.NUMBER && vtb === VAL.NUMBER) return typed(['f64.ne', asF64(va), asF64(vb)], 'i32')
1570
+ const vta = resolveValType(a, valTypeOf, lookupValType)
1571
+ const vtb = resolveValType(b, valTypeOf, lookupValType)
1572
+ if (vta === VAL.NUMBER || vtb === VAL.NUMBER) return typed(['f64.ne', asF64(va), asF64(vb)], 'i32')
1248
1573
  if (vta && vta === vtb && REF_EQ_KINDS.has(vta)) {
1249
1574
  return typed(['i64.ne', ['i64.reinterpret_f64', asF64(va)], ['i64.reinterpret_f64', asF64(vb)]], 'i32')
1250
1575
  }
1251
1576
  inc('__eq')
1252
- return typed(['i32.eqz', ['call', '$__eq', asF64(va), asF64(vb)]], 'i32')
1577
+ return typed(['i32.eqz', ['call', '$__eq', asI64(va), asI64(vb)]], 'i32')
1253
1578
  },
1254
1579
  '<': cmpOp('lt_s', 'lt', (a, b) => a < b),
1255
1580
  '>': cmpOp('gt_s', 'gt', (a, b) => a > b),
@@ -1264,13 +1589,13 @@ export const emitter = {
1264
1589
  // Unboxed pointer offsets: falsy iff zero offset.
1265
1590
  if (v.ptrKind != null) return typed(['i32.eqz', v], 'i32')
1266
1591
  // Known pointer-kinded operand: `!x` is just `x is nullish` (null/undefined).
1267
- // Pointers are never 0 / NaN / false / empty-string in the boxed form.
1268
- const vt = valTypeOf(a) ?? (typeof a === 'string' ? lookupValType(a) : null)
1269
- if (vt && vt !== VAL.NUMBER && vt !== VAL.BIGINT) {
1592
+ // Excludes STRING empty string '' is a valid (non-null) pointer but is falsy.
1593
+ const vt = resolveValType(a, valTypeOf, lookupValType)
1594
+ if (vt && vt !== VAL.NUMBER && vt !== VAL.BIGINT && vt !== VAL.STRING) {
1270
1595
  return isNullish(asF64(v))
1271
1596
  }
1272
1597
  inc('__is_truthy')
1273
- return typed(['i32.eqz', ['call', '$__is_truthy', asF64(v)]], 'i32')
1598
+ return typed(['i32.eqz', ['call', '$__is_truthy', asI64(v)]], 'i32')
1274
1599
  },
1275
1600
 
1276
1601
  '?:': (a, b, c) => {
@@ -1311,7 +1636,21 @@ export const emitter = {
1311
1636
  }
1312
1637
  }
1313
1638
  const fb = asF64(vb), fc = asF64(vc)
1314
- if (isPureIR(fb) && isPureIR(fc))
1639
+ const vtb = resolveValType(b, valTypeOf, lookupValType)
1640
+ const vtc = resolveValType(c, valTypeOf, lookupValType)
1641
+ const isNaNBoxLit = n => Array.isArray(n) && n[0] === 'f64.const' && typeof n[1] === 'string' && n[1].startsWith('nan:')
1642
+ const refPayload = (vtb && vtb === vtc && REF_EQ_KINDS.has(vtb))
1643
+ || vb.closureFuncIdx != null || vc.closureFuncIdx != null
1644
+ || isNaNBoxLit(fb) || isNaNBoxLit(fc)
1645
+ if (refPayload) {
1646
+ const ib = ['i64.reinterpret_f64', fb]
1647
+ const ic = ['i64.reinterpret_f64', fc]
1648
+ const bits = isPureIR(fb) && isPureIR(fc)
1649
+ ? ['select', ib, ic, cond]
1650
+ : ['if', ['result', 'i64'], cond, ['then', ib], ['else', ic]]
1651
+ return typed(['f64.reinterpret_i64', bits], 'f64')
1652
+ }
1653
+ if (!refPayload && isPureIR(fb) && isPureIR(fc))
1315
1654
  return typed(['select', fb, fc, cond], 'f64')
1316
1655
  return typed(['if', ['result', 'f64'], cond, ['then', fb], ['else', fc]], 'f64')
1317
1656
  },
@@ -1397,7 +1736,11 @@ export const emitter = {
1397
1736
 
1398
1737
  // === Bitwise (i32 for numbers, i64 for BigInt) ===
1399
1738
 
1400
- '~': a => { const v = emit(a); return isLit(v) ? emitNum(~litVal(v)) : typed(['i32.xor', toI32(v), typed(['i32.const', -1], 'i32')], 'i32') },
1739
+ // Per ECMAScript ToInt32, bitwise ops first ToNumber-coerce non-numeric operands.
1740
+ // i32 / lit values are already numeric — the toNumF64 wrap is skipped to keep
1741
+ // the numeric fast path at one wasm instruction. Non-numeric (NaN-boxed string,
1742
+ // unknown type) routes through __to_num so "2026" | 0 === 2026.
1743
+ '~': 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') },
1401
1744
  ...Object.fromEntries([
1402
1745
  ['&', 'and'], ['|', 'or'], ['^', 'xor'], ['<<', 'shl'], ['>>', 'shr_s'],
1403
1746
  ].map(([op, fn]) => [op, (a, b) => {
@@ -1410,16 +1753,20 @@ export const emitter = {
1410
1753
  if (op === '^') return emitNum(la ^ lb); if (op === '<<') return emitNum(la << lb)
1411
1754
  if (op === '>>') return emitNum(la >> lb)
1412
1755
  }
1413
- return typed([`i32.${fn}`, toI32(va), toI32(vb)], 'i32')
1756
+ const ca = va.type === 'i32' || isLit(va) ? va : toNumF64(a, va)
1757
+ const cb = vb.type === 'i32' || isLit(vb) ? vb : toNumF64(b, vb)
1758
+ return typed([`i32.${fn}`, toI32(ca), toI32(cb)], 'i32')
1414
1759
  }])),
1415
1760
  '>>>': (a, b) => {
1416
- const va = emit(a), vb = emit(b)
1417
- if (isLit(va) && isLit(vb)) return emitNum(litVal(va) >>> litVal(vb))
1761
+ const va = emit(a), vb = emit(b), _f = foldConst(va, vb, (a, b) => a >>> b)
1762
+ if (_f) return _f
1418
1763
  // F: Mark unsigned so `asF64` lifts via `f64.convert_i32_u` (preserving the
1419
1764
  // [0, 2^32) value range). Without this, `(s >>> 0) / 4294967296` would convert
1420
1765
  // signed for negative-high-bit s values, flipping sign and breaking the
1421
1766
  // canonical "uint32 → f64" idiom used in PRNGs and bit-manipulation code.
1422
- const node = typed(['i32.shr_u', toI32(va), toI32(vb)], 'i32')
1767
+ const ca = va.type === 'i32' || isLit(va) ? va : toNumF64(a, va)
1768
+ const cb = vb.type === 'i32' || isLit(vb) ? vb : toNumF64(b, vb)
1769
+ const node = typed(['i32.shr_u', toI32(ca), toI32(cb)], 'i32')
1423
1770
  node.unsigned = true
1424
1771
  return node
1425
1772
  },
@@ -1547,6 +1894,8 @@ export const emitter = {
1547
1894
  let argList = Array.isArray(callArgs)
1548
1895
  ? (callArgs[0] === ',' ? callArgs.slice(1) : [callArgs])
1549
1896
  : callArgs ? [callArgs] : []
1897
+ // Trailing comma in call: parser emits a trailing null sentinel — drop it
1898
+ while (argList.length && argList[argList.length - 1] == null) argList.pop()
1550
1899
 
1551
1900
  // Helper: expand spread arguments into flat list of normal arguments + spread markers
1552
1901
  // Returns { normal: [...], spreads: [(pos, expr), ...] }
@@ -1566,6 +1915,26 @@ export const emitter = {
1566
1915
 
1567
1916
  const parsed = parseArgs(argList)
1568
1917
 
1918
+ // Optional method call: obj?.method(args) — null if obj is nullish, else
1919
+ // obj.method(args). The parser shapes this as ['()', ['?.', obj, method], args],
1920
+ // distinct from the regular method call's ['.', obj, method] callee. Receiver
1921
+ // hoists into a temp so the nullish check and the method dispatch below see
1922
+ // the same evaluation; recursion with a synthetic '.'-callee reuses the type-
1923
+ // aware method-dispatch in this same handler rather than duplicating it.
1924
+ if (Array.isArray(callee) && callee[0] === '?.') {
1925
+ const [, obj, method] = callee
1926
+ const t = `${T}om${ctx.func.uniq++}`
1927
+ ctx.func.locals.set(t, 'f64')
1928
+ const va = asF64(emit(obj))
1929
+ const methodCall = emitter['()'](['.', t, method], callArgs)
1930
+ return typed(['block', ['result', 'f64'],
1931
+ ['local.set', `$${t}`, va],
1932
+ ['if', ['result', 'f64'],
1933
+ ['i32.eqz', isNullish(typed(['local.get', `$${t}`], 'f64'))],
1934
+ ['then', asF64(methodCall)],
1935
+ ['else', nullExpr()]]], 'f64')
1936
+ }
1937
+
1569
1938
  // Method call: obj.method(args) → type-aware dispatch
1570
1939
  if (Array.isArray(callee) && callee[0] === '.') {
1571
1940
  const [, obj, method] = callee
@@ -1577,7 +1946,7 @@ export const emitter = {
1577
1946
  const func = ctx.func.map.get(fname)
1578
1947
  const emittedArgs = parsed.normal.map((a, k) => emitArgForParam(emit(a), func.sig.params[k]))
1579
1948
  while (emittedArgs.length < func.sig.params.length)
1580
- emittedArgs.push(func.sig.params[emittedArgs.length].type === 'i32' ? typed(['i32.const', 0], 'i32') : nullExpr())
1949
+ emittedArgs.push(func.sig.params[emittedArgs.length].type === 'i32' ? typed(['i32.const', 0], 'i32') : undefExpr())
1581
1950
  const callIR = typed(['call', `$${fname}`, ...emittedArgs], func.sig.results[0])
1582
1951
  if (func.sig.ptrKind != null) callIR.ptrKind = func.sig.ptrKind
1583
1952
  if (func.sig.ptrAux != null) callIR.ptrAux = func.sig.ptrAux
@@ -1585,7 +1954,10 @@ export const emitter = {
1585
1954
  }
1586
1955
  }
1587
1956
 
1588
- const vt = keyValType(obj)
1957
+ let vt = keyValType(obj)
1958
+ if (typeof obj === 'string' && isReassigned(ctx.func.body, obj)
1959
+ && (method === 'slice' || method === 'concat')
1960
+ && vt !== VAL.STRING && vt !== VAL.ARRAY) vt = null
1589
1961
 
1590
1962
  // Helper to call method with arguments (handles spread expansion)
1591
1963
  const callMethod = (objArg, methodEmitter) => {
@@ -1624,23 +1996,23 @@ export const emitter = {
1624
1996
  ir.push(['local.set', `$${o}`, asF64(emit(objArg))])
1625
1997
  ir.push(['local.set', `$${sa}`, n ? materializeMulti(spreadExpr) : asF64(emit(spreadExpr))])
1626
1998
  if (srcIsArr) {
1627
- ir.push(['local.set', `$${srcBase}`, ['call', '$__ptr_offset', ['local.get', `$${sa}`]]])
1999
+ ir.push(['local.set', `$${srcBase}`, ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['local.get', `$${sa}`]]]])
1628
2000
  ir.push(['local.set', `$${sl}`, ['i32.load', ['i32.sub', ['local.get', `$${srcBase}`], ['i32.const', 8]]]])
1629
2001
  } else {
1630
- ir.push(['local.set', `$${sl}`, ['call', '$__len', ['local.get', `$${sa}`]]])
2002
+ ir.push(['local.set', `$${sl}`, ['call', '$__len', ['i64.reinterpret_f64', ['local.get', `$${sa}`]]]])
1631
2003
  }
1632
2004
  // Old length: inline as `i32.load (off-8)` if obj is known ARRAY (matches .push handler).
1633
2005
  if (objIsArr) {
1634
2006
  ir.push(['local.set', `$${ol}`,
1635
- ['i32.load', ['i32.sub', ['call', '$__ptr_offset', ['local.get', `$${o}`]], ['i32.const', 8]]]])
2007
+ ['i32.load', ['i32.sub', ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['local.get', `$${o}`]]], ['i32.const', 8]]]])
1636
2008
  } else {
1637
- ir.push(['local.set', `$${ol}`, ['call', '$__len', ['local.get', `$${o}`]]])
2009
+ ir.push(['local.set', `$${ol}`, ['call', '$__len', ['i64.reinterpret_f64', ['local.get', `$${o}`]]]])
1638
2010
  }
1639
2011
  // Single grow for the full spread (vs per-element grow check in the generic loop).
1640
- ir.push(['local.set', `$${o}`, ['call', '$__arr_grow', ['local.get', `$${o}`],
2012
+ ir.push(['local.set', `$${o}`, ['call', '$__arr_grow', ['i64.reinterpret_f64', ['local.get', `$${o}`]],
1641
2013
  ['i32.add', ['local.get', `$${ol}`], ['local.get', `$${sl}`]]]])
1642
2014
  // base captured AFTER grow (grow may relocate the array).
1643
- ir.push(['local.set', `$${base}`, ['call', '$__ptr_offset', ['local.get', `$${o}`]]])
2015
+ ir.push(['local.set', `$${base}`, ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['local.get', `$${o}`]]]])
1644
2016
  // Tight store loop.
1645
2017
  ir.push(['local.set', `$${si}`, ['i32.const', 0]])
1646
2018
  const loopId = ctx.func.uniq++
@@ -1656,7 +2028,7 @@ export const emitter = {
1656
2028
  ['local.set', `$${si}`, ['i32.add', ['local.get', `$${si}`], ['i32.const', 1]]],
1657
2029
  ['br', `$continue${loopId}`]]])
1658
2030
  // Single set_len for the full spread.
1659
- ir.push(['call', '$__set_len', ['local.get', `$${o}`],
2031
+ ir.push(['call', '$__set_len', ['i64.reinterpret_f64', ['local.get', `$${o}`]],
1660
2032
  ['i32.add', ['local.get', `$${ol}`], ['local.get', `$${sl}`]]])
1661
2033
  // Update source variable: grow may have moved the pointer.
1662
2034
  if (ctx.func.boxed?.has(objArg)) {
@@ -1694,7 +2066,7 @@ export const emitter = {
1694
2066
  inc('__len')
1695
2067
  const n = multiCount(spreadExpr)
1696
2068
  ir.push(['local.set', `$${arr}`, n ? materializeMulti(spreadExpr) : asF64(emit(spreadExpr))])
1697
- ir.push(['local.set', `$${len}`, ['call', '$__len', ['local.get', `$${arr}`]]])
2069
+ ir.push(['local.set', `$${len}`, ['call', '$__len', ['i64.reinterpret_f64', ['local.get', `$${arr}`]]]])
1698
2070
  ir.push(['local.set', `$${idx}`,
1699
2071
  reverseIter ? ['i32.sub', ['local.get', `$${len}`], ['i32.const', 1]] : ['i32.const', 0]])
1700
2072
  const loopId = ctx.func.uniq++
@@ -1745,7 +2117,7 @@ export const emitter = {
1745
2117
  const n = multiCount(spreadExpr)
1746
2118
  irG.push(
1747
2119
  ['local.set', `$${arrL}`, n ? materializeMulti(spreadExpr) : asF64(emit(spreadExpr))],
1748
- ['local.set', `$${lenL}`, ['call', '$__len', ['local.get', `$${arrL}`]]],
2120
+ ['local.set', `$${lenL}`, ['call', '$__len', ['i64.reinterpret_f64', ['local.get', `$${arrL}`]]]],
1749
2121
  ['local.set', `$${idxL}`, ['i32.const', 0]])
1750
2122
  const loopId = ctx.func.uniq++
1751
2123
  const loopBody = asF64(methodEmitter(objArg, ['[]', arrL, idxL]))
@@ -1784,7 +2156,7 @@ export const emitter = {
1784
2156
  const n = multiCount(spreadExpr)
1785
2157
  irG.push(
1786
2158
  ['local.set', `$${arrL}`, n ? materializeMulti(spreadExpr) : asF64(emit(spreadExpr))],
1787
- ['local.set', `$${lenL}`, ['call', '$__len', ['local.get', `$${arrL}`]]],
2159
+ ['local.set', `$${lenL}`, ['call', '$__len', ['i64.reinterpret_f64', ['local.get', `$${arrL}`]]]],
1788
2160
  ['local.set', `$${idxL}`, ['i32.const', 0]])
1789
2161
  const loopId = ctx.func.uniq++
1790
2162
  const loopBody = asF64(methodEmitter(accG, ['[]', arrL, idxL]))
@@ -1845,15 +2217,26 @@ export const emitter = {
1845
2217
  const genEmitter = ctx.core.emit[genKey]
1846
2218
  return typed(['block', ['result', 'f64'],
1847
2219
  ['local.set', `$${t}`, asF64(emit(obj))],
1848
- ['local.set', `$${tt}`, ['call', '$__ptr_type', ['local.get', `$${t}`]]],
2220
+ ['local.set', `$${tt}`, ['call', '$__ptr_type', ['i64.reinterpret_f64', ['local.get', `$${t}`]]]],
1849
2221
  ['if', ['result', 'f64'],
1850
- ['i32.or',
1851
- ['i32.eq', ['local.get', `$${tt}`], ['i32.const', PTR.STRING]],
1852
- ['i32.eq', ['local.get', `$${tt}`], ['i32.const', PTR.SSO]]],
2222
+ ['i32.eq', ['local.get', `$${tt}`], ['i32.const', PTR.STRING]],
1853
2223
  ['then', callMethod(t, strEmitter)],
1854
2224
  ['else', callMethod(t, genEmitter)]]], 'f64')
1855
2225
  }
1856
2226
 
2227
+ // Schema property function call: x.prop(args) where prop is a closure in boxed schema
2228
+ if (typeof obj === 'string' && ctx.schema.find && ctx.closure.call) {
2229
+ const idx = ctx.schema.find(obj, method)
2230
+ if (idx >= 0 && !ctx.schema.isBoxed?.(obj)) {
2231
+ const propRead = typed(['f64.load', ['i32.add', ptrOffsetIR(asF64(emit(obj)), lookupValType(obj) || VAL.OBJECT), ['i32.const', idx * 8]]], 'f64')
2232
+ if (parsed.hasSpread) {
2233
+ const combined = reconstructArgsWithSpreads(parsed.normal, parsed.spreads)
2234
+ return ctx.closure.call(propRead, [buildArrayWithSpreads(combined)], true)
2235
+ }
2236
+ return ctx.closure.call(propRead, parsed.normal)
2237
+ }
2238
+ }
2239
+
1857
2240
  // Schema property function call: x.prop(args) where prop is a closure in boxed schema
1858
2241
  if (typeof obj === 'string' && ctx.schema.find && ctx.closure.call && ctx.schema.isBoxed?.(obj)) {
1859
2242
  const idx = ctx.schema.find(obj, method)
@@ -1876,7 +2259,7 @@ export const emitter = {
1876
2259
  ctx.func.locals.set(objTmp, 'f64')
1877
2260
  const combined = reconstructArgsWithSpreads(parsed.normal, parsed.spreads)
1878
2261
  const arrayIR = buildArrayWithSpreads(combined)
1879
- const propRead = typed(['call', '$__dyn_get_expr', ['local.get', `$${objTmp}`], asF64(emit(['str', method]))], 'f64')
2262
+ const propRead = typed(['f64.reinterpret_i64', ['call', '$__dyn_get_expr', ['i64.reinterpret_f64', ['local.get', `$${objTmp}`]], asI64(emit(['str', method]))]], 'f64')
1880
2263
  const propTmp = `${T}mprop${ctx.func.uniq++}`
1881
2264
  ctx.func.locals.set(propTmp, 'f64')
1882
2265
  if (usesDynProps(vt)) {
@@ -1885,7 +2268,18 @@ export const emitter = {
1885
2268
  ['local.set', `$${objTmp}`, asF64(emit(obj))],
1886
2269
  ['local.set', `$${propTmp}`, propRead],
1887
2270
  ['if', ['result', 'f64'],
1888
- ['i32.eq', ['call', '$__ptr_type', ['local.get', `$${propTmp}`]], ['i32.const', PTR.CLOSURE]],
2271
+ ['i32.eq', ['call', '$__ptr_type', ['i64.reinterpret_f64', ['local.get', `$${propTmp}`]]], ['i32.const', PTR.CLOSURE]],
2272
+ ['then', ctx.closure.call(typed(['local.get', `$${propTmp}`], 'f64'), [arrayIR], true)],
2273
+ ['else', undefExpr()]]], 'f64')
2274
+ }
2275
+ // WASI: no PTR.EXTERNAL values; closure-only dispatch is correct.
2276
+ if (ctx.transform.host === 'wasi') {
2277
+ inc('__dyn_get_expr', '__ptr_type')
2278
+ return typed(['block', ['result', 'f64'],
2279
+ ['local.set', `$${objTmp}`, asF64(emit(obj))],
2280
+ ['local.set', `$${propTmp}`, propRead],
2281
+ ['if', ['result', 'f64'],
2282
+ ['i32.eq', ['call', '$__ptr_type', ['i64.reinterpret_f64', ['local.get', `$${propTmp}`]]], ['i32.const', PTR.CLOSURE]],
1889
2283
  ['then', ctx.closure.call(typed(['local.get', `$${propTmp}`], 'f64'), [arrayIR], true)],
1890
2284
  ['else', undefExpr()]]], 'f64')
1891
2285
  }
@@ -1895,22 +2289,29 @@ export const emitter = {
1895
2289
  ['local.set', `$${objTmp}`, asF64(emit(obj))],
1896
2290
  ['local.set', `$${propTmp}`, propRead],
1897
2291
  ['if', ['result', 'f64'],
1898
- ['i32.eq', ['call', '$__ptr_type', ['local.get', `$${propTmp}`]], ['i32.const', PTR.CLOSURE]],
2292
+ ['i32.eq', ['call', '$__ptr_type', ['i64.reinterpret_f64', ['local.get', `$${propTmp}`]]], ['i32.const', PTR.CLOSURE]],
1899
2293
  ['then', ctx.closure.call(typed(['local.get', `$${propTmp}`], 'f64'), [arrayIR], true)],
1900
2294
  ['else', ['if', ['result', 'f64'],
1901
- ['i32.eq', ['call', '$__ptr_type', ['local.get', `$${objTmp}`]], ['i32.const', PTR.EXTERNAL]],
1902
- ['then', ['call', '$__ext_call', ['local.get', `$${objTmp}`], asF64(emit(['str', method])), arrayIR]],
2295
+ ['i32.eq', ['call', '$__ptr_type', ['i64.reinterpret_f64', ['local.get', `$${objTmp}`]]], ['i32.const', PTR.EXTERNAL]],
2296
+ ['then', ['f64.reinterpret_i64', ['call', '$__ext_call',
2297
+ ['i64.reinterpret_f64', ['local.get', `$${objTmp}`]],
2298
+ ['i64.reinterpret_f64', asF64(emit(['str', method]))],
2299
+ ['i64.reinterpret_f64', arrayIR]]]],
1903
2300
  ['else', undefExpr()]]]]], 'f64')
1904
2301
  }
1905
2302
 
1906
2303
  // Unknown callee - assume external method
1907
2304
  if (ctx.transform.strict)
1908
2305
  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 }.`)
2306
+ if (ctx.transform.host === 'wasi') return undefExpr()
1909
2307
  inc('__ext_call')
1910
2308
  ctx.features.external = true
1911
2309
  const combined = reconstructArgsWithSpreads(parsed.normal, parsed.spreads)
1912
2310
  const arrayIR = buildArrayWithSpreads(combined)
1913
- return typed(['call', '$__ext_call', asF64(emit(obj)), asF64(emit(['str', method])), arrayIR], 'f64');
2311
+ return typed(['f64.reinterpret_i64', ['call', '$__ext_call',
2312
+ ['i64.reinterpret_f64', asF64(emit(obj))],
2313
+ ['i64.reinterpret_f64', asF64(emit(['str', method]))],
2314
+ ['i64.reinterpret_f64', arrayIR]]], 'f64');
1914
2315
  }
1915
2316
 
1916
2317
  if (ctx.core.emit[callee]) {
@@ -1929,17 +2330,17 @@ export const emitter = {
1929
2330
  }
1930
2331
 
1931
2332
  // Direct call if callee is a known top-level function
1932
- if (typeof callee === 'string' && ctx.func.names.has(callee)) {
2333
+ if (typeof callee === 'string' && ctx.func.names.has(callee) && !ctx.func.locals?.has(callee)) {
1933
2334
  const func = ctx.func.map.get(callee)
1934
2335
 
1935
2336
  // Rest param case: collect all args (including expanded spreads) into array
1936
2337
  if (func?.rest) {
1937
2338
  const fixedParamCount = func.sig.params.length - 1
1938
2339
  const fixedArgs = parsed.normal.slice(0, fixedParamCount)
1939
- // Pad missing fixed args with sentinel for defaults
2340
+ // Pad missing fixed args with `undefined` so default-param init triggers per spec.
1940
2341
  const emittedFixed = fixedArgs.map((a, k) => emitArgForParam(emit(a), func.sig.params[k]))
1941
2342
  while (emittedFixed.length < fixedParamCount)
1942
- emittedFixed.push(func.sig.params[emittedFixed.length].type === 'i32' ? typed(['i32.const', 0], 'i32') : nullExpr())
2343
+ emittedFixed.push(func.sig.params[emittedFixed.length].type === 'i32' ? typed(['i32.const', 0], 'i32') : undefExpr())
1943
2344
 
1944
2345
  // Reconstruct with spreads, then take rest args
1945
2346
  const combined = reconstructArgsWithSpreads(parsed.normal, parsed.spreads)
@@ -1957,10 +2358,16 @@ export const emitter = {
1957
2358
 
1958
2359
  // Regular function call without rest params
1959
2360
  if (parsed.hasSpread) err(`Spread not supported in calls to non-variadic function ${callee}`)
1960
- // Pad missing args with canonical NaN (triggers default param init)
2361
+ // Pad missing args with `undefined` so default-param init triggers per spec
2362
+ // (only undefined, not null, should trigger defaults). Drop extras to match
2363
+ // JS calling convention — emitting them anyway produces an invalid call
2364
+ // when the callee is a fixed-arity import (e.g. `_interp`-registered host
2365
+ // stubs) since wasm validates arg count. Use ?? rather than || so a
2366
+ // legitimate 0-arity callee isn't bypassed.
1961
2367
  const args = parsed.normal.map((a, k) => emitArgForParam(emit(a), func?.sig.params[k]))
1962
- const expected = func?.sig.params.length || args.length
1963
- while (args.length < expected) args.push(func?.sig.params[args.length]?.type === 'i32' ? typed(['i32.const', 0], 'i32') : nullExpr())
2368
+ const expected = func?.sig.params.length ?? args.length
2369
+ while (args.length < expected) args.push(func?.sig.params[args.length]?.type === 'i32' ? typed(['i32.const', 0], 'i32') : undefExpr())
2370
+ if (args.length > expected) args.length = expected
1964
2371
  // Multi-value return: materialize as heap array (caller expects single pointer)
1965
2372
  if (func?.sig.results.length > 1) return materializeMulti(['()', callee, ...parsed.normal])
1966
2373
  const callIR = typed(['call', `$${callee}`, ...args], func?.sig.results[0] || 'f64')
@@ -2001,13 +2408,80 @@ export const emitter = {
2001
2408
  return ctx.closure.call(emit(callee), parsed.normal)
2002
2409
  }
2003
2410
 
2004
- // Unknown callee — assume direct call
2005
- return typed(['call', `$${callee}`, ...argList.map(a => asF64(emit(a)))], 'f64')
2411
+ // Unknown callee — assume direct call. Match arg count to the declared
2412
+ // signature when the callee is a registered env import (e.g. `_interp`-
2413
+ // wired host stubs). JS calling convention drops extras and pads missing;
2414
+ // wasm requires exact arity, so emitting raw argList against a fixed-arity
2415
+ // import would invalidate the module.
2416
+ let calleeArity = null
2417
+ if (typeof callee === 'string') {
2418
+ const imp = ctx.module.imports?.find(i =>
2419
+ Array.isArray(i) && i[0] === 'import' && i[3]?.[0] === 'func' && i[3]?.[1] === `$${callee}`)
2420
+ if (imp) {
2421
+ let n = 0
2422
+ for (let k = 2; k < imp[3].length; k++) if (Array.isArray(imp[3][k]) && imp[3][k][0] === 'param') n++
2423
+ calleeArity = n
2424
+ }
2425
+ }
2426
+ const emittedArgs = argList.map(a => asF64(emit(a)))
2427
+ if (calleeArity != null) {
2428
+ while (emittedArgs.length < calleeArity) emittedArgs.push(undefExpr())
2429
+ if (emittedArgs.length > calleeArity) emittedArgs.length = calleeArity
2430
+ }
2431
+ return typed(['call', `$${callee}`, ...emittedArgs], 'f64')
2006
2432
  },
2007
2433
  }
2008
2434
 
2009
2435
  // === Emit dispatch ===
2010
2436
 
2437
+ // Optional-chain continuation: `a?.b.c` → if `a` nullish then undefined, else `a.b.c`.
2438
+ // Per ECMAScript, an optional access short-circuits the entire continuation, not just
2439
+ // its own access. Without this, `a?.b.c` parses as `(a?.b).c` and `.c` runs on the
2440
+ // nullish result of `a?.b`, returning a wrong value (or trapping in typed lowerings).
2441
+ //
2442
+ // At the outermost `.` / `[]` / `()` whose leftmost descent contains an optional, hoist
2443
+ // the deepest such optional's head into a temp, nullish-guard, and rebuild the chain
2444
+ // with that optional replaced by a regular access. The single guard short-circuits the
2445
+ // whole continuation. Nested optionals further inside the chain are left intact and
2446
+ // handle their own short-circuiting on recursion.
2447
+ function liftOptionalChain(node) {
2448
+ const path = []
2449
+ let cur = node
2450
+ while (Array.isArray(cur) && (cur[0] === '.' || cur[0] === '[]' || cur[0] === '()' ||
2451
+ cur[0] === '?.' || cur[0] === '?.[]' || cur[0] === '?.()')) {
2452
+ path.push(cur)
2453
+ cur = cur[1]
2454
+ }
2455
+ // Find the deepest optional with continuation outside it. optIdx === 0 means the
2456
+ // chain root itself is optional with no continuation — handled by the regular
2457
+ // `?.` / `?.[]` / `?.()` emitters.
2458
+ let optIdx = -1
2459
+ for (let i = path.length - 1; i >= 1; i--) {
2460
+ if (path[i][0] === '?.' || path[i][0] === '?.[]' || path[i][0] === '?.()') {
2461
+ optIdx = i
2462
+ break
2463
+ }
2464
+ }
2465
+ if (optIdx <= 0) return null
2466
+ const opt = path[optIdx]
2467
+ const headExpr = opt[1]
2468
+ const t = temp('oc')
2469
+ let rebuilt
2470
+ if (opt[0] === '?.') rebuilt = ['.', t, opt[2]]
2471
+ else if (opt[0] === '?.[]') rebuilt = ['[]', t, opt[2]]
2472
+ else rebuilt = ['()', t, ...opt.slice(2)]
2473
+ for (let i = optIdx - 1; i >= 0; i--) {
2474
+ const outer = path[i]
2475
+ rebuilt = [outer[0], rebuilt, ...outer.slice(2)]
2476
+ }
2477
+ return typed(['block', ['result', 'f64'],
2478
+ ['local.set', `$${t}`, asF64(emit(headExpr))],
2479
+ ['if', ['result', 'f64'],
2480
+ ['i32.eqz', isNullish(typed(['local.get', `$${t}`], 'f64'))],
2481
+ ['then', asF64(emit(rebuilt))],
2482
+ ['else', undefExpr()]]], 'f64')
2483
+ }
2484
+
2011
2485
  /**
2012
2486
  * Emit single AST node to typed WASM IR.
2013
2487
  * Every returned node has .type = 'i32' | 'f64'.
@@ -2028,14 +2502,10 @@ export function emit(node, expect) {
2028
2502
  const n = node & 0xFFFFFFFFFFFFFFFFn
2029
2503
  return typed(['f64.reinterpret_i64', ['i64.const', '0x' + n.toString(16)]], 'f64')
2030
2504
  }
2031
- if (typeof node === 'number') {
2032
- if (Number.isInteger(node) && node >= -2147483648 && node <= 2147483647)
2033
- return typed(['i32.const', node], 'i32')
2034
- return typed(['f64.const', node], 'f64')
2035
- }
2505
+ if (typeof node === 'number') return emitNum(node)
2036
2506
  if (typeof node === 'string') {
2037
2507
  // Variable read: boxed / local / param / global (check before emitter table to avoid name collisions)
2038
- if (ctx.func.boxed?.has(node) || ctx.func.locals?.has(node) || ctx.func.current?.params?.some(p => p.name === node) || isGlobal(node))
2508
+ if (ctx.func.boxed?.has(node) || ctx.func.locals?.has(node) || ctx.func.current?.params?.some(p => p.name === node) || isGlobal(node) || repOf(node)?.intConst != null)
2039
2509
  return readVar(node)
2040
2510
  // Top-level function used as value → wrap as closure pointer for call_indirect
2041
2511
  if (ctx.func.names.has(node) && !ctx.func.locals?.has(node) && !ctx.func.current?.params?.some(p => p.name === node) && ctx.closure.table) {
@@ -2072,17 +2542,28 @@ export function emit(node, expect) {
2072
2542
  }
2073
2543
  let idx = ctx.closure.table.indexOf(trampolineName)
2074
2544
  if (idx < 0) { idx = ctx.closure.table.length; ctx.closure.table.push(trampolineName) }
2075
- return mkPtrIR(PTR.CLOSURE, idx, 0)
2545
+ const ir = mkPtrIR(PTR.CLOSURE, idx, 0)
2546
+ ir.closureFuncIdx = idx
2547
+ return ir
2076
2548
  }
2077
2549
  // Emitter table: only namespace-resolved names (contain '.', e.g. 'math.PI') — safe from user variable collision
2078
- if (node.includes('.') && ctx.core.emit[node]) return ctx.core.emit[node]()
2550
+ if (node.includes('.') && ctx.core.emit[node]) {
2551
+ const handler = ctx.core.emit[node]
2552
+ return handler.length > 0 ? builtinFunctionValue(node) : handler()
2553
+ }
2079
2554
  // Auto-import known host globals (WebAssembly, globalThis, etc.)
2080
2555
  const HOST_GLOBALS = new Set(['WebAssembly', 'globalThis', 'self', 'window', 'global', 'process'])
2081
2556
  if (HOST_GLOBALS.has(node) && !ctx.func.locals?.has(node) && !ctx.func.current?.params?.some(p => p.name === node) && !isGlobal(node)) {
2557
+ if (ctx.transform.host === 'wasi') err(`host:'wasi': reference to host global \`${node}\` requires an env import. Remove the reference or use host:'js'.`)
2082
2558
  ctx.features.external = true
2083
2559
  ctx.scope.globals.set(node, null)
2084
- ctx.module.imports.push(['import', '"env"', `"${node}"`, ['global', `$${node}`, ['mut', 'f64']]])
2085
- return typed(['global.get', `$${node}`], 'f64')
2560
+ // Carrier is i64 (not f64) so V8 can't canonicalize the NaN-boxed
2561
+ // external-ref payload across the wasm↔JS global boundary (same hazard
2562
+ // as env.print/setTimeout — see module/console.js header). asF64()
2563
+ // reinterprets to f64 at every read site.
2564
+ ctx.module.imports.push(['import', '"env"', `"${node}"`, ['global', `$${node}`, 'i64']])
2565
+ ctx.scope.globalTypes.set(node, 'i64')
2566
+ return typed(['global.get', `$${node}`], 'i64')
2086
2567
  }
2087
2568
  const t = ctx.func.locals?.get(node) || ctx.func.current?.params.find(p => p.name === node)?.type || 'f64'
2088
2569
  return typed(['local.get', `$${node}`], t)
@@ -2099,6 +2580,14 @@ export function emit(node, expect) {
2099
2580
  return v === undefined ? undefExpr() : v === null ? nullExpr() : emit(v)
2100
2581
  }
2101
2582
 
2583
+ // Optional-chain continuation: `a?.b.c` → if `a` nullish then undefined else `a.b.c`.
2584
+ // Lift before dispatch so the regular `.` / `[]` / `()` handler sees the rebuilt chain
2585
+ // with the optional already replaced by a non-optional access on a guarded temp.
2586
+ if (op === '.' || op === '[]' || op === '()') {
2587
+ const lifted = liftOptionalChain(node)
2588
+ if (lifted) return lifted
2589
+ }
2590
+
2102
2591
  const handler = ctx.core.emit[op]
2103
2592
  if (!handler) err(`Unknown op: ${op}`)
2104
2593
  return handler(...args)