jz 0.5.0 → 0.6.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.
Files changed (80) hide show
  1. package/README.md +288 -314
  2. package/bench/README.md +319 -0
  3. package/bench/bench.svg +112 -0
  4. package/cli.js +32 -24
  5. package/index.js +177 -55
  6. package/interop.js +88 -159
  7. package/jz.svg +5 -0
  8. package/jzify/arguments.js +97 -0
  9. package/jzify/bundler.js +382 -0
  10. package/jzify/classes.js +328 -0
  11. package/jzify/hoist-vars.js +177 -0
  12. package/jzify/index.js +51 -0
  13. package/jzify/names.js +37 -0
  14. package/jzify/switch.js +106 -0
  15. package/jzify/transform.js +349 -0
  16. package/layout.js +179 -0
  17. package/module/array.js +322 -153
  18. package/module/collection.js +603 -145
  19. package/module/console.js +55 -43
  20. package/module/core.js +281 -142
  21. package/module/date.js +15 -3
  22. package/module/function.js +73 -6
  23. package/module/index.js +2 -1
  24. package/module/json.js +226 -61
  25. package/module/math.js +461 -185
  26. package/module/number.js +306 -60
  27. package/module/object.js +448 -184
  28. package/module/regex.js +255 -25
  29. package/module/schema.js +24 -6
  30. package/module/simd.js +85 -0
  31. package/module/string.js +591 -220
  32. package/module/symbol.js +1 -1
  33. package/module/timer.js +9 -14
  34. package/module/typedarray.js +45 -48
  35. package/package.json +41 -12
  36. package/src/abi/index.js +39 -23
  37. package/src/abi/string.js +38 -41
  38. package/src/ast.js +460 -0
  39. package/src/autoload.js +26 -24
  40. package/src/bridge.js +111 -0
  41. package/src/compile/analyze-scans.js +661 -0
  42. package/src/compile/analyze.js +1565 -0
  43. package/src/compile/emit-assign.js +408 -0
  44. package/src/compile/emit.js +3201 -0
  45. package/src/compile/flow-types.js +103 -0
  46. package/src/{compile.js → compile/index.js} +497 -125
  47. package/src/{infer.js → compile/infer.js} +27 -98
  48. package/src/{narrow.js → compile/narrow.js} +302 -96
  49. package/src/compile/plan/advise.js +316 -0
  50. package/src/compile/plan/common.js +150 -0
  51. package/src/compile/plan/index.js +118 -0
  52. package/src/compile/plan/inline.js +679 -0
  53. package/src/compile/plan/literals.js +984 -0
  54. package/src/compile/plan/loops.js +472 -0
  55. package/src/compile/plan/scope.js +573 -0
  56. package/src/compile/program-facts.js +404 -0
  57. package/src/ctx.js +176 -58
  58. package/src/ir.js +540 -171
  59. package/src/kind-traits.js +105 -0
  60. package/src/kind.js +462 -0
  61. package/src/op-policy.js +57 -0
  62. package/src/{optimize.js → optimize/index.js} +1106 -446
  63. package/src/optimize/vectorize.js +1874 -0
  64. package/src/param-reps.js +65 -0
  65. package/src/parse.js +44 -0
  66. package/src/{prepare.js → prepare/index.js} +600 -205
  67. package/src/reps.js +115 -0
  68. package/src/resolve.js +12 -3
  69. package/src/static.js +199 -0
  70. package/src/type.js +647 -0
  71. package/src/{assemble.js → wat/assemble.js} +86 -48
  72. package/src/wat/optimize.js +3760 -0
  73. package/transform.js +21 -0
  74. package/wasi.js +47 -5
  75. package/src/analyze.js +0 -3818
  76. package/src/emit.js +0 -2997
  77. package/src/jzify.js +0 -1553
  78. package/src/plan.js +0 -2132
  79. package/src/vectorize.js +0 -1088
  80. /package/src/{codegen.js → wat/codegen.js} +0 -0
@@ -25,18 +25,20 @@
25
25
  * @module compile
26
26
  */
27
27
 
28
- import { parse as parseWat } from 'watr'
29
- import { ctx, err, inc, resolveIncludes, PTR, LAYOUT } from './ctx.js'
28
+ import parseWat from 'watr/parse'
29
+ import { ctx, err, inc, resolveIncludes, PTR, LAYOUT, declGlobal } from '../ctx.js'
30
+ import { T, isBlockBody, isReassigned, refsName, REFS_IN_EXPR } from '../ast.js'
31
+ import { intLiteralValue } from '../static.js'
30
32
  import {
31
- T, VAL, analyzeBody,
32
- unboxablePtrs, cseSafeLoadBases, typedElemAux, invalidateLocalsCache,
33
- boxedCaptures, updateRep,
34
- isBlockBody, analyzeStructInline,
33
+ analyzeBody, unboxablePtrs, cseSafeLoadBases, boxedCaptures,
34
+ analyzeStructInline, invalidateLocalsCache,
35
35
  } from './analyze.js'
36
+ import { typedElemAux } from '../../layout.js'
37
+ import { VAL, updateRep, REP_FIELDS } from '../reps.js'
36
38
  import { inferLocals } from './infer.js'
37
- import { optimizeFunc, treeshake } from './optimize.js'
38
- import { emit, emitter, emitFlat, emitBody } from './emit.js'
39
- import { emitCharDecompPrologue, JSS_IMPORT_SIGS } from './abi/string.js'
39
+ import { optimizeFunc, treeshake } from '../optimize/index.js'
40
+ import { emit, emitter, emitVoid, emitBlockBody } from './emit.js'
41
+ import { emitCharDecompPrologue, JSS_IMPORT_SIGS } from '../abi/string.js'
40
42
  import {
41
43
  typed, asF64, asI32, asPtrOffset, asParamType, toI32, asI64, fromI64,
42
44
  NULL_NAN, UNDEF_NAN, NULL_WAT, UNDEF_WAT, NULL_IR, UNDEF_IR, nullExpr, undefExpr,
@@ -49,13 +51,13 @@ import {
49
51
  multiCount, loopTop, flat, reconstructArgsWithSpreads,
50
52
  valKindToPtr, findBodyStart, tcoTailRewrite,
51
53
  boolBoxIR,
52
- I32_MIN, I32_MAX,
53
- } from './ir.js'
54
- import plan from './plan.js'
54
+ I32_MIN, I32_MAX, dollar,
55
+ } from '../ir.js'
56
+ import plan from './plan/index.js'
55
57
  import {
56
58
  buildStartFn, dedupClosureBodies, finalizeClosureTable,
57
59
  pullStdlib, syncImports, optimizeModule, stripStaticDataPrefix,
58
- } from './assemble.js'
60
+ } from '../wat/assemble.js'
59
61
 
60
62
  // =============================================================================
61
63
  // Single-source export semantics
@@ -94,17 +96,18 @@ const isExported = f => {
94
96
  return false
95
97
  }
96
98
 
