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/compile.js CHANGED
@@ -26,13 +26,13 @@
26
26
  */
27
27
 
28
28
  import { parse as parseWat } from 'watr'
29
- import { ctx, err, inc, resolveIncludes, PTR } from './ctx.js'
29
+ import { ctx, err, inc, resolveIncludes, PTR, LAYOUT } from './ctx.js'
30
30
  import {
31
31
  T, VAL, analyzeValTypes, analyzeIntCertain, analyzeLocals,
32
32
  analyzePtrUnboxable, typedElemAux, invalidateLocalsCache,
33
- analyzeBoxedCaptures, updateRep,
33
+ analyzeBoxedCaptures, updateRep, inferStringParams,
34
34
  } from './analyze.js'
35
- import { optimizeFunc, hoistConstantPool, specializeMkptr, specializePtrBase, sortStrPoolByFreq, treeshake } from './optimize.js'
35
+ import { optimizeFunc, hoistConstantPool, specializeMkptr, specializePtrBase, sortStrPoolByFreq, treeshake, arenaRewindModule } from './optimize.js'
36
36
  import { emit, emitter, emitFlat, emitBody } from './emit.js'
37
37
  import {
38
38
  typed, asF64, asI32, asPtrOffset, asParamType, toI32, asI64, fromI64,
@@ -42,7 +42,7 @@ import {
42
42
  isLit, litVal, isNullishLit, isPureIR, isPostfix, emitNum,
43
43
  temp, tempI32, tempI64, f64rem, toNumF64, truthyIR, toBoolFromEmitted,
44
44
  keyValType, usesDynProps, needsDynShadow,
45
- isGlobal, isConst, boxedAddr, readVar, writeVar, isNullish,
45
+ isGlobal, isConst, boxedAddr, readVar, writeVar, isNullish, isUndef,
46
46
  slotAddr, elemLoad, elemStore, arrayLoop, allocPtr,
47
47
  multiCount, loopTop, flat, reconstructArgsWithSpreads,
48
48
  valKindToPtr,
@@ -54,10 +54,15 @@ const timePhase = (profiler, name, fn) => profiler ? profiler.time(name, fn) : f
54
54
  // Per-compile func name set + map live on ctx.func.names / ctx.func.map,
55
55
  // populated at compile() entry. Both reset by ctx.js reset() and re-filled here.
56
56
 
57
- // NaN-box high-bits mask: used by the static-prefix-strip pass below to
58
- // identify pointer slots in the data segment. Kept local (ir.js owns the
59
- // runtime packing via mkPtrIR).
60
- const NAN_PREFIX_BITS = 0x7FF8n
57
+ // NaN-prefix top-13-bits as BigInt — used by the static-prefix-strip pass
58
+ // below to identify pointer slots in the data segment.
59
+ const NAN_PREFIX = BigInt(LAYOUT.NAN_PREFIX)
60
+ const TAG_MASK_BIG = BigInt(LAYOUT.TAG_MASK)
61
+ const AUX_MASK_BIG = BigInt(LAYOUT.AUX_MASK)
62
+ const OFFSET_MASK_BIG = BigInt(LAYOUT.OFFSET_MASK)
63
+ const TAG_SHIFT_BIG = BigInt(LAYOUT.TAG_SHIFT)
64
+ const AUX_SHIFT_BIG = BigInt(LAYOUT.AUX_SHIFT)
65
+ const SSO_BIT_BIG = BigInt(LAYOUT.SSO_BIT)
61
66
 
62
67
  // Low-level IR helpers previously lived here. Pure ones moved to src/ir.js;
63
68
  // emit-calling ones (toBool, emitTypeofCmp, emitDecl, materializeMulti,
@@ -140,6 +145,95 @@ const tcoTailRewrite = (ir, resultType) => {
140
145
  return ir
141
146
  }
142
147
 
148
+ const ARENA_SAFE_CALLS = new Set([
149
+ '$__alloc', '$__alloc_hdr', '$__mkptr',
150
+ '$__ptr_offset', '$__ptr_type', '$__ptr_aux',
151
+ '$__len', '$__cap', '$__typed_shift', '$__typed_data',
152
+ ])
153
+
154
+ const findFuncBodyStart = (fn) => {
155
+ for (let i = 2; i < fn.length; i++) {
156
+ const n = fn[i]
157
+ if (Array.isArray(n) && (n[0] === 'param' || n[0] === 'result' || n[0] === 'local' || n[0] === 'export')) continue
158
+ return i
159
+ }
160
+ return fn.length
161
+ }
162
+
163
+ const heapGetIR = () => ctx.memory.shared
164
+ ? ['i32.load', ['i32.const', 1020]]
165
+ : ['global.get', '$__heap']
166
+
167
+ const heapSetIR = value => ctx.memory.shared
168
+ ? ['i32.store', ['i32.const', 1020], value]
169
+ : ['global.set', '$__heap', value]
170
+
171
+ function applyArenaRewind(func, fn, safeCallees) {
172
+ if (ctx.transform.optimize?.arenaRewind === false) return false
173
+ if (func.raw || func.sig.params.length !== 0 || func.sig.results.length !== 1) return false
174
+ if (func.sig.ptrKind != null) return false
175
+ if (func.sig.results[0] === 'f64' && func.valResult !== VAL.NUMBER) return false
176
+ if (func.sig.results[0] !== 'f64' && func.sig.results[0] !== 'i32') return false
177
+
178
+ const bodyStart = findFuncBodyStart(fn)
179
+ let hasAlloc = false
180
+ let unsafe = false
181
+ const scan = node => {
182
+ if (unsafe || !Array.isArray(node)) return
183
+ const op = node[0]
184
+ if (op === 'global.set' || op === 'return_call' || op === 'call_indirect' || op === 'call_ref') {
185
+ unsafe = true
186
+ return
187
+ }
188
+ if (op === 'call') {
189
+ const name = node[1]
190
+ if (name === '$__alloc' || name === '$__alloc_hdr') hasAlloc = true
191
+ if (!(safeCallees ?? ARENA_SAFE_CALLS).has(name)) {
192
+ unsafe = true
193
+ return
194
+ }
195
+ }
196
+ for (let i = 1; i < node.length; i++) scan(node[i])
197
+ }
198
+ for (let i = bodyStart; i < fn.length; i++) scan(fn[i])
199
+ if (unsafe || !hasAlloc) return false
200
+
201
+ let id = 0
202
+ const hasLocal = name => fn.some(n => Array.isArray(n) && n[0] === 'local' && n[1] === name)
203
+ while (hasLocal(`$${T}heap_save${id}`) || hasLocal(`$${T}arena_ret${id}`)) id++
204
+ const save = `$${T}heap_save${id}`
205
+ const ret = `$${T}arena_ret${id}`
206
+ const restore = () => heapSetIR(['local.get', save])
207
+ const resultType = func.sig.results[0]
208
+
209
+ const rewriteReturns = node => {
210
+ if (!Array.isArray(node)) return node
211
+ if (node[0] === 'return' && node.length > 1) {
212
+ return ['block',
213
+ ['result', resultType],
214
+ ['local.set', ret, node[1]],
215
+ restore(),
216
+ ['return', ['local.get', ret]],
217
+ ['unreachable']]
218
+ }
219
+ for (let i = 1; i < node.length; i++) node[i] = rewriteReturns(node[i])
220
+ return node
221
+ }
222
+
223
+ const endsWithReturn = fn.at(-1)?.[0] === 'return' || fn.at(-1)?.[0] === 'return_call'
224
+ for (let i = bodyStart; i < fn.length; i++) fn[i] = rewriteReturns(fn[i])
225
+ const newBodyStart = findFuncBodyStart(fn)
226
+ fn.splice(newBodyStart, 0,
227
+ ['local', save, 'i32'],
228
+ ['local', ret, resultType],
229
+ ['local.set', save, heapGetIR()])
230
+ if (!endsWithReturn) {
231
+ const last = fn.pop()
232
+ fn.push(['local.set', ret, last], restore(), ['local.get', ret])
233
+ }
234
+ return true
235
+ }
236
+
143
237
  // === Module compilation ===
144
238
 
145
239
  const cloneRepMap = map => map ? new Map([...map].map(([k, v]) => [k, { ...v }])) : null
@@ -186,20 +280,39 @@ function analyzeFuncForEmit(func, programFacts) {
186
280
  // cached widths reflect the pre-narrow state. Re-walk now with reps in place.
187
281
  invalidateLocalsCache(body)
188
282
  ctx.func.locals = block ? analyzeLocals(body) : new Map()
283
+ // Usage-based VAL.STRING inference for params not already typed by paramReps.
284
+ // Descends into nested closures so a param used as STRING only inside an inner
285
+ // arrow (e.g. parseLevel's `str` capture in watr) still gets seeded — the
286
+ // closure capture path then propagates VAL.STRING via captureValTypes.
287
+ if (block) {
288
+ const candidates = sig.params
289
+ .filter(p => !ctx.func.repByLocal?.get(p.name)?.val)
290
+ .map(p => p.name)
291
+ if (candidates.length) {
292
+ const inferred = inferStringParams(body, candidates)
293
+ for (const [n, vt] of inferred) updateRep(n, { val: vt })
294
+ }
295
+ }
189
296
  if (block) {
190
297
  analyzeValTypes(body)
191
298
  analyzeIntCertain(body)
192
299
  analyzeBoxedCaptures(body)
193
300
  // Lower provably-monomorphic pointer locals to i32 offset storage.
301
+ // VAL.TYPED unbox requires a known element ctor (aux byte) — without it,
302
+ // the use site can't pick the right i32.store{8,16}/i32.store width and
303
+ // the rebox path can't reconstruct the NaN-box. Heterogeneous decls (two
304
+ // `let arr = ...` with different ctors, or a multi-ctor ternary) leave
305
+ // typedElem unset; skip unbox so reads/writes go through `__typed_set_idx`.
194
306
  const unbox = analyzePtrUnboxable(body, ctx.func.locals, ctx.func.boxed)
195
307
  if (unbox.size > 0) {
196
308
  for (const [n, kind] of unbox) {
197
- ctx.func.locals.set(n, 'i32')
198
309
  const fields = { ptrKind: kind }
199
310
  if (kind === VAL.TYPED) {
200
311
  const aux = typedElemAux(ctx.types.typedElem?.get(n))
201
- if (aux != null) fields.ptrAux = aux
312
+ if (aux == null) continue
313
+ fields.ptrAux = aux
202
314
  }
315
+ ctx.func.locals.set(n, 'i32')
203
316
  updateRep(n, fields)
204
317
  }
205
318
  }
@@ -233,7 +346,7 @@ function analyzeFuncForEmit(func, programFacts) {
233
346
  function emitFunc(func, funcFacts, programFacts) {
234
347
  const { paramReps } = programFacts
235
348
 
236
- // Raw WAT functions (e.g., _alloc, _reset from memory module)
349
+ // Raw WAT functions (e.g., _alloc, _clear from memory module)
237
350
  if (func.raw) return parseWat(func.raw)
238
351
 
239
352
  const { name, body, exported, sig } = func
@@ -275,24 +388,28 @@ function emitFunc(func, funcFacts, programFacts) {
275
388
  fn.push(...sig.params.map(p => ['param', `$${p.name}`, p.type]))
276
389
  fn.push(...sig.results.map(t => ['result', t]))
277
390
 
278
- // Default params: missing JS args become canonical NaN (0x7FF8000000000000) in WASM f64 params.
279
- // Check for canonical NaN specifically NaN-boxed pointers are also NaN but have non-zero payload.
391
+ // Default params: ES spec says default applies only when arg is `undefined`
392
+ // (or missing). `null`, `0`, `false`, etc. all skip the default.
280
393
  const defaults = func.defaults || {}
281
394
  const defaultInits = []
282
395
  for (const [pname, defVal] of Object.entries(defaults)) {
283
396
  const p = sig.params.find(p => p.name === pname)
284
397
  const t = p?.type || 'f64'
285
398
  defaultInits.push(
286
- ['if', isNullish(typed(['local.get', `$${pname}`], 'f64')),
399
+ ['if', isUndef(typed(['local.get', `$${pname}`], 'f64')),
287
400
  ['then', ['local.set', `$${pname}`, t === 'f64' ? asF64(emit(defVal)) : asI32(emit(defVal))]]])
288
401
  }
289
402
 
290
403
  // Box params that are mutably captured: allocate cell, copy param value
291
404
  const boxedParamInits = []
405
+ const preboxedLocalInits = []
406
+ ctx.func.preboxed = new Set()
407
+ const paramNames = new Set(sig.params.map(p => p.name))
292
408
  for (const p of sig.params) {
293
409
  if (ctx.func.boxed.has(p.name)) {
294
410
  const cell = ctx.func.boxed.get(p.name)
295
411
  ctx.func.locals.set(cell, 'i32')
412
+ ctx.func.preboxed.add(p.name)
296
413
  const lget = typed(['local.get', `$${p.name}`], p.type)
297
414
  if (p.ptrKind != null) lget.ptrKind = p.ptrKind
298
415
  boxedParamInits.push(
@@ -300,6 +417,14 @@ function emitFunc(func, funcFacts, programFacts) {
300
417
  ['f64.store', ['local.get', `$${cell}`], asF64(lget)])
301
418
  }
302
419
  }
420
+ for (const [name, cell] of ctx.func.boxed) {
421
+ if (paramNames.has(name)) continue
422
+ ctx.func.locals.set(cell, 'i32')
423
+ ctx.func.preboxed.add(name)
424
+ preboxedLocalInits.push(
425
+ ['local.set', `$${cell}`, ['call', '$__alloc', ['i32.const', 8]]],
426
+ ['f64.store', ['local.get', `$${cell}`], nullExpr()])
427
+ }
303
428
 
304
429
  if (block) {
305
430
  const stmts = emitBody(body)
@@ -307,16 +432,16 @@ function emitFunc(func, funcFacts, programFacts) {
307
432
  // I: Skip trailing fallback when last statement is return (unreachable code)
308
433
  const lastStmt = stmts.at(-1)
309
434
  const endsWithReturn = lastStmt && (lastStmt[0] === 'return' || lastStmt[0] === 'return_call')
310
- fn.push(...defaultInits, ...boxedParamInits, ...stmts, ...(endsWithReturn ? [] : sig.results.map(t => [`${t}.const`, 0])))
435
+ fn.push(...defaultInits, ...boxedParamInits, ...preboxedLocalInits, ...stmts, ...(endsWithReturn ? [] : sig.results.map(t => [`${t}.const`, 0])))
311
436
  } else if (multi && body[0] === '[') {
312
437
  const values = body.slice(1).map(e => asF64(emit(e)))
313
438
  for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
314
- fn.push(...boxedParamInits, ...values)
439
+ fn.push(...boxedParamInits, ...preboxedLocalInits, ...values)
315
440
  } else {
316
441
  const ir = emit(body)
317
442
  for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
318
443
  const finalIR = sig.ptrKind != null ? asPtrOffset(ir, sig.ptrKind) : asParamType(ir, sig.results[0])
319
- fn.push(...defaultInits, ...boxedParamInits, tcoTailRewrite(finalIR, sig.results[0]))
444
+ fn.push(...defaultInits, ...boxedParamInits, ...preboxedLocalInits, tcoTailRewrite(finalIR, sig.results[0]))
320
445
  }
321
446
 
322
447
  // Restore schema.vars so param bindings don't leak to next function.
@@ -329,18 +454,22 @@ function emitFunc(func, funcFacts, programFacts) {
329
454
  *
330
455
  * For each `isBoundaryWrapped(func)`, emit a sibling `$${name}$exp` that:
331
456
  * - holds the (export "name") attribute (JS sees the wrapper)
332
- * - takes f64 params always (JS calling convention via host.js wrap)
457
+ * - takes i64 params always JS-side carrier is BigInt that reinterprets to
458
+ * f64 NaN-box bits. i64 dodges V8's spec-permitted NaN canonicalization at
459
+ * the wasm↔JS boundary (see ToJSValue / ToWebAssemblyValue). Host wrap()
460
+ * in src/host.js pairs by converting BigInt↔f64 via reinterpret bits.
333
461
  * - converts each narrowed param at the call: f64 → i32 (truncate-sat) for
334
462
  * numeric narrowed, f64 → i32-offset (`i32.wrap_i64 + i64.reinterpret_f64`)
335
- * for pointer narrowed
463
+ * for pointer narrowed. The reinterpret happens once at param decode and
464
+ * once at result encode; numeric exports without narrowing skip wrapping
465
+ * entirely (no NaN-class values).
336
466
  * - forwards args to the inner $${name}
337
- * - reboxes the narrowed result back to f64 so JS sees Number / NaN-boxed ptr
467
+ * - reboxes the narrowed result and reinterprets to i64 for the boundary
338
468
  *
339
- * Param convert cases (each narrowed inner-param):
340
- * - p.type = 'i32', no ptrKind → i32.trunc_sat_f64_s(local.get $p)
341
- * - p.type = 'i32', ptrKind set → i32.wrap_i64(i64.reinterpret_f64(local.get $p))
469
+ * Param decode (i64 f64): each param gets `f64.reinterpret_i64` before the
470
+ * existing narrowing convert. f64 inner params just need the reinterpret.
342
471
  *
343
- * Result rebox cases:
472
+ * Result rebox cases (then reinterpret to i64 at the boundary):
344
473
  * - sig.ptrKind != null → mkPtrIR(ptrKind, ptrAux ?? 0, callIR)
345
474
  * - sig.results[0] = i32 → f64.convert_i32_s(callIR)
346
475
  * - sig.results[0] = f64 → callIR (some params narrowed but result stayed f64)
@@ -350,18 +479,23 @@ function synthesizeBoundaryWrappers() {
350
479
  for (const func of ctx.func.list) {
351
480
  if (!isBoundaryWrapped(func)) continue
352
481
  const { name, sig } = func
482
+ // Per-position i64 carrier: only swap to i64 where a NaN-boxed pointer
483
+ // actually crosses the boundary (param.ptrKind set, or result with
484
+ // sig.ptrKind set). Numeric narrowing (i32 trunc-sat / convert) keeps f64
485
+ // so callers seeing the raw export get a plain Number for numerics.
486
+ const paramI64 = sig.params.map(p => p.ptrKind != null)
487
+ const resultI64 = sig.ptrKind != null
353
488
  const wrapNode = ['func', `$${name}$exp`, ['export', `"${name}"`]]
354
- // External ABI: every param is f64 (JS Number / NaN-boxed ptr).
355
- for (const p of sig.params) wrapNode.push(['param', `$${p.name}`, 'f64'])
356
- wrapNode.push(['result', 'f64'])
357
- const args = sig.params.map(p => {
489
+ sig.params.forEach((p, i) => wrapNode.push(['param', `$${p.name}`, paramI64[i] ? 'i64' : 'f64']))
490
+ wrapNode.push(['result', resultI64 ? 'i64' : 'f64'])
491
+ const args = sig.params.map((p, i) => {
358
492
  const get = ['local.get', `$${p.name}`]
359
- if (p.type === 'f64') return get
360
493
  if (p.ptrKind != null) {
361
- // NaN-boxed f64raw i32 offset
362
- return ['i32.wrap_i64', ['i64.reinterpret_f64', get]]
494
+ // ptrKind: i64 carrier carries NaN-box bitswrap to i32 offset
495
+ return ['i32.wrap_i64', get]
363
496
  }
364
- // Numeric i32 — JS Number → i32 truncation (matches `n | 0` for integers).
497
+ if (p.type === 'f64') return get
498
+ // Numeric narrowing: f64 → i32 truncate
365
499
  return ['i32.trunc_sat_f64_s', get]
366
500
  })
367
501
  const callIR = ['call', `$${name}`, ...args]
@@ -374,7 +508,9 @@ function synthesizeBoundaryWrappers() {
374
508
  } else {
375
509
  body = callIR
376
510
  }
377
- wrapNode.push(body)
511
+ wrapNode.push(resultI64 ? ['i64.reinterpret_f64', body] : body)
512
+ func._exportUsesI64 = resultI64 || paramI64.some(Boolean)
513
+ func._exportI64Sig = { params: paramI64, result: resultI64 }
378
514
  wrappers.push(wrapNode)
379
515
  }
380
516
  return wrappers
@@ -398,6 +534,7 @@ function emitClosureBody(cb) {
398
534
  // Reset per-function state for closure body
399
535
  ctx.func.locals = new Map()
400
536
  ctx.func.repByLocal = null
537
+ if (cb.intConsts) for (const [name, v] of cb.intConsts) updateRep(name, { intConst: v })
401
538
  if (cb.valTypes) for (const [name, vt] of cb.valTypes) updateRep(name, { val: vt })
402
539
  if (cb.schemaVars) {
403
540
  ctx.schema.vars = new Map([...prevSchemaVars, ...cb.schemaVars])
@@ -413,6 +550,8 @@ function emitClosureBody(cb) {
413
550
  }
414
551
  // In closure bodies, boxed captures use the original name as both var and cell local
415
552
  ctx.func.boxed = cb.boxed ? new Map([...cb.boxed].map(v => [v, v])) : new Map()
553
+ const parentBoxedCaptures = new Set(cb.boxed || [])
554
+ ctx.func.preboxed = new Set()
416
555
  ctx.func.stack = []
417
556
  ctx.func.uniq = Math.max(ctx.func.uniq, 100) // avoid label collisions
418
557
  ctx.func.body = cb.body
@@ -445,11 +584,35 @@ function emitClosureBody(cb) {
445
584
  const block = Array.isArray(cb.body) && cb.body[0] === '{}' && cb.body[1]?.[0] !== ':'
446
585
  let bodyIR
447
586
  if (block) {
587
+ invalidateLocalsCache(cb.body)
448
588
  for (const [k, v] of analyzeLocals(cb.body)) if (!ctx.func.locals.has(k)) ctx.func.locals.set(k, v)
589
+ // Usage-based STRING inference for closure params not seeded by captureValTypes.
590
+ // (Captures already have their parent's val type via cb.valTypes above.)
591
+ {
592
+ const candidates = cb.params.filter(p => !ctx.func.repByLocal?.get(p)?.val)
593
+ if (candidates.length) {
594
+ const inferred = inferStringParams(cb.body, candidates)
595
+ for (const [n, vt] of inferred) updateRep(n, { val: vt })
596
+ }
597
+ }
598
+ analyzeValTypes(cb.body)
599
+ analyzeIntCertain(cb.body)
449
600
  // Detect captures from deeper nested arrows that mutate this body's locals/params/captures
450
601
  analyzeBoxedCaptures(cb.body)
451
602
  for (const name of ctx.func.boxed.keys()) {
452
- if (ctx.func.locals.get(name) === 'f64') ctx.func.locals.set(name, 'i32')
603
+ if (parentBoxedCaptures.has(name) && ctx.func.locals.get(name) === 'f64') ctx.func.locals.set(name, 'i32')
604
+ }
605
+ const unbox = analyzePtrUnboxable(cb.body, ctx.func.locals, ctx.func.boxed)
606
+ for (const [name, kind] of unbox) {
607
+ if (cb.params.includes(name) || cb.captures.includes(name)) continue
608
+ const fields = { ptrKind: kind }
609
+ if (kind === VAL.TYPED) {
610
+ const aux = typedElemAux(ctx.types.typedElem?.get(name))
611
+ if (aux == null) continue
612
+ fields.ptrAux = aux
613
+ }
614
+ ctx.func.locals.set(name, 'i32')
615
+ updateRep(name, fields)
453
616
  }
454
617
  bodyIR = emitBody(cb.body)
455
618
  } else {
@@ -469,6 +632,28 @@ function emitClosureBody(cb) {
469
632
  inc('__alloc_hdr', '__mkptr')
470
633
  }
471
634
 
635
+ const boxedCaptureNames = new Set(cb.captures.filter(name => parentBoxedCaptures.has(name)))
636
+ for (const name of boxedCaptureNames) ctx.func.preboxed.add(name)
637
+ const boxedValueCaptureNames = new Set(cb.captures.filter(name => ctx.func.boxed.has(name) && !parentBoxedCaptures.has(name)))
638
+ for (const name of boxedValueCaptureNames) {
639
+ ctx.func.locals.set(ctx.func.boxed.get(name), 'i32')
640
+ ctx.func.preboxed.add(name)
641
+ }
642
+ const boxedParamNames = new Set(cb.params.filter(name => ctx.func.boxed.has(name)))
643
+ for (const name of boxedParamNames) {
644
+ ctx.func.locals.set(ctx.func.boxed.get(name), 'i32')
645
+ ctx.func.preboxed.add(name)
646
+ }
647
+ const preboxedLocalInits = []
648
+ for (const [name, cell] of ctx.func.boxed) {
649
+ if (boxedCaptureNames.has(name) || boxedValueCaptureNames.has(name) || boxedParamNames.has(name)) continue
650
+ ctx.func.locals.set(cell, 'i32')
651
+ ctx.func.preboxed.add(name)
652
+ preboxedLocalInits.push(
653
+ ['local.set', `$${cell}`, ['call', '$__alloc', ['i32.const', 8]]],
654
+ ['f64.store', ['local.get', `$${cell}`], nullExpr()])
655
+ }
656
+
472
657
  // Insert locals (captures + params + declared)
473
658
  for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
474
659
 
@@ -481,8 +666,15 @@ function emitClosureBody(cb) {
481
666
  for (let i = 0; i < cb.captures.length; i++) {
482
667
  const name = cb.captures[i]
483
668
  const addr = ['i32.add', ['local.get', `$${envBase}`], ['i32.const', i * 8]]
484
- fn.push(['local.set', `$${name}`,
485
- ctx.func.boxed.has(name) ? ['i32.load', addr] : ['f64.load', addr]])
669
+ if (parentBoxedCaptures.has(name)) {
670
+ fn.push(['local.set', `$${name}`, ['i32.load', addr]])
671
+ } else if (boxedValueCaptureNames.has(name)) {
672
+ fn.push(
673
+ ['local.set', `$${ctx.func.boxed.get(name)}`, ['call', '$__alloc', ['i32.const', 8]]],
674
+ ['f64.store', boxedAddr(name), ['f64.load', addr]])
675
+ } else {
676
+ fn.push(['local.set', `$${name}`, ['f64.load', addr]])
677
+ }
486
678
  }
487
679
  }
488
680
 
@@ -490,7 +682,14 @@ function emitClosureBody(cb) {
490
682
  // Rest name (if present) is last in cb.params — handled separately below.
491
683
  const fixedParamN = cb.params.length - (cb.rest ? 1 : 0)
492
684
  for (let i = 0; i < fixedParamN && i < W; i++) {
493
- fn.push(['local.set', `$${cb.params[i]}`, ['local.get', `$__a${i}`]])
685
+ const pname = cb.params[i]
686
+ if (boxedParamNames.has(pname)) {
687
+ fn.push(
688
+ ['local.set', `$${ctx.func.boxed.get(pname)}`, ['call', '$__alloc', ['i32.const', 8]]],
689
+ ['f64.store', boxedAddr(pname), ['local.get', `$__a${i}`]])
690
+ } else {
691
+ fn.push(['local.set', `$${pname}`, ['local.get', `$__a${i}`]])
692
+ }
494
693
  }
495
694
 
496
695
  // Rest param: pack slots a[fixedParams..argc-1] into fresh array.
@@ -516,20 +715,34 @@ function emitClosureBody(cb) {
516
715
  ['i32.add', ['local.get', `$${restOff}`], ['i32.const', i * 8]],
517
716
  ['local.get', `$__a${fixedN + i}`]]]])
518
717
  }
519
- fn.push(['local.set', `$${cb.rest}`,
520
- ['call', '$__mkptr', ['i32.const', PTR.ARRAY], ['i32.const', 0], ['local.get', `$${restOff}`]]])
718
+ const restValue = ['call', '$__mkptr', ['i32.const', PTR.ARRAY], ['i32.const', 0], ['local.get', `$${restOff}`]]
719
+ if (boxedParamNames.has(cb.rest)) {
720
+ fn.push(
721
+ ['local.set', `$${ctx.func.boxed.get(cb.rest)}`, ['call', '$__alloc', ['i32.const', 8]]],
722
+ ['f64.store', boxedAddr(cb.rest), restValue])
723
+ } else {
724
+ fn.push(['local.set', `$${cb.rest}`, restValue])
725
+ }
521
726
  }
522
727
 
523
728
  // Default params for closures (check sentinel after unpack)
729
+ // Only `undefined` triggers default per spec — `null`/`0`/`false` pass through.
524
730
  if (cb.defaults) {
525
731
  for (const [pname, defVal] of Object.entries(cb.defaults)) {
526
- fn.push(['if', isNullish(['local.get', `$${pname}`]),
527
- ['then', ['local.set', `$${pname}`, asF64(emit(defVal))]]])
732
+ if (boxedParamNames.has(pname)) {
733
+ fn.push(['if', isUndef(['f64.load', boxedAddr(pname)]),
734
+ ['then', ['f64.store', boxedAddr(pname), asF64(emit(defVal))]]])
735
+ } else {
736
+ fn.push(['if', isUndef(['local.get', `$${pname}`]),
737
+ ['then', ['local.set', `$${pname}`, asF64(emit(defVal))]]])
738
+ }
528
739
  }
529
740
  }
741
+ fn.push(...preboxedLocalInits)
530
742
  fn.push(...bodyIR)
531
743
  // I: Skip trailing fallback when last statement is return
532
- if (block && !(bodyIR.at(-1)?.[0] === 'return' || bodyIR.at(-1)?.[0] === 'return_call')) fn.push(['f64.const', 0])
744
+ // Implicit fall-through return is `undefined` per JS spec, not 0.
745
+ if (block && !(bodyIR.at(-1)?.[0] === 'return' || bodyIR.at(-1)?.[0] === 'return_call')) fn.push(undefExpr())
533
746
  ctx.schema.vars = prevSchemaVars
534
747
  ctx.types.typedElem = prevTypedElems
535
748
  return fn
@@ -582,9 +795,9 @@ function buildStartFn(ast, sec, closureFuncs, compilePendingClosures) {
582
795
  const bt = `${T}box`
583
796
  ctx.func.locals.set(bt, 'i32')
584
797
  for (const [name, { schemaId, schema }] of ctx.schema.autoBox) {
585
- inc('__alloc', '__mkptr')
798
+ inc('__alloc_hdr', '__mkptr')
586
799
  boxInit.push(
587
- ['local.set', `$${bt}`, ['call', '$__alloc', ['i32.const', schema.length * 8]]],
800
+ ['local.set', `$${bt}`, ['call', '$__alloc_hdr', ['i32.const', 0], ['i32.const', Math.max(1, schema.length)], ['i32.const', 8]]],
588
801
  ['f64.store', ['local.get', `$${bt}`],
589
802
  ctx.func.names.has(name) ? ['f64.const', 0] : ['global.get', `$${name}`]],
590
803
  ...schema.slice(1).map((_, i) =>
@@ -602,7 +815,12 @@ function buildStartFn(ast, sec, closureFuncs, compilePendingClosures) {
602
815
  // __dyn_get (set transitively by resolveIncludes() later) are listed
603
816
  // explicitly here because the dep graph hasn't been expanded yet at
604
817
  // start-fn build time.
605
- const needsSchemaTbl = ctx.schema.list.length && (
818
+ // __jp_obj registers schemas at runtime, so the table must be allocated
819
+ // (and __schema_next initialized) even with zero compile-time schemas.
820
+ // __jp pulls __jp_obj transitively via the include-graph but resolveIncludes
821
+ // runs after buildStartFn, so check the top-level __jp parser as a proxy.
822
+ const hasJpObj = ctx.core.includes.has('__jp_obj') || ctx.core.includes.has('__jp')
823
+ const needsSchemaTbl = (ctx.schema.list.length && (
606
824
  ctx.core.includes.has('__stringify') ||
607
825
  ctx.core.includes.has('__dyn_get') ||
608
826
  ctx.core.includes.has('__dyn_get_t') ||
@@ -610,17 +828,24 @@ function buildStartFn(ast, sec, closureFuncs, compilePendingClosures) {
610
828
  ctx.core.includes.has('__dyn_get_any_t') ||
611
829
  ctx.core.includes.has('__dyn_get_expr') ||
612
830
  ctx.core.includes.has('__dyn_get_expr_t') ||
613
- ctx.core.includes.has('__dyn_get_or'))
831
+ ctx.core.includes.has('__dyn_get_or'))) ||
832
+ hasJpObj
614
833
  if (needsSchemaTbl) {
615
834
  const nSchemas = ctx.schema.list.length
835
+ // Reserve trailing slots for runtime-registered schemas (used by JSON.parse
836
+ // shape caching). __schema_next tracks the first free runtime slot.
837
+ const runtimeReserve = hasJpObj ? 256 : 0
616
838
  const stbl = `${T}stbl`
617
839
  const sarr = `${T}sarr`
618
840
  ctx.func.locals.set(stbl, 'i32')
619
841
  ctx.func.locals.set(sarr, 'i32')
620
842
  inc('__alloc', '__alloc_hdr', '__mkptr')
621
843
  schemaInit.push(
622
- ['local.set', `$${stbl}`, ['call', '$__alloc', ['i32.const', nSchemas * 8]]],
844
+ ['local.set', `$${stbl}`, ['call', '$__alloc', ['i32.const', (nSchemas + runtimeReserve) * 8]]],
623
845
  ['global.set', '$__schema_tbl', ['local.get', `$${stbl}`]])
846
+ if (runtimeReserve) {
847
+ schemaInit.push(['global.set', '$__schema_next', ['i32.const', nSchemas]])
848
+ }
624
849
  for (let s = 0; s < nSchemas; s++) {
625
850
  const keys = ctx.schema.list[s]
626
851
  const n = keys.length
@@ -651,12 +876,15 @@ function buildStartFn(ast, sec, closureFuncs, compilePendingClosures) {
651
876
  for (const s of ctx.runtime.typeofStrs)
652
877
  typeofInit.push(['global.set', `$__tof_${s}`, emit(['str', s])])
653
878
  }
654
- if (moduleInits.length || init?.length || boxInit.length || schemaInit.length || typeofInit.length || strPoolInit.length || ctx.features.timers) {
879
+ // WASI timer queue needs an init call in __start; JS-host mode delegates
880
+ // scheduling to the host so no init/loop is needed.
881
+ const wasiTimers = ctx.features.timers && ctx.transform.host === 'wasi'
882
+ if (moduleInits.length || init?.length || boxInit.length || schemaInit.length || typeofInit.length || strPoolInit.length || wasiTimers) {
655
883
  const initIR = normalizeIR(init)
656
884
  const startFn = ['func', '$__start']
657
885
  for (const [l, t] of ctx.func.locals) startFn.push(['local', `$${l}`, t])
658
886
  startFn.push(...strPoolInit, ...typeofInit, ...boxInit, ...schemaInit,
659
- ...(ctx.features.timers ? [['call', '$__timer_init']] : []),
887
+ ...(wasiTimers ? [['call', '$__timer_init']] : []),
660
888
  ...moduleInits, ...initIR,
661
889
  ...(ctx.features.blockingTimers ? [['call', '$__timer_loop']] : []),
662
890
  )
@@ -721,12 +949,21 @@ function dedupClosureBodies(closureFuncs, sec) {
721
949
  else { hashToName.set(key, name); keepSet.add(name) }
722
950
  }
723
951
  if (!redirect.size) return
724
- ctx.closure.table = ctx.closure.table.map(n => redirect.get(n) || n)
952
+ // Remove duplicate declarations BEFORE rewriting references — otherwise redirectRefs
953
+ // renames the declaration itself and the filter can't find the original name.
725
954
  const kept = sec.funcs.filter(fn => {
726
955
  if (!Array.isArray(fn) || fn[0] !== 'func') return true
727
956
  const name = typeof fn[1] === 'string' && fn[1][0] === '$' ? fn[1].slice(1) : null
728
957
  return !name || !redirect.has(name)
729
958
  })
959
+ const redirectRefs = node => {
960
+ if (typeof node === 'string') return node[0] === '$' && redirect.has(node.slice(1)) ? `$${redirect.get(node.slice(1))}` : node
961
+ if (!Array.isArray(node)) return node
962
+ for (let i = 0; i < node.length; i++) node[i] = redirectRefs(node[i])
963
+ return node
964
+ }
965
+ for (const fn of kept) redirectRefs(fn)
966
+ ctx.closure.table = ctx.closure.table.map(n => redirect.get(n) || n)
730
967
  sec.funcs.length = 0
731
968
  sec.funcs.push(...kept)
732
969
  }
@@ -752,8 +989,7 @@ function dedupClosureBodies(closureFuncs, sec) {
752
989
  * Both `call` and `return_call` (tail call) sites are rewritten in the same walk.
753
990
  */
754
991
  function finalizeClosureTable(sec) {
755
- if (!ctx.closure.table?.length) return
756
- let indirectUsed = false
992
+ let indirectUsed = ctx.transform.host === 'wasi'
757
993
  const scan = (n) => {
758
994
  if (!Array.isArray(n) || indirectUsed) return
759
995
  if (n[0] === 'call_indirect') { indirectUsed = true; return }
@@ -767,8 +1003,9 @@ function finalizeClosureTable(sec) {
767
1003
  }
768
1004
  // Keep table if call_indirect is used (closures, timer dispatch, etc.)
769
1005
  if (indirectUsed) {
1006
+ if (!ctx.closure.table) ctx.closure.table = []
770
1007
  sec.table = [['table', ['export', '"__jz_table"'], ctx.closure.table.length, 'funcref']]
771
- sec.elem = [['elem', ['i32.const', 0], 'func', ...ctx.closure.table.map(n => `$${n}`)]]
1008
+ sec.elem = ctx.closure.table.length ? [['elem', ['i32.const', 0], 'func', ...ctx.closure.table.map(n => `$${n}`)]] : []
772
1009
  return
773
1010
  }
774
1011
  sec.table = []
@@ -832,7 +1069,7 @@ function finalizeClosureTable(sec) {
832
1069
  * 1. resolveIncludes() — close the include set under stdlib dependencies.
833
1070
  * 2. Emit memory section ONLY when some included helper uses memory ops
834
1071
  * (G optimization: pure scalar programs ship without memory + __heap).
835
- * When memory is needed, the allocator (__alloc + __alloc_hdr + __reset)
1072
+ * When memory is needed, the allocator (__alloc + __alloc_hdr + __clear)
836
1073
  * is force-included since stdlib funcs may call into it.
837
1074
  * 3. Pull external (host) stdlibs into sec.extStdlib (must precede normal
838
1075
  * imports in the module byte order).
@@ -848,11 +1085,11 @@ function pullStdlib(sec) {
848
1085
  const needsMemory = [...ctx.core.includes].some(n => ctx.core.stdlib[n] && MEM_OPS.test(ctx.core.stdlib[n]))
849
1086
  if (!needsMemory) ctx.scope.globals.delete('__heap')
850
1087
  if (needsMemory && ctx.module.modules.core) {
851
- for (const fn of ['__alloc', '__alloc_hdr', '__reset']) if (!ctx.core.includes.has(fn)) ctx.core.includes.add(fn)
1088
+ for (const fn of ['__alloc', '__alloc_hdr', '__clear']) if (!ctx.core.includes.has(fn)) ctx.core.includes.add(fn)
852
1089
  const pages = ctx.memory.pages || 1
853
1090
  if (ctx.memory.shared) sec.imports.push(['import', '"env"', '"memory"', ['memory', pages]])
854
1091
  else sec.memory.push(['memory', ['export', '"memory"'], pages])
855
- if (ctx.transform.runtimeExports !== false && ctx.core._allocRawFuncs)
1092
+ if (ctx.transform.alloc !== false && ctx.core._allocRawFuncs)
856
1093
  sec.funcs.push(...ctx.core._allocRawFuncs.map(s => parseWat(s)))
857
1094
  }
858
1095
 
@@ -860,10 +1097,15 @@ function pullStdlib(sec) {
860
1097
  const v = ctx.core.stdlib[name]
861
1098
  return typeof v === 'function' ? v() : v
862
1099
  }
1100
+ // Track __ext_* names emitted to host imports. The cleanup below deletes them
1101
+ // from ctx.core.includes (moved into sec.extStdlib instead), so any post-compile
1102
+ // audit (e.g. host: 'wasi') needs to read this list rather than the includes set.
1103
+ ctx.core.extImports ??= new Set()
863
1104
  for (const name of Object.keys(ctx.core.stdlib)) {
864
1105
  if (name.startsWith('__ext_') && ctx.core.includes.has(name)) {
865
1106
  const parsed = parseWat(stdlibStr(name))
866
1107
  sec.extStdlib.push(parsed[0] === "module" ? parsed[1] : parsed)
1108
+ ctx.core.extImports.add(name)
867
1109
  ctx.core.includes.delete(name)
868
1110
  }
869
1111
  }
@@ -871,6 +1113,12 @@ function pullStdlib(sec) {
871
1113
  sec.stdlib.push(...[...ctx.core.includes].map(n => parseWat(stdlibStr(n))))
872
1114
  }
873
1115
 
1116
+ function syncImports(sec) {
1117
+ for (const imp of ctx.module.imports) {
1118
+ if (!sec.imports.some(i => i[1] === imp[1] && i[2] === imp[2])) sec.imports.push(imp)
1119
+ }
1120
+ }
1121
+
874
1122
  /**
875
1123
  * Phase: whole-module + per-function optimization passes.
876
1124
  *
@@ -904,6 +1152,19 @@ function optimizeModule(sec) {
904
1152
  ctx.runtime.strPool = poolRef.pool
905
1153
  }
906
1154
  for (const s of [...sec.funcs, ...sec.stdlib, ...sec.start]) optimizeFunc(s, cfg)
1155
+ if (!cfg || cfg.arenaRewind !== false) {
1156
+ const safeCallees = arenaRewindModule([...sec.funcs, ...sec.stdlib, ...sec.start])
1157
+ // Build name → WAT IR map for user functions
1158
+ const fnByName = new Map()
1159
+ for (const fn of sec.funcs) {
1160
+ if (Array.isArray(fn) && fn[0] === 'func' && typeof fn[1] === 'string')
1161
+ fnByName.set(fn[1], fn)
1162
+ }
1163
+ for (const func of ctx.func.list) {
1164
+ const fn = fnByName.get(`$${func.name}`)
1165
+ if (fn) applyArenaRewind(func, fn, safeCallees)
1166
+ }
1167
+ }
907
1168
  if (!cfg || cfg.hoistConstantPool !== false)
908
1169
  hoistConstantPool([...sec.funcs, ...sec.stdlib, ...sec.start], (name, wat) => ctx.scope.globals.set(name, wat))
909
1170
 
@@ -911,8 +1172,10 @@ function optimizeModule(sec) {
911
1172
  if (dataLen > 1024 && !ctx.memory.shared) {
912
1173
  const heapBase = (dataLen + 7) & ~7
913
1174
  ctx.scope.globals.set('__heap', `(global $__heap (mut i32) (i32.const ${heapBase}))`)
1175
+ if (ctx.scope.globals.has('__heap_start'))
1176
+ ctx.scope.globals.set('__heap_start', `(global $__heap_start (mut i32) (i32.const ${heapBase}))`)
914
1177
  for (const s of sec.stdlib)
915
- if (s[0] === 'func' && s[1] === '$__reset')
1178
+ if (s[0] === 'func' && s[1] === '$__clear')
916
1179
  for (let i = 2; i < s.length; i++)
917
1180
  if (Array.isArray(s[i]) && s[i][0] === 'global.set' && Array.isArray(s[i][2]) && s[i][2][0] === 'i32.const')
918
1181
  s[i][2][1] = `${heapBase}`
@@ -942,12 +1205,14 @@ function stripStaticDataPrefix(sec) {
942
1205
  for (const slotOff of ctx.runtime.staticPtrSlots) {
943
1206
  if (slotOff < prefix) continue
944
1207
  const bits = dv.getBigUint64(slotOff, true)
945
- if (((bits >> 48n) & 0xFFF8n) !== NAN_PREFIX_BITS) continue
946
- const ty = Number((bits >> 47n) & 0xFn)
1208
+ if (((bits >> 48n) & 0xFFF8n) !== NAN_PREFIX) continue
1209
+ const ty = Number((bits >> TAG_SHIFT_BIG) & TAG_MASK_BIG)
947
1210
  if (!SHIFTABLE.has(ty)) continue
948
- const off = Number(bits & 0xFFFFFFFFn)
1211
+ // SSO STRING: "offset" holds packed bytes, not a heap address — never shift.
1212
+ if (ty === PTR.STRING && ((bits >> AUX_SHIFT_BIG) & SSO_BIT_BIG)) continue
1213
+ const off = Number(bits & OFFSET_MASK_BIG)
949
1214
  if (off < prefix) continue
950
- const hi = bits & ~0xFFFFFFFFn
1215
+ const hi = bits & ~OFFSET_MASK_BIG
951
1216
  dv.setBigUint64(slotOff, hi | BigInt(off - prefix), true)
952
1217
  }
953
1218
  }
@@ -965,16 +1230,21 @@ function stripStaticDataPrefix(sec) {
965
1230
  Array.isArray(child[2]) && SHIFTABLE.has(child[2][1]) &&
966
1231
  Array.isArray(child[4]) && child[4][0] === 'i32.const' &&
967
1232
  typeof child[4][1] === 'number' && child[4][1] >= prefix) {
968
- child[4][1] -= prefix
1233
+ // SSO STRING: aux carries SSO_BIT, "offset" holds packed bytes — don't shift.
1234
+ const isSsoString = child[2][1] === PTR.STRING &&
1235
+ Array.isArray(child[3]) && child[3][0] === 'i32.const' &&
1236
+ typeof child[3][1] === 'number' && (child[3][1] & LAYOUT.SSO_BIT)
1237
+ if (!isSsoString) child[4][1] -= prefix
969
1238
  } else if (child[0] === 'f64.const' &&
970
1239
  typeof child[1] === 'string' && child[1].startsWith('nan:0x')) {
971
1240
  const bits = BigInt(child[1].slice(4)) | 0x7FF0000000000000n
972
- if (((bits >> 48n) & 0xFFF8n) === NAN_PREFIX_BITS) {
973
- const ty = Number((bits >> 47n) & 0xFn)
974
- if (SHIFTABLE.has(ty)) {
975
- const off = Number(bits & 0xFFFFFFFFn)
1241
+ if (((bits >> 48n) & 0xFFF8n) === NAN_PREFIX) {
1242
+ const ty = Number((bits >> TAG_SHIFT_BIG) & TAG_MASK_BIG)
1243
+ if (SHIFTABLE.has(ty) &&
1244
+ !(ty === PTR.STRING && ((bits >> AUX_SHIFT_BIG) & SSO_BIT_BIG))) {
1245
+ const off = Number(bits & OFFSET_MASK_BIG)
976
1246
  if (off >= prefix) {
977
- const hi = bits & ~0xFFFFFFFFn
1247
+ const hi = bits & ~OFFSET_MASK_BIG
978
1248
  const newBits = hi | BigInt(off - prefix)
979
1249
  child[1] = 'nan:0x' + newBits.toString(16).toUpperCase().padStart(16, '0')
980
1250
  }
@@ -997,9 +1267,24 @@ export default function compile(ast, profiler) {
997
1267
  ctx.func.names.clear()
998
1268
  ctx.func.map.clear()
999
1269
  for (const f of ctx.func.list) { ctx.func.names.add(f.name); ctx.func.map.set(f.name, f) }
1000
- // Include imported functions for call resolution (e.g. template interpolations)
1001
- for (const imp of ctx.module.imports)
1002
- if (imp[3]?.[0] === 'func') ctx.func.names.add(imp[3][1].replace(/^\$/, ''))
1270
+ // Include imported functions for call resolution (e.g. template interpolations).
1271
+ // Also register a synthesized sig in func.map so emit's arity-aware branches see
1272
+ // the import's declared param count — needed for arg pad/truncate to match it.
1273
+ for (const imp of ctx.module.imports) {
1274
+ if (imp[3]?.[0] !== 'func') continue
1275
+ const fname = imp[3][1].replace(/^\$/, '')
1276
+ ctx.func.names.add(fname)
1277
+ if (!ctx.func.map.has(fname)) {
1278
+ const params = []
1279
+ let result = 'f64'
1280
+ for (let k = 2; k < imp[3].length; k++) {
1281
+ const part = imp[3][k]
1282
+ if (Array.isArray(part) && part[0] === 'param') params.push({ type: part[1] || 'f64' })
1283
+ else if (Array.isArray(part) && part[0] === 'result') result = part[1] || 'f64'
1284
+ }
1285
+ ctx.func.map.set(fname, { name: fname, sig: { params, results: [result] } })
1286
+ }
1287
+ }
1003
1288
 
1004
1289
  // Check user globals don't conflict with runtime globals (modules loaded after user decls)
1005
1290
  for (const name of ctx.scope.userGlobals)
@@ -1106,11 +1391,33 @@ export default function compile(ast, profiler) {
1106
1391
 
1107
1392
  sec.funcs.push(...closureFuncs, ...funcs)
1108
1393
 
1394
+ // WASI command-mode entries (`run`, `_start`) must export as () -> ();
1395
+ // wasmtime/wasmer reject f64-returning functions under those names.
1396
+ // Parametric entries skip this — a CLI invocation has no way to supply args.
1397
+ if (ctx.transform.host === 'wasi') {
1398
+ const WASI_ENTRIES = new Set(['run', '_start'])
1399
+ for (const func of ctx.func.list) {
1400
+ if (!func.exported || !WASI_ENTRIES.has(func.name)) continue
1401
+ if (func.sig.params.length) continue
1402
+ const inner = isBoundaryWrapped(func) ? `$${func.name}$exp` : `$${func.name}`
1403
+ for (const f of sec.funcs) {
1404
+ if (f[1] === inner || f[1] === `$${func.name}`) {
1405
+ const expIdx = f.findIndex(n => Array.isArray(n) && n[0] === 'export')
1406
+ if (expIdx >= 0) f.splice(expIdx, 1)
1407
+ }
1408
+ }
1409
+ sec.funcs.push(['func', `$${func.name}$wasi`, ['export', `"${func.name}"`],
1410
+ ['drop', ['call', inner]]])
1411
+ }
1412
+ }
1413
+
1109
1414
  if (ctx.closure.table?.length)
1110
1415
  sec.elem.push(['elem', ['i32.const', 0], 'func', ...ctx.closure.table.map(n => `$${n}`)])
1111
1416
 
1112
1417
  buildStartFn(ast, sec, closureFuncs, compilePendingClosures)
1113
1418
 
1419
+ syncImports(sec)
1420
+
1114
1421
  dedupClosureBodies(closureFuncs, sec)
1115
1422
 
1116
1423
  finalizeClosureTable(sec)
@@ -1164,6 +1471,28 @@ export default function compile(ast, profiler) {
1164
1471
  if (restParamFuncs.length)
1165
1472
  sec.customs.push(['@custom', '"jz:rest"', `"${JSON.stringify(restParamFuncs).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`])
1166
1473
 
1474
+ // Custom section: per-export i64 ABI map. Each entry describes an export
1475
+ // whose boundary wrapper carries NaN-boxed pointers via i64 (rather than
1476
+ // f64) to dodge V8's NaN canonicalization. Format: { name, p, r } where p
1477
+ // is an array of i64 param indices and r is 1 if result is i64. host.js
1478
+ // wrap() reinterprets BigInt↔f64 at i64 positions; numeric f64 positions
1479
+ // stay as Numbers on the JS side.
1480
+ const i64Exports = []
1481
+ for (const f of ctx.func.list) {
1482
+ if (!f.exported || !isBoundaryWrapped(f) || !f._exportUsesI64) continue
1483
+ const p = []
1484
+ f._exportI64Sig.params.forEach((b, i) => { if (b) p.push(i) })
1485
+ const r = f._exportI64Sig.result ? 1 : 0
1486
+ i64Exports.push({ name: f.name, p, r })
1487
+ // Aliases (export { foo as bar }) re-export the same wrapper under a
1488
+ // different JS-visible name; list each alias too so wrap() finds it.
1489
+ for (const [alias, val] of Object.entries(ctx.func.exports)) {
1490
+ if (val === f.name && alias !== f.name) i64Exports.push({ name: alias, p, r })
1491
+ }
1492
+ }
1493
+ if (i64Exports.length)
1494
+ sec.customs.push(['@custom', '"jz:i64exp"', `"${JSON.stringify(i64Exports).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`])
1495
+
1167
1496
  // Named export aliases: export { name } or export { source as alias }
1168
1497
  for (const [name, val] of Object.entries(ctx.func.exports)) {
1169
1498
  if (val === true) {