jz 0.1.0 → 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,19 +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
- T, VAL, valTypeOf, lookupValType, analyzeValTypes, analyzeIntCertain, analyzeLocals, analyzeBody, analyzePtrUnboxable, typedElemAux, exprType, invalidateLocalsCache, invalidateValTypesCache,
32
- extractParams, classifyParam, collectParamNames,
33
- findFreeVars, analyzeBoxedCaptures, analyzeDynKeys, typedElemCtor,
34
- repOf, updateRep, repOfGlobal, updateGlobalRep,
35
- staticObjectProps, mergeParamFact, ensureParamRep, callerParamFactMap, clearStickyNull,
36
- inferArgType, inferArgSchema, inferArgArrElemSchema, inferArgArrElemValType, inferArgTypedCtor,
37
- ctorFromElemAux, collectProgramFacts, observeProgramSlots, narrowReturnArrayElems,
38
- isBlockBody, alwaysReturns, hasBareReturn, returnExprs, collectReturnExprs,
39
- findMutations,
31
+ T, VAL, analyzeValTypes, analyzeIntCertain, analyzeLocals,
32
+ analyzePtrUnboxable, typedElemAux, invalidateLocalsCache,
33
+ analyzeBoxedCaptures, updateRep, inferStringParams,
40
34
  } from './analyze.js'
41
- import { optimizeFunc, hoistConstantPool, specializeMkptr, specializePtrBase, sortStrPoolByFreq, treeshake } from './optimize.js'
35
+ import { optimizeFunc, hoistConstantPool, specializeMkptr, specializePtrBase, sortStrPoolByFreq, treeshake, arenaRewindModule } from './optimize.js'
42
36
  import { emit, emitter, emitFlat, emitBody } from './emit.js'
43
37
  import {
44
38
  typed, asF64, asI32, asPtrOffset, asParamType, toI32, asI64, fromI64,
@@ -48,39 +42,27 @@ import {
48
42
  isLit, litVal, isNullishLit, isPureIR, isPostfix, emitNum,
49
43
  temp, tempI32, tempI64, f64rem, toNumF64, truthyIR, toBoolFromEmitted,
50
44
  keyValType, usesDynProps, needsDynShadow,
51
- isGlobal, isConst, boxedAddr, readVar, writeVar, isNullish,
45
+ isGlobal, isConst, boxedAddr, readVar, writeVar, isNullish, isUndef,
52
46
  slotAddr, elemLoad, elemStore, arrayLoop, allocPtr,
53
47
  multiCount, loopTop, flat, reconstructArgsWithSpreads,
54
48
  valKindToPtr,
55
49
  } from './ir.js'
50
+ import plan from './plan.js'
56
51
 
57
- // Re-export for backward compatibility (modules import from compile.js)
58
- export { T, VAL, valTypeOf, lookupValType, extractParams, classifyParam, collectParamNames, repOf, updateRep, repOfGlobal, updateGlobalRep }
59
- export { emit, emitter, emitFlat }
60
- // IR helpers — re-export from ir.js so module/*.js keep their existing import paths.
61
- export {
62
- typed, asF64, asI32, asParamType, toI32, asI64, fromI64,
63
- NULL_NAN, UNDEF_NAN, NULL_WAT, UNDEF_WAT, NULL_IR, UNDEF_IR, nullExpr, undefExpr,
64
- MAX_CLOSURE_ARITY, MEM_OPS, WASM_OPS, SPREAD_MUTATORS, BOXED_MUTATORS,
65
- mkPtrIR, ptrOffsetIR, ptrTypeIR, extractF64Bits, appendStaticSlots,
66
- isLit, litVal, isNullishLit, isPureIR, isPostfix, emitNum,
67
- temp, tempI32, tempI64, f64rem, toNumF64, truthyIR, toBoolFromEmitted,
68
- keyValType, usesDynProps, needsDynShadow,
69
- isGlobal, isConst, boxedAddr, readVar, writeVar, isNullish,
70
- slotAddr, elemLoad, elemStore, arrayLoop, allocPtr,
71
- multiCount, loopTop, flat, reconstructArgsWithSpreads,
72
- }
73
- // Emit-dependent helpers (emitTypeofCmp, toBool, materializeMulti, emitDecl,
74
- // buildArrayWithSpreads) live in emit.js and are re-exported there for modules.
75
- export { emitTypeofCmp, toBool, materializeMulti, emitDecl, buildArrayWithSpreads } from './emit.js'
52
+ const timePhase = (profiler, name, fn) => profiler ? profiler.time(name, fn) : fn()
76
53
 
77
54
  // Per-compile func name set + map live on ctx.func.names / ctx.func.map,
78
55
  // populated at compile() entry. Both reset by ctx.js reset() and re-filled here.
79
56
 
80
- // NaN-box high-bits mask: used by the static-prefix-strip pass below to
81
- // identify pointer slots in the data segment. Kept local (ir.js owns the
82
- // runtime packing via mkPtrIR).
83
- 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)
84
66
 
85
67
  // Low-level IR helpers previously lived here. Pure ones moved to src/ir.js;
86
68
  // emit-calling ones (toBool, emitTypeofCmp, emitDecl, materializeMulti,
@@ -163,911 +145,120 @@ const tcoTailRewrite = (ir, resultType) => {
163
145
  return ir
164
146
  }
165
147
 
166
- // === Module compilation ===
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
+ ])
167
153
 