97
- /** Iterate JS-visible export names that resolve to `funcName`. Used to emit
98
- * per-export ABI metadata in custom sections — one entry per JS-visible name,
99
- * since the host (interop.js wrap) keys by export name. */
100
- function* exportNamesOf(funcName) {
99
+ /** Collect JS-visible export names that resolve to `funcName` (as an array).
100
+ * Used to emit per-export ABI metadata in custom sections — one entry per
101
+ * JS-visible name, since the host (interop.js wrap) keys by export name. */
102
+ function exportNamesOf(funcName) {
103
+ const names = []
101
104
  for (const [key, val] of Object.entries(ctx.func.exports)) {
102
- if (val === true && key === funcName) yield key
103
- else if (val === funcName) yield key
105
+ if ((val === true && key === funcName) || val === funcName) names.push(key)
104
106
  }
107
+ return names
105
108
  }
106
109
 
107
- const timePhase = (profiler, name, fn) => profiler ? profiler.time(name, fn) : fn()
110
+ const timePhase = (profiler, name, fn) => profiler?.time ? profiler.time(name, fn) : fn()
108
111
 
109
112
  // Per-compile func name set + map live on ctx.func.names / ctx.func.map,
110
113
  // populated at compile() entry. Both reset by ctx.js reset() and re-filled here.
@@ -113,8 +116,7 @@ const timePhase = (profiler, name, fn) => profiler ? profiler.time(name, fn) : f
113
116
  // emit-calling ones (toBool, emitTypeofCmp, emitDecl, materializeMulti,
114
117
  // buildArrayWithSpreads) moved to src/emit.js.
115
118
 
116
- // AST-analysis primitives (staticObjectProps, paramReps lattice helpers,
117
- // infer* cross-call inference, collectProgramFacts) moved to src/analyze.js.
119
+ // AST-analysis primitives live in kind.js, type.js, static.js, program-facts.js.
118
120
 
119
121
  /**
120
122
  * Boundary-wrap predicate: exports whose body-driven result OR any param narrowed
@@ -133,9 +135,62 @@ const isBoundaryWrapped = (func) => {
133
135
  // A boolean result rides the 0/1 number carrier internally; the export thunk
134
136
  // boxes it to the TRUE_NAN/FALSE_NAN atom so the host sees a real boolean.
135
137
  if (func.valResult === VAL.BOOL) return true
138
+ // A bigint result rides the i64-reinterpreted-f64 carrier internally; the export thunk converts
139
+ // it to a real Number so a JS host doesn't see raw i64 bits (`() => 100n` was returning 4.94e-322).
140
+ if (func.valResult === VAL.BIGINT) return true
136
141
  return func.sig.params.some(p => p.type !== 'f64' || p.ptrKind != null)
137
142
  }
138
143
 
144
+ // Static-string intern index (the `internStrings` pass). Open-addressing table
145
+ // over the deduped static string literals (5–32 bytes): [hash u32][ptr u32]
146
+ // pairs appended to the data segment, FNV-1a matching __str_hash's heap branch.
147
+ // __str_slice/__str_slice_view probe it so a runtime substring whose content
148
+ // equals any source literal returns the CANONICAL static pointer — string
149
+ // equality then short-circuits on bit-eq instead of walking bytes (a compiler
150
+ // or parser compares each token against tag literals many times; ~25% of
151
+ // self-host compile time was __str_eq/__eq/__str_hash volume). Built before
152
+ // pullStdlib (the slice thunks emit the probe only when `__internBase` exists);
153
+ // stripStaticDataPrefix shifts the stored ptr slots like every other static
154
+ // reference. Misses cost one FNV + one probe per slice; the table is read-only.
155
+ function buildInternTable() {
156
+ const cfg = ctx.transform.optimize
157
+ if (!cfg || cfg.internStrings === false) return
158
+ if (ctx.memory.shared || !ctx.runtime.dataDedup?.size) return
159
+ const enc = new TextEncoder()
160
+ const entries = []
161
+ for (const [str, off] of ctx.runtime.dataDedup) {
162
+ const b = enc.encode(str)
163
+ if (b.length < 5 || b.length > 32) continue
164
+ let h = 0x811c9dc5 | 0
165
+ for (let i = 0; i < b.length; i++) h = Math.imul(h ^ b[i], 0x01000193) | 0
166
+ if (h <= 1) h = (h + 2) | 0 // mirror __str_hash's empty/tombstone clamp
167
+ entries.push([h >>> 0, off + 8])
168
+ }
169
+ if (!entries.length) return
170
+ let size = 4
171
+ while (size < entries.length * 2) size = (size * 2) | 0
172
+ const mask = size - 1
173
+ const slots = new Uint32Array(size * 2)
174
+ for (let e = 0; e < entries.length; e++) {
175
+ const h = entries[e][0], off = entries[e][1]
176
+ let i = h & mask
177
+ while (slots[i * 2 + 1] !== 0) i = (i + 1) & mask
178
+ slots[i * 2] = h
179
+ slots[i * 2 + 1] = off
180
+ }
181
+ while (ctx.runtime.data.length % 8 !== 0) ctx.runtime.data += '\0'
182
+ const base = ctx.runtime.data.length
183
+ let s = ''
184
+ for (let i = 0; i < slots.length; i++) {
185
+ const v = slots[i]
186
+ s += String.fromCharCode(v & 0xFF, (v >>> 8) & 0xFF, (v >>> 16) & 0xFF, (v >>> 24) & 0xFF)
187
+ }
188
+ ctx.runtime.data += s
189
+ ctx.runtime.internTable = { base, size }
190
+ declGlobal('__internBase', 'i32', base, { mut: false })
191
+ declGlobal('__internMask', 'i32', mask, { mut: false })
192
+ }
193
+
139
194
  const ensureThrowRuntime = (sec) => {
140
195
  // A pulled stdlib helper may throw $__jz_err even when no user `throw` set the
141
196
  // flag (e.g. __to_num on a Symbol). Detect it from the included stdlib bodies
@@ -147,7 +202,7 @@ const ensureThrowRuntime = (sec) => {
147
202
  if (!ctx.runtime.throws) return
148
203
 
149
204
  if (!ctx.scope.globals.has('__jz_last_err_bits'))
150
- ctx.scope.globals.set('__jz_last_err_bits', '(global $__jz_last_err_bits (mut i64) (i64.const 0))')
205
+ declGlobal('__jz_last_err_bits', 'i64')
151
206
  if (!sec.tags.some(t => Array.isArray(t) && t[0] === 'tag' && t[1] === '$__jz_err'))
152
207
  sec.tags.push(['tag', '$__jz_err', ['param', 'f64']])
153
208
  if (!sec.tags.some(t => Array.isArray(t) && t[0] === 'export' && t[1] === '"__jz_last_err_bits"'))
@@ -181,13 +236,12 @@ const pruneUnusedThrowRuntime = (sec) => {
181
236
  const cloneRepMap = map => map ? new Map([...map].map(([k, v]) => [k, { ...v }])) : null
182
237
 
183
238
  /** Serialize a ValueRep entry into a plain object for inspect output.
184
- * Omits undefined fields so consumers can JSON-stringify without noise. */
239
+ * Omits undefined fields so consumers can JSON-stringify without noise.
240
+ * Iterates REP_FIELDS (the closed shape in reps.js) so it can't drift. */
185
241
  const repView = (rep) => {
186
242
  if (!rep) return null
187
243
  const out = {}
188
- for (const k of ['val', 'ptrKind', 'ptrAux', 'schemaId', 'intConst', 'intCertain', 'notString', 'arrayElemSchema', 'arrayElemValType', 'typedCtor', 'jsonShape', 'wasm']) {
189
- if (rep[k] != null) out[k] = rep[k]
190
- }
244
+ for (const k of REP_FIELDS) if (rep[k] != null) out[k] = rep[k]
191
245
  return Object.keys(out).length ? out : null
192
246
  }
193
247
 
@@ -230,18 +284,79 @@ function captureFuncInspect(func, facts, programFacts) {
230
284
  }
231
285
  }
232
286
 
287
+ function scanAndTagNonEscapingClosures(body) {
288
+ if (!body) return
289
+ const onlyCalledNotReferenced = (node, name) => {
290
+ if (typeof node === 'string') return node !== name
291
+ if (!Array.isArray(node)) return true
292
+ const op = node[0]
293
+ if (op === 'str') return true
294
+ if (op === '=>') {
295
+ return !refsName(node[1], name, REFS_IN_EXPR) && !refsName(node[2], name, REFS_IN_EXPR)
296
+ }
297
+ if (op === '=' && node[1] === name) {
298
+ return onlyCalledNotReferenced(node[2], name)
299
+ }
300
+ if (op === '()' && node[1] === name) {
301
+ for (let i = 2; i < node.length; i++) {
302
+ if (!onlyCalledNotReferenced(node[i], name)) return false
303
+ }
304
+ return true
305
+ }
306
+ if (op === '.' || op === '?.') return onlyCalledNotReferenced(node[1], name)
307
+ if (op === ':') return onlyCalledNotReferenced(node[2], name)
308
+ for (let i = 1; i < node.length; i++) {
309
+ if (!onlyCalledNotReferenced(node[i], name)) return false
310
+ }
311
+ return true
312
+ }
313
+
314
+ const walk = (node) => {
315
+ if (!Array.isArray(node)) return
316
+ const op = node[0]
317
+ if (op === 'let' || op === 'const') {
318
+ for (const decl of node.slice(1)) {
319
+ if (Array.isArray(decl) && decl[0] === '=' && typeof decl[1] === 'string') {
320
+ const name = decl[1]
321
+ const init = decl[2]
322
+ if (Array.isArray(init) && init[0] === '=>') {
323
+ const arrow_body = init[2]
324
+ if (arrow_body && typeof arrow_body === 'object' && !ctx.func.boxed?.has(name) && !isGlobal(name) && !isReassigned(body, name) && onlyCalledNotReferenced(body, name)) {
325
+ arrow_body._nonEscaping = name
326
+ }
327
+ }
328
+ }
329
+ }
330
+ } else if (op === '=' && typeof node[1] === 'string' && Array.isArray(node[2]) && node[2][0] === '=>') {
331
+ const name = node[1]
332
+ const init = node[2]
333
+ const arrow_body = init[2]
334
+ if (arrow_body && typeof arrow_body === 'object' && !ctx.func.boxed?.has(name) && !isGlobal(name) && !isReassigned(body, name) && onlyCalledNotReferenced(body, name)) {
335
+ arrow_body._nonEscaping = name
336
+ }
337
+ }
338
+ for (let i = 1; i < node.length; i++) walk(node[i])
339
+ }
340
+ walk(body)
341
+ }
342
+
233
343
  // Reset per-function emit-frame state — the single source of frame entry.
234
344
  // `emitFunc`, `analyzeFuncForEmit`, and `emitClosureBody` all route through
235
345
  // here. Top-level funcs start `uniq` at 0; closures pass a higher base so
236
346
  // their synthetic labels can't collide with the parent frame's.
237
347
  function enterFunc(sig, body, { uniq = 0, directClosures = null } = {}) {
238
348
  ctx.func.stack = []
349
+ ctx.func.maybeNullish = new Set() // bindings assigned a nullish literal → coerce in arithmetic (null-flow)
350
+ ctx.func.pendingLabel = null // label awaiting its loop, for `continue <label>`
239
351
  ctx.func.uniq = uniq
240
352
  ctx.func.current = sig
241
353
  ctx.func.body = body
242
354
  ctx.func.directClosures = directClosures
243
355
  ctx.func.localProps = null
244
356
  ctx.func.charDecomp = null
357
+ if (ctx.transform.optimize) {
358
+ scanAndTagNonEscapingClosures(body)
359
+ }
245
360
  }
246
361
 
247
362
  // Allocate + null-init a heap cell for every boxed local that isn't seeded
@@ -288,6 +403,27 @@ function analyzeFuncForEmit(func, programFacts) {
288
403
  if (r.intConst != null) updateRep(pname, { intConst: r.intConst })
289
404
  }
290
405
  }
406
+ // Trust numeric export params. An exported f64 param used only in numeric
407
+ // positions is marked VAL.NUMBER so its uses skip the `__to_num` coercion
408
+ // entirely (not just hoist it). External callers reach jz through interop's
409
+ // `mem.wrapVal`, which passes a JS number straight to f64 — so the coercion
410
+ // only ever fired for a *string* arg to a numeric param (a type misuse). When
411
+ // that lone coercion is the only `__to_num` consumer, dropping it lets the whole
412
+ // ToNumber string-parse dep tree (`__to_str`→`__itoa`/`__toExp`/`__mkstr`/…)
413
+ // treeshake away — a ~4× module shrink that, decisively, lets V8 tier the hot
414
+ // fill loop up properly (the bloated module JITs the *identical* loop ~2× slower).
415
+ if (func.exported && block) {
416
+ for (const p of sig.params) {
417
+ if (p.type === 'f64' && p.ptrKind == null && !p.jsstring
418
+ && !func.defaults?.[p.name] && !ctx.func.boxed?.has(p.name)
419
+ && !ctx.func.localReps?.get(p.name)?.val
420
+ && paramAllUsesNumeric(body, p.name))
421
+ updateRep(p.name, { val: VAL.NUMBER })
422
+ }
423
+ }
424
+ if (block) {
425
+ seedLocalIntConsts(body)
426
+ }
291
427
  // Drop any earlier-cached analyzeBody.locals slice for this body —
292
428
  // narrowSignatures called it before our pre-seed, when params still had no
293
429
  // inferred VAL.TYPED, so the cached widths reflect the pre-narrow state.