168
- /**
169
- * Phase: signature narrowing.
170
- *
171
- * Reads programFacts.callSites + valueUsed; mutates each user func's `sig`:
172
- * - param types (f64 → i32 / pointer-ABI i32+ptrKind, when call sites agree)
173
- * - param schemas (per-arg schemaId, recorded into programFacts.paramReps[k].schemaId)
174
- * - result type (f64 → i32, when body always returns i32)
175
- * - result valType (`func.valResult`) and pointer narrowing (sig.ptrKind)
176
- *
177
- * Pure w.r.t. the AST — only the function `sig` records change. The unified
178
- * paramReps record is populated here (per-field lattice) and consumed by the
179
- * per-function emit phase below.
180
- *
181
- * Encoded structurally as a phase so future S3 work can move it into a
182
- * pipeline runner without re-deriving the in/out contract from comments.
183
- */
184
- function narrowSignatures(programFacts, ast) {
185
- const { callSites, valueUsed, paramReps } = programFacts
186
-
187
- // Reachability filter: dead callerFuncs (e.g. unused stdlib helpers from bundled
188
- // modules) shouldn't poison narrowing of live functions. Without this, a never-
189
- // executed call like `checksumF64 → mix(h, u[i])` would force mix's `x` rep to
190
- // bimorphic (f64 ∪ i32) and block i32 narrowing of mix's hot caller (runKernel).
191
- // Live = exported ∪ value-used ∪ transitively reached from those + top-level.
192
- // Top-level call sites have callerFunc === null and are unconditionally live.
193
- if (callSites.length) {
194
- const live = new Set()
195
- for (const f of ctx.func.list) {
196
- if (f.exported || valueUsed.has(f.name)) live.add(f.name)
197
- }
198
- let changed = true
199
- while (changed) {
200
- changed = false
201
- for (const cs of callSites) {
202
- if (cs.callerFunc === null || live.has(cs.callerFunc.name)) {
203
- if (!live.has(cs.callee)) { live.add(cs.callee); changed = true }
204
- }
205
- }
206
- }
207
- // Mutate in place — every later phase reads the same array.
208
- let w = 0
209
- for (let r = 0; r < callSites.length; r++) {
210
- const cs = callSites[r]
211
- if (cs.callerFunc === null || live.has(cs.callerFunc.name)) callSites[w++] = cs
212
- }
213
- callSites.length = w
214
- }
215
-
216
- // D: Call-site type propagation — infer param types from how functions are called.
217
- // Drives off `callSites` collected during the ProgramFacts walk; no AST re-walking.
218
- // For non-exported internal functions, if all call sites agree on a param's type,
219
- // seed the param's val rep (ctx.func.repByLocal) during per-function compilation.
220
- // Also infer i32/f64 WASM type — when all call sites pass i32 for a param, specialize
221
- // sig.params[k].type to i32 (no default, no rest, not exported, not value-used).
222
- // Also propagate schema ID — when all call sites pass objects with the same schema,
223
- // bind the callee's param to that schema so `p.x` becomes a direct slot load.
224
- // Inference helpers (inferArgType/inferArgSchema/inferArgArr*/inferArgTypedCtor)
225
- // live in analyze.js — pure AST→fact resolvers shared across fixpoint phases.
226
- // Per-caller analysis is stable across fixpoint iterations — precompute once.
227
- // callerCtx[null] (top-level) uses module globals for both locals and valTypes.
228
- const callerCtx = new Map() // funcObj | null → { callerLocals, callerValTypes }
229
- callerCtx.set(null, { callerLocals: ctx.scope.globalTypes, callerValTypes: ctx.scope.globalValTypes })
230
- for (const func of ctx.func.list) {
231
- if (!func.body || func.raw) continue
232
- // Single unified walk — locals + valTypes from the same traversal.
233
- const facts = analyzeBody(func.body)
234
- for (const p of func.sig.params) if (!facts.locals.has(p.name)) facts.locals.set(p.name, p.type)
235
- callerCtx.set(func, { callerLocals: facts.locals, callerValTypes: facts.valTypes })
236
- }
237
- // Per-caller arr-elem observations. Recomputed each fixpoint iteration so
238
- // newly-narrowed func.arrayElemSchema/.arrayElemValType results propagate
239
- // from `const rows = initRows()` observations. Two-pass fixpoint: first
240
- // pass learns from literals + module vars; second pass forwards through
241
- // chained helpers (f → addXY → {getX, getY}).
242
- const buildCallerElems = (sliceKey) => {
243
- const m = new Map()
244
- m.set(null, new Map())
245
- for (const func of ctx.func.list) {
246
- if (!func.body || func.raw) continue
247
- m.set(func, analyzeBody(func.body)[sliceKey])
248
- }
249
- return m
250
- }
251
- let callerArrElemsCtx = buildCallerElems('arrElemSchemas')
252
- const rebuildArrElems = () => { callerArrElemsCtx = buildCallerElems('arrElemSchemas') }
253
- let callerArrElemValsCtx = buildCallerElems('arrElemValTypes')
254
- const rebuildArrElemVals = () => { callerArrElemValsCtx = buildCallerElems('arrElemValTypes') }
255
- const runFixpoint = () => {
256
- for (let s = 0; s < callSites.length; s++) {
257
- const { callee, argList, callerFunc } = callSites[s]
258
- const func = ctx.func.map.get(callee)
259
- if (!func || func.exported || valueUsed.has(callee)) continue
260
- const ctxEntry = callerCtx.get(callerFunc)
261
- if (!ctxEntry) continue
262
- const { callerLocals, callerValTypes } = ctxEntry
263
- const callerSchemas = callerParamFactMap(paramReps, callerFunc, 'schemaId')
264
- const restIdx = func.rest ? func.sig.params.length - 1 : -1
265
- for (let k = 0; k < func.sig.params.length; k++) {
266
- const r = ensureParamRep(paramReps, callee, k)
267
- if (k < argList.length) {
268
- if (r.val !== null) mergeParamFact(r, 'val', inferArgType(argList[k], callerValTypes))
269
- // Wasm-type lattice: exprType always returns 'i32'|'f64' — no null sentinel.
270
- if (r.wasm !== null) {
271
- const wt = exprType(argList[k], callerLocals)
272
- if (r.wasm === undefined) r.wasm = wt
273
- else if (r.wasm !== wt) r.wasm = null
274
- }
275
- if (r.schemaId !== null) mergeParamFact(r, 'schemaId', inferArgSchema(argList[k], callerSchemas))
276
- // intConst lattice: bare-integer literal at every site → param has fixed value.
277
- // Skip rest position — argList[restIdx] is just the first packed arg, not the
278
- // whole array. Drop intConst for the rest param so it's never substituted.
279
- if (k === restIdx) r.intConst = null
280
- else if (r.intConst !== null) {
281
- // Literal forms after prepare: bare number, `[null, n]` (literal wrap),
282
- // or `['u-', n]` (negative literal). A bare string referencing a known
283
- // module-scope `const NAME = <int-literal>` resolves through ctx.scope.constInts.
284
- // Anything else → no-consensus.
285
- const a = argList[k]
286
- let raw = null
287
- if (typeof a === 'number') raw = a
288
- else if (Array.isArray(a) && a[0] == null && typeof a[1] === 'number') raw = a[1]
289
- else if (Array.isArray(a) && a[0] === 'u-' && typeof a[1] === 'number') raw = -a[1]
290
- else if (typeof a === 'string' && ctx.scope.constInts?.has(a)) raw = ctx.scope.constInts.get(a)
291
- const v = (raw != null && Number.isInteger(raw) && raw >= -2147483648 && raw <= 2147483647) ? raw : null
292
- mergeParamFact(r, 'intConst', v)
293
- }
294
- } else {
295
- // Missing arg — call pads with nullExpr (f64). Prevents narrowing.
296
- r.val = null; r.wasm = null; r.schemaId = null; r.intConst = null
297
- }
298
- }
299
- }
300
- }
301
- // Generic arr-elem fixpoint: same shape for arrayElemSchema (schema-id),
302
- // arrayElemValType (VAL.*), and typedCtor. `field` selects which fact;
303
- // `inferFn` and `elemsCtxMap` provide per-callee inference.
304
- const runArrElemFixpoint = (field, inferFn, elemsCtxMap) => {
305
- for (let s = 0; s < callSites.length; s++) {
306
- const { callee, argList, callerFunc } = callSites[s]
307
- const func = ctx.func.map.get(callee)
308
- if (!func || func.exported || valueUsed.has(callee)) continue
309
- if (!callerCtx.get(callerFunc)) continue
310
- const callerParams = callerParamFactMap(paramReps, callerFunc, field)
311
- const callerElems = elemsCtxMap.get(callerFunc)
312
- for (let k = 0; k < func.sig.params.length; k++) {
313
- const r = ensureParamRep(paramReps, callee, k)
314
- if (k >= argList.length) { r[field] = null; continue }
315
- if (r[field] === null) continue
316
- mergeParamFact(r, field, inferFn(argList[k], callerElems, callerParams))
317
- }
318
- }
319
- }
320
- const runArrFixpoint = () => runArrElemFixpoint('arrayElemSchema', inferArgArrElemSchema, callerArrElemsCtx)
321
- const runArrValTypeFixpoint = () => runArrElemFixpoint('arrayElemValType', inferArgArrElemValType, callerArrElemValsCtx)
322
- runFixpoint()
323
- runFixpoint()
324
-
325
- // Apply i32 specialization: for non-value-used funcs with consistent i32 call
326
- // sites and no defaults/rest at that position, narrow sig.params[k].type.
327
- // Exports too — boundary wrapper handles the f64→i32 truncation at the JS edge.
328
- for (const func of ctx.func.list) {
329
- if (func.raw || valueUsed.has(func.name)) continue
330
- const reps = paramReps.get(func.name)
331
- if (!reps) continue
332
- const restIdx = func.rest ? func.sig.params.length - 1 : -1
333
- for (const [k, r] of reps) {
334
- if (r.wasm !== 'i32' || k === restIdx) continue
335
- const pname = func.sig.params[k].name
336
- if (func.defaults?.[pname] != null) continue // defaults need nullish-sentinel f64
337
- func.sig.params[k].type = 'i32'
338
- }
339
- }
340
-
341
- // intConst validation: a param marked with a unanimous integer literal at every call
342
- // site is only safe to substitute if the body never reassigns it. Clear intConst on any
343
- // param whose name appears on the LHS of an assignment / `++` / `--`. Skip exported
344
- // (callable from JS with arbitrary value), value-used (closure callees), raw, defaulted,
345
- // and rest params — same exclusions as the wasm-narrowing pass above.
346
- for (const func of ctx.func.list) {
347
- if (func.exported || func.raw || valueUsed.has(func.name)) continue
348
- if (!func.body) continue
349
- const reps = paramReps.get(func.name)
350
- if (!reps) continue
351
- const restIdx = func.rest ? func.sig.params.length - 1 : -1
352
- let candidates = null
353
- for (const [k, r] of reps) {
354
- if (r.intConst == null || k === restIdx) continue
355
- if (k >= func.sig.params.length) { r.intConst = null; continue }
356
- const pname = func.sig.params[k].name
357
- if (func.defaults?.[pname] != null) { r.intConst = null; continue }
358
- ;(candidates ||= new Map()).set(pname, r)
359
- }
360
- if (!candidates) continue
361
- const mutated = new Set()
362
- findMutations(func.body, new Set(candidates.keys()), mutated)
363
- for (const name of mutated) candidates.get(name).intConst = null
364
- }
365
-
366
- // Pointer-ABI specialization: for non-forwarding pointer params consistent across
367
- // call sites, narrow from NaN-boxed f64 to i32 offset. Eliminates per-call __ptr_offset
368
- // extraction + f64→i64→i32 reinterpret chains that dominate watr-style compilers.
369
- // Safety:
370
- // - exclude ARRAY (forwards on realloc — f64 NaN-box is a stable identity) and
371
- // STRING (SSO vs heap dual encoding depends on ptr-type bits we'd drop).
372
- // - exclude CLOSURE/TYPED (aux bits carry schema/element-type, lost with offset).
373
- // - exclude params with defaults (nullish sentinel needs the f64 NaN space).
374
- // - exclude rest position (array pack/unpack stays f64).
375
- const PTR_ABI_KINDS = new Set([VAL.OBJECT, VAL.SET, VAL.MAP, VAL.BUFFER])
376
- for (const func of ctx.func.list) {
377
- if (func.exported || func.raw || valueUsed.has(func.name)) continue
378
- const reps = paramReps.get(func.name)
379
- if (!reps) continue
380
- const restIdx = func.rest ? func.sig.params.length - 1 : -1
381
- for (const [k, r] of reps) {
382
- if (!PTR_ABI_KINDS.has(r.val)) continue
383
- if (k === restIdx) continue
384
- if (k >= func.sig.params.length) continue
385
- const p = func.sig.params[k]
386
- if (p.type === 'i32') continue // already narrowed by numeric pass
387
- if (func.defaults?.[p.name] != null) continue
388
- p.type = 'i32'
389
- p.ptrKind = r.val
390
- }
391
- }
392
-
393
- // E: Result-type monomorphization — narrow sig.results[0] to 'i32' when body only
394
- // produces i32 values. Fixpoint: a call to another narrowed func now contributes i32;
395
- // iterate until stable so chains of i32-only helpers all narrow together.
396
- // Safety: skip exported (JS boundary preserves number semantics), value-used (closure
397
- // trampolines assume f64 result), raw WAT, multi-value. `undefined` return = skip.
398
- // exprType already consults ctx.func.map for narrowed user-function results
399
- // (analyze.js exprType `()` branch), plus the Math.imul/Math.clz32/charCodeAt
400
- // stdlib subset and primitive-op rules. Earlier we had a local shim here that
401
- // shadowed exprType's stdlib rules with `return 'f64'` for any non-user call;
402
- // unifying through exprType lets a single rule (math.imul → i32) flow through
403
- // to mix-style helpers (`(h, x) => Math.imul(h ^ (x|0), C)`) and unblocks the
404
- // E-phase result narrowing on every call site that consumes them.
405
- const exprTypeWithCalls = exprType
406
- // Body-driven: safe for exports — the result type is determined by what the body
407
- // computes, not by what JS callers might pass. JS-visible f64 ABI is restored at
408
- // the boundary via a synthesized wrapper (see synthesizeBoundaryWrappers below).
409
- // Shared pool for E (numeric), E2 (valType) and E3 (ptr) narrowing — same predicate.
410
- const narrowableFuncs = ctx.func.list.filter(f =>
411
- !f.raw && !valueUsed.has(f.name) && f.sig.results.length === 1
412
- )
413
- let changed = true
414
- while (changed) {
415
- changed = false
416
- for (const func of narrowableFuncs) {
417
- if (func.sig.results[0] === 'i32') continue
418
- const body = func.body
419
- // Bare `return;` produces undef (f64) — narrowing to i32 would lose that.
420
- if (isBlockBody(body) && hasBareReturn(body)) continue
421
- const exprs = returnExprs(body)
422
- if (!exprs.length) continue
423
- // Skip narrowing when any return-tail is `>>>` (unsigned uint32). Narrowing to i32
424
- // loses the unsigned interpretation: the wrapper rebox via `f64.convert_i32_s` would
425
- // sign-flip values with bit 31 set, breaking the canonical `(x >>> 0)` uint32 idiom.
426
- // A future pass could track sig.unsignedResult and emit `f64.convert_i32_u` instead.
427
- if (exprs.some(e => Array.isArray(e) && e[0] === '>>>')) continue
428
- const savedCurrent = ctx.func.current
429
- ctx.func.current = func.sig
430
- const locals = isBlockBody(body) ? analyzeLocals(body) : new Map()
431
- for (const p of func.sig.params) if (!locals.has(p.name)) locals.set(p.name, p.type)
432
- const allI32 = exprs.every(e => exprTypeWithCalls(e, locals) === 'i32')
433
- ctx.func.current = savedCurrent
434
- if (allI32) { func.sig.results = ['i32']; changed = true }
435
- }
436
- }
437
-
438
- // E2: VAL-type result inference — if a function always returns the same VAL kind,
439
- // record it so callers inherit that type (enables static dispatch on .length, .[],
440
- // .prop through a call chain). Fixpoint propagates through helper chains.
441
- // Safety: skip exported (host sees raw f64), value-used (indirect call signature).
442
- // Shim so calls to already-typed funcs contribute their result type.
443
- const valTypeOfWithCalls = (expr, localValTypes) => {
444
- if (expr == null) return null
445
- if (typeof expr === 'string') return localValTypes?.get(expr) || ctx.scope.globalValTypes?.get(expr) || null
446
- if (!Array.isArray(expr)) return valTypeOf(expr)
447
- const [op, ...args] = expr
448
- if (op === '()' && typeof args[0] === 'string') {
449
- const f = ctx.func.map.get(args[0])
450
- if (f?.valResult) return f.valResult
451
- }
452
- if (op === '?:') {
453
- const a = valTypeOfWithCalls(args[1], localValTypes), b = valTypeOfWithCalls(args[2], localValTypes)
454
- return a && a === b ? a : null
455
- }
456
- if (op === '&&' || op === '||') {
457
- const a = valTypeOfWithCalls(args[0], localValTypes), b = valTypeOfWithCalls(args[1], localValTypes)
458
- return a && a === b ? a : null
459
- }
460
- return valTypeOf(expr)
461
- }
462
- // Body-driven valResult inference: same safety analysis as numeric narrowing
463
- // above — exports OK because boundary wrapper restores f64 ABI for JS callers.
464
- changed = true
465
- while (changed) {
466
- changed = false
467
- for (const func of narrowableFuncs) {
468
- if (func.valResult) continue
469
- const body = func.body
470
- const isBlock = isBlockBody(body)
471
- if (isBlock && hasBareReturn(body)) continue
472
- const exprs = returnExprs(body)
473
- if (!exprs.length) continue
474
- const localValTypes = isBlock ? analyzeBody(body).valTypes : new Map()
475
- // Params of this function contribute no known VAL type yet (paramReps may help later).
476
- const vt0 = valTypeOfWithCalls(exprs[0], localValTypes)
477
- if (!vt0) continue
478
- const allSame = exprs.every(e => valTypeOfWithCalls(e, localValTypes) === vt0)
479
- if (allSame) { func.valResult = vt0; changed = true }
480
- }
481
- }
482
-
483
- // Now that E2 set `valResult` on funcs, narrow per-func `arrayElemSchema` for
484
- // VAL.ARRAY-returning funcs (via push observations + call chains). Then re-run the
485
- // D-pass arrayElemSchema/val fixpoints so `const rows = initRows()` in main
486
- // resolves to VAL.ARRAY (lets runKernel pick up r.val=ARRAY) and its arr-elem
487
- // schema (sets paramReps[runKernel][0].arrayElemSchema=sid).
488
- // Cache invalidation: analyzeBody.valTypes is body-keyed, and entries cached
489
- // during the first D pass have stale (null) `valTypeOf(call)` results because
490
- // valResult was unset back then.
491
- narrowReturnArrayElems('arrayElemSchema', paramReps, valueUsed)
492
- narrowReturnArrayElems('arrayElemValType', paramReps, valueUsed)
493
- for (const func of ctx.func.list) {
494
- if (func.body && !func.raw) invalidateValTypesCache(func.body)
495
- }
496
- for (const func of ctx.func.list) {
497
- if (!func.body || func.raw) continue
498
- const entry = callerCtx.get(func)
499
- if (entry) entry.callerValTypes = analyzeBody(func.body).valTypes
500
- }
501
- // Re-observe schema slot val-types now that E2 has set `valResult` on user
502
- // funcs. First pass runs in collectProgramFacts before valResult is known, so
503
- // a slot like `cs` in `{ ..., cs }` (where `cs = checksum(out)`) gets observed
504
- // as null. observeSlot's first-wins-then-clash rule lets a later precise
505
- // observation upgrade `undefined` → NUMBER without poisoning earlier
506
- // monomorphic observations.
507
- observeProgramSlots(ast)
508
- rebuildArrElems()
509
- rebuildArrElemVals()
510
- // Clear sticky-null on val/schemaId — first 2 passes ran with valResult unset, so
511
- // call args resolving via `f.valResult` returned null and got stuck. Re-running
512
- // with refreshed callerValTypes lets these flow.
513
- clearStickyNull(paramReps, 'val')
514
- clearStickyNull(paramReps, 'schemaId')
515
- runFixpoint()
516
- // Now that .val is refreshed, dedicated arr-elem-schema fixpoint.
517
- runArrFixpoint()
518
- runArrFixpoint()
519
- // Parallel arr-elem-val fixpoint (NUMBER/STRING/…). Twice for transitive closure
520
- // through helper chains: `init()→main→runKernel`.
521
- runArrValTypeFixpoint()
522
- runArrValTypeFixpoint()
523
- // E3: Result-type pointer narrowing — when valResult is a non-ambiguous pointer kind
524
- // with constant aux, narrow sig.results[0] from f64 to i32 and tag sig.ptrKind/.ptrAux.
525
- // Eliminates the f64.reinterpret_i64+i64.or rebox at every return and the
526
- // i32.wrap_i64+i64.reinterpret_f64 unbox at every callsite that uses the value as a
527
- // pointer (load .[], .length, .prop slot dispatch).
528
- // - SET/MAP/BUFFER: aux always 0 — no per-callsite aux preservation needed.
529
- // - OBJECT: aux is schema-id; narrow only when all return exprs share a constant
530
- // schema (literal `{a,b,c}`, schemaId-bound param, module-bound var, or call to
531
- // another OBJECT-narrowed func). Caller picks aux up via callIR.ptrAux → readVar →
532
- // repByLocal.schemaId, restoring property-slot dispatch through the call boundary.
533
- // Safety: ARRAY forwards on realloc (no narrowing). STRING dual-encoded SSO/heap.
534
- // CLOSURE/TYPED also carry meaningful aux — TYPED narrowing is a follow-up. Body must
535
- // be a guaranteed-return form — fallthrough fallback i32.const 0 would be a valid
536
- // offset 0 of the narrowed kind, not undefined.
537
- const PTR_RESULT_KINDS_NOAUX = new Set([VAL.SET, VAL.MAP, VAL.BUFFER])
538
- // Schema-id inference for a return expression. Returns id (number), or null if unknown
539
- // / not constant. Mirrors inferArgSchema but extends with calls to already-narrowed
540
- // OBJECT-result funcs (fixpoint propagation through helper chains).
541
- const schemaIdOfReturn = (expr, paramSchemasMap) => {
542
- if (typeof expr === 'string') {
543
- if (paramSchemasMap?.has(expr)) return paramSchemasMap.get(expr)
544
- if (ctx.schema.vars.has(expr)) return ctx.schema.vars.get(expr)
545
- return null
546
- }
547
- if (!Array.isArray(expr)) return null
548
- const [op, ...args] = expr
549
- if (op === '{}') {
550
- // Object literal: bail to null on block body, dynamic key, or spread.
551
- const parsed = staticObjectProps(args)
552
- return parsed ? ctx.schema.register(parsed.names) : null
553
- }
554
- if (op === '()' && typeof args[0] === 'string') {
555
- const f = ctx.func.map.get(args[0])
556
- if (f?.valResult === VAL.OBJECT && f.sig.ptrAux != null) return f.sig.ptrAux
557
- return null
558
- }
559
- if (op === '?:') {
560
- const a = schemaIdOfReturn(args[1], paramSchemasMap)
561
- const b = schemaIdOfReturn(args[2], paramSchemasMap)
562
- return a != null && a === b ? a : null
563
- }
564
- if (op === '&&' || op === '||') {
565
- const a = schemaIdOfReturn(args[0], paramSchemasMap)
566
- const b = schemaIdOfReturn(args[1], paramSchemasMap)
567
- return a != null && a === b ? a : null
568
- }
569
- return null
570
- }
571
- // Per-body local elemAux map: scans `let/const x = new TypedArray(...)` decls so
572
- // a return like `let a = new Float64Array(...); return a` resolves to a constant
573
- // aux. Result calls + ?: are handled inline in typedAuxOfReturn.
574
- const localElemAuxMap = (body) => {
575
- const m = new Map()
576
- const walk = (n) => {
577
- if (!Array.isArray(n)) return
578
- const op = n[0]
579
- if (op === '=>') return
580
- if ((op === 'let' || op === 'const') && n.length > 1) {
581
- for (let i = 1; i < n.length; i++) {
582
- const a = n[i]
583
- if (Array.isArray(a) && a[0] === '=' && typeof a[1] === 'string') {
584
- const aux = typedElemAux(typedElemCtor(a[2]))
585
- if (aux != null) m.set(a[1], aux)
586
- }
587
- }
588
- }
589
- for (let i = 1; i < n.length; i++) walk(n[i])
590
- }
591
- walk(body)
592
- return m
593
- }
594
- const typedAuxOfReturn = (expr, localElemMap) => {
595
- if (typeof expr === 'string') return localElemMap?.get(expr) ?? null
596
- if (!Array.isArray(expr)) return null
597
- const [op, ...args] = expr
598
- if (op === '()' && typeof args[0] === 'string') {
599
- if (args[0].startsWith('new.')) {
600
- const ctor = typedElemCtor(expr)
601
- return ctor != null ? typedElemAux(ctor) : null
602
- }
603
- const f = ctx.func.map.get(args[0])
604
- if (f?.valResult === VAL.TYPED && f.sig.ptrAux != null) return f.sig.ptrAux
605
- return null
606
- }
607
- if (op === '?:') {
608
- const a = typedAuxOfReturn(args[1], localElemMap)
609
- const b = typedAuxOfReturn(args[2], localElemMap)
610
- return a != null && a === b ? a : null
611
- }
612
- if (op === '&&' || op === '||') {
613
- const a = typedAuxOfReturn(args[0], localElemMap)
614
- const b = typedAuxOfReturn(args[1], localElemMap)
615
- return a != null && a === b ? a : null
616
- }
617
- return null
618
- }
619
- // Fixpoint: a chain `outer → inner → {a,b}` needs inner to narrow first so outer's
620
- // call to inner contributes a known schema-id.
621
- let narrowChanged = true
622
- while (narrowChanged) {
623
- narrowChanged = false
624
- for (const func of narrowableFuncs) {
625
- if (!func.valResult) continue
626
- if (func.sig.results[0] !== 'f64') continue
627
- const isBlock = isBlockBody(func.body)
628
- if (isBlock && !alwaysReturns(func.body)) continue
629
- if (PTR_RESULT_KINDS_NOAUX.has(func.valResult)) {
630
- func.sig.results = ['i32']
631
- func.sig.ptrKind = func.valResult
632
- narrowChanged = true
633
- continue
634
- }
635
- const exprs = returnExprs(func.body)
636
- if (!exprs.length) continue
637
- if (func.valResult === VAL.OBJECT) {
638
- const paramSchemasMap = callerParamFactMap(paramReps, func, 'schemaId')
639
- const sid0 = schemaIdOfReturn(exprs[0], paramSchemasMap)
640
- if (sid0 == null) continue
641
- if (!exprs.every(e => schemaIdOfReturn(e, paramSchemasMap) === sid0)) continue
642
- func.sig.results = ['i32']
643
- func.sig.ptrKind = VAL.OBJECT
644
- func.sig.ptrAux = sid0
645
- narrowChanged = true
646
- } else if (func.valResult === VAL.TYPED) {
647
- const localMap = isBlock ? localElemAuxMap(func.body) : null
648
- const aux0 = typedAuxOfReturn(exprs[0], localMap)
649
- if (aux0 == null) continue
650
- if (!exprs.every(e => typedAuxOfReturn(e, localMap) === aux0)) continue
651
- func.sig.results = ['i32']
652
- func.sig.ptrKind = VAL.TYPED
653
- func.sig.ptrAux = aux0
654
- narrowChanged = true
655
- }
656
- }
657
- }
658
-
659
- // F: Cross-call typed-array element ctor propagation. Runs AFTER E3 so that
660
- // calls to user functions returning a TYPED-narrowed pointer (with constant
661
- // ptrAux, e.g. mkInput → Float64Array) contribute their element type to the
662
- // caller's local typedElem map. Result: callees pick up `ctx.types.typedElem`
663
- // for their own params and `arr[i]` reads emit a direct `f64.load` instead of
664
- // the runtime `__is_str_key + __typed_idx` dispatch — closes the largest
665
- // chunk of the JS→wasm gap on f64-heavy hot loops.
666
- // (Helpers `inferArgTypedCtor`/`ctorFromElemAux` live in analyze.js so the
667
- // bimorphic-typed specialization pass below can reuse them.)
668
- // Per-caller typed-elem map, recomputed now that E3 has tagged helper sigs.
669
- // Cache invalidation: analyzeBody.typedElems reads `ctx.func.map.get(...).sig.ptrKind`
670
- // for `let x = mkInput(...)` decls; entries cached during the initial walk
671
- // (before E3 ran) are stale (mkInput's ptrKind was unset then).
672
- for (const func of ctx.func.list) {
673
- if (func.body && !func.raw) invalidateValTypesCache(func.body)
674
- }
675
- const callerTypedCtx = new Map()
676
- callerTypedCtx.set(null, ctx.scope.globalTypedElem || new Map())
677
- for (const func of ctx.func.list) {
678
- if (!func.body || func.raw) continue
679
- callerTypedCtx.set(func, analyzeBody(func.body).typedElems)
680
- }
681
- // Two-pass fixpoint: lets a caller's params, once typed, propagate further to
682
- // its own callees (e.g. if `outer(buf)` calls `inner(buf)` and we learn `buf`
683
- // for outer, the second pass picks it up for inner). Reuses runArrElemFixpoint
684
- // (same shape — field/inferFn/elemsCtxMap parameterization).
685
- const runTypedFixpoint = () => runArrElemFixpoint('typedCtor', inferArgTypedCtor, callerTypedCtx)
686
- runTypedFixpoint()
687
- runTypedFixpoint()
688
-
689
- // G: TYPED pointer-ABI narrowing — once .typedCtor agrees on a single
690
- // ctor across all call sites, narrow the param from NaN-boxed f64 to raw
691
- // i32 offset (with ptrAux carrying the elem-type bits). Eliminates the
692
- // per-read `i32.wrap_i64 (i64.reinterpret_f64 (local.get $arr))` unbox dance
693
- // that today dominates hot loops dominated by typed-array indexing.
694
- // Call sites coerce via emitArgForParam → ptrOffsetIR(arg, VAL.TYPED).
695
- // Safety: same exclusions as the OBJECT/SET/MAP/BUFFER narrowing above —
696
- // exported, value-used, raw, defaults, rest position.
697
- for (const func of ctx.func.list) {
698
- if (func.exported || func.raw || valueUsed.has(func.name)) continue
699
- const reps = paramReps.get(func.name)
700
- if (!reps) continue
701
- const restIdx = func.rest ? func.sig.params.length - 1 : -1
702
- for (const [k, r] of reps) {
703
- const ctor = r.typedCtor
704
- if (ctor == null) continue
705
- if (k === restIdx) continue
706
- if (k >= func.sig.params.length) continue
707
- const p = func.sig.params[k]
708
- if (p.type === 'i32') continue
709
- if (func.defaults?.[p.name] != null) continue
710
- const aux = typedElemAux(ctor)
711
- if (aux == null) continue
712
- p.type = 'i32'
713
- p.ptrKind = VAL.TYPED
714
- p.ptrAux = aux
715
- }
716
- }
717
-
718
- // H: Post-F/G re-fixpoint — propagates VAL kinds through bimorphic call sites
719
- // where ptrKind narrowed but ptrAux disagreed (e.g. `sum(f64arr)` and `sum(i32arr)`
720
- // → both VAL.TYPED, different ctors). Without this, callerValTypes carries no entry
721
- // for caller's params, so inferArgType returns null and paramReps[callee][k].val is
722
- // sticky null. With ptrKind enriching callerValTypes, sum's arr gets val=TYPED in
723
- // its rep, letting array.js skip __is_str_key + __str_idx dispatch on `arr[i]`.
724
- for (const func of ctx.func.list) {
725
- if (!func.body || func.raw) continue
726
- const entry = callerCtx.get(func)
727
- if (!entry) continue
728
- for (const p of func.sig.params) {
729
- if (p.ptrKind == null) continue
730
- if (entry.callerValTypes.has(p.name)) continue
731
- entry.callerValTypes.set(p.name, p.ptrKind)
732
- }
733
- }
734
- clearStickyNull(paramReps, 'val')
735
- runFixpoint()
736
-
737
- // I: Post-E re-narrow of numeric (i32) params. The first numeric narrowing pass
738
- // ran before E narrowed any result types, so callerLocals saw `let h = mix(...)`
739
- // as f64 (mix's result was f64 then). After E narrowed mix's result to i32,
740
- // exprType (which now consults func.sig.results for user calls) sees `h` as i32.
741
- // Refresh callerLocals + clear sticky-null wasm + re-run fixpoint + re-apply
742
- // numeric narrowing to propagate i32 through chains of i32-only helpers
743
- // (callback bench: mix is FNV — params and result all i32-shaped, but inferred
744
- // only after E phase narrowed mix's result).
745
- for (const func of ctx.func.list) {
746
- if (!func.body || func.raw) continue
747
- invalidateLocalsCache(func.body)
748
- const fresh = analyzeLocals(func.body)
749
- for (const p of func.sig.params) if (!fresh.has(p.name)) fresh.set(p.name, p.type)
750
- callerCtx.get(func).callerLocals = fresh
751
- }
752
- // Reset wasm field unconditionally — first pass populated it from stale callerLocals
753
- // (where `let h = mix(...)` widened h to f64 because mix's result wasn't narrowed
754
- // yet). clearStickyNull only resets null; here we need to reset f64-observed too
755
- // so the refreshed exprType view propagates.
756
- for (const m of paramReps.values()) for (const r of m.values()) r.wasm = undefined
757
- runFixpoint()
758
- for (const func of ctx.func.list) {
759
- if (func.raw || valueUsed.has(func.name)) continue
760
- const reps = paramReps.get(func.name)
761
- if (!reps) continue
762
- const restIdx = func.rest ? func.sig.params.length - 1 : -1
763
- for (const [k, r] of reps) {
764
- if (r.wasm !== 'i32' || k === restIdx) continue
765
- if (k >= func.sig.params.length) continue
766
- const p = func.sig.params[k]
767
- if (p.type === 'i32') continue // already narrowed (incl. ptr-ABI)
768
- if (func.defaults?.[p.name] != null) continue
769
- // Don't steal typed-array params from specializeBimorphicTyped: F phase parks
770
- // bimorphic typed params at type='f64' with sticky-null typedCtor (two distinct
771
- // ctors at call sites). Their callers post-F pass them as i32 (pointer ABI),
772
- // so r.wasm flips to 'i32' here — but narrowing now breaks the clone path
773
- // that still needs to mint per-ctor sigs with ptrKind=TYPED, ptrAux=ctor-aux.
774
- if (r.val === VAL.TYPED) continue
775
- p.type = 'i32'
776
- }
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
777
159
  }
160
+ return fn.length
778
161
  }
779
162
 
780
- /**
781
- * Phase: bimorphic typed-array param specialization.
782
- *
783
- * For each non-exported user function with a typed-array param that F/G-phase
784
- * left bimorphic (paramReps[name][k].typedCtor === null because two or more call sites
785
- * disagreed on the elem-ctor — e.g. `sum(f64)` and `sum(i32)`), clone the
786
- * function once per concrete ctor seen at the call sites, narrow each clone's
787
- * sig.params[k] to a monomorphic typed pointer ABI (type='i32', ptrKind=TYPED,
788
- * ptrAux=ctor's aux), and rewrite the call AST nodes to dispatch to the right
789
- * clone. The original survives as a fallback for any non-static call sites
790
- * (e.g. inside arrow bodies); treeshake removes it if every site got rewritten.
791
- *
792
- * Why this matters: without specialization, `arr[i]` inside `sum` falls into
793
- * the runtime `__typed_idx` path on every iteration — V8 can't inline a wasm
794
- * call dominated by a switch on elem type. After specialization, each clone's
795
- * `arr[i]` lowers to a direct `f64.load` (or `i32.load + f64.convert`) with
796
- * the elem-ctor known at compile time. On poly bench this is the difference
797
- * between ~5 ms and matching AS at ~1 ms.
798
- *
799
- * Safety mirrors G-phase: skip exported, raw, value-used, defaulted, rest, or
800
- * already-i32 params. Bounded by MAX_CLONES_PER_FN to guard against polymorphic
801
- * blow-up (≥5 distinct ctors at one site no specialization).
802
- */
803
- function specializeBimorphicTyped(programFacts) {
804
- const { callSites, valueUsed, paramReps } = programFacts
805
- const MAX_CLONES_PER_FN = 4
806
-
807
- // Per-callee static-call-site index. Built once; cheap.
808
- const sitesByCallee = new Map()
809
- for (const cs of callSites) {
810
- const list = sitesByCallee.get(cs.callee)
811
- if (list) list.push(cs); else sitesByCallee.set(cs.callee, [cs])
812
- }
813
-
814
- // Per-caller typedElem map (literal `new TypedArray(N)` bindings inside body).
815
- const callerTypedCtx = new Map()
816
- callerTypedCtx.set(null, ctx.scope.globalTypedElem || new Map())
817
- for (const func of ctx.func.list) {
818
- if (!func.body || func.raw) continue
819
- callerTypedCtx.set(func, analyzeBody(func.body).typedElems)
820
- }
821
- // Per-caller typed-param map: caller's own params that F/G already narrowed
822
- // (so transitive `sum(arr)` inside a func that took `arr` from above resolves).
823
- const callerTypedParamsCtx = new Map()
824
- for (const func of ctx.func.list) {
825
- const m = callerParamFactMap(paramReps, func, 'typedCtor') || null
826
- let acc = m
827
- if (func.sig?.params) for (const p of func.sig.params) {
828
- if (p.ptrKind === VAL.TYPED && p.ptrAux != null) {
829
- acc ||= new Map()
830
- if (!acc.has(p.name)) acc.set(p.name, ctorFromElemAux(p.ptrAux))
831
- }
832
- }
833
- if (acc) callerTypedParamsCtx.set(func, acc)
834
- }
835
-
836
- // Snapshot ctx.func.list — we'll be appending clones during the loop.
837
- const originals = ctx.func.list.slice()
838
- for (const func of originals) {
839
- if (func.exported || func.raw || valueUsed.has(func.name)) continue
840
- if (!func.body) continue
841
- if (func.rest) continue
842
- const reps = paramReps.get(func.name)
843
- if (!reps) continue
844
- const sites = sitesByCallee.get(func.name)
845
- if (!sites || sites.length < 2) continue
846
-
847
- // Find sticky-bimorphic typed-param positions left by F-phase.
848
- const bimorphic = []
849
- for (let k = 0; k < func.sig.params.length; k++) {
850
- if (reps.get(k)?.typedCtor !== null) continue
851
- const p = func.sig.params[k]
852
- if (p.type === 'i32') continue
853
- if (func.defaults?.[p.name] != null) continue
854
- bimorphic.push(k)
855
- }
856
- if (bimorphic.length === 0) continue
857
-
858
- // For each site, infer the ctor combination across bimorphic positions.
859
- // Abort if any site has unknown ctor at any bimorphic position — we can't
860
- // route that call to a specific clone without it.
861
- const siteCombos = []
862
- let abort = false
863
- for (const site of sites) {
864
- const callerTypedElems = callerTypedCtx.get(site.callerFunc)
865
- const callerTypedParams = callerTypedParamsCtx.get(site.callerFunc)
866
- const combo = []
867
- for (const k of bimorphic) {
868
- if (k >= site.argList.length) { abort = true; break }
869
- const c = inferArgTypedCtor(site.argList[k], callerTypedElems, callerTypedParams)
870
- if (c == null || typedElemAux(c) == null) { abort = true; break }
871
- combo.push(c)
872
- }
873
- if (abort) break
874
- siteCombos.push(combo)
875
- }
876
- if (abort) continue
877
-
878
- // Distinct combos seen across call sites.
879
- const distinct = new Map()
880
- for (const combo of siteCombos) {
881
- const key = combo.join('|')
882
- if (!distinct.has(key)) distinct.set(key, combo)
883
- }
884
- if (distinct.size < 2) continue // F-phase already mono — nothing to do
885
- if (distinct.size > MAX_CLONES_PER_FN) continue // polymorphic blow-up
886
-
887
- // Build one clone per distinct combo.
888
- const cloneByKey = new Map()
889
- for (const [key, combo] of distinct) {
890
- const suffix = combo.map(c => c.replace(/^new\./, '').replace(/\./g, '_')).join('$')
891
- let cloneName = `${func.name}$${suffix}`
892
- let n = 0
893
- while (ctx.func.names.has(cloneName)) cloneName = `${func.name}$${suffix}$${++n}`
894
-
895
- const cloneSig = {
896
- ...func.sig,
897
- params: func.sig.params.map(p => ({ ...p })),
898
- results: [...func.sig.results],
899
- }
900
- for (let i = 0; i < bimorphic.length; i++) {
901
- const k = bimorphic[i]
902
- const aux = typedElemAux(combo[i])
903
- const p = cloneSig.params[k]
904
- p.type = 'i32'
905
- p.ptrKind = VAL.TYPED
906
- p.ptrAux = aux
907
- }
908
- const clone = { ...func, name: cloneName, sig: cloneSig }
909
- ctx.func.list.push(clone)
910
- ctx.func.map.set(cloneName, clone)
911
- ctx.func.names.add(cloneName)
912
-
913
- // Mirror per-param reps under the clone's name with mono ctors at bimorphic
914
- // positions. emitFunc's preseed reads typedCtor → seeds typedElem map →
915
- // `arr[i]` lowers to direct typed load.
916
- const cloneReps = new Map()
917
- for (const [k, r] of reps) cloneReps.set(k, { ...r })
918
- for (let i = 0; i < bimorphic.length; i++) {
919
- const k = bimorphic[i]
920
- const r = cloneReps.get(k) || {}
921
- r.typedCtor = combo[i]
922
- r.val = VAL.TYPED
923
- cloneReps.set(k, r)
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
924
194
  }
925
- paramReps.set(cloneName, cloneReps)
926
-
927
- cloneByKey.set(key, clone)
928
- }
929
-
930
- // Rewrite each site's call AST to point at the matching clone.
931
- for (let i = 0; i < sites.length; i++) {
932
- const clone = cloneByKey.get(siteCombos[i].join('|'))
933
- sites[i].node[1] = clone.name
934
195
  }
935
- }
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
936
235
  }