@@ -295,6 +431,9 @@ function analyzeFuncForEmit(func, programFacts) {
295
431
  invalidateLocalsCache(body)
296
432
  const bodyFacts = block ? analyzeBody(body) : null
297
433
  ctx.func.locals = bodyFacts ? bodyFacts.locals : new Map()
434
+ if (bodyFacts?.valTypes) {
435
+ for (const [name, vt] of bodyFacts.valTypes) updateRep(name, { val: vt })
436
+ }
298
437
  // Proven uint32 accumulator locals — readVar tags reads `.unsigned` so the
299
438
  // f64 round-trip widens with convert_i32_u (not _s).
300
439
  if (bodyFacts?.unsignedLocals) for (const n of bodyFacts.unsignedLocals) updateRep(n, { unsigned: true })
@@ -351,6 +490,9 @@ function analyzeFuncForEmit(func, programFacts) {
351
490
  if (p.ptrAux != null) fields.ptrAux = p.ptrAux
352
491
  updateRep(p.name, fields)
353
492
  }
493
+ for (const p of sig.params) {
494
+ if (p.jsstring) updateRep(p.name, { carrier: 'jsstring', val: VAL.STRING })
495
+ }
354
496
 
355
497
  // CSE-safe load bases — pointer locals whose memory reads `cseScalarLoad`
356
498
  // may scalar-replace. Computed last: needs every `let`/param ptrKind in place.
@@ -358,10 +500,23 @@ function analyzeFuncForEmit(func, programFacts) {
358
500
  ? cseSafeLoadBases(body, ctx.func.locals, ctx.func.localReps)
359
501
  : new Set()
360
502
 
503
+ // Closure-capture narrowing: a boxed var whose every defining RHS — owner
504
+ // body AND nested arrows, the narrower's intCertain contract — is integer-
505
+ // valued keeps its CELL in i32, so readVar/writeVar skip the f64↔i32
506
+ // round-trip per access. Params are excluded: their cell is seeded from the
507
+ // raw f64 param value, which would desync an i32-read cell. Same asm.js-style
508
+ // range contract as plain intCertain locals.
509
+ const cellTypes = new Set()
510
+ for (const name of ctx.func.boxed.keys()) {
511
+ if (sig.params.some(p => p.name === name)) continue
512
+ if (ctx.func.localReps?.get(name)?.intCertain === true) cellTypes.add(name)
513
+ }
514
+
361
515
  return {
362
516
  block,
363
517
  locals: new Map(ctx.func.locals),
364
518
  boxed: new Map(ctx.func.boxed),
519
+ cellTypes,
365
520
  flatObjects: new Map(ctx.func.flatObjects),
366
521
  sliceViews: new Set(ctx.func.sliceViews),
367
522
  cseLoadBases,
@@ -370,6 +525,156 @@ function analyzeFuncForEmit(func, programFacts) {
370
525
  }
371
526
  }
372
527
 
528
+ function seedLocalIntConsts(body) {
529
+ const walk = (node) => {
530
+ if (!Array.isArray(node)) return
531
+ const [op, ...args] = node
532
+ if (op === '=>') return
533
+ if (op === 'let' || op === 'const') {
534
+ for (const decl of args) {
535
+ if (!Array.isArray(decl) || decl[0] !== '=' || typeof decl[1] !== 'string') continue
536
+ const value = intLiteralValue(decl[2])
537
+ if (value != null && !isReassigned(body, decl[1])) updateRep(decl[1], { intConst: value })
538
+ }
539
+ return
540
+ }
541
+ for (const arg of args) walk(arg)
542
+ }
543
+ walk(body)
544
+ }
545
+
546
+ // ── Loop-invariant exported-param coercion hoist ────────────────────────────
547
+ //
548
+ // An exported numeric param arrives as a NaN-box (jz's value ABI), so each use
549
+ // in an arithmetic context emits `__to_num(p)`. When the param is never
550
+ // reassigned and *every* use is an unconditional-ToNumber arithmetic operand,
551
+ // the coercion is loop-invariant: do it once at entry and let every use read the
552
+ // already-unboxed f64. This flips a serial recurrence like the de Jong attractor
553
+ // (4 `__to_num`/iter × millions) from ~parity to a clear win over V8.
554
+ //
555
+ // Self-gating: the rewrite only fires when the emitted body ALREADY contains
556
+ // `__to_num(p)` calls — meaning the helper is loaded for other reasons (global
557
+ // typed-array assigns, strings, …). A provably-numeric program (`(a,b)=>a*b`)
558
+ // never loads the helper, has no pattern to match, and is left byte-for-byte
559
+ // alone, preserving the minimal-bundle / golden-size guarantee.
560
+
561
+ // `=`/`+=`/`++`/… targets — reassigning the param breaks the coerce-once premise.
562
+ const PARAM_REASSIGN_OPS = new Set(['=', '+=', '-=', '*=', '/=', '%=', '&=', '|=',
563
+ '^=', '>>=', '<<=', '>>>=', '||=', '&&=', '??=', '++', '--'])
564
+ // Binary ops that unconditionally ToNumber BOTH operands, so a bare param operand
565
+ // is a pure numeric use. `+` is excluded (may concatenate); comparisons / `===`
566
+ // are excluded (they branch on type, never coerce a string operand to number).
567
+ const NUM_BIN_OPS = new Set(['*', '/', '%', '**', '&', '|', '^', '<<', '>>', '>>>'])
568
+
569
+ /** True iff every use of param `name` in `body` is an unconditional-numeric
570
+ * operand, so coercing it to a number once at entry is observationally exact.
571
+ * Rejects conservatively: reassignment and any appearance outside a numeric
572
+ * operator (member/index/call-arg/return/compare/concat). Two transparencies:
573
+ * - copy aliases: `let x = name` makes `x` carry the same value, so `x`'s uses
574
+ * must be numeric too (fixpoint-collected). Catches `let T = t` then `…T…`.
575
+ * - captured closures: a non-shadowing inner arrow captures the binding by
576
+ * reference, so its body's uses count — we recurse instead of rejecting.
577
+ * Catches floatbeat helpers `let s=(f)=>…t…` that read the param numerically. */
578
+ function paramAllUsesNumeric(body, name) {
579
+ if (body == null) return false
580
+ // Fixpoint-collect copy aliases: `let/const x = <name-or-alias>`.
581
+ const names = new Set([name])
582
+ for (let grew = true; grew;) {
583
+ grew = false
584
+ const collect = (node) => {
585
+ if (!Array.isArray(node)) return
586
+ if ((node[0] === 'let' || node[0] === 'const') && node.length === 2
587
+ && Array.isArray(node[1]) && node[1][0] === '=' && typeof node[1][1] === 'string'
588
+ && typeof node[1][2] === 'string' && names.has(node[1][2]) && !names.has(node[1][1])) {
589
+ names.add(node[1][1]); grew = true
590
+ }
591
+ for (let i = 1; i < node.length; i++) collect(node[i])
592
+ }
593
+ collect(body)
594
+ }
595
+ let ok = true
596
+ const walk = (node) => {
597
+ if (!ok) return
598
+ if (typeof node === 'string') { if (names.has(node)) ok = false; return } // bare use → reject
599
+ if (!Array.isArray(node)) return
600
+ const op = node[0]
601
+ // single `let/const x = init`: x is a binding (not a use). A pure copy of an
602
+ // alias is consumed (already in `names`); otherwise the init must be numeric.
603
+ if ((op === 'let' || op === 'const') && node.length === 2
604
+ && Array.isArray(node[1]) && node[1][0] === '=' && typeof node[1][1] === 'string') {
605
+ const init = node[1][2]
606
+ if (typeof init === 'string' && names.has(init)) return
607
+ walk(init)
608
+ return
609
+ }
610
+ if (op === '=>') { // closure capture: recurse unless shadowed
611
+ const ps = node[1]
612
+ const shadowed = Array.isArray(ps)
613
+ ? ps.some(p => names.has(p) || (Array.isArray(p) && names.has(p[1])))
614
+ : names.has(ps)
615
+ if (!shadowed) { walk(node[1]); walk(node[2]) } // defaults + body; param names aren't in `names`
616
+ return
617
+ }
618
+ if (PARAM_REASSIGN_OPS.has(op) && names.has(node[1])) { ok = false; return }
619
+ if (NUM_BIN_OPS.has(op) && node.length === 3) { // numeric binary: operands are ToNumber'd
620
+ if (!names.has(node[1])) walk(node[1])
621
+ if (!names.has(node[2])) walk(node[2])
622
+ return
623
+ }
624
+ if (op === '-' && node.length === 2) { if (!names.has(node[1])) walk(node[1]); return } // unary negate
625
+ if (op === '-' && node.length === 3) { if (!names.has(node[1])) walk(node[1]); if (!names.has(node[2])) walk(node[2]); return }
626
+ // `u-`/`u+` are the normalized unary minus/plus (prepare rewrites `-x`/`+x`); both ToNumber.
627
+ if ((op === 'u-' || op === 'u+') && node.length === 2) { if (!names.has(node[1])) walk(node[1]); return }
628
+ if (op === '+' && node.length === 2) { if (!names.has(node[1])) walk(node[1]); return } // unary + = ToNumber
629
+ if (op === '~' && node.length === 2) { if (!names.has(node[1])) walk(node[1]); return }
630
+ for (let i = 1; i < node.length; i++) walk(node[i]) // bare param reaching here → rejected above
631
+ }
632
+ walk(body)
633
+ return ok
634
+ }
635
+
636
+ /** Hoist each eligible param's `__to_num` coercion to a single entry `local.set`,
637
+ * rewriting per-use calls in `stmts` to a bare typed `local.get`. Mutates
638
+ * `stmts` in place; returns the prologue inits to splice ahead of the body.
639
+ * Only fires for params whose coercion appears inside a loop (or ≥2×) — a lone
640
+ * straight-line coercion isn't worth the rebind. */
641
+ function hoistInvariantParamCoercions(stmts, func) {
642
+ const inits = []
643
+ const defaults = func.defaults || {}
644
+ for (const p of func.sig.params) {
645
+ if (p.type !== 'f64' || p.ptrKind != null || p.jsstring) continue
646
+ if (ctx.func.boxed?.has(p.name)) continue
647
+ if (p.name in defaults) continue
648
+ if (!paramAllUsesNumeric(func.body, p.name)) continue
649
+ const pat = (n) => Array.isArray(n) && n[0] === 'call' && n[1] === '$__to_num'
650
+ && Array.isArray(n[2]) && n[2][0] === 'i64.reinterpret_f64'
651
+ && Array.isArray(n[2][1]) && n[2][1][0] === 'local.get' && n[2][1][1] === `$${p.name}`
652
+ let total = 0, inLoop = 0
653
+ const count = (node, depth) => {
654
+ if (!Array.isArray(node)) return
655
+ const d = node[0] === 'loop' ? depth + 1 : depth
656
+ for (let i = 1; i < node.length; i++) {
657
+ if (pat(node[i])) { total++; if (d > 0) inLoop++ }
658
+ else count(node[i], d)
659
+ }
660
+ }
661
+ for (const s of stmts) count(s, 0)
662
+ if (total === 0 || (inLoop === 0 && total < 2)) continue
663
+ const strip = (node) => {
664
+ if (!Array.isArray(node)) return
665
+ for (let i = 1; i < node.length; i++) {
666
+ if (pat(node[i])) node[i] = typed(['local.get', `$${p.name}`], 'f64')
667
+ else strip(node[i])
668
+ }
669
+ }
670
+ for (const s of stmts) strip(s)
671
+ inits.push(['local.set', `$${p.name}`,
672
+ typed(['call', '$__to_num', ['i64.reinterpret_f64', typed(['local.get', `$${p.name}`], 'f64')]], 'f64')])
673
+ inc('__to_num')
674
+ }
675
+ return inits
676
+ }
677
+
373
678
  /**
374
679
  * Phase: emit one user function to WAT IR.
375
680
  *
@@ -390,6 +695,7 @@ function emitFunc(func, funcFacts, programFacts) {
390
695
  const block = funcFacts.block
391
696
  ctx.func.locals = new Map(funcFacts.locals)
392
697
  ctx.func.boxed = new Map(funcFacts.boxed)
698
+ ctx.func.cellTypes = new Set(funcFacts.cellTypes)
393
699
  ctx.func.flatObjects = new Map(funcFacts.flatObjects)
394
700
  ctx.func.sliceViews = new Set(funcFacts.sliceViews)
395
701
  ctx.func.localReps = cloneRepMap(funcFacts.localReps)
@@ -431,7 +737,7 @@ function emitFunc(func, funcFacts, programFacts) {
431
737
  // Boundary-wrapped exports also defer the attribute to the synthesized
432
738
  // wrapper ($${name}$exp) that reboxes the narrowed result back to f64.
433
739
  if (exported && !isBoundaryWrapped(func)) fn.push(['export', `"${name}"`])
434
- fn.push(...sig.params.map(p => ['param', `$${p.name}`, p.type]))
740
+ fn.push(...sig.params.map(p => ['param', dollar(p.name), p.type]))
435
741
  fn.push(...sig.results.map(t => ['result', t]))
436
742
 
437
743
  // Default params: ES spec says default applies only when arg is `undefined`
@@ -497,9 +803,11 @@ function emitFunc(func, funcFacts, programFacts) {
497
803
  }
498
804
 
499
805
  if (block) {
500
- const stmts = emitBody(body)
806
+ const stmts = emitBlockBody(body)
807
+ // Hoist loop-invariant `__to_num(param)` coercions to a single entry rebind.
808
+ const numCoerceInits = hoistInvariantParamCoercions(stmts, func)
501
809
  const paramInits = collectParamInits()
502
- for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
810
+ for (const [l, t] of ctx.func.locals) fn.push(['local', dollar(l), t])
503
811
  // I: Skip trailing fallback when last statement is return (unreachable code)
504
812
  const lastStmt = stmts.at(-1)
505
813
  const endsWithReturn = lastStmt && (lastStmt[0] === 'return' || lastStmt[0] === 'return_call')
@@ -510,16 +818,16 @@ function emitFunc(func, funcFacts, programFacts) {
510
818
  const fallthrough = endsWithReturn ? []
511
819
  : sig.results.length === 1 && sig.results[0] === 'f64' ? [undefExpr()]
512
820
  : sig.results.map(t => [`${t}.const`, 0])
513
- fn.push(...paramInits, ...boxedParamInits, ...preboxedLocalInits, ...stmts, ...fallthrough)
821
+ fn.push(...paramInits, ...boxedParamInits, ...preboxedLocalInits, ...numCoerceInits, ...stmts, ...fallthrough)
514
822
  } else if (multi && body[0] === '[') {
515
823
  const values = body.slice(1).map(e => asF64(emit(e)))
516
824
  const paramInits = collectParamInits()
517
- for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
825
+ for (const [l, t] of ctx.func.locals) fn.push(['local', dollar(l), t])
518
826
  fn.push(...paramInits, ...boxedParamInits, ...preboxedLocalInits, ...values)
519
827
  } else {
520
828
  const ir = emit(body)
521
829
  const paramInits = collectParamInits()
522
- for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
830
+ for (const [l, t] of ctx.func.locals) fn.push(['local', dollar(l), t])
523
831
  const finalIR = sig.ptrKind != null ? asPtrOffset(ir, sig.ptrKind) : asParamType(ir, sig.results[0])
524
832
  fn.push(...paramInits, ...boxedParamInits, ...preboxedLocalInits, tcoTailRewrite(finalIR, sig.results[0]))
525
833
  }
@@ -560,17 +868,17 @@ function synthesizeBoundaryWrappers() {
560
868
  for (const func of ctx.func.list) {
561
869
  if (!isBoundaryWrapped(func)) continue
562
870
  const { name, sig } = func
563
- // Per-position i64 carrier: only swap to i64 where a NaN-boxed pointer
564
- // actually crosses the boundary (param.ptrKind set, or result with
565
- // sig.ptrKind set). Numeric narrowing (i32 trunc-sat / convert) keeps f64
566
- // so callers seeing the raw export get a plain Number for numerics.
567
- // jsstring params bypass both i64 and f64they flow as externref end-to-end.
568
- const paramI64 = sig.params.map(p => p.ptrKind != null)
569
- // A VAL.BOOL result is boxed to a NaN-box atom here, so it crosses as i64
570
- // (same carrier as a pointer result), even though the inner func returns the
571
- // plain 0/1 number carrier.
871
+ // Quiet NaN-box ABI: every boundary value is f64. A number is a plain f64; a
872
+ // tagged value (heap pointer, null/undef/bool atom) is an f64 whose quiet-NaN
873
+ // (0x7FF8…) payload carries the tag. Quiet-NaN payloads are preserved across the
874
+ // JS↔wasm call boundary by every real engine (and non-JS hosts don't canonicalize
875
+ // at all), so no i64 carrier is needed the wasm signature is self-describing
876
+ // (f64 everywhere) and a consumer discriminates a tagged value by the NaN prefix.
877
+ // Env requirement: a non-canonicalizing NaN boundary. To support a canonicalizing
878
+ // engine, a per-position i64 carrier would re-enter here (param/result type i64 +
879
+ // `i64.reinterpret_f64`) plus a `jz:i64exp` section for interop.js.
572
880
  const resultBool = func.valResult === VAL.BOOL && sig.ptrKind == null
573
- const resultI64 = sig.ptrKind != null || resultBool
881
+ const resultBigint = func.valResult === VAL.BIGINT && sig.ptrKind == null
574
882
  // Inline `(export ...)` attribute only when the func decl carried the
575
883
  // inline-export keyword (`export function foo`). For re-exports
576
884
  // (`function foo; export { foo as bar }`) the `name` is the *internal*
@@ -580,19 +888,17 @@ function synthesizeBoundaryWrappers() {
580
888
  const wrapNode = func.exported
581
889
  ? ['func', `$${name}$exp`, ['export', `"${name}"`]]
582
890
  : ['func', `$${name}$exp`]
583
- sig.params.forEach((p, i) => {
584
- const t = p.jsstring ? 'externref' : (paramI64[i] ? 'i64' : 'f64')
585
- wrapNode.push(['param', `$${p.name}`, t])
891
+ // jsstring params flow as externref end-to-end; every other boundary value is f64.
892
+ sig.params.forEach((p) => {
893
+ wrapNode.push(['param', `$${p.name}`, p.jsstring ? 'externref' : 'f64'])
586
894
  })
587
- wrapNode.push(['result', resultI64 ? 'i64' : 'f64'])
588
- const args = sig.params.map((p, i) => {
895
+ wrapNode.push(['result', resultBigint ? 'i64' : 'f64'])
896
+ const args = sig.params.map((p) => {
589
897
  const get = ['local.get', `$${p.name}`]
590
898
  // jsstring: externref flows through unchanged — inner func also takes externref.
591
899
  if (p.jsstring) return get
592
- if (p.ptrKind != null) {
593
- // ptrKind: i64 carrier carries NaN-box bits → wrap to i32 offset
594
- return ['i32.wrap_i64', get]
595
- }
900
+ // ptrKind param: the f64 NaN-box carries the pointer — extract the i32 offset.
901
+ if (p.ptrKind != null) return ['i32.wrap_i64', ['i64.reinterpret_f64', get]]
596
902
  if (p.type === 'f64') return get
597
903
  // Numeric narrowing: f64 → i32 truncate
598
904
  return ['i32.trunc_sat_f64_s', get]
@@ -603,17 +909,26 @@ function synthesizeBoundaryWrappers() {
603
909
  const ptrType = valKindToPtr(sig.ptrKind)
604
910
  body = mkPtrIR(ptrType, sig.ptrAux ?? 0, callIR)
605
911
  } else if (resultBool) {
606
- // boolBoxIR's truthy extraction depends on the carrier type, so the bare
607
- // call node must declare it (f64 0/1, or i32 when the result narrowed).
608
- body = boolBoxIR(typed(callIR, sig.results[0])) // 0/1 carrier TRUE_NAN/FALSE_NAN atom
912
+ // The inner func returns a clean 0/1 boolean carrier never NaN. The i32
913
+ // carrier already takes truthyIR's identity path; the f64 carrier would
914
+ // otherwise fall through to the full __is_truthy NaN-discrimination, every
915
+ // arm of which is dead for a boolean. Pull the bit out with one f64.ne so
916
+ // boolBoxIR boxes `4|bit` straight into the TRUE_NAN/FALSE_NAN atom.
917
+ const carrier = sig.results[0] === 'i32'
918
+ ? typed(callIR, 'i32')
919
+ : typed(['f64.ne', callIR, ['f64.const', 0]], 'i32')
920
+ body = boolBoxIR(carrier)
921
+ } else if (resultBigint) {
922
+ // BigInt rides the i64-reinterpret-f64 carrier internally; expose the raw i64 at the JS
923
+ // boundary so the host receives a real, lossless BigInt (wasm i64 <-> JS BigInt). Internal
924
+ // callers use `$name` (the f64 carrier) untouched; only the `$exp` export result is i64.
925
+ body = ['i64.reinterpret_f64', callIR]
609
926
  } else if (sig.results[0] === 'i32') {
610
927
  body = [sig.unsignedResult ? 'f64.convert_i32_u' : 'f64.convert_i32_s', callIR]
611
928
  } else {
612
929
  body = callIR
613
930
  }
614
- wrapNode.push(resultI64 ? ['i64.reinterpret_f64', body] : body)
615
- func._exportUsesI64 = resultI64 || paramI64.some(Boolean)
616
- func._exportI64Sig = { params: paramI64, result: resultI64 }
931
+ wrapNode.push(body)
617
932
  // Track externref param positions so interop.js can pass JS values
618
933
  // raw (skipping `mem.wrapVal`) at those slots. Today this only fires
619
934
  // for `jsstring`-tagged params; future externref carriers wire here too.
@@ -649,6 +964,7 @@ function emitClosureBody(cb) {
649
964
  ctx.func.localReps = null
650
965
  if (cb.intConsts) for (const [name, v] of cb.intConsts) updateRep(name, { intConst: v })
651
966
  if (cb.intCertain) for (const name of cb.intCertain) updateRep(name, { intCertain: true })
967
+ if (cb.nullables) for (const name of cb.nullables) updateRep(name, { nullable: true })
652
968
  if (cb.valTypes) for (const [name, vt] of cb.valTypes) updateRep(name, { val: vt })
653
969
  if (cb.schemaVars) {
654
970
  ctx.schema.vars = new Map([...prevSchemaVars, ...cb.schemaVars])
@@ -664,6 +980,9 @@ function emitClosureBody(cb) {
664
980
  }
665
981
  // In closure bodies, boxed captures use the original name as both var and cell local
666
982
  ctx.func.boxed = cb.boxed ? new Map([...cb.boxed].map(v => [v, v])) : new Map()
983
+ // i32-narrowed cells: the owner decided the cell width (funcFacts.cellTypes);
984
+ // every body sharing the cell must read/write it at that width.
985
+ ctx.func.cellTypes = new Set(cb.cellI32 || [])
667
986
  const parentBoxedCaptures = new Set(cb.boxed || [])
668
987
  ctx.func.preboxed = new Set()
669
988
  // Bare `;`-sequence bodies (no enclosing `{}`) reach us when callers built a
@@ -693,6 +1012,19 @@ function emitClosureBody(cb) {
693
1012
 
694
1013
  // Params are locals, assigned directly from inline slots
695
1014
  for (const p of cb.params) ctx.func.locals.set(p, 'f64')
1015
+ // Mark params that every direct call site passed a number (seeded by
1016
+ // tryDirectClosureCall) VAL.NUMBER — their body uses then skip the __to_num
1017
+ // coercion. All direct calls were emitted before this body (module end), so the
1018
+ // lattice is complete; a `false`/unobserved slot leaves the param boxed.
1019
+ const ptRow = ctx.closure.paramTypes?.get(cb.name)
1020
+ // A param numeric at every call site is typed NUMBER so its body uses skip __to_num. If some
1021
+ // call omits it (index ≥ minArgc) it can hold UNDEF_NAN, so also flag it nullable — that keeps
1022
+ // the boxing win yet stops `x === undefined` mis-folding to false (it bit-compares instead).
1023
+ const minArgc = ctx.closure.minArgc?.get(cb.name) ?? 0
1024
+ if (ptRow) for (let i = 0; i < cb.params.length; i++) {
1025
+ if (ptRow[i] === true && !ctx.func.localReps?.get(cb.params[i])?.val)
1026
+ updateRep(cb.params[i], i < minArgc ? { val: VAL.NUMBER } : { val: VAL.NUMBER, nullable: true })
1027
+ }
696
1028
 
697
1029
  // Register captured variable locals: boxed = i32 cell pointer, otherwise f64 value
698
1030
  for (let i = 0; i < cb.captures.length; i++) {
@@ -726,7 +1058,7 @@ function emitClosureBody(cb) {
726
1058
  ctx.func.locals.set(name, 'i32')
727
1059
  updateRep(name, fields)
728
1060
  }
729
- bodyIR = emitBody(cb.body)
1061
+ bodyIR = emitBlockBody(cb.body)
730
1062
  } else {
731
1063
  bodyIR = [asF64(emit(cb.body))]
732
1064
  }
@@ -734,13 +1066,15 @@ function emitClosureBody(cb) {
734
1066
  // Pre-allocate cache locals for env unpacking
735
1067
  const envBase = cb.captures.length > 0 ? `${T}envBase${ctx.func.uniq++}` : null
736
1068
  if (envBase) ctx.func.locals.set(envBase, 'i32')
737
- // Rest param: allocate helper locals (len + offset) before emitting decls
738
- let restOff, restLen
1069
+ // Rest param: allocate helper locals (len + offset + spill loop index) before emitting decls
1070
+ let restOff, restLen, restIdx
739
1071
  if (cb.rest) {
740
1072
  restOff = `${T}restOff${ctx.func.uniq++}`
741
1073
  restLen = `${T}restLen${ctx.func.uniq++}`
1074
+ restIdx = `${T}restIdx${ctx.func.uniq++}`
742
1075
  ctx.func.locals.set(restOff, 'i32')
743
1076
  ctx.func.locals.set(restLen, 'i32')
1077
+ ctx.func.locals.set(restIdx, 'i32')
744
1078
  inc('__alloc_hdr', '__mkptr')
745
1079
  }
746
1080
 
@@ -777,7 +1111,7 @@ function emitClosureBody(cb) {
777
1111
  }
778
1112
  }
779
1113
 
780
- for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
1114
+ for (const [l, t] of ctx.func.locals) fn.push(['local', dollar(l), t])
781
1115
 
782
1116
  // Load captures from env: boxed → i32.load (raw cell pointer), immutable → f64.load value.
783
1117
  // env is the CLOSURE pointer (PTR.CLOSURE) — never an ARRAY, no forwarding chain.
@@ -814,20 +1148,20 @@ function emitClosureBody(cb) {
814
1148
  }
815
1149
  }
816
1150
 
817
- // Rest param: pack slots a[fixedParams..argc-1] into fresh array.
818
- // len = clamp(argc - fixedParams, 0, restSlots). Rest-param closures receive
819
- // at most (width - fixedParams) rest args spread callers with
820
- // more dynamic elements lose the overflow (documented limitation).
1151
+ // Rest param: pack args a[fixedParams..argc-1] into a fresh array.
1152
+ // len = max(argc - fixedParams, 0). The first `restSlots = width - fixedParams`
1153
+ // come from the inline arg slots; any overflow (argc > width, only reachable via a
1154
+ // spread call) is read straight from the caller's full args array, whose offset the
1155
+ // spread path published in $__closure_spill. This gives unbounded variadic arity.
821
1156
  if (cb.rest) {
822
1157
  const fixedN = fixedParamN
823
1158
  const restSlots = W - fixedN
1159
+ declGlobal('__closure_spill', 'i32')
824
1160
  fn.push(['local.set', `$${restLen}`,
825
1161
  ['select',
826
1162
  ['i32.sub', ['local.get', '$__argc'], ['i32.const', fixedN]],
827
1163
  ['i32.const', 0],
828
1164
  ['i32.gt_s', ['local.get', '$__argc'], ['i32.const', fixedN]]]])
829
- fn.push(['if', ['i32.gt_s', ['local.get', `$${restLen}`], ['i32.const', restSlots]],
830
- ['then', ['local.set', `$${restLen}`, ['i32.const', restSlots]]]])
831
1165
  fn.push(['local.set', `$${restOff}`,
832
1166
  ['call', '$__alloc_hdr',
833
1167
  ['local.get', `$${restLen}`], ['local.get', `$${restLen}`]]])
@@ -837,6 +1171,21 @@ function emitClosureBody(cb) {
837
1171
  ['i32.add', ['local.get', `$${restOff}`], ['i32.const', i * 8]],
838
1172
  ['local.get', `$__a${fixedN + i}`]]]])
839
1173
  }
1174
+ // Overflow beyond the inline slots: copy args[width..argc-1] from the spill array
1175
+ // (set by the spread-call site). rest[i] = spill[(fixedN+i)*8] for i in [restSlots, restLen).
1176
+ const rid = ctx.func.uniq++
1177
+ fn.push(['if', ['i32.gt_s', ['local.get', `$${restLen}`], ['i32.const', restSlots]],
1178
+ ['then',
1179
+ ['local.set', `$${restIdx}`, ['i32.const', restSlots]],
1180
+ ['block', `$restEnd${rid}`,
1181
+ ['loop', `$restLoop${rid}`,
1182
+ ['br_if', `$restEnd${rid}`, ['i32.ge_s', ['local.get', `$${restIdx}`], ['local.get', `$${restLen}`]]],
1183
+ ['f64.store',
1184
+ ['i32.add', ['local.get', `$${restOff}`], ['i32.mul', ['local.get', `$${restIdx}`], ['i32.const', 8]]],
1185
+ ['f64.load', ['i32.add', ['global.get', '$__closure_spill'],
1186
+ ['i32.mul', ['i32.add', ['local.get', `$${restIdx}`], ['i32.const', fixedN]], ['i32.const', 8]]]]],
1187
+ ['local.set', `$${restIdx}`, ['i32.add', ['local.get', `$${restIdx}`], ['i32.const', 1]]],
1188
+ ['br', `$restLoop${rid}`]]]]])
840
1189
  const restValue = ['call', '$__mkptr', ['i32.const', PTR.ARRAY], ['i32.const', 0], ['local.get', `$${restOff}`]]
841
1190
  if (boxedParamNames.has(cb.rest)) {
842
1191
  fn.push(
@@ -866,6 +1215,9 @@ function emitClosureBody(cb) {
866
1215
  * @returns {Array} Complete WASM module as S-expression
867
1216
  */
868
1217
  export default function compile(ast, profiler) {
1218
+ // Contract: callers (jzCompileInner / scripts/self.js compileSelf) must set
1219
+ // ctx.transform.optimize before reaching here — every optimize-gated pass below
1220
+ // reads `cfg && cfg.x === false`, so a null cfg silently runs every pass.
869
1221
  // Populate known function names + lookup map on ctx.func for direct call detection
870
1222
  ctx.func.names.clear()
871
1223
  ctx.func.map.clear()
@@ -891,7 +1243,7 @@ export default function compile(ast, profiler) {
891
1243
 
892
1244
  // Check user globals don't conflict with runtime globals (modules loaded after user decls)
893
1245
  for (const name of ctx.scope.userGlobals)
894
- if (!ctx.scope.globals.get(name)?.includes('mut f64'))
1246
+ if (!(ctx.scope.globals.get(name)?.mut && ctx.scope.globals.get(name)?.type === 'f64'))
895
1247
  err(`'${name}' conflicts with a compiler internal — choose a different name`)
896
1248
 
897
1249
  // Pre-fold const globals: evaluate constant initializers before function compilation
@@ -903,6 +1255,12 @@ export default function compile(ast, profiler) {
903
1255
  if (ast) {
904
1256
  const evalConst = n => {
905
1257
  if (typeof n === 'number') return n
1258
+ // A reference to an already-folded integer const (`const NEW = CALL + 1`):
1259
+ // resolve it from constInts so const-referencing-const initializers fold too.
1260
+ // Without this they stay unfolded → decl defaults to 0 AND emitDecl skips the
1261
+ // (const) runtime init → the binding reads 0 (e.g. subscript's NEW=CALL+1 → the
1262
+ // `new` keyword registers with precedence 0 and never dispatches).
1263
+ if (typeof n === 'string') return ctx.scope.constInts?.get(n) ?? null
906
1264
  if (Array.isArray(n) && n[0] == null && typeof n[1] === 'number') return n[1]
907
1265
  if (!Array.isArray(n)) return null
908
1266
  const [op, a, b] = n
@@ -922,27 +1280,35 @@ export default function compile(ast, profiler) {
922
1280
  : Array.isArray(n) && n[0] === 'const' ? [n] : []
923
1281
  const stmts = [...topStmts(ast)]
924
1282
  for (const mi of ctx.module.moduleInits || []) stmts.push(...topStmts(mi))
925
- for (const s of stmts) {
926
- if (!Array.isArray(s) || s[0] !== 'const') continue
927
- for (const decl of s.slice(1)) {
928
- if (!Array.isArray(decl) || decl[0] !== '=' || typeof decl[1] !== 'string') continue
929
- const [, name, init] = decl
930
- if (!ctx.scope.globals.has(name) || !ctx.scope.consts?.has(name)) continue
931
- const v = evalConst(init)
932
- if (v == null || !isFinite(v)) continue
933
- const isInt = Number.isInteger(v) && v >= I32_MIN && v <= I32_MAX
934
- ctx.scope.globals.set(name, isInt
935
- ? `(global $${name} i32 (i32.const ${v}))`
936
- : `(global $${name} f64 (f64.const ${v}))`)
937
- ctx.scope.globalTypes.set(name, isInt ? 'i32' : 'f64')
938
- // Cache integer values for cross-call const-arg propagation: `f(N)` where
939
- // `const N = 8` should observe the param as intConst=8.
940
- if (isInt) (ctx.scope.constInts ||= new Map()).set(name, v)
1283
+ // Fixpoint: a const may reference one declared later or in another module
1284
+ // (`NEW = CALL + 1`). Each pass folds every now-resolvable initializer (its refs
1285
+ // already in constInts); repeat until none change so order/cross-module refs resolve.
1286
+ const foldedDecls = new Set()
1287
+ let changed = true
1288
+ while (changed) {
1289
+ changed = false
1290
+ for (const s of stmts) {
1291
+ if (!Array.isArray(s) || s[0] !== 'const') continue
1292
+ for (const decl of s.slice(1)) {
1293
+ if (!Array.isArray(decl) || decl[0] !== '=' || typeof decl[1] !== 'string') continue
1294
+ const [, name, init] = decl
1295
+ if (foldedDecls.has(name)) continue
1296
+ if (!ctx.scope.globals.has(name) || !ctx.scope.consts?.has(name)) continue
1297
+ const v = evalConst(init)
1298
+ if (v == null || !isFinite(v)) continue
1299
+ foldedDecls.add(name)
1300
+ changed = true
1301
+ const isInt = Number.isInteger(v) && v >= I32_MIN && v <= I32_MAX
1302
+ declGlobal(name, isInt ? 'i32' : 'f64', v, { mut: false })
1303
+ // Cache integer values for cross-call const-arg propagation: `f(N)` where
1304
+ // `const N = 8` should observe the param as intConst=8.
1305
+ if (isInt) (ctx.scope.constInts ||= new Map()).set(name, v)
1306
+ }
941
1307
  }
942
1308
  }
943
1309
  }
944
1310
 
945
- const programFacts = timePhase(profiler, 'plan', () => plan(ast))
1311
+ const programFacts = timePhase(profiler, 'plan', () => plan(ast, profiler))
946
1312
 
947
1313
  // Inspect sink: editor hosts opt in via { inspect: true } to read inferred shapes.
948
1314
  // Initialized here (post-plan) so paramReps and schema.list are stable, populated
@@ -950,28 +1316,30 @@ export default function compile(ast, profiler) {
950
1316
  if (ctx.transform.inspect) ctx.inspect = { functions: {}, schemas: ctx.schema.list.map(s => s.slice()) }
951
1317
 
952
1318
  const funcFacts = new Map()
953
- for (const func of ctx.func.list) {
954
- if (func.raw) continue
955
- const facts = analyzeFuncForEmit(func, programFacts)
956
- funcFacts.set(func, facts)
957
- captureFuncInspect(func, facts, programFacts)
958
- }
1319
+ timePhase(profiler, 'analyzeFuncs', () => {
1320
+ for (const func of ctx.func.list) {
1321
+ if (func.raw) continue
1322
+ const facts = analyzeFuncForEmit(func, programFacts)
1323
+ funcFacts.set(func, facts)
1324
+ captureFuncInspect(func, facts, programFacts)
1325
+ }
1326
+ })
959
1327
  // Whole-program SRoA: pick the schemas whose `Array<S>` instances use the
960
1328
  // `structInline` carrier. Runs once the per-function reps have settled (they
961
1329
  // are codegen truth) and before any function is emitted.
962
- analyzeStructInline(funcFacts, programFacts)
963
- const funcs = ctx.func.list.map(func => emitFunc(func, funcFacts.get(func), programFacts))
1330
+ timePhase(profiler, 'structInline', () => analyzeStructInline(funcFacts, programFacts))
1331
+ const funcs = timePhase(profiler, 'emitFuncs', () => ctx.func.list.map(func => emitFunc(func, funcFacts.get(func), programFacts)))
964
1332
  funcs.push(...synthesizeBoundaryWrappers())
965
1333
 
966
1334
  const closureFuncs = []
967
1335
  let compiledBodyCount = 0
968
- const compilePendingClosures = () => {
1336
+ const compilePendingClosures = () => timePhase(profiler, 'emitClosures', () => {
969
1337
  const bodies = ctx.closure.bodies || []
970
1338
  for (let bodyIndex = compiledBodyCount; bodyIndex < bodies.length; bodyIndex++) {
971
1339
  closureFuncs.push(emitClosureBody(bodies[bodyIndex]))
972
1340
  }
973
1341
  compiledBodyCount = bodies.length
974
- }
1342
+ })
975
1343
  compilePendingClosures()
976
1344
 
977
1345
  // `wasm:js-string` imports — drained from `ctx.core.jsstring`, one
@@ -1055,7 +1423,15 @@ export default function compile(ast, profiler) {
1055
1423
  if (ctx.closure.table?.length)
1056
1424
  sec.elem.push(['elem', ['i32.const', 0], 'func', ...ctx.closure.table.map(n => `$${n}`)])
1057
1425
 
1058
- buildStartFn(ast, sec, closureFuncs, compilePendingClosures)
1426
+ timePhase(profiler, 'buildStart', () => buildStartFn(ast, sec, closureFuncs, compilePendingClosures))
1427
+
1428
+ // Host globals (globalThis/process/WebAssembly/…) referenced as values are
1429
+ // recorded in ctx.core.hostGlobals during emit; register them as env imports
1430
+ // now (assembly owns ctx.module.imports). Drained after buildStartFn so a
1431
+ // host global first used in a top-level statement (emitted into __start) is
1432
+ // captured; syncImports below merges them into sec.imports.
1433
+ for (const name of ctx.core.hostGlobals)
1434
+ ctx.module.imports.push(['import', '"env"', `"${name}"`, ['global', `$${name}`, 'i64']])
1059
1435
 
1060
1436
  syncImports(sec)
1061
1437
 
@@ -1063,16 +1439,22 @@ export default function compile(ast, profiler) {
1063
1439
 
1064
1440
  finalizeClosureTable(sec)
1065
1441
 
1066
- pullStdlib(sec)
1442
+ buildInternTable()
1443
+
1444
+ timePhase(profiler, 'pullStdlib', () => pullStdlib(sec))
1067
1445
 
1068
1446
  stripStaticDataPrefix(sec)
1069
1447
 
1070
1448
  ensureThrowRuntime(sec)
1071
1449
 
1072
- optimizeModule(sec)
1450
+ timePhase(profiler, 'optimizeModule', () => optimizeModule(sec, profiler))
1073
1451
 
1074
- // Populate globals (after __start — const folding may update declarations)
1075
- sec.globals.push(...[...ctx.scope.globals.values()].filter(g => g).map(g => parseWat(g)))
1452
+ // Populate globals (after __start — const folding may update declarations).
1453
+ // Records build IR directly — no WAT-text parse-back.
1454
+ sec.globals.push(...[...ctx.scope.globals].filter(([, g]) => g).map(([n, g]) => ['global', `$${n}`,
1455
+ ...(g.export ? [['export', `"${g.export}"`]] : []),
1456
+ g.mut ? ['mut', g.type] : g.type,
1457
+ [`${g.type}.const`, g.init]]))
1076
1458
 
1077
1459
  // Data segments (after emit — string literals append to ctx.runtime.data / strPool during emit)
1078
1460
  // Active segment at address 0 — skipped for shared memory (would collide across modules)
@@ -1124,26 +1506,6 @@ export default function compile(ast, profiler) {
1124
1506
  if (restParamFuncs.length)
1125
1507
  sec.customs.push(['@custom', '"jz:rest"', `"${JSON.stringify(restParamFuncs).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`])
1126
1508
 
1127
- // Custom section: per-export i64 ABI map. Each entry describes an export
1128
- // whose boundary wrapper carries NaN-boxed pointers via i64 (rather than
1129
- // f64) to dodge V8's NaN canonicalization. Format: { name, p, r } where p
1130
- // is an array of i64 param indices and r is 1 if result is i64. host.js
1131
- // wrap() reinterprets BigInt↔f64 at i64 positions; numeric f64 positions
1132
- // stay as Numbers on the JS side.
1133
- const i64Exports = []
1134
- for (const f of ctx.func.list) {
1135
- if (!isExported(f) || !isBoundaryWrapped(f) || !f._exportUsesI64) continue
1136
- const p = []
1137
- f._exportI64Sig.params.forEach((b, i) => { if (b) p.push(i) })
1138
- const r = f._exportI64Sig.result ? 1 : 0
1139
- // One entry per JS-visible export name (inline + non-aliased + aliased
1140
- // re-exports). `exportNamesOf` yields every name that resolves to f —
1141
- // wrap() looks up by export name, not by internal symbol.
1142
- for (const exportName of exportNamesOf(f.name)) i64Exports.push({ name: exportName, p, r })
1143
- }
1144
- if (i64Exports.length)
1145
- sec.customs.push(['@custom', '"jz:i64exp"', `"${JSON.stringify(i64Exports).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`])
1146
-
1147
1509
  // Custom section: per-export externref param positions. interop.js reads
1148
1510
  // this to pass JS arguments straight through at those positions (no
1149
1511
  // `mem.wrapVal`, no SSO encoding). Format: { name, p, d? } where p lists
@@ -1158,12 +1520,22 @@ export default function compile(ast, profiler) {
1158
1520
  f._exportExtParams.forEach((b, i) => {
1159
1521
  if (!b) return
1160
1522
  p.push(i)
1161
- if (typeof b === 'object' && b.def != null) d[i] = b.def
1523
+ // String-key the index: object property keys are conceptually strings (JSON renders
1524
+ // `{"0":…}` either way), and the self-host kernel's objects don't enumerate a numeric
1525
+ // key — `d[0]=…` stores but Object.keys(d) misses it, so the `d` map would read empty
1526
+ // and the default never reach the jz:extparam section. Same coercion as the optimize
1527
+ // LEVEL_PRESETS lookup. (Native is unaffected: numeric keys auto-stringify.)
1528
+ if (typeof b === 'object' && b.def != null) d[String(i)] = b.def
1162
1529
  })
1163
1530
  if (!p.length) continue
1164
- const entry = { name: '', p }
1165
- if (Object.keys(d).length) entry.d = d
1166
- for (const exportName of exportNamesOf(f.name)) extExports.push({ ...entry, name: exportName })
1531
+ // Build each export entry as a direct literal — no `entry.d = d` after the fact and no
1532
+ // `{...entry, name}` spread. The self-host kernel's fixed-schema objects don't enumerate
1533
+ // a key added to a non-empty literal (JSON.stringify/spread would silently drop a post-hoc
1534
+ // `d`), and spreading a 3-key literal mis-resolves the merged schema there. Constructing
1535
+ // the final shape directly sidesteps both.
1536
+ const hasDefaults = Object.keys(d).length > 0
1537
+ for (const exportName of exportNamesOf(f.name))
1538
+ extExports.push(hasDefaults ? { name: exportName, p, d } : { name: exportName, p })
1167
1539
  }
1168
1540
  if (extExports.length)
1169
1541
  sec.customs.push(['@custom', '"jz:extparam"', `"${JSON.stringify(extExports).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`])