937
236
 
938
- /**
939
- * Phase: refine ctx.types.anyDynKey using post-narrowSignatures type info.
940
- *
941
- * collectProgramFacts conservatively flags `anyDynKey=true` whenever it sees
942
- * `obj[idx]` with a non-literal-string index — but typed-array / array /
943
- * string `[]` is element access (sound for that base type), not a true
944
- * dyn-key lookup that needs the hash-table shadow on object literals.
945
- *
946
- * After narrowSignatures populates paramReps (call-site fixpoint), we can
947
- * type each `obj` in `obj[idx]` and skip the ones that are provably non-object.
948
- * If no genuine dyn-key access remains program-wide, drop anyDynKey to false
949
- * — object literals then skip the __dyn_set shadow loop (large code + perf win,
950
- * especially on hot allocators like aos.initRows).
951
- *
952
- * Live-function gate: walking dead funcs (e.g. unused benchlib helpers) would
953
- * pollute analysis with `out` params we never narrowed. Restrict to functions
954
- * reachable from exports / first-class value uses.
955
- */
956
- const NON_DYN_VTS = new Set([VAL.TYPED, VAL.ARRAY, VAL.STRING, VAL.BUFFER])
957
- const TYPED_ARRAY_CTOR = /^(Float|Int|Uint|BigInt|BigUint)(8|16|32|64)(Clamped)?Array$/
958
-
959
- function refineDynKeys(programFacts) {
960
- if (!ctx.types.anyDynKey) return
961
- const { paramReps, valueUsed } = programFacts
962
- const isLitStr = idx => Array.isArray(idx) && idx[0] === 'str' && typeof idx[1] === 'string'
963
-
964
- // Per-function type map: param vtypes from paramReps, plus locals
965
- // we can prove are typed arrays from `let v = new TypedArray(...)`. After
966
- // prepare, that node is `['()', 'new.Float64Array', ...args]`.
967
- const buildTypeMap = (funcName, body, params) => {
968
- const map = new Map()
969
- if (params) {
970
- const reps = paramReps.get(funcName)
971
- if (reps) for (let i = 0; i < params.length; i++) {
972
- const t = reps.get(i)?.val
973
- if (t != null) map.set(params[i].name, t)
974
- }
975
- }
976
- const walk = (node) => {
977
- if (!Array.isArray(node)) return
978
- const op = node[0]
979
- if (op === 'let' || op === 'const') {
980
- for (let i = 1; i < node.length; i++) {
981
- const d = node[i]
982
- if (!Array.isArray(d) || d[0] !== '=' || typeof d[1] !== 'string') continue
983
- const init = d[2]
984
- let ctor = null
985
- if (Array.isArray(init) && init[0] === '()' && typeof init[1] === 'string' && init[1].startsWith('new.'))
986
- ctor = init[1].slice(4)
987
- if (ctor && TYPED_ARRAY_CTOR.test(ctor)) map.set(d[1], VAL.TYPED)
988
- else if (typeof init === 'string' && map.has(init)) map.set(d[1], map.get(init))
989
- }
990
- }
991
- if (op === '=>') return // don't cross into nested arrows; they're separate funcs
992
- for (let i = 1; i < node.length; i++) walk(node[i])
993
- }
994
- walk(body)
995
- return map
996
- }
997
-
998
- let real = false
999
- const visit = (typeMap, node) => {
1000
- if (real || !Array.isArray(node)) return
1001
- const op = node[0]
1002
- if (op === '[]') {
1003
- const idx = node[2]
1004
- if (!isLitStr(idx)) {
1005
- const obj = node[1]
1006
- const vt = typeof obj === 'string' ? typeMap.get(obj) : null
1007
- if (!NON_DYN_VTS.has(vt)) real = true
1008
- }
1009
- } else if (op === 'for-in') real = true
1010
- if (op === '=>') return
1011
- for (let i = 1; i < node.length; i++) visit(typeMap, node[i])
1012
- }
1013
-
1014
- // Live: anything reachable from exports/first-class value uses. Skipping
1015
- // dead helpers (unused benchlib imports) keeps their generic params from
1016
- // pretending to be dyn-key access.
1017
- const isLive = f => f.exported || paramReps.has(f.name) || (valueUsed && valueUsed.has(f.name))
237
+ // === Module compilation ===
1018
238
 
1019
- const topMap = buildTypeMap(null, null, null)
1020
- for (const f of ctx.func.list) {
1021
- if (real) break
1022
- if (!f.body || !isLive(f)) continue
1023
- visit(buildTypeMap(f.name, f.body, f.sig?.params), f.body)
1024
- }
1025
- if (!real && ctx.module.moduleInits) for (const mi of ctx.module.moduleInits) {
1026
- if (real) break
1027
- visit(topMap, mi)
1028
- }
239
+ const cloneRepMap = map => map ? new Map([...map].map(([k, v]) => [k, { ...v }])) : null
1029
240
 
1030
- if (!real) ctx.types.anyDynKey = false
241
+ function enterFunc(func) {
242
+ ctx.func.stack = []
243
+ ctx.func.uniq = 0
244
+ ctx.func.current = func.sig
245
+ ctx.func.body = func.body
246
+ ctx.func.directClosures = null
247
+ ctx.func.localProps = null
1031
248
  }
1032
249
 
1033
- /**
1034
- * Phase: emit one user function to WAT IR.
1035
- *
1036
- * Reads the (already-narrowed) `func.sig` and `programFacts.paramReps[name]`
1037
- * to seed per-param val reps / schema bindings; emits body via emit / emitBody.
1038
- *
1039
- * Mutates ctx.func.* per-function state (locals, boxed, repByLocal, …) and
1040
- * ctx.schema.vars (restored on exit so bindings don't leak across functions).
1041
- */
1042
- function emitFunc(func, programFacts) {
250
+ function analyzeFuncForEmit(func, programFacts) {
1043
251
  const { paramReps } = programFacts
252
+ if (func.raw) return null
1044
253
 
1045
- // Raw WAT functions (e.g., _alloc, _reset from memory module)
1046
- if (func.raw) return parseWat(func.raw)
254
+ const { name, body, sig } = func
255
+ enterFunc(func)
1047
256
 
1048
- const { name, body, exported, sig } = func
1049
- const multi = sig.results.length > 1
1050
-
1051
- // Reset per-function state
1052
- ctx.func.stack = []
1053
- ctx.func.uniq = 0
1054
- ctx.func.current = sig
1055
- ctx.func.body = body
1056
- ctx.func.directClosures = null
1057
-
1058
- // Pre-analyze local types from body
1059
- // Block body vs object literal: object has ':' property nodes
1060
257
  const block = Array.isArray(body) && body[0] === '{}' && body[1]?.[0] !== ':'
1061
- ctx.func.boxed = new Map() // variable name → cell local name (i32) for mutable capture
1062
- ctx.func.localProps = null // reset per function
1063
- ctx.func.repByLocal = null // Map<name, ValueRep> — populated lazily; reset per function
258
+ ctx.func.boxed = new Map()
259
+ ctx.func.repByLocal = null
1064
260
  ctx.types.typedElem = ctx.scope.globalTypedElem ? new Map(ctx.scope.globalTypedElem) : null
1065
- // Pre-seed cross-call param facts BEFORE analyzeLocals/analyzeValTypes(body) so that
1066
- // when the walker sees `const b0 = arr[i]` or `let n = arr.length`, lookupValType(arr)
1067
- // already resolves to VAL.TYPED — letting valTypeOf's `[]` rule propagate VAL.NUMBER
1068
- // to b0 (skips __to_num) and exprType's `.length` rule keep n as i32 (skips per-iter
1069
- // f64.convert_i32_s + i32.trunc_sat_f64_s on the loop counter). Without this seed,
1070
- // params don't gain VAL.TYPED until after analyzeLocals freezes counter widths.
261
+
1071
262
  const _reps = paramReps.get(name)
1072
263
  if (_reps) {
1073
264
  for (const [k, r] of _reps) {
@@ -1089,20 +280,39 @@ function emitFunc(func, programFacts) {
1089
280
  // cached widths reflect the pre-narrow state. Re-walk now with reps in place.
1090
281
  invalidateLocalsCache(body)
1091
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
+ }
1092
296
  if (block) {
1093
297
  analyzeValTypes(body)
1094
298
  analyzeIntCertain(body)
1095
299
  analyzeBoxedCaptures(body)
1096
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`.
1097
306
  const unbox = analyzePtrUnboxable(body, ctx.func.locals, ctx.func.boxed)
1098
307
  if (unbox.size > 0) {
1099
308
  for (const [n, kind] of unbox) {
1100
- ctx.func.locals.set(n, 'i32')
1101
309
  const fields = { ptrKind: kind }
1102
310
  if (kind === VAL.TYPED) {
1103
311
  const aux = typedElemAux(ctx.types.typedElem?.get(n))
1104
- if (aux != null) fields.ptrAux = aux
312
+ if (aux == null) continue
313
+ fields.ptrAux = aux
1105
314
  }
315
+ ctx.func.locals.set(n, 'i32')
1106
316
  updateRep(n, fields)
1107
317
  }
1108
318
  }
@@ -1117,6 +327,39 @@ function emitFunc(func, programFacts) {
1117
327
  if (p.ptrAux != null) fields.ptrAux = p.ptrAux
1118
328
  updateRep(p.name, fields)
1119
329
  }
330
+
331
+ return {
332
+ block,
333
+ locals: new Map(ctx.func.locals),
334
+ boxed: new Map(ctx.func.boxed),
335
+ typedElem: ctx.types.typedElem ? new Map(ctx.types.typedElem) : null,
336
+ repByLocal: cloneRepMap(ctx.func.repByLocal),
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Phase: emit one user function to WAT IR.
342
+ *
343
+ * Reads precomputed `funcFacts` and the narrowed `func.sig`; applies scoped
344
+ * schema param bindings during emission so they cannot leak between functions.
345
+ */
346
+ function emitFunc(func, funcFacts, programFacts) {
347
+ const { paramReps } = programFacts
348
+
349
+ // Raw WAT functions (e.g., _alloc, _clear from memory module)
350
+ if (func.raw) return parseWat(func.raw)
351
+
352
+ const { name, body, exported, sig } = func
353
+ const multi = sig.results.length > 1
354
+ const _reps = paramReps.get(name)
355
+
356
+ enterFunc(func)
357
+ const block = funcFacts.block
358
+ ctx.func.locals = new Map(funcFacts.locals)
359
+ ctx.func.boxed = new Map(funcFacts.boxed)
360
+ ctx.func.repByLocal = cloneRepMap(funcFacts.repByLocal)
361
+ ctx.types.typedElem = funcFacts.typedElem ? new Map(funcFacts.typedElem) : null
362
+
1120
363
  // D: Apply call-site param facts (only if body analysis didn't already set them).
1121
364
  // Schema bindings additionally write into ctx.schema.vars so prop-access dispatch
1122
365
  // hits the slot map. ctx.schema.vars is saved/restored so bindings don't leak.
@@ -1145,24 +388,28 @@ function emitFunc(func, programFacts) {
1145
388
  fn.push(...sig.params.map(p => ['param', `$${p.name}`, p.type]))
1146
389
  fn.push(...sig.results.map(t => ['result', t]))
1147
390
 
1148
- // Default params: missing JS args become canonical NaN (0x7FF8000000000000) in WASM f64 params.
1149
- // 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.
1150
393
  const defaults = func.defaults || {}
1151
394
  const defaultInits = []
1152
395
  for (const [pname, defVal] of Object.entries(defaults)) {
1153
396
  const p = sig.params.find(p => p.name === pname)
1154
397
  const t = p?.type || 'f64'
1155
398
  defaultInits.push(
1156
- ['if', isNullish(typed(['local.get', `$${pname}`], 'f64')),
399
+ ['if', isUndef(typed(['local.get', `$${pname}`], 'f64')),
1157
400
  ['then', ['local.set', `$${pname}`, t === 'f64' ? asF64(emit(defVal)) : asI32(emit(defVal))]]])
1158
401
  }
1159
402
 
1160
403
  // Box params that are mutably captured: allocate cell, copy param value
1161
404
  const boxedParamInits = []
405
+ const preboxedLocalInits = []
406
+ ctx.func.preboxed = new Set()
407
+ const paramNames = new Set(sig.params.map(p => p.name))
1162
408
  for (const p of sig.params) {
1163
409
  if (ctx.func.boxed.has(p.name)) {
1164
410
  const cell = ctx.func.boxed.get(p.name)
1165
411
  ctx.func.locals.set(cell, 'i32')
412
+ ctx.func.preboxed.add(p.name)
1166
413
  const lget = typed(['local.get', `$${p.name}`], p.type)
1167
414
  if (p.ptrKind != null) lget.ptrKind = p.ptrKind
1168
415
  boxedParamInits.push(
@@ -1170,6 +417,14 @@ function emitFunc(func, programFacts) {
1170
417
  ['f64.store', ['local.get', `$${cell}`], asF64(lget)])
1171
418
  }
1172
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
+ }
1173
428
 
1174
429
  if (block) {
1175
430
  const stmts = emitBody(body)
@@ -1177,16 +432,16 @@ function emitFunc(func, programFacts) {
1177
432
  // I: Skip trailing fallback when last statement is return (unreachable code)
1178
433
  const lastStmt = stmts.at(-1)
1179
434
  const endsWithReturn = lastStmt && (lastStmt[0] === 'return' || lastStmt[0] === 'return_call')
1180
- 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])))
1181
436
  } else if (multi && body[0] === '[') {
1182
437
  const values = body.slice(1).map(e => asF64(emit(e)))
1183
438
  for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
1184
- fn.push(...boxedParamInits, ...values)
439
+ fn.push(...boxedParamInits, ...preboxedLocalInits, ...values)
1185
440
  } else {
1186
441
  const ir = emit(body)
1187
442
  for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
1188
443
  const finalIR = sig.ptrKind != null ? asPtrOffset(ir, sig.ptrKind) : asParamType(ir, sig.results[0])
1189
- fn.push(...defaultInits, ...boxedParamInits, tcoTailRewrite(finalIR, sig.results[0]))
444
+ fn.push(...defaultInits, ...boxedParamInits, ...preboxedLocalInits, tcoTailRewrite(finalIR, sig.results[0]))
1190
445
  }
1191
446
 
1192
447
  // Restore schema.vars so param bindings don't leak to next function.
@@ -1199,18 +454,22 @@ function emitFunc(func, programFacts) {
1199
454
  *
1200
455
  * For each `isBoundaryWrapped(func)`, emit a sibling `$${name}$exp` that:
1201
456
  * - holds the (export "name") attribute (JS sees the wrapper)
1202
- * - 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.
1203
461
  * - converts each narrowed param at the call: f64 → i32 (truncate-sat) for
1204
462
  * numeric narrowed, f64 → i32-offset (`i32.wrap_i64 + i64.reinterpret_f64`)
1205
- * 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).
1206
466
  * - forwards args to the inner $${name}
1207
- * - 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
1208
468
  *
1209
- * Param convert cases (each narrowed inner-param):
1210
- * - p.type = 'i32', no ptrKind → i32.trunc_sat_f64_s(local.get $p)
1211
- * - 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.
1212
471
  *
1213
- * Result rebox cases:
472
+ * Result rebox cases (then reinterpret to i64 at the boundary):
1214
473
  * - sig.ptrKind != null → mkPtrIR(ptrKind, ptrAux ?? 0, callIR)
1215
474
  * - sig.results[0] = i32 → f64.convert_i32_s(callIR)
1216
475
  * - sig.results[0] = f64 → callIR (some params narrowed but result stayed f64)
@@ -1220,18 +479,23 @@ function synthesizeBoundaryWrappers() {
1220
479
  for (const func of ctx.func.list) {
1221
480
  if (!isBoundaryWrapped(func)) continue
1222
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
1223
488
  const wrapNode = ['func', `$${name}$exp`, ['export', `"${name}"`]]
1224
- // External ABI: every param is f64 (JS Number / NaN-boxed ptr).
1225
- for (const p of sig.params) wrapNode.push(['param', `$${p.name}`, 'f64'])
1226
- wrapNode.push(['result', 'f64'])
1227
- 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) => {
1228
492
  const get = ['local.get', `$${p.name}`]
1229
- if (p.type === 'f64') return get
1230
493
  if (p.ptrKind != null) {
1231
- // NaN-boxed f64raw i32 offset
1232
- 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]
1233
496
  }
1234
- // Numeric i32 — JS Number → i32 truncation (matches `n | 0` for integers).
497
+ if (p.type === 'f64') return get
498
+ // Numeric narrowing: f64 → i32 truncate
1235
499
  return ['i32.trunc_sat_f64_s', get]
1236
500
  })
1237
501
  const callIR = ['call', `$${name}`, ...args]
@@ -1244,7 +508,9 @@ function synthesizeBoundaryWrappers() {
1244
508
  } else {
1245
509
  body = callIR
1246
510
  }
1247
- 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 }
1248
514
  wrappers.push(wrapNode)
1249
515
  }
1250
516
  return wrappers
@@ -1268,6 +534,7 @@ function emitClosureBody(cb) {
1268
534
  // Reset per-function state for closure body
1269
535
  ctx.func.locals = new Map()
1270
536
  ctx.func.repByLocal = null
537
+ if (cb.intConsts) for (const [name, v] of cb.intConsts) updateRep(name, { intConst: v })
1271
538
  if (cb.valTypes) for (const [name, vt] of cb.valTypes) updateRep(name, { val: vt })
1272
539
  if (cb.schemaVars) {
1273
540
  ctx.schema.vars = new Map([...prevSchemaVars, ...cb.schemaVars])
@@ -1283,6 +550,8 @@ function emitClosureBody(cb) {
1283
550
  }
1284
551
  // In closure bodies, boxed captures use the original name as both var and cell local
1285
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()
1286
555
  ctx.func.stack = []
1287
556
  ctx.func.uniq = Math.max(ctx.func.uniq, 100) // avoid label collisions
1288
557
  ctx.func.body = cb.body
@@ -1315,11 +584,35 @@ function emitClosureBody(cb) {
1315
584
  const block = Array.isArray(cb.body) && cb.body[0] === '{}' && cb.body[1]?.[0] !== ':'
1316
585
  let bodyIR
1317
586
  if (block) {
587
+ invalidateLocalsCache(cb.body)
1318
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)
1319
600
  // Detect captures from deeper nested arrows that mutate this body's locals/params/captures
1320
601
  analyzeBoxedCaptures(cb.body)
1321
602
  for (const name of ctx.func.boxed.keys()) {
1322
- 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)
1323
616
  }
1324
617
  bodyIR = emitBody(cb.body)
1325
618
  } else {
@@ -1339,6 +632,28 @@ function emitClosureBody(cb) {
1339
632
  inc('__alloc_hdr', '__mkptr')
1340
633
  }
1341
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
+
1342
657
  // Insert locals (captures + params + declared)
1343
658
  for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
1344
659
 
@@ -1351,8 +666,15 @@ function emitClosureBody(cb) {
1351
666
  for (let i = 0; i < cb.captures.length; i++) {
1352
667
  const name = cb.captures[i]
1353
668
  const addr = ['i32.add', ['local.get', `$${envBase}`], ['i32.const', i * 8]]
1354
- fn.push(['local.set', `$${name}`,
1355
- 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
+ }
1356
678
  }
1357
679
  }
1358
680
 
@@ -1360,7 +682,14 @@ function emitClosureBody(cb) {
1360
682
  // Rest name (if present) is last in cb.params — handled separately below.
1361
683
  const fixedParamN = cb.params.length - (cb.rest ? 1 : 0)
1362
684
  for (let i = 0; i < fixedParamN && i < W; i++) {
1363
- 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
+ }
1364
693
  }
1365
694
 
1366
695
  // Rest param: pack slots a[fixedParams..argc-1] into fresh array.
@@ -1386,20 +715,34 @@ function emitClosureBody(cb) {
1386
715
  ['i32.add', ['local.get', `$${restOff}`], ['i32.const', i * 8]],
1387
716
  ['local.get', `$__a${fixedN + i}`]]]])
1388
717
  }
1389
- fn.push(['local.set', `$${cb.rest}`,
1390
- ['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
+ }
1391
726
  }
1392
727
 
1393
728
  // Default params for closures (check sentinel after unpack)
729
+ // Only `undefined` triggers default per spec — `null`/`0`/`false` pass through.
1394
730
  if (cb.defaults) {
1395
731
  for (const [pname, defVal] of Object.entries(cb.defaults)) {
1396
- fn.push(['if', isNullish(['local.get', `$${pname}`]),
1397
- ['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
+ }
1398
739
  }
1399
740
  }
741
+ fn.push(...preboxedLocalInits)
1400
742
  fn.push(...bodyIR)
1401
743
  // I: Skip trailing fallback when last statement is return
1402
- 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())
1403
746
  ctx.schema.vars = prevSchemaVars
1404
747
  ctx.types.typedElem = prevTypedElems
1405
748
  return fn
@@ -1452,9 +795,9 @@ function buildStartFn(ast, sec, closureFuncs, compilePendingClosures) {
1452
795
  const bt = `${T}box`
1453
796
  ctx.func.locals.set(bt, 'i32')
1454
797
  for (const [name, { schemaId, schema }] of ctx.schema.autoBox) {
1455
- inc('__alloc', '__mkptr')
798
+ inc('__alloc_hdr', '__mkptr')
1456
799
  boxInit.push(
1457
- ['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]]],
1458
801
  ['f64.store', ['local.get', `$${bt}`],
1459
802
  ctx.func.names.has(name) ? ['f64.const', 0] : ['global.get', `$${name}`]],
1460
803
  ...schema.slice(1).map((_, i) =>
@@ -1472,7 +815,12 @@ function buildStartFn(ast, sec, closureFuncs, compilePendingClosures) {
1472
815
  // __dyn_get (set transitively by resolveIncludes() later) are listed
1473
816
  // explicitly here because the dep graph hasn't been expanded yet at
1474
817
  // start-fn build time.
1475
- 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 && (
1476
824
  ctx.core.includes.has('__stringify') ||
1477
825
  ctx.core.includes.has('__dyn_get') ||
1478
826
  ctx.core.includes.has('__dyn_get_t') ||
@@ -1480,17 +828,24 @@ function buildStartFn(ast, sec, closureFuncs, compilePendingClosures) {
1480
828
  ctx.core.includes.has('__dyn_get_any_t') ||
1481
829
  ctx.core.includes.has('__dyn_get_expr') ||
1482
830
  ctx.core.includes.has('__dyn_get_expr_t') ||
1483
- ctx.core.includes.has('__dyn_get_or'))
831
+ ctx.core.includes.has('__dyn_get_or'))) ||
832
+ hasJpObj
1484
833
  if (needsSchemaTbl) {
1485
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
1486
838
  const stbl = `${T}stbl`
1487
839
  const sarr = `${T}sarr`
1488
840
  ctx.func.locals.set(stbl, 'i32')
1489
841
  ctx.func.locals.set(sarr, 'i32')
1490
842
  inc('__alloc', '__alloc_hdr', '__mkptr')
1491
843
  schemaInit.push(
1492
- ['local.set', `$${stbl}`, ['call', '$__alloc', ['i32.const', nSchemas * 8]]],
844
+ ['local.set', `$${stbl}`, ['call', '$__alloc', ['i32.const', (nSchemas + runtimeReserve) * 8]]],
1493
845
  ['global.set', '$__schema_tbl', ['local.get', `$${stbl}`]])
846
+ if (runtimeReserve) {
847
+ schemaInit.push(['global.set', '$__schema_next', ['i32.const', nSchemas]])
848
+ }
1494
849
  for (let s = 0; s < nSchemas; s++) {
1495
850
  const keys = ctx.schema.list[s]
1496
851
  const n = keys.length
@@ -1521,12 +876,15 @@ function buildStartFn(ast, sec, closureFuncs, compilePendingClosures) {
1521
876
  for (const s of ctx.runtime.typeofStrs)
1522
877
  typeofInit.push(['global.set', `$__tof_${s}`, emit(['str', s])])
1523
878
  }
1524
- 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) {
1525
883
  const initIR = normalizeIR(init)
1526
884
  const startFn = ['func', '$__start']
1527
885
  for (const [l, t] of ctx.func.locals) startFn.push(['local', `$${l}`, t])
1528
886
  startFn.push(...strPoolInit, ...typeofInit, ...boxInit, ...schemaInit,
1529
- ...(ctx.features.timers ? [['call', '$__timer_init']] : []),
887
+ ...(wasiTimers ? [['call', '$__timer_init']] : []),
1530
888
  ...moduleInits, ...initIR,
1531
889
  ...(ctx.features.blockingTimers ? [['call', '$__timer_loop']] : []),
1532
890
  )
@@ -1591,12 +949,21 @@ function dedupClosureBodies(closureFuncs, sec) {
1591
949
  else { hashToName.set(key, name); keepSet.add(name) }
1592
950
  }
1593
951
  if (!redirect.size) return
1594
- 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.
1595
954
  const kept = sec.funcs.filter(fn => {
1596
955
  if (!Array.isArray(fn) || fn[0] !== 'func') return true
1597
956
  const name = typeof fn[1] === 'string' && fn[1][0] === '$' ? fn[1].slice(1) : null
1598
957
  return !name || !redirect.has(name)
1599
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)
1600
967
  sec.funcs.length = 0
1601
968
  sec.funcs.push(...kept)
1602
969
  }
@@ -1622,8 +989,7 @@ function dedupClosureBodies(closureFuncs, sec) {
1622
989
  * Both `call` and `return_call` (tail call) sites are rewritten in the same walk.
1623
990
  */
1624
991
  function finalizeClosureTable(sec) {
1625
- if (!ctx.closure.table?.length) return
1626
- let indirectUsed = false
992
+ let indirectUsed = ctx.transform.host === 'wasi'
1627
993
  const scan = (n) => {
1628
994
  if (!Array.isArray(n) || indirectUsed) return
1629
995
  if (n[0] === 'call_indirect') { indirectUsed = true; return }
@@ -1637,8 +1003,9 @@ function finalizeClosureTable(sec) {
1637
1003
  }
1638
1004
  // Keep table if call_indirect is used (closures, timer dispatch, etc.)
1639
1005
  if (indirectUsed) {
1006
+ if (!ctx.closure.table) ctx.closure.table = []
1640
1007
  sec.table = [['table', ['export', '"__jz_table"'], ctx.closure.table.length, 'funcref']]
1641
- 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}`)]] : []
1642
1009
  return
1643
1010
  }
1644
1011
  sec.table = []
@@ -1702,7 +1069,7 @@ function finalizeClosureTable(sec) {
1702
1069
  * 1. resolveIncludes() — close the include set under stdlib dependencies.
1703
1070
  * 2. Emit memory section ONLY when some included helper uses memory ops
1704
1071
  * (G optimization: pure scalar programs ship without memory + __heap).
1705
- * When memory is needed, the allocator (__alloc + __alloc_hdr + __reset)
1072
+ * When memory is needed, the allocator (__alloc + __alloc_hdr + __clear)
1706
1073
  * is force-included since stdlib funcs may call into it.
1707
1074
  * 3. Pull external (host) stdlibs into sec.extStdlib (must precede normal
1708
1075
  * imports in the module byte order).
@@ -1718,11 +1085,11 @@ function pullStdlib(sec) {
1718
1085
  const needsMemory = [...ctx.core.includes].some(n => ctx.core.stdlib[n] && MEM_OPS.test(ctx.core.stdlib[n]))
1719
1086
  if (!needsMemory) ctx.scope.globals.delete('__heap')
1720
1087
  if (needsMemory && ctx.module.modules.core) {
1721
- 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)
1722
1089
  const pages = ctx.memory.pages || 1
1723
1090
  if (ctx.memory.shared) sec.imports.push(['import', '"env"', '"memory"', ['memory', pages]])
1724
1091
  else sec.memory.push(['memory', ['export', '"memory"'], pages])
1725
- if (ctx.transform.runtimeExports !== false && ctx.core._allocRawFuncs)
1092
+ if (ctx.transform.alloc !== false && ctx.core._allocRawFuncs)
1726
1093
  sec.funcs.push(...ctx.core._allocRawFuncs.map(s => parseWat(s)))
1727
1094
  }
1728
1095
 
@@ -1730,10 +1097,15 @@ function pullStdlib(sec) {
1730
1097
  const v = ctx.core.stdlib[name]
1731
1098
  return typeof v === 'function' ? v() : v
1732
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()
1733
1104
  for (const name of Object.keys(ctx.core.stdlib)) {
1734
1105
  if (name.startsWith('__ext_') && ctx.core.includes.has(name)) {
1735
1106
  const parsed = parseWat(stdlibStr(name))
1736
1107
  sec.extStdlib.push(parsed[0] === "module" ? parsed[1] : parsed)
1108
+ ctx.core.extImports.add(name)
1737
1109
  ctx.core.includes.delete(name)
1738
1110
  }
1739
1111
  }
@@ -1741,6 +1113,12 @@ function pullStdlib(sec) {
1741
1113
  sec.stdlib.push(...[...ctx.core.includes].map(n => parseWat(stdlibStr(n))))
1742
1114
  }
1743
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
+
1744
1122
  /**
1745
1123
  * Phase: whole-module + per-function optimization passes.
1746
1124
  *
@@ -1774,6 +1152,19 @@ function optimizeModule(sec) {
1774
1152
  ctx.runtime.strPool = poolRef.pool
1775
1153
  }
1776
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
+ }
1777
1168
  if (!cfg || cfg.hoistConstantPool !== false)
1778
1169
  hoistConstantPool([...sec.funcs, ...sec.stdlib, ...sec.start], (name, wat) => ctx.scope.globals.set(name, wat))
1779
1170
 
@@ -1781,8 +1172,10 @@ function optimizeModule(sec) {
1781
1172
  if (dataLen > 1024 && !ctx.memory.shared) {
1782
1173
  const heapBase = (dataLen + 7) & ~7
1783
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}))`)
1784
1177
  for (const s of sec.stdlib)
1785
- if (s[0] === 'func' && s[1] === '$__reset')
1178
+ if (s[0] === 'func' && s[1] === '$__clear')
1786
1179
  for (let i = 2; i < s.length; i++)
1787
1180
  if (Array.isArray(s[i]) && s[i][0] === 'global.set' && Array.isArray(s[i][2]) && s[i][2][0] === 'i32.const')
1788
1181
  s[i][2][1] = `${heapBase}`
@@ -1812,12 +1205,14 @@ function stripStaticDataPrefix(sec) {
1812
1205
  for (const slotOff of ctx.runtime.staticPtrSlots) {
1813
1206
  if (slotOff < prefix) continue
1814
1207
  const bits = dv.getBigUint64(slotOff, true)
1815
- if (((bits >> 48n) & 0xFFF8n) !== NAN_PREFIX_BITS) continue
1816
- 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)
1817
1210
  if (!SHIFTABLE.has(ty)) continue
1818
- 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)
1819
1214
  if (off < prefix) continue
1820
- const hi = bits & ~0xFFFFFFFFn
1215
+ const hi = bits & ~OFFSET_MASK_BIG
1821
1216
  dv.setBigUint64(slotOff, hi | BigInt(off - prefix), true)
1822
1217
  }
1823
1218
  }
@@ -1835,16 +1230,21 @@ function stripStaticDataPrefix(sec) {
1835
1230
  Array.isArray(child[2]) && SHIFTABLE.has(child[2][1]) &&
1836
1231
  Array.isArray(child[4]) && child[4][0] === 'i32.const' &&
1837
1232
  typeof child[4][1] === 'number' && child[4][1] >= prefix) {
1838
- 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
1839
1238
  } else if (child[0] === 'f64.const' &&
1840
1239
  typeof child[1] === 'string' && child[1].startsWith('nan:0x')) {
1841
1240
  const bits = BigInt(child[1].slice(4)) | 0x7FF0000000000000n
1842
- if (((bits >> 48n) & 0xFFF8n) === NAN_PREFIX_BITS) {
1843
- const ty = Number((bits >> 47n) & 0xFn)
1844
- if (SHIFTABLE.has(ty)) {
1845
- 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)
1846
1246
  if (off >= prefix) {
1847
- const hi = bits & ~0xFFFFFFFFn
1247
+ const hi = bits & ~OFFSET_MASK_BIG
1848
1248
  const newBits = hi | BigInt(off - prefix)
1849
1249
  child[1] = 'nan:0x' + newBits.toString(16).toUpperCase().padStart(16, '0')
1850
1250
  }
@@ -1862,14 +1262,29 @@ function stripStaticDataPrefix(sec) {
1862
1262
  * @param {import('./prepare.js').ASTNode} ast - Prepared AST
1863
1263
  * @returns {Array} Complete WASM module as S-expression
1864
1264
  */
1865
- export default function compile(ast) {
1265
+ export default function compile(ast, profiler) {
1866
1266
  // Populate known function names + lookup map on ctx.func for direct call detection
1867
1267
  ctx.func.names.clear()
1868
1268
  ctx.func.map.clear()
1869
1269
  for (const f of ctx.func.list) { ctx.func.names.add(f.name); ctx.func.map.set(f.name, f) }
1870
- // Include imported functions for call resolution (e.g. template interpolations)
1871
- for (const imp of ctx.module.imports)
1872
- 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
+ }
1873
1288
 
1874
1289
  // Check user globals don't conflict with runtime globals (modules loaded after user decls)
1875
1290
  for (const name of ctx.scope.userGlobals)
@@ -1918,105 +1333,11 @@ export default function compile(ast) {
1918
1333
  }
1919
1334
  }
1920
1335
 
1921
- // Pre-scan module-scope value types so functions can dispatch methods on globals.
1922
- // Also scan moduleInits so cross-module imports (e.g. regex literals from util.js)
1923
- // resolve to the correct static dispatch path.
1924
- const scanStmts = (root) => {
1925
- if (!root) return
1926
- const stmts = Array.isArray(root) && root[0] === ';' ? root.slice(1) : [root]
1927
- for (const s of stmts) {
1928
- if (!Array.isArray(s) || (s[0] !== 'const' && s[0] !== 'let')) continue
1929
- for (const decl of s.slice(1)) {
1930
- if (!Array.isArray(decl) || decl[0] !== '=' || typeof decl[1] !== 'string') continue
1931
- const vt = valTypeOf(decl[2])
1932
- if (vt) {
1933
- if (!ctx.scope.globalValTypes) ctx.scope.globalValTypes = new Map()
1934
- ctx.scope.globalValTypes.set(decl[1], vt)
1935
- if (vt === VAL.REGEX && ctx.runtime.regex) ctx.runtime.regex.vars.set(decl[1], decl[2])
1936
- }
1937
- const ctor = typedElemCtor(decl[2])
1938
- if (ctor) {
1939
- if (!ctx.scope.globalTypedElem) ctx.scope.globalTypedElem = new Map()
1940
- ctx.scope.globalTypedElem.set(decl[1], ctor)
1941
- }
1942
- }
1943
- }
1944
- }
1945
- scanStmts(ast)
1946
- if (ctx.module.moduleInits) for (const init of ctx.module.moduleInits) scanStmts(init)
1947
-
1948
- // Unbox const TYPED globals: change `(mut f64)` slot to `(mut i32)` and store the raw
1949
- // pointer offset. Reads tag the global.get with ptrKind=TYPED + ptrAux=elemType so
1950
- // typed-array consumers (.[]/.buffer/…) can resolve through ptrOffsetIR without ever
1951
- // calling __ptr_offset on a NaN-box. Init still flows through emit.js, but the assign
1952
- // coerces via asPtrOffset(val, VAL.TYPED) — one bit-extract at startup, then every
1953
- // hot read is a plain `global.get` of an i32.
1954
- if (ctx.scope.globalTypedElem && ctx.scope.consts) {
1955
- for (const [name, ctor] of ctx.scope.globalTypedElem) {
1956
- if (!ctx.scope.consts.has(name)) continue
1957
- if (ctx.scope.globalValTypes?.get(name) !== VAL.TYPED) continue
1958
- const aux = typedElemAux(ctor)
1959
- if (aux == null) continue
1960
- const decl = ctx.scope.globals.get(name)
1961
- if (typeof decl !== 'string' || !decl.includes('mut f64')) continue
1962
- ctx.scope.globals.set(name, `(global $${name} (mut i32) (i32.const 0))`)
1963
- ctx.scope.globalTypes.set(name, 'i32')
1964
- updateGlobalRep(name, { ptrKind: VAL.TYPED, ptrAux: aux })
1965
- }
1966
- }
1336
+ const programFacts = timePhase(profiler, 'plan', () => plan(ast))
1967
1337
 
1968
- // === ProgramFacts: single whole-program walk over ast + user funcs + moduleInits ===
1969
- // See collectProgramFacts (top of file) for the contract. ctx.types.* mirrors are
1970
- // kept here because ir.js consumes them at emit time (will be replaced when emit
1971
- // takes facts explicitly — S3).
1972
- const programFacts = collectProgramFacts(ast)
1973
- ctx.types.dynKeyVars = programFacts.dynVars
1974
- ctx.types.anyDynKey = programFacts.anyDyn
1975
-
1976
- // Materialize auto-box schemas from collected propMap
1977
- if (ast && ctx.schema.register) {
1978
- for (const [name, props] of programFacts.propMap) {
1979
- if (ctx.schema.vars.has(name)) {
1980
- const existing = ctx.schema.resolve(name)
1981
- const newProps = [...props].filter(p => !existing.includes(p))
1982
- if (newProps.length) {
1983
- const merged = [...existing, ...newProps]
1984
- const mergedId = ctx.schema.register(merged)
1985
- ctx.schema.vars.set(name, mergedId)
1986
- }
1987
- continue
1988
- }
1989
- const valueProps = [...props].filter(p => !ctx.func.names.has(`${name}$${p}`))
1990
- if (!valueProps.length) continue
1991
- const allProps = [...props]
1992
- const schema = ['__inner__', ...allProps]
1993
- const schemaId = ctx.schema.register(schema)
1994
- ctx.schema.vars.set(name, schemaId)
1995
- if (ctx.func.names.has(name) && !ctx.scope.globals.has(name))
1996
- ctx.scope.globals.set(name, `(global $${name} (mut f64) (f64.const 0))`)
1997
- if (!ctx.schema.autoBox) ctx.schema.autoBox = new Map()
1998
- ctx.schema.autoBox.set(name, { schemaId, schema })
1999
- }
2000
- }
2001
-
2002
- // Dynamic closure ABI width: max param count (`=>` defs), max call arity, rest/spread
2003
- // accumulated by walkFacts above. $ftN type, call-site padding, and body slot decls
2004
- // use this instead of the static MAX_CLOSURE_ARITY cap. hasRest adds +1 for rest
2005
- // overflow. hasSpread + hasRest together force MAX (spread expands unknown element
2006
- // count at runtime, and any rest receiver may consume them).
2007
- if (ctx.closure.make) {
2008
- const { hasSpread, hasRest, maxCall, maxDef } = programFacts
2009
- const floor = ctx.closure.floor ?? 0
2010
- ctx.closure.width = (hasSpread && hasRest)
2011
- ? MAX_CLOSURE_ARITY
2012
- : Math.min(MAX_CLOSURE_ARITY, Math.max(maxCall, maxDef + (hasRest ? 1 : 0), floor))
2013
- }
2014
-
2015
- narrowSignatures(programFacts, ast)
2016
- specializeBimorphicTyped(programFacts)
2017
- refineDynKeys(programFacts)
2018
-
2019
- const funcs = ctx.func.list.map(func => emitFunc(func, programFacts))
1338
+ const funcFacts = new Map()
1339
+ for (const func of ctx.func.list) if (!func.raw) funcFacts.set(func, analyzeFuncForEmit(func, programFacts))
1340
+ const funcs = ctx.func.list.map(func => emitFunc(func, funcFacts.get(func), programFacts))
2020
1341
  funcs.push(...synthesizeBoundaryWrappers())
2021
1342
 
2022
1343
  const closureFuncs = []
@@ -2070,11 +1391,33 @@ export default function compile(ast) {
2070
1391
 
2071
1392
  sec.funcs.push(...closureFuncs, ...funcs)
2072
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
+
2073
1414
  if (ctx.closure.table?.length)
2074
1415
  sec.elem.push(['elem', ['i32.const', 0], 'func', ...ctx.closure.table.map(n => `$${n}`)])
2075
1416
 
2076
1417
  buildStartFn(ast, sec, closureFuncs, compilePendingClosures)
2077
1418
 
1419
+ syncImports(sec)
1420
+
2078
1421
  dedupClosureBodies(closureFuncs, sec)
2079
1422
 
2080
1423
  finalizeClosureTable(sec)
@@ -2128,6 +1471,28 @@ export default function compile(ast) {
2128
1471
  if (restParamFuncs.length)
2129
1472
  sec.customs.push(['@custom', '"jz:rest"', `"${JSON.stringify(restParamFuncs).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`])
2130
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
+
2131
1496
  // Named export aliases: export { name } or export { source as alias }
2132
1497
  for (const [name, val] of Object.entries(ctx.func.exports)) {
2133
1498
  if (val === true) {