jz 0.5.1 → 0.7.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 (86) hide show
  1. package/README.md +315 -318
  2. package/bench/README.md +369 -0
  3. package/bench/bench.svg +102 -0
  4. package/cli.js +104 -30
  5. package/dist/interop.js +1 -0
  6. package/dist/jz.js +6024 -0
  7. package/index.d.ts +126 -0
  8. package/index.js +362 -84
  9. package/interop.js +159 -186
  10. package/jz.svg +5 -0
  11. package/jzify/arguments.js +97 -0
  12. package/jzify/bundler.js +382 -0
  13. package/jzify/classes.js +328 -0
  14. package/jzify/hoist-vars.js +177 -0
  15. package/jzify/index.js +51 -0
  16. package/jzify/names.js +37 -0
  17. package/jzify/switch.js +106 -0
  18. package/jzify/transform.js +349 -0
  19. package/layout.js +184 -0
  20. package/module/array.js +377 -176
  21. package/module/collection.js +639 -145
  22. package/module/console.js +55 -43
  23. package/module/core.js +264 -153
  24. package/module/date.js +15 -3
  25. package/module/function.js +73 -6
  26. package/module/index.js +2 -1
  27. package/module/json.js +226 -61
  28. package/module/math.js +551 -187
  29. package/module/number.js +327 -60
  30. package/module/object.js +474 -184
  31. package/module/regex.js +255 -25
  32. package/module/schema.js +24 -6
  33. package/module/simd.js +117 -0
  34. package/module/string.js +621 -226
  35. package/module/symbol.js +1 -1
  36. package/module/timer.js +9 -14
  37. package/module/typedarray.js +459 -73
  38. package/package.json +58 -14
  39. package/src/abi/index.js +39 -23
  40. package/src/abi/string.js +38 -41
  41. package/src/ast.js +460 -0
  42. package/src/autoload.js +29 -24
  43. package/src/bridge.js +111 -0
  44. package/src/compile/analyze-scans.js +824 -0
  45. package/src/compile/analyze.js +1600 -0
  46. package/src/compile/emit-assign.js +411 -0
  47. package/src/compile/emit.js +3512 -0
  48. package/src/compile/flow-types.js +103 -0
  49. package/src/{compile.js → compile/index.js} +778 -128
  50. package/src/{infer.js → compile/infer.js} +62 -98
  51. package/src/compile/loop-divmod.js +155 -0
  52. package/src/{narrow.js → compile/narrow.js} +409 -106
  53. package/src/compile/peel-stencil.js +261 -0
  54. package/src/compile/plan/advise.js +370 -0
  55. package/src/compile/plan/common.js +150 -0
  56. package/src/compile/plan/index.js +122 -0
  57. package/src/compile/plan/inline.js +682 -0
  58. package/src/compile/plan/literals.js +1199 -0
  59. package/src/compile/plan/loops.js +472 -0
  60. package/src/compile/plan/scope.js +649 -0
  61. package/src/compile/program-facts.js +423 -0
  62. package/src/ctx.js +220 -64
  63. package/src/ir.js +589 -172
  64. package/src/kind-traits.js +132 -0
  65. package/src/kind.js +524 -0
  66. package/src/op-policy.js +57 -0
  67. package/src/{optimize.js → optimize/index.js} +1432 -473
  68. package/src/optimize/vectorize.js +4660 -0
  69. package/src/param-reps.js +65 -0
  70. package/src/parse.js +44 -0
  71. package/src/{prepare.js → prepare/index.js} +649 -205
  72. package/src/prepare/lift-iife.js +149 -0
  73. package/src/reps.js +116 -0
  74. package/src/resolve.js +12 -3
  75. package/src/static.js +208 -0
  76. package/src/type.js +651 -0
  77. package/src/{assemble.js → wat/assemble.js} +275 -55
  78. package/src/wat/optimize.js +3938 -0
  79. package/transform.js +21 -0
  80. package/wasi.js +47 -5
  81. package/src/analyze.js +0 -3818
  82. package/src/emit.js +0 -3040
  83. package/src/jzify.js +0 -1580
  84. package/src/plan.js +0 -2132
  85. package/src/vectorize.js +0 -1088
  86. /package/src/{codegen.js → wat/codegen.js} +0 -0
@@ -25,18 +25,22 @@
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 { strengthReduceLoopDivMod } from './loop-divmod.js'
41
+ import { peelClampedStencil } from './peel-stencil.js'
42
+ import { emit, emitter, emitVoid, emitBlockBody } from './emit.js'
43
+ import { emitCharDecompPrologue, JSS_IMPORT_SIGS } from '../abi/string.js'
40
44
  import {
41
45
  typed, asF64, asI32, asPtrOffset, asParamType, toI32, asI64, fromI64,
42
46
  NULL_NAN, UNDEF_NAN, NULL_WAT, UNDEF_WAT, NULL_IR, UNDEF_IR, nullExpr, undefExpr,
@@ -49,13 +53,14 @@ import {
49
53
  multiCount, loopTop, flat, reconstructArgsWithSpreads,
50
54
  valKindToPtr, findBodyStart, tcoTailRewrite,
51
55
  boolBoxIR,
52
- I32_MIN, I32_MAX,
53
- } from './ir.js'
54
- import plan from './plan.js'
56
+ I32_MIN, I32_MAX, dollar,
57
+ } from '../ir.js'
58
+ import plan from './plan/index.js'
59
+ import { foldStaticConstAggregates } from './plan/literals.js'
55
60
  import {
56
61
  buildStartFn, dedupClosureBodies, finalizeClosureTable,
57
- pullStdlib, syncImports, optimizeModule, stripStaticDataPrefix,
58
- } from './assemble.js'
62
+ pullStdlib, syncImports, optimizeModule, stripStaticDataPrefix, hoistConstGlobalInits,
63
+ } from '../wat/assemble.js'
59
64
 
60
65
  // =============================================================================
61
66
  // Single-source export semantics
@@ -94,17 +99,18 @@ const isExported = f => {
94
99
  return false
95
100
  }
96
101
 
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) {
102
+ /** Collect JS-visible export names that resolve to `funcName` (as an array).
103
+ * Used to emit per-export ABI metadata in custom sections — one entry per
104
+ * JS-visible name, since the host (interop.js wrap) keys by export name. */
105
+ function exportNamesOf(funcName) {
106
+ const names = []
101
107
  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
108
+ if ((val === true && key === funcName) || val === funcName) names.push(key)
104
109
  }
110
+ return names
105
111
  }
106
112
 
107
- const timePhase = (profiler, name, fn) => profiler ? profiler.time(name, fn) : fn()
113
+ const timePhase = (profiler, name, fn) => profiler?.time ? profiler.time(name, fn) : fn()
108
114
 
109
115
  // Per-compile func name set + map live on ctx.func.names / ctx.func.map,
110
116
  // populated at compile() entry. Both reset by ctx.js reset() and re-filled here.
@@ -113,8 +119,7 @@ const timePhase = (profiler, name, fn) => profiler ? profiler.time(name, fn) : f
113
119
  // emit-calling ones (toBool, emitTypeofCmp, emitDecl, materializeMulti,
114
120
  // buildArrayWithSpreads) moved to src/emit.js.
115
121
 
116
- // AST-analysis primitives (staticObjectProps, paramReps lattice helpers,
117
- // infer* cross-call inference, collectProgramFacts) moved to src/analyze.js.
122
+ // AST-analysis primitives live in kind.js, type.js, static.js, program-facts.js.
118
123
 
119
124
  /**
120
125
  * Boundary-wrap predicate: exports whose body-driven result OR any param narrowed
@@ -133,9 +138,62 @@ const isBoundaryWrapped = (func) => {
133
138
  // A boolean result rides the 0/1 number carrier internally; the export thunk
134
139
  // boxes it to the TRUE_NAN/FALSE_NAN atom so the host sees a real boolean.
135
140
  if (func.valResult === VAL.BOOL) return true
141
+ // A bigint result rides the i64-reinterpreted-f64 carrier internally; the export thunk converts
142
+ // it to a real Number so a JS host doesn't see raw i64 bits (`() => 100n` was returning 4.94e-322).
143
+ if (func.valResult === VAL.BIGINT) return true
136
144
  return func.sig.params.some(p => p.type !== 'f64' || p.ptrKind != null)
137
145
  }
138
146
 
147
+ // Static-string intern index (the `internStrings` pass). Open-addressing table
148
+ // over the deduped static string literals (5–32 bytes): [hash u32][ptr u32]
149
+ // pairs appended to the data segment, FNV-1a matching __str_hash's heap branch.
150
+ // __str_slice/__str_slice_view probe it so a runtime substring whose content
151
+ // equals any source literal returns the CANONICAL static pointer — string
152
+ // equality then short-circuits on bit-eq instead of walking bytes (a compiler
153
+ // or parser compares each token against tag literals many times; ~25% of
154
+ // self-host compile time was __str_eq/__eq/__str_hash volume). Built before
155
+ // pullStdlib (the slice thunks emit the probe only when `__internBase` exists);
156
+ // stripStaticDataPrefix shifts the stored ptr slots like every other static
157
+ // reference. Misses cost one FNV + one probe per slice; the table is read-only.
158
+ function buildInternTable() {
159
+ const cfg = ctx.transform.optimize
160
+ if (!cfg || cfg.internStrings === false) return
161
+ if (ctx.memory.shared || !ctx.runtime.dataDedup?.size) return
162
+ const enc = new TextEncoder()
163
+ const entries = []
164
+ for (const [str, off] of ctx.runtime.dataDedup) {
165
+ const b = enc.encode(str)
166
+ if (b.length < 5 || b.length > 32) continue
167
+ let h = 0x811c9dc5 | 0
168
+ for (let i = 0; i < b.length; i++) h = Math.imul(h ^ b[i], 0x01000193) | 0
169
+ if (h <= 1) h = (h + 2) | 0 // mirror __str_hash's empty/tombstone clamp
170
+ entries.push([h >>> 0, off + 8])
171
+ }
172
+ if (!entries.length) return
173
+ let size = 4
174
+ while (size < entries.length * 2) size = (size * 2) | 0
175
+ const mask = size - 1
176
+ const slots = new Uint32Array(size * 2)
177
+ for (let e = 0; e < entries.length; e++) {
178
+ const h = entries[e][0], off = entries[e][1]
179
+ let i = h & mask
180
+ while (slots[i * 2 + 1] !== 0) i = (i + 1) & mask
181
+ slots[i * 2] = h
182
+ slots[i * 2 + 1] = off
183
+ }
184
+ while (ctx.runtime.data.length % 8 !== 0) ctx.runtime.data += '\0'
185
+ const base = ctx.runtime.data.length
186
+ let s = ''
187
+ for (let i = 0; i < slots.length; i++) {
188
+ const v = slots[i]
189
+ s += String.fromCharCode(v & 0xFF, (v >>> 8) & 0xFF, (v >>> 16) & 0xFF, (v >>> 24) & 0xFF)
190
+ }
191
+ ctx.runtime.data += s
192
+ ctx.runtime.internTable = { base, size }
193
+ declGlobal('__internBase', 'i32', base, { mut: false })
194
+ declGlobal('__internMask', 'i32', mask, { mut: false })
195
+ }
196
+
139
197
  const ensureThrowRuntime = (sec) => {
140
198
  // A pulled stdlib helper may throw $__jz_err even when no user `throw` set the
141
199
  // flag (e.g. __to_num on a Symbol). Detect it from the included stdlib bodies
@@ -147,7 +205,7 @@ const ensureThrowRuntime = (sec) => {
147
205
  if (!ctx.runtime.throws) return
148
206
 
149
207
  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))')
208
+ declGlobal('__jz_last_err_bits', 'i64')
151
209
  if (!sec.tags.some(t => Array.isArray(t) && t[0] === 'tag' && t[1] === '$__jz_err'))
152
210
  sec.tags.push(['tag', '$__jz_err', ['param', 'f64']])
153
211
  if (!sec.tags.some(t => Array.isArray(t) && t[0] === 'export' && t[1] === '"__jz_last_err_bits"'))
@@ -181,13 +239,12 @@ const pruneUnusedThrowRuntime = (sec) => {
181
239
  const cloneRepMap = map => map ? new Map([...map].map(([k, v]) => [k, { ...v }])) : null
182
240
 
183
241
  /** Serialize a ValueRep entry into a plain object for inspect output.
184
- * Omits undefined fields so consumers can JSON-stringify without noise. */
242
+ * Omits undefined fields so consumers can JSON-stringify without noise.
243
+ * Iterates REP_FIELDS (the closed shape in reps.js) so it can't drift. */
185
244
  const repView = (rep) => {
186
245
  if (!rep) return null
187
246
  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
- }
247
+ for (const k of REP_FIELDS) if (rep[k] != null) out[k] = rep[k]
191
248
  return Object.keys(out).length ? out : null
192
249
  }
193
250
 
@@ -230,18 +287,80 @@ function captureFuncInspect(func, facts, programFacts) {
230
287
  }
231
288
  }
232
289
 
290
+ function scanAndTagNonEscapingClosures(body) {
291
+ if (!body) return
292
+ const onlyCalledNotReferenced = (node, name) => {
293
+ if (typeof node === 'string') return node !== name
294
+ if (!Array.isArray(node)) return true
295
+ const op = node[0]
296
+ if (op === 'str') return true
297
+ if (op === '=>') {
298
+ return !refsName(node[1], name, REFS_IN_EXPR) && !refsName(node[2], name, REFS_IN_EXPR)
299
+ }
300
+ if (op === '=' && node[1] === name) {
301
+ return onlyCalledNotReferenced(node[2], name)
302
+ }
303
+ if (op === '()' && node[1] === name) {
304
+ for (let i = 2; i < node.length; i++) {
305
+ if (!onlyCalledNotReferenced(node[i], name)) return false
306
+ }
307
+ return true
308
+ }
309
+ if (op === '.' || op === '?.') return onlyCalledNotReferenced(node[1], name)
310
+ if (op === ':') return onlyCalledNotReferenced(node[2], name)
311
+ for (let i = 1; i < node.length; i++) {
312
+ if (!onlyCalledNotReferenced(node[i], name)) return false
313
+ }
314
+ return true
315
+ }
316
+
317
+ const walk = (node) => {
318
+ if (!Array.isArray(node)) return
319
+ const op = node[0]
320
+ if (op === 'let' || op === 'const') {
321
+ for (const decl of node.slice(1)) {
322
+ if (Array.isArray(decl) && decl[0] === '=' && typeof decl[1] === 'string') {
323
+ const name = decl[1]
324
+ const init = decl[2]
325
+ if (Array.isArray(init) && init[0] === '=>') {
326
+ const arrow_body = init[2]
327
+ if (arrow_body && typeof arrow_body === 'object' && !ctx.func.boxed?.has(name) && !isGlobal(name) && !isReassigned(body, name) && onlyCalledNotReferenced(body, name)) {
328
+ arrow_body._nonEscaping = name
329
+ }
330
+ }
331
+ }
332
+ }
333
+ } else if (op === '=' && typeof node[1] === 'string' && Array.isArray(node[2]) && node[2][0] === '=>') {
334
+ const name = node[1]
335
+ const init = node[2]
336
+ const arrow_body = init[2]
337
+ if (arrow_body && typeof arrow_body === 'object' && !ctx.func.boxed?.has(name) && !isGlobal(name) && !isReassigned(body, name) && onlyCalledNotReferenced(body, name)) {
338
+ arrow_body._nonEscaping = name
339
+ }
340
+ }
341
+ for (let i = 1; i < node.length; i++) walk(node[i])
342
+ }
343
+ walk(body)
344
+ }
345
+
233
346
  // Reset per-function emit-frame state — the single source of frame entry.
234
347
  // `emitFunc`, `analyzeFuncForEmit`, and `emitClosureBody` all route through
235
348
  // here. Top-level funcs start `uniq` at 0; closures pass a higher base so
236
349
  // their synthetic labels can't collide with the parent frame's.
237
350
  function enterFunc(sig, body, { uniq = 0, directClosures = null } = {}) {
238
351
  ctx.func.stack = []
352
+ ctx.func.zeroInitSeen = new Set() // names whose `let x=0` zero-init was elided once; a 2nd is a real re-init (unrolled bodies)
353
+ ctx.func.maybeNullish = new Set() // bindings assigned a nullish literal → coerce in arithmetic (null-flow)
354
+ ctx.func.pendingLabel = null // label awaiting its loop, for `continue <label>`
239
355
  ctx.func.uniq = uniq
240
356
  ctx.func.current = sig
241
357
  ctx.func.body = body
242
358
  ctx.func.directClosures = directClosures
243
359
  ctx.func.localProps = null
244
360
  ctx.func.charDecomp = null
361
+ if (ctx.transform.optimize) {
362
+ scanAndTagNonEscapingClosures(body)
363
+ }
245
364
  }
246
365
 
247
366
  // Allocate + null-init a heap cell for every boxed local that isn't seeded
@@ -264,6 +383,15 @@ function analyzeFuncForEmit(func, programFacts) {
264
383
  const { paramReps } = programFacts
265
384
  if (func.raw) return null
266
385
 
386
+ // Strength-reduce per-iteration `i % w` / `(i/w)|0` to incremental i32 counters
387
+ // (idempotent: a reduced loop has no modulo left to match). Before analyze so the
388
+ // counters are typed/narrowed like any i32 local. Off at L0 / `loopIVDivMod:false`.
389
+ const _o = ctx.transform.optimize
390
+ if (_o && _o.loopIVDivMod !== false && isBlockBody(func.body)) func.body = strengthReduceLoopDivMod(func.body)
391
+ // Edge-clamp peeling: split a clamped stencil loop into clamp-free interior + edges
392
+ // (the interior then lifts to SIMD). Before analyze so the new loops are analyzed.
393
+ if (_o && _o.clampPeel !== false && isBlockBody(func.body)) func.body = peelClampedStencil(func.body)
394
+
267
395
  const { name, body, sig } = func
268
396
  enterFunc(sig, body)
269
397
 
@@ -288,6 +416,31 @@ function analyzeFuncForEmit(func, programFacts) {
288
416
  if (r.intConst != null) updateRep(pname, { intConst: r.intConst })
289
417
  }
290
418
  }
419
+ // Trust numeric export params. An exported f64 param used only in numeric
420
+ // positions is marked VAL.NUMBER so its uses skip the `__to_num` coercion
421
+ // entirely (not just hoist it). External callers reach jz through interop's
422
+ // `mem.wrapVal`, which passes a JS number straight to f64 — so the coercion
423
+ // only ever fired for a *string* arg to a numeric param (a type misuse). When
424
+ // that lone coercion is the only `__to_num` consumer, dropping it lets the whole
425
+ // ToNumber string-parse dep tree (`__to_str`→`__itoa`/`__toExp`/`__mkstr`/…)
426
+ // treeshake away — a ~4× module shrink that, decisively, lets V8 tier the hot
427
+ // fill loop up properly (the bloated module JITs the *identical* loop ~2× slower).
428
+ if (func.exported && block) {
429
+ for (const p of sig.params) {
430
+ if (p.type === 'f64' && p.ptrKind == null && !p.jsstring
431
+ && !func.defaults?.[p.name] && !ctx.func.boxed?.has(p.name)
432
+ && !ctx.func.localReps?.get(p.name)?.val
433
+ // Numeric either by PROOF (ToNumber-forcing uses) or by the export
434
+ // boundary contract (never used as a string → wrapVal guarantees a
435
+ // number). The latter catches `acc + cre` float kernels whose `+` would
436
+ // otherwise pull a per-iteration string-concat fork (julia, floatbeats).
437
+ && (paramAllUsesNumeric(body, p.name) || paramNeverString(body, p.name)))
438
+ updateRep(p.name, { val: VAL.NUMBER })
439
+ }
440
+ }
441
+ if (block) {
442
+ seedLocalIntConsts(body)
443
+ }
291
444
  // Drop any earlier-cached analyzeBody.locals slice for this body —
292
445
  // narrowSignatures called it before our pre-seed, when params still had no
293
446
  // inferred VAL.TYPED, so the cached widths reflect the pre-narrow state.
@@ -295,6 +448,11 @@ function analyzeFuncForEmit(func, programFacts) {
295
448
  invalidateLocalsCache(body)
296
449
  const bodyFacts = block ? analyzeBody(body) : null
297
450
  ctx.func.locals = bodyFacts ? bodyFacts.locals : new Map()
451
+ if (bodyFacts?.valTypes) {
452
+ for (const [name, vt] of bodyFacts.valTypes) updateRep(name, { val: vt })
453
+ }
454
+ // Never-relocated array bindings — the `[]` reader skips the forwarding follow.
455
+ if (bodyFacts?.neverGrown) for (const name of bodyFacts.neverGrown) updateRep(name, { neverGrown: true })
298
456
  // Proven uint32 accumulator locals — readVar tags reads `.unsigned` so the
299
457
  // f64 round-trip widens with convert_i32_u (not _s).
300
458
  if (bodyFacts?.unsignedLocals) for (const n of bodyFacts.unsignedLocals) updateRep(n, { unsigned: true })
@@ -351,6 +509,9 @@ function analyzeFuncForEmit(func, programFacts) {
351
509
  if (p.ptrAux != null) fields.ptrAux = p.ptrAux
352
510
  updateRep(p.name, fields)
353
511
  }
512
+ for (const p of sig.params) {
513
+ if (p.jsstring) updateRep(p.name, { carrier: 'jsstring', val: VAL.STRING })
514
+ }
354
515
 
355
516
  // CSE-safe load bases — pointer locals whose memory reads `cseScalarLoad`
356
517
  // may scalar-replace. Computed last: needs every `let`/param ptrKind in place.
@@ -358,10 +519,23 @@ function analyzeFuncForEmit(func, programFacts) {
358
519
  ? cseSafeLoadBases(body, ctx.func.locals, ctx.func.localReps)
359
520
  : new Set()
360
521
 
522
+ // Closure-capture narrowing: a boxed var whose every defining RHS — owner
523
+ // body AND nested arrows, the narrower's intCertain contract — is integer-
524
+ // valued keeps its CELL in i32, so readVar/writeVar skip the f64↔i32
525
+ // round-trip per access. Params are excluded: their cell is seeded from the
526
+ // raw f64 param value, which would desync an i32-read cell. Same asm.js-style
527
+ // range contract as plain intCertain locals.
528
+ const cellTypes = new Set()
529
+ for (const name of ctx.func.boxed.keys()) {
530
+ if (sig.params.some(p => p.name === name)) continue
531
+ if (ctx.func.localReps?.get(name)?.intCertain === true) cellTypes.add(name)
532
+ }
533
+
361
534
  return {
362
535
  block,
363
536
  locals: new Map(ctx.func.locals),
364
537
  boxed: new Map(ctx.func.boxed),
538
+ cellTypes,
365
539
  flatObjects: new Map(ctx.func.flatObjects),
366
540
  sliceViews: new Set(ctx.func.sliceViews),
367
541
  cseLoadBases,
@@ -370,6 +544,367 @@ function analyzeFuncForEmit(func, programFacts) {
370
544
  }
371
545
  }
372
546
 
547
+ function seedLocalIntConsts(body) {
548
+ // Fold each never-reassigned local `const`/`let NAME = EXPR` to a known i32, so a
549
+ // divisor / bound / size built from earlier consts (`rr = R|0; win = 2*rr+1`) becomes
550
+ // a compile-time literal — which lets the int-divide lowering hand the wasm backend a
551
+ // constant divisor to magic-multiply (no runtime sdiv), array bounds resolve, etc.
552
+ // Mirrors the module-scope fold (evalConst above); a string ref resolves through the
553
+ // intConst already recorded on its rep, and the fixpoint lets a later const see an
554
+ // earlier one regardless of declaration order. Skips nested functions (own scope).
555
+ const evalC = (n) => {
556
+ if (typeof n === 'number') return Number.isInteger(n) ? n : null
557
+ if (Array.isArray(n) && n[0] == null && typeof n[1] === 'number') return Number.isInteger(n[1]) ? n[1] : null
558
+ if (typeof n === 'string') return intLiteralValue(n) // a seeded intConst / literal local
559
+ if (!Array.isArray(n)) return null
560
+ const [op, a, b] = n
561
+ const va = evalC(a); if (va == null) return null
562
+ if (op === 'u-' || (op === '-' && b === undefined)) return -va
563
+ const vb = evalC(b); if (vb == null) return null
564
+ switch (op) {
565
+ case '+': return va + vb; case '-': return va - vb; case '*': return va * vb
566
+ case '&': return va & vb; case '|': return va | vb; case '^': return va ^ vb
567
+ case '<<': return va << vb; case '>>': return va >> vb; case '>>>': return va >>> vb
568
+ default: return null
569
+ }
570
+ }
571
+ const decls = []
572
+ const walk = (node) => {
573
+ if (!Array.isArray(node)) return
574
+ const [op, ...args] = node
575
+ if (op === '=>') return
576
+ if (op === 'let' || op === 'const') {
577
+ for (const decl of args)
578
+ if (Array.isArray(decl) && decl[0] === '=' && typeof decl[1] === 'string' && !isReassigned(body, decl[1])) decls.push(decl)
579
+ return
580
+ }
581
+ for (const arg of args) walk(arg)
582
+ }
583
+ walk(body)
584
+ const seeded = new Set()
585
+ let changed = true
586
+ while (changed) {
587
+ changed = false
588
+ for (const decl of decls) {
589
+ if (seeded.has(decl[1])) continue
590
+ const value = evalC(decl[2])
591
+ if (value != null && Number.isInteger(value) && value >= I32_MIN && value <= I32_MAX) {
592
+ updateRep(decl[1], { intConst: value }); seeded.add(decl[1]); changed = true
593
+ }
594
+ }
595
+ }
596
+ }
597
+
598
+ // ── Loop-invariant exported-param coercion hoist ────────────────────────────
599
+ //
600
+ // An exported numeric param arrives as a NaN-box (jz's value ABI), so each use
601
+ // in an arithmetic context emits `__to_num(p)`. When the param is never
602
+ // reassigned and *every* use is an unconditional-ToNumber arithmetic operand,
603
+ // the coercion is loop-invariant: do it once at entry and let every use read the
604
+ // already-unboxed f64. This flips a serial recurrence like the de Jong attractor
605
+ // (4 `__to_num`/iter × millions) from ~parity to a clear win over V8.
606
+ //
607
+ // Self-gating: the rewrite only fires when the emitted body ALREADY contains
608
+ // `__to_num(p)` calls — meaning the helper is loaded for other reasons (global
609
+ // typed-array assigns, strings, …). A provably-numeric program (`(a,b)=>a*b`)
610
+ // never loads the helper, has no pattern to match, and is left byte-for-byte
611
+ // alone, preserving the minimal-bundle / golden-size guarantee.
612
+
613
+ // `=`/`+=`/`++`/… targets — reassigning the param breaks the coerce-once premise.
614
+ const PARAM_REASSIGN_OPS = new Set(['=', '+=', '-=', '*=', '/=', '%=', '&=', '|=',
615
+ '^=', '>>=', '<<=', '>>>=', '||=', '&&=', '??=', '++', '--'])
616
+ // Binary ops that unconditionally ToNumber BOTH operands, so a bare param operand
617
+ // is a pure numeric use. `+` is excluded (may concatenate); `===`/`==` are excluded
618
+ // (they branch on type, never coerce a string operand to number).
619
+ const NUM_BIN_OPS = new Set(['*', '/', '%', '**', '&', '|', '^', '<<', '>>', '>>>'])
620
+ // Relational ops: jz has no lexicographic compare for an untyped operand — `<`
621
+ // lowers to `f64.lt`, taking the string path only when a *known-string* operand
622
+ // is present (emit.js cmpOp). So a bare param compared against a non-string is a
623
+ // pure numeric use, same as NUM_BIN_OPS. A string-literal counterpart (`x < "m"`)
624
+ // signals string intent and is rejected (handled in the walk below).
625
+ const REL_OPS = new Set(['<', '<=', '>', '>='])
626
+ // A string literal/template operand poisons relational numeric inference.
627
+ const isStrLiteral = (n) => Array.isArray(n) && (n[0] === 'str' || n[0] === 'template')
628
+
629
+ /** True iff every use of param `name` in `body` is numeric-COMPATIBLE *and* at
630
+ * least one use is numeric-PROVING — so coercing it to a number once at entry is
631
+ * observationally exact. Two verdict levels guard against a polymorphic slot
632
+ * passing on absence of evidence:
633
+ * - PROVING (`proven=true`): arithmetic / relational / bitwise / unary operand —
634
+ * JS ToNumbers these, and a string/array value would have shown a disqualifying
635
+ * use elsewhere.
636
+ * - COMPATIBLE-ONLY: the length slot of `new TypedArray(x)` / `new ArrayBuffer(x)`.
637
+ * A number sizes the buffer, but an array is COPIED and a buffer VIEWED — so a
638
+ * bare param here proves nothing. A param used *solely* as `new Float64Array(arr)`
639
+ * stays unproven and keeps the polymorphic ctor dispatch (else array-copy is lost).
640
+ * Any other appearance (member/call-arg/return/concat/`===`/reassignment) rejects.
641
+ * Two transparencies:
642
+ * - copy aliases: `let x = name` makes `x` carry the same value, so `x`'s uses
643
+ * must be numeric too (fixpoint-collected). Catches `let T = t` then `…T…`.
644
+ * - captured closures: a non-shadowing inner arrow captures the binding by
645
+ * reference, so its body's uses count — we recurse instead of rejecting.
646
+ * Catches floatbeat helpers `let s=(f)=>…t…` that read the param numerically. */
647
+ // requireProof=true (default): the param has a ToNumber-FORCING use (PROVES numeric).
648
+ // requireProof=false: the param merely has NO string-requiring use (numeric-COMPATIBLE).
649
+ // Forwarding recursions use the latter — a callee receiving the param need only be
650
+ // string-free (e.g. fbm's `ph`, used additively inside Math.sin), since the OUTER
651
+ // param earns its own proof from its own uses; requiring the callee be self-proven
652
+ // wrongly rejected forwards into additive-only params.
653
+ function paramAllUsesNumeric(body, name, _seen = new Set(), requireProof = true) {
654
+ if (body == null) return false
655
+ // Local closure defs (`let f = (p,…) => …`) so a call `f(name)` can be judged by
656
+ // f's own param numericity (see the call-arg handler in the walk).
657
+ const closures = new Map() // name → { params:[string], body }
658
+ // Fixpoint-collect copy aliases: `let/const x = <name-or-alias>`.
659
+ const names = new Set([name])
660
+ for (let grew = true; grew;) {
661
+ grew = false
662
+ const collect = (node) => {
663
+ if (!Array.isArray(node)) return
664
+ if ((node[0] === 'let' || node[0] === 'const') && node.length === 2
665
+ && Array.isArray(node[1]) && node[1][0] === '=' && typeof node[1][1] === 'string') {
666
+ const init = node[1][2]
667
+ if (typeof init === 'string' && names.has(init) && !names.has(node[1][1])) { names.add(node[1][1]); grew = true }
668
+ else if (Array.isArray(init) && init[0] === '=>' && !closures.has(node[1][1])) {
669
+ const ps = Array.isArray(init[1]) ? init[1].slice(1) : [init[1]] // ['()', p0, p1] → [p0,p1]
670
+ if (ps.every(p => typeof p === 'string')) closures.set(node[1][1], { params: ps, body: init[2] })
671
+ }
672
+ }
673
+ for (let i = 1; i < node.length; i++) collect(node[i])
674
+ }
675
+ collect(body)
676
+ }
677
+ let ok = true, proven = false
678
+ // A param in a numeric-operand slot is a PROVING use; recurse into a non-param sub-expr.
679
+ const numOperand = (n) => { if (names.has(n)) proven = true; else walk(n) }
680
+ // Positional call args, flattening the `(, a b c)` node multi-arg calls parse to —
681
+ // without this a forward like `fbm(x, y, t, …)` never matched its param positions.
682
+ const flat1 = (a) => Array.isArray(a) && a[0] === ',' ? a.slice(1).flatMap(flat1) : [a]
683
+ const callArgList = (n) => n.slice(2).flatMap(flat1)
684
+ const walk = (node) => {
685
+ if (!ok) return
686
+ if (typeof node === 'string') { if (names.has(node)) ok = false; return } // bare use → reject
687
+ if (!Array.isArray(node)) return
688
+ const op = node[0]
689
+ // single `let/const x = init`: x is a binding (not a use). A pure copy of an
690
+ // alias is consumed (already in `names`); otherwise the init must be numeric.
691
+ if ((op === 'let' || op === 'const') && node.length === 2
692
+ && Array.isArray(node[1]) && node[1][0] === '=' && typeof node[1][1] === 'string') {
693
+ const init = node[1][2]
694
+ if (typeof init === 'string' && names.has(init)) return
695
+ walk(init)
696
+ return
697
+ }
698
+ if (op === '=>') { // closure capture: recurse unless shadowed
699
+ const ps = node[1]
700
+ const shadowed = Array.isArray(ps)
701
+ ? ps.some(p => names.has(p) || (Array.isArray(p) && names.has(p[1])))
702
+ : names.has(ps)
703
+ if (!shadowed) { walk(node[1]); walk(node[2]) } // defaults + body; param names aren't in `names`
704
+ return
705
+ }
706
+ if (PARAM_REASSIGN_OPS.has(op) && names.has(node[1])) { ok = false; return }
707
+ if (NUM_BIN_OPS.has(op) && node.length === 3) { // numeric binary: operands are ToNumber'd
708
+ numOperand(node[1]); numOperand(node[2])
709
+ return
710
+ }
711
+ if (REL_OPS.has(op) && node.length === 3) { // relational: numeric unless a known string is present
712
+ if (isStrLiteral(node[1]) || isStrLiteral(node[2])) { ok = false; return }
713
+ numOperand(node[1]); numOperand(node[2])
714
+ return
715
+ }
716
+ // `new TypedArray(x)` / `new ArrayBuffer(x)`: the length argument is ToNumber'd
717
+ // on the alloc path, but a pointer arg is copied (array) or viewed (buffer).
718
+ // A bare param in the length slot is numeric-COMPATIBLE but not PROVING — skip it
719
+ // (no reject, no proof); other args walk normally. A param used *solely* as
720
+ // `new Float64Array(param)` thus stays unproven → keeps the polymorphic ctor (so
721
+ // `f(arr)` copies the array instead of mis-sizing a zero buffer).
722
+ if (op === '()' && typeof node[1] === 'string' && node[1].startsWith('new.')
723
+ && (node[1].endsWith('Array') || node[1] === 'new.ArrayBuffer')) {
724
+ for (let i = 2; i < node.length; i++) if (!names.has(node[i])) walk(node[i])
725
+ return
726
+ }
727
+ // Call of a LOCAL closure `f(…name…)`: forwarding the param flows its value into
728
+ // f's positional param. If that param is itself all-numeric (recursively, with a
729
+ // cycle guard), `name` in that slot is numeric-COMPATIBLE — neither rejected nor
730
+ // proving (so a param used *only* as a forwarded arg stays unproven, like the ctor
731
+ // length slot). Unknown / non-numeric callees fall through and reject (a string
732
+ // could flow in). Covers heapsort's `heapify(n)` and crc32's `crc32(buf)`.
733
+ if (op === '()' && typeof node[1] === 'string' && closures.has(node[1]) && !_seen.has(node[1])) {
734
+ const cl = closures.get(node[1])
735
+ const args = callArgList(node)
736
+ for (let i = 0; i < args.length; i++) {
737
+ if (!names.has(args[i])) { walk(args[i]); continue }
738
+ const param = cl.params[i]
739
+ if (param == null || !paramAllUsesNumeric(cl.body, param, new Set([..._seen, node[1]]), false)) { ok = false; return }
740
+ }
741
+ return
742
+ }
743
+ // Same forwarding judgement for a call to a MODULE-LEVEL user function (sibling,
744
+ // not a body-local closure): `frame` passing its param into a helper `fbm(x,y,t,…)`.
745
+ // Without this the bare arg fell through and rejected, leaving an exported numeric
746
+ // param (plasma/raymarcher's `t`) unproven → per-pixel `__to_num` + polymorphic-`+`
747
+ // string forks. Judge by the callee param's own numericity (recursive, cycle-guarded).
748
+ if (op === '()' && typeof node[1] === 'string' && !_seen.has(node[1])) {
749
+ const fn = ctx.func.map?.get(node[1])
750
+ if (fn && fn.body && !fn.raw && Array.isArray(fn.sig?.params) && !fn.rest) {
751
+ const args = callArgList(node)
752
+ for (let i = 0; i < args.length; i++) {
753
+ if (!names.has(args[i])) { walk(args[i]); continue }
754
+ const p = fn.sig.params[i]
755
+ if (!p || !paramAllUsesNumeric(fn.body, p.name, new Set([..._seen, node[1]]), false)) { ok = false; return }
756
+ }
757
+ return
758
+ }
759
+ }
760
+ // `Math.f(...)` ToNumbers every argument (Math operates on numbers), so a bare
761
+ // param in any arg slot is a PROVING numeric use — same contract as `*`/`-`.
762
+ // Without this, `Math.sin(t)` rejected the param via the generic-call fallthrough,
763
+ // so a numeric kernel like `Math.sin(tick) + …` lost its NUMBER proof and paid a
764
+ // per-use `__to_num` + a polymorphic-`+` string-concat fork (interference example).
765
+ // The callee is the lowered `math.sin` string at emit time (post-autoload), or the
766
+ // raw `(. Math sin)` member pre-lowering — match both.
767
+ const isMathCall = op === '()' && (
768
+ (typeof node[1] === 'string' && node[1].startsWith('math.')) ||
769
+ (Array.isArray(node[1]) && node[1][0] === '.' && node[1][1] === 'Math'))
770
+ if (isMathCall) {
771
+ const numArg = (a) => { if (Array.isArray(a) && a[0] === ',') { numArg(a[1]); numArg(a[2]) } else numOperand(a) }
772
+ for (let i = 2; i < node.length; i++) numArg(node[i])
773
+ return
774
+ }
775
+ // Binary `+` is overloaded (numeric add | string concat). A string-literal
776
+ // operand means concat intent → reject. Otherwise it is numeric-COMPATIBLE but
777
+ // not self-PROVING (a string param would concat) — recurse the non-param operand
778
+ // and treat a bare param as compatible (neither prove nor reject), exactly like
779
+ // paramNeverString. The numeric proof must still come from a ToNumber-forcing use
780
+ // (`*`, `Math.*`, …); a param used ONLY in `+` stays unproven (sound).
781
+ if (op === '+' && node.length === 3) {
782
+ if (isStrLiteral(node[1]) || isStrLiteral(node[2])) { ok = false; return }
783
+ if (!names.has(node[1])) walk(node[1])
784
+ if (!names.has(node[2])) walk(node[2])
785
+ return
786
+ }
787
+ if (op === '-' && node.length === 2) { numOperand(node[1]); return } // unary negate
788
+ if (op === '-' && node.length === 3) { numOperand(node[1]); numOperand(node[2]); return }
789
+ // `u-`/`u+` are the normalized unary minus/plus (prepare rewrites `-x`/`+x`); both ToNumber.
790
+ if ((op === 'u-' || op === 'u+') && node.length === 2) { numOperand(node[1]); return }
791
+ if (op === '+' && node.length === 2) { numOperand(node[1]); return } // unary + = ToNumber
792
+ if (op === '~' && node.length === 2) { numOperand(node[1]); return }
793
+ for (let i = 1; i < node.length; i++) walk(node[i]) // bare param reaching here → rejected above
794
+ }
795
+ walk(body)
796
+ return requireProof ? (ok && proven) : ok
797
+ }
798
+
799
+ // String methods whose receiver MUST be a string — their presence proves the
800
+ // param is (sometimes) string and disqualifies the boundary-numeric trust.
801
+ const STRING_RECV_METHODS = new Set([
802
+ 'charCodeAt', 'charAt', 'codePointAt', 'startsWith', 'endsWith', 'toUpperCase',
803
+ 'toLowerCase', 'normalize', 'localeCompare', 'padStart', 'padEnd', 'repeat',
804
+ 'trim', 'trimStart', 'trimEnd', 'split', 'match', 'matchAll', 'replace',
805
+ 'replaceAll', 'substring', 'substr', 'concat', 'indexOf', 'lastIndexOf',
806
+ 'includes', 'slice',
807
+ ])
808
+
809
+ /** True iff no use of exported f64 param `name` REQUIRES it to be a string — so
810
+ * the interop boundary contract (`wrapVal` passes a JS number straight to an f64
811
+ * param; a string arg is a type misuse already unsupported, returning NaN) makes
812
+ * it provably numeric. Weaker than `paramAllUsesNumeric`: that PROVES numericity
813
+ * from ToNumber-forcing ops, this DISPROVES stringness so binary `+` (the common
814
+ * `accumulator + cre` shape) no longer pessimistically pulls the string-concat
815
+ * fork into a pure float kernel. Only sound under the export boundary — never use
816
+ * for locals/closures, whose values can genuinely be strings.
817
+ *
818
+ * Disqualifying (string-requiring) uses:
819
+ * - `+` with a string-literal/template operand (`"px" + name`) — concat intent
820
+ * - a string-receiver method call (`name.charCodeAt(…)`, `name.split(…)`)
821
+ * - `name[k]` / `name.length` is NOT disqualifying (works on arrays/typed too,
822
+ * but an f64 param is neither — so a member access means the caller passed a
823
+ * pointer, out of the f64-number contract; conservatively we reject it)
824
+ * - passing `name` to a call / returning it / storing into an aggregate: the
825
+ * value escapes where it could be ToString'd; reject conservatively. */
826
+ function paramNeverString(body, name) {
827
+ if (body == null) return false
828
+ let ok = true
829
+ const walk = (node) => {
830
+ if (!ok || node == null) return
831
+ if (typeof node === 'string') { if (node === name) ok = false; return } // bare escape → reject
832
+ if (!Array.isArray(node)) return
833
+ const op = node[0]
834
+ if (op === '=>') return // shadowing-safe: closure handled conservatively (escape)
835
+ // `+` (binary): a string-literal/template operand makes it concat → reject.
836
+ // Otherwise the param is in an arithmetic add; recurse the non-name operand.
837
+ if (op === '+' && node.length === 3) {
838
+ if (isStrLiteral(node[1]) || isStrLiteral(node[2])) { ok = false; return }
839
+ for (let i = 1; i <= 2; i++) if (node[i] !== name) walk(node[i])
840
+ return
841
+ }
842
+ // Numeric/relational/bitwise binary + unary: param operand is fine, recurse rest.
843
+ if ((NUM_BIN_OPS.has(op) || REL_OPS.has(op)) && node.length === 3) {
844
+ for (let i = 1; i <= 2; i++) if (node[i] !== name) walk(node[i])
845
+ return
846
+ }
847
+ if ((op === 'u-' || op === 'u+' || op === '~') && node.length === 2) {
848
+ if (node[1] !== name) walk(node[1]); return
849
+ }
850
+ if (op === '-' && (node.length === 2 || node.length === 3)) {
851
+ for (let i = 1; i < node.length; i++) if (node[i] !== name) walk(node[i])
852
+ return
853
+ }
854
+ // Member access / method call on the param → it's a pointer, not an f64 number:
855
+ // reject (out of contract). `.`/`?.`/`[]` with the name as receiver.
856
+ if ((op === '.' || op === '?.' || op === '[]') && node[1] === name) { ok = false; return }
857
+ // `=`/compound reassignment of the param to a non-numeric value: reject if it
858
+ // could become a string. A reassignment makes the param mutable — conservatively
859
+ // require the RHS to be string-free too (recurse), and the target isn't a use.
860
+ for (let i = 1; i < node.length; i++) walk(node[i])
861
+ }
862
+ walk(body)
863
+ return ok
864
+ }
865
+
866
+ /** Hoist each eligible param's `__to_num` coercion to a single entry `local.set`,
867
+ * rewriting per-use calls in `stmts` to a bare typed `local.get`. Mutates
868
+ * `stmts` in place; returns the prologue inits to splice ahead of the body.
869
+ * Only fires for params whose coercion appears inside a loop (or ≥2×) — a lone
870
+ * straight-line coercion isn't worth the rebind. */
871
+ function hoistInvariantParamCoercions(stmts, func) {
872
+ const inits = []
873
+ const defaults = func.defaults || {}
874
+ for (const p of func.sig.params) {
875
+ if (p.type !== 'f64' || p.ptrKind != null || p.jsstring) continue
876
+ if (ctx.func.boxed?.has(p.name)) continue
877
+ if (p.name in defaults) continue
878
+ if (!paramAllUsesNumeric(func.body, p.name)) continue
879
+ const pat = (n) => Array.isArray(n) && n[0] === 'call' && n[1] === '$__to_num'
880
+ && Array.isArray(n[2]) && n[2][0] === 'i64.reinterpret_f64'
881
+ && Array.isArray(n[2][1]) && n[2][1][0] === 'local.get' && n[2][1][1] === `$${p.name}`
882
+ let total = 0, inLoop = 0
883
+ const count = (node, depth) => {
884
+ if (!Array.isArray(node)) return
885
+ const d = node[0] === 'loop' ? depth + 1 : depth
886
+ for (let i = 1; i < node.length; i++) {
887
+ if (pat(node[i])) { total++; if (d > 0) inLoop++ }
888
+ else count(node[i], d)
889
+ }
890
+ }
891
+ for (const s of stmts) count(s, 0)
892
+ if (total === 0 || (inLoop === 0 && total < 2)) continue
893
+ const strip = (node) => {
894
+ if (!Array.isArray(node)) return
895
+ for (let i = 1; i < node.length; i++) {
896
+ if (pat(node[i])) node[i] = typed(['local.get', `$${p.name}`], 'f64')
897
+ else strip(node[i])
898
+ }
899
+ }
900
+ for (const s of stmts) strip(s)
901
+ inits.push(['local.set', `$${p.name}`,
902
+ typed(['call', '$__to_num', ['i64.reinterpret_f64', typed(['local.get', `$${p.name}`], 'f64')]], 'f64')])
903
+ inc('__to_num')
904
+ }
905
+ return inits
906
+ }
907
+
373
908
  /**
374
909
  * Phase: emit one user function to WAT IR.
375
910
  *
@@ -390,6 +925,7 @@ function emitFunc(func, funcFacts, programFacts) {
390
925
  const block = funcFacts.block
391
926
  ctx.func.locals = new Map(funcFacts.locals)
392
927
  ctx.func.boxed = new Map(funcFacts.boxed)
928
+ ctx.func.cellTypes = new Set(funcFacts.cellTypes)
393
929
  ctx.func.flatObjects = new Map(funcFacts.flatObjects)
394
930
  ctx.func.sliceViews = new Set(funcFacts.sliceViews)
395
931
  ctx.func.localReps = cloneRepMap(funcFacts.localReps)
@@ -431,7 +967,7 @@ function emitFunc(func, funcFacts, programFacts) {
431
967
  // Boundary-wrapped exports also defer the attribute to the synthesized
432
968
  // wrapper ($${name}$exp) that reboxes the narrowed result back to f64.
433
969
  if (exported && !isBoundaryWrapped(func)) fn.push(['export', `"${name}"`])
434
- fn.push(...sig.params.map(p => ['param', `$${p.name}`, p.type]))
970
+ fn.push(...sig.params.map(p => ['param', dollar(p.name), p.type]))
435
971
  fn.push(...sig.results.map(t => ['result', t]))
436
972
 
437
973
  // Default params: ES spec says default applies only when arg is `undefined`
@@ -497,9 +1033,11 @@ function emitFunc(func, funcFacts, programFacts) {
497
1033
  }
498
1034
 
499
1035
  if (block) {
500
- const stmts = emitBody(body)
1036
+ const stmts = emitBlockBody(body)
1037
+ // Hoist loop-invariant `__to_num(param)` coercions to a single entry rebind.
1038
+ const numCoerceInits = hoistInvariantParamCoercions(stmts, func)
501
1039
  const paramInits = collectParamInits()
502
- for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
1040
+ for (const [l, t] of ctx.func.locals) fn.push(['local', dollar(l), t])
503
1041
  // I: Skip trailing fallback when last statement is return (unreachable code)
504
1042
  const lastStmt = stmts.at(-1)
505
1043
  const endsWithReturn = lastStmt && (lastStmt[0] === 'return' || lastStmt[0] === 'return_call')
@@ -510,16 +1048,16 @@ function emitFunc(func, funcFacts, programFacts) {
510
1048
  const fallthrough = endsWithReturn ? []
511
1049
  : sig.results.length === 1 && sig.results[0] === 'f64' ? [undefExpr()]
512
1050
  : sig.results.map(t => [`${t}.const`, 0])
513
- fn.push(...paramInits, ...boxedParamInits, ...preboxedLocalInits, ...stmts, ...fallthrough)
1051
+ fn.push(...paramInits, ...boxedParamInits, ...preboxedLocalInits, ...numCoerceInits, ...stmts, ...fallthrough)
514
1052
  } else if (multi && body[0] === '[') {
515
1053
  const values = body.slice(1).map(e => asF64(emit(e)))
516
1054
  const paramInits = collectParamInits()
517
- for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
1055
+ for (const [l, t] of ctx.func.locals) fn.push(['local', dollar(l), t])
518
1056
  fn.push(...paramInits, ...boxedParamInits, ...preboxedLocalInits, ...values)
519
1057
  } else {
520
1058
  const ir = emit(body)
521
1059
  const paramInits = collectParamInits()
522
- for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
1060
+ for (const [l, t] of ctx.func.locals) fn.push(['local', dollar(l), t])
523
1061
  const finalIR = sig.ptrKind != null ? asPtrOffset(ir, sig.ptrKind) : asParamType(ir, sig.results[0])
524
1062
  fn.push(...paramInits, ...boxedParamInits, ...preboxedLocalInits, tcoTailRewrite(finalIR, sig.results[0]))
525
1063
  }
@@ -560,17 +1098,17 @@ function synthesizeBoundaryWrappers() {
560
1098
  for (const func of ctx.func.list) {
561
1099
  if (!isBoundaryWrapped(func)) continue
562
1100
  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.
1101
+ // Quiet NaN-box ABI: every boundary value is f64. A number is a plain f64; a
1102
+ // tagged value (heap pointer, null/undef/bool atom) is an f64 whose quiet-NaN
1103
+ // (0x7FF8…) payload carries the tag. Quiet-NaN payloads are preserved across the
1104
+ // JS↔wasm call boundary by every real engine (and non-JS hosts don't canonicalize
1105
+ // at all), so no i64 carrier is needed the wasm signature is self-describing
1106
+ // (f64 everywhere) and a consumer discriminates a tagged value by the NaN prefix.
1107
+ // Env requirement: a non-canonicalizing NaN boundary. To support a canonicalizing
1108
+ // engine, a per-position i64 carrier would re-enter here (param/result type i64 +
1109
+ // `i64.reinterpret_f64`) plus a `jz:i64exp` section for interop.js.
572
1110
  const resultBool = func.valResult === VAL.BOOL && sig.ptrKind == null
573
- const resultI64 = sig.ptrKind != null || resultBool
1111
+ const resultBigint = func.valResult === VAL.BIGINT && sig.ptrKind == null
574
1112
  // Inline `(export ...)` attribute only when the func decl carried the
575
1113
  // inline-export keyword (`export function foo`). For re-exports
576
1114
  // (`function foo; export { foo as bar }`) the `name` is the *internal*
@@ -580,19 +1118,17 @@ function synthesizeBoundaryWrappers() {
580
1118
  const wrapNode = func.exported
581
1119
  ? ['func', `$${name}$exp`, ['export', `"${name}"`]]
582
1120
  : ['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])
1121
+ // jsstring params flow as externref end-to-end; every other boundary value is f64.
1122
+ sig.params.forEach((p) => {
1123
+ wrapNode.push(['param', `$${p.name}`, p.jsstring ? 'externref' : 'f64'])
586
1124
  })
587
- wrapNode.push(['result', resultI64 ? 'i64' : 'f64'])
588
- const args = sig.params.map((p, i) => {
1125
+ wrapNode.push(['result', resultBigint ? 'i64' : 'f64'])
1126
+ const args = sig.params.map((p) => {
589
1127
  const get = ['local.get', `$${p.name}`]
590
1128
  // jsstring: externref flows through unchanged — inner func also takes externref.
591
1129
  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
- }
1130
+ // ptrKind param: the f64 NaN-box carries the pointer — extract the i32 offset.
1131
+ if (p.ptrKind != null) return ['i32.wrap_i64', ['i64.reinterpret_f64', get]]
596
1132
  if (p.type === 'f64') return get
597
1133
  // Numeric narrowing: f64 → i32 truncate
598
1134
  return ['i32.trunc_sat_f64_s', get]
@@ -603,17 +1139,26 @@ function synthesizeBoundaryWrappers() {
603
1139
  const ptrType = valKindToPtr(sig.ptrKind)
604
1140
  body = mkPtrIR(ptrType, sig.ptrAux ?? 0, callIR)
605
1141
  } 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
1142
+ // The inner func returns a clean 0/1 boolean carrier never NaN. The i32
1143
+ // carrier already takes truthyIR's identity path; the f64 carrier would
1144
+ // otherwise fall through to the full __is_truthy NaN-discrimination, every
1145
+ // arm of which is dead for a boolean. Pull the bit out with one f64.ne so
1146
+ // boolBoxIR boxes `4|bit` straight into the TRUE_NAN/FALSE_NAN atom.
1147
+ const carrier = sig.results[0] === 'i32'
1148
+ ? typed(callIR, 'i32')
1149
+ : typed(['f64.ne', callIR, ['f64.const', 0]], 'i32')
1150
+ body = boolBoxIR(carrier)
1151
+ } else if (resultBigint) {
1152
+ // BigInt rides the i64-reinterpret-f64 carrier internally; expose the raw i64 at the JS
1153
+ // boundary so the host receives a real, lossless BigInt (wasm i64 <-> JS BigInt). Internal
1154
+ // callers use `$name` (the f64 carrier) untouched; only the `$exp` export result is i64.
1155
+ body = ['i64.reinterpret_f64', callIR]
609
1156
  } else if (sig.results[0] === 'i32') {
610
1157
  body = [sig.unsignedResult ? 'f64.convert_i32_u' : 'f64.convert_i32_s', callIR]
611
1158
  } else {
612
1159
  body = callIR
613
1160
  }
614
- wrapNode.push(resultI64 ? ['i64.reinterpret_f64', body] : body)
615
- func._exportUsesI64 = resultI64 || paramI64.some(Boolean)
616
- func._exportI64Sig = { params: paramI64, result: resultI64 }
1161
+ wrapNode.push(body)
617
1162
  // Track externref param positions so interop.js can pass JS values
618
1163
  // raw (skipping `mem.wrapVal`) at those slots. Today this only fires
619
1164
  // for `jsstring`-tagged params; future externref carriers wire here too.
@@ -649,6 +1194,7 @@ function emitClosureBody(cb) {
649
1194
  ctx.func.localReps = null
650
1195
  if (cb.intConsts) for (const [name, v] of cb.intConsts) updateRep(name, { intConst: v })
651
1196
  if (cb.intCertain) for (const name of cb.intCertain) updateRep(name, { intCertain: true })
1197
+ if (cb.nullables) for (const name of cb.nullables) updateRep(name, { nullable: true })
652
1198
  if (cb.valTypes) for (const [name, vt] of cb.valTypes) updateRep(name, { val: vt })
653
1199
  if (cb.schemaVars) {
654
1200
  ctx.schema.vars = new Map([...prevSchemaVars, ...cb.schemaVars])
@@ -664,6 +1210,9 @@ function emitClosureBody(cb) {
664
1210
  }
665
1211
  // In closure bodies, boxed captures use the original name as both var and cell local
666
1212
  ctx.func.boxed = cb.boxed ? new Map([...cb.boxed].map(v => [v, v])) : new Map()
1213
+ // i32-narrowed cells: the owner decided the cell width (funcFacts.cellTypes);
1214
+ // every body sharing the cell must read/write it at that width.
1215
+ ctx.func.cellTypes = new Set(cb.cellI32 || [])
667
1216
  const parentBoxedCaptures = new Set(cb.boxed || [])
668
1217
  ctx.func.preboxed = new Set()
669
1218
  // Bare `;`-sequence bodies (no enclosing `{}`) reach us when callers built a
@@ -693,6 +1242,45 @@ function emitClosureBody(cb) {
693
1242
 
694
1243
  // Params are locals, assigned directly from inline slots
695
1244
  for (const p of cb.params) ctx.func.locals.set(p, 'f64')
1245
+ // Mark params that every direct call site passed a number (seeded by
1246
+ // tryDirectClosureCall) VAL.NUMBER — their body uses then skip the __to_num
1247
+ // coercion. All direct calls were emitted before this body (module end), so the
1248
+ // lattice is complete; a `false`/unobserved slot leaves the param boxed.
1249
+ const ptRow = ctx.closure.paramTypes?.get(cb.name)
1250
+ // A param numeric at every call site is typed NUMBER so its body uses skip __to_num. If some
1251
+ // call omits it (index ≥ minArgc) it can hold UNDEF_NAN, so also flag it nullable — that keeps
1252
+ // the boxing win yet stops `x === undefined` mis-folding to false (it bit-compares instead).
1253
+ const minArgc = ctx.closure.minArgc?.get(cb.name) ?? 0
1254
+ if (ptRow) for (let i = 0; i < cb.params.length; i++) {
1255
+ if (ptRow[i] === true && !ctx.func.localReps?.get(cb.params[i])?.val)
1256
+ updateRep(cb.params[i], i < minArgc ? { val: VAL.NUMBER } : { val: VAL.NUMBER, nullable: true })
1257
+ }
1258
+ // A param passed the same typed-array ctor at every direct call site is TYPED:
1259
+ // register its element ctor so `buf[i]` reads use the typed load (it stays an f64
1260
+ // NaN-box in the closure ABI, but knowing the kind avoids the dynamic `__typed_idx`
1261
+ // /`__len` dispatch that pulls the string runtime). Numeric trust (above) wins if it
1262
+ // already classified the slot — they're disjoint anyway (NUMBER vs TYPED arg).
1263
+ const tcRow = ctx.closure.paramTypedCtors?.get(cb.name)
1264
+ if (tcRow) for (let i = 0; i < cb.params.length; i++) {
1265
+ const ctor = tcRow[i]
1266
+ if (ctor && !ctx.func.localReps?.get(cb.params[i])?.val) {
1267
+ updateRep(cb.params[i], { val: VAL.TYPED })
1268
+ ;(ctx.types.typedElem ||= new Map()).set(cb.params[i], ctor)
1269
+ }
1270
+ }
1271
+ // Body-usage numeric trust for closure params — the same proof the export path
1272
+ // applies (paramAllUsesNumeric). A nested helper like heapsort's `heapify(n)` whose
1273
+ // param is used only in arithmetic/relational positions is VAL.NUMBER, so `(n>>1)-1`
1274
+ // skips the `__to_num` coercion that would otherwise drag the ToNumber string-parse
1275
+ // tree into a pure-integer kernel. paramAllUsesNumeric walks any AST node, so this
1276
+ // also covers expression-bodied arrows (`(m) => m | 0`) — the common closure shape
1277
+ // whose dynamic param would otherwise emit a polymorphic add/coerce that pulls the
1278
+ // whole string runtime in. Call-site evidence (ptRow) already covers the monomorphic
1279
+ // case; this also catches params the lattice left unobserved.
1280
+ for (const p of cb.params) {
1281
+ if (!ctx.func.localReps?.get(p)?.val && !cb.defaults?.[p] && paramAllUsesNumeric(cb.body, p))
1282
+ updateRep(p, { val: VAL.NUMBER })
1283
+ }
696
1284
 
697
1285
  // Register captured variable locals: boxed = i32 cell pointer, otherwise f64 value
698
1286
  for (let i = 0; i < cb.captures.length; i++) {
@@ -726,7 +1314,7 @@ function emitClosureBody(cb) {
726
1314
  ctx.func.locals.set(name, 'i32')
727
1315
  updateRep(name, fields)
728
1316
  }
729
- bodyIR = emitBody(cb.body)
1317
+ bodyIR = emitBlockBody(cb.body)
730
1318
  } else {
731
1319
  bodyIR = [asF64(emit(cb.body))]
732
1320
  }
@@ -734,13 +1322,15 @@ function emitClosureBody(cb) {
734
1322
  // Pre-allocate cache locals for env unpacking
735
1323
  const envBase = cb.captures.length > 0 ? `${T}envBase${ctx.func.uniq++}` : null
736
1324
  if (envBase) ctx.func.locals.set(envBase, 'i32')
737
- // Rest param: allocate helper locals (len + offset) before emitting decls
738
- let restOff, restLen
1325
+ // Rest param: allocate helper locals (len + offset + spill loop index) before emitting decls
1326
+ let restOff, restLen, restIdx
739
1327
  if (cb.rest) {
740
1328
  restOff = `${T}restOff${ctx.func.uniq++}`
741
1329
  restLen = `${T}restLen${ctx.func.uniq++}`
1330
+ restIdx = `${T}restIdx${ctx.func.uniq++}`
742
1331
  ctx.func.locals.set(restOff, 'i32')
743
1332
  ctx.func.locals.set(restLen, 'i32')
1333
+ ctx.func.locals.set(restIdx, 'i32')
744
1334
  inc('__alloc_hdr', '__mkptr')
745
1335
  }
746
1336
 
@@ -777,7 +1367,7 @@ function emitClosureBody(cb) {
777
1367
  }
778
1368
  }
779
1369
 
780
- for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
1370
+ for (const [l, t] of ctx.func.locals) fn.push(['local', dollar(l), t])
781
1371
 
782
1372
  // Load captures from env: boxed → i32.load (raw cell pointer), immutable → f64.load value.
783
1373
  // env is the CLOSURE pointer (PTR.CLOSURE) — never an ARRAY, no forwarding chain.
@@ -814,20 +1404,20 @@ function emitClosureBody(cb) {
814
1404
  }
815
1405
  }
816
1406
 
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).
1407
+ // Rest param: pack args a[fixedParams..argc-1] into a fresh array.
1408
+ // len = max(argc - fixedParams, 0). The first `restSlots = width - fixedParams`
1409
+ // come from the inline arg slots; any overflow (argc > width, only reachable via a
1410
+ // spread call) is read straight from the caller's full args array, whose offset the
1411
+ // spread path published in $__closure_spill. This gives unbounded variadic arity.
821
1412
  if (cb.rest) {
822
1413
  const fixedN = fixedParamN
823
1414
  const restSlots = W - fixedN
1415
+ declGlobal('__closure_spill', 'i32')
824
1416
  fn.push(['local.set', `$${restLen}`,
825
1417
  ['select',
826
1418
  ['i32.sub', ['local.get', '$__argc'], ['i32.const', fixedN]],
827
1419
  ['i32.const', 0],
828
1420
  ['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
1421
  fn.push(['local.set', `$${restOff}`,
832
1422
  ['call', '$__alloc_hdr',
833
1423
  ['local.get', `$${restLen}`], ['local.get', `$${restLen}`]]])
@@ -837,6 +1427,21 @@ function emitClosureBody(cb) {
837
1427
  ['i32.add', ['local.get', `$${restOff}`], ['i32.const', i * 8]],
838
1428
  ['local.get', `$__a${fixedN + i}`]]]])
839
1429
  }
1430
+ // Overflow beyond the inline slots: copy args[width..argc-1] from the spill array
1431
+ // (set by the spread-call site). rest[i] = spill[(fixedN+i)*8] for i in [restSlots, restLen).
1432
+ const rid = ctx.func.uniq++
1433
+ fn.push(['if', ['i32.gt_s', ['local.get', `$${restLen}`], ['i32.const', restSlots]],
1434
+ ['then',
1435
+ ['local.set', `$${restIdx}`, ['i32.const', restSlots]],
1436
+ ['block', `$restEnd${rid}`,
1437
+ ['loop', `$restLoop${rid}`,
1438
+ ['br_if', `$restEnd${rid}`, ['i32.ge_s', ['local.get', `$${restIdx}`], ['local.get', `$${restLen}`]]],
1439
+ ['f64.store',
1440
+ ['i32.add', ['local.get', `$${restOff}`], ['i32.mul', ['local.get', `$${restIdx}`], ['i32.const', 8]]],
1441
+ ['f64.load', ['i32.add', ['global.get', '$__closure_spill'],
1442
+ ['i32.mul', ['i32.add', ['local.get', `$${restIdx}`], ['i32.const', fixedN]], ['i32.const', 8]]]]],
1443
+ ['local.set', `$${restIdx}`, ['i32.add', ['local.get', `$${restIdx}`], ['i32.const', 1]]],
1444
+ ['br', `$restLoop${rid}`]]]]])
840
1445
  const restValue = ['call', '$__mkptr', ['i32.const', PTR.ARRAY], ['i32.const', 0], ['local.get', `$${restOff}`]]
841
1446
  if (boxedParamNames.has(cb.rest)) {
842
1447
  fn.push(
@@ -866,6 +1471,9 @@ function emitClosureBody(cb) {
866
1471
  * @returns {Array} Complete WASM module as S-expression
867
1472
  */
868
1473
  export default function compile(ast, profiler) {
1474
+ // Contract: callers (jzCompileInner / scripts/self.js compileSelf) must set
1475
+ // ctx.transform.optimize before reaching here — every optimize-gated pass below
1476
+ // reads `cfg && cfg.x === false`, so a null cfg silently runs every pass.
869
1477
  // Populate known function names + lookup map on ctx.func for direct call detection
870
1478
  ctx.func.names.clear()
871
1479
  ctx.func.map.clear()
@@ -891,7 +1499,7 @@ export default function compile(ast, profiler) {
891
1499
 
892
1500
  // Check user globals don't conflict with runtime globals (modules loaded after user decls)
893
1501
  for (const name of ctx.scope.userGlobals)
894
- if (!ctx.scope.globals.get(name)?.includes('mut f64'))
1502
+ if (!(ctx.scope.globals.get(name)?.mut && ctx.scope.globals.get(name)?.type === 'f64'))
895
1503
  err(`'${name}' conflicts with a compiler internal — choose a different name`)
896
1504
 
897
1505
  // Pre-fold const globals: evaluate constant initializers before function compilation
@@ -903,6 +1511,12 @@ export default function compile(ast, profiler) {
903
1511
  if (ast) {
904
1512
  const evalConst = n => {
905
1513
  if (typeof n === 'number') return n
1514
+ // A reference to an already-folded integer const (`const NEW = CALL + 1`):
1515
+ // resolve it from constInts so const-referencing-const initializers fold too.
1516
+ // Without this they stay unfolded → decl defaults to 0 AND emitDecl skips the
1517
+ // (const) runtime init → the binding reads 0 (e.g. subscript's NEW=CALL+1 → the
1518
+ // `new` keyword registers with precedence 0 and never dispatches).
1519
+ if (typeof n === 'string') return ctx.scope.constInts?.get(n) ?? null
906
1520
  if (Array.isArray(n) && n[0] == null && typeof n[1] === 'number') return n[1]
907
1521
  if (!Array.isArray(n)) return null
908
1522
  const [op, a, b] = n
@@ -922,27 +1536,40 @@ export default function compile(ast, profiler) {
922
1536
  : Array.isArray(n) && n[0] === 'const' ? [n] : []
923
1537
  const stmts = [...topStmts(ast)]
924
1538
  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)
1539
+ // Fixpoint: a const may reference one declared later or in another module
1540
+ // (`NEW = CALL + 1`). Each pass folds every now-resolvable initializer (its refs
1541
+ // already in constInts); repeat until none change so order/cross-module refs resolve.
1542
+ const foldedDecls = new Set()
1543
+ let changed = true
1544
+ while (changed) {
1545
+ changed = false
1546
+ for (const s of stmts) {
1547
+ if (!Array.isArray(s) || s[0] !== 'const') continue
1548
+ for (const decl of s.slice(1)) {
1549
+ if (!Array.isArray(decl) || decl[0] !== '=' || typeof decl[1] !== 'string') continue
1550
+ const [, name, init] = decl
1551
+ if (foldedDecls.has(name)) continue
1552
+ if (!ctx.scope.globals.has(name) || !ctx.scope.consts?.has(name)) continue
1553
+ const v = evalConst(init)
1554
+ if (v == null || !isFinite(v)) continue
1555
+ foldedDecls.add(name)
1556
+ changed = true
1557
+ const isInt = Number.isInteger(v) && v >= I32_MIN && v <= I32_MAX
1558
+ declGlobal(name, isInt ? 'i32' : 'f64', v, { mut: false })
1559
+ // Cache integer values for cross-call const-arg propagation: `f(N)` where
1560
+ // `const N = 8` should observe the param as intConst=8.
1561
+ if (isInt) (ctx.scope.constInts ||= new Map()).set(name, v)
1562
+ }
941
1563
  }
942
1564
  }
943
1565
  }
944
1566
 
945
- const programFacts = timePhase(profiler, 'plan', () => plan(ast))
1567
+ // Whole-program constant fold of module-scope aggregate literals — `var x=[1,2,3];
1568
+ // y=x[0]` → `y=1`, dropping the array (no data segment, no __arr_idx_known) when
1569
+ // every reference is a static read. The scalar analog of the constInts fold above.
1570
+ timePhase(profiler, 'foldAggregates', () => foldStaticConstAggregates(ast))
1571
+
1572
+ const programFacts = timePhase(profiler, 'plan', () => plan(ast, profiler))
946
1573
 
947
1574
  // Inspect sink: editor hosts opt in via { inspect: true } to read inferred shapes.
948
1575
  // Initialized here (post-plan) so paramReps and schema.list are stable, populated
@@ -950,28 +1577,30 @@ export default function compile(ast, profiler) {
950
1577
  if (ctx.transform.inspect) ctx.inspect = { functions: {}, schemas: ctx.schema.list.map(s => s.slice()) }
951
1578
 
952
1579
  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
- }
1580
+ timePhase(profiler, 'analyzeFuncs', () => {
1581
+ for (const func of ctx.func.list) {
1582
+ if (func.raw) continue
1583
+ const facts = analyzeFuncForEmit(func, programFacts)
1584
+ funcFacts.set(func, facts)
1585
+ captureFuncInspect(func, facts, programFacts)
1586
+ }
1587
+ })
959
1588
  // Whole-program SRoA: pick the schemas whose `Array<S>` instances use the
960
1589
  // `structInline` carrier. Runs once the per-function reps have settled (they
961
1590
  // 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))
1591
+ timePhase(profiler, 'structInline', () => analyzeStructInline(funcFacts, programFacts))
1592
+ const funcs = timePhase(profiler, 'emitFuncs', () => ctx.func.list.map(func => emitFunc(func, funcFacts.get(func), programFacts)))
964
1593
  funcs.push(...synthesizeBoundaryWrappers())
965
1594
 
966
1595
  const closureFuncs = []
967
1596
  let compiledBodyCount = 0
968
- const compilePendingClosures = () => {
1597
+ const compilePendingClosures = () => timePhase(profiler, 'emitClosures', () => {
969
1598
  const bodies = ctx.closure.bodies || []
970
1599
  for (let bodyIndex = compiledBodyCount; bodyIndex < bodies.length; bodyIndex++) {
971
1600
  closureFuncs.push(emitClosureBody(bodies[bodyIndex]))
972
1601
  }
973
1602
  compiledBodyCount = bodies.length
974
- }
1603
+ })
975
1604
  compilePendingClosures()
976
1605
 
977
1606
  // `wasm:js-string` imports — drained from `ctx.core.jsstring`, one
@@ -1055,7 +1684,15 @@ export default function compile(ast, profiler) {
1055
1684
  if (ctx.closure.table?.length)
1056
1685
  sec.elem.push(['elem', ['i32.const', 0], 'func', ...ctx.closure.table.map(n => `$${n}`)])
1057
1686
 
1058
- buildStartFn(ast, sec, closureFuncs, compilePendingClosures)
1687
+ timePhase(profiler, 'buildStart', () => buildStartFn(ast, sec, closureFuncs, compilePendingClosures))
1688
+
1689
+ // Host globals (globalThis/process/WebAssembly/…) referenced as values are
1690
+ // recorded in ctx.core.hostGlobals during emit; register them as env imports
1691
+ // now (assembly owns ctx.module.imports). Drained after buildStartFn so a
1692
+ // host global first used in a top-level statement (emitted into __start) is
1693
+ // captured; syncImports below merges them into sec.imports.
1694
+ for (const name of ctx.core.hostGlobals)
1695
+ ctx.module.imports.push(['import', '"env"', `"${name}"`, ['global', `$${name}`, 'i64']])
1059
1696
 
1060
1697
  syncImports(sec)
1061
1698
 
@@ -1063,16 +1700,38 @@ export default function compile(ast, profiler) {
1063
1700
 
1064
1701
  finalizeClosureTable(sec)
1065
1702
 
1066
- pullStdlib(sec)
1703
+ buildInternTable()
1704
+
1705
+ timePhase(profiler, 'pullStdlib', () => pullStdlib(sec))
1067
1706
 
1068
1707
  stripStaticDataPrefix(sec)
1069
1708
 
1070
1709
  ensureThrowRuntime(sec)
1071
1710
 
1072
- optimizeModule(sec)
1073
-
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)))
1711
+ timePhase(profiler, 'optimizeModule', () => optimizeModule(sec, profiler))
1712
+
1713
+ // Fold constant `__start` global inits into immutable inline decls (drops the
1714
+ // store, and `__start` with it when that empties it). Runs HERE — after
1715
+ // stripStaticDataPrefix and optimizeModule — so any data-segment offset a hoisted
1716
+ // pointer carries is already in its final, shifted form (hoisting earlier would
1717
+ // freeze a pre-strip offset the shift pass never revisits in the global decl).
1718
+ hoistConstGlobalInits(sec)
1719
+
1720
+ // Populate globals (after __start — const folding may update declarations).
1721
+ // Records build IR directly — no WAT-text parse-back.
1722
+ // The wasm type comes from globalTypes (the canonical name→type map declGlobal
1723
+ // maintains alongside the entry), falling back to the entry's own `.type`. They
1724
+ // are normally identical, but a global whose entry object is later rebuilt (e.g.
1725
+ // hoistConstGlobalInits' `{...g, …}` spread) must not depend on that rebuild
1726
+ // preserving `.type` — globalTypes is the stable source, so an entry that lost
1727
+ // its `.type` still emits a well-typed `(global …)` rather than `(undefined.const)`.
1728
+ sec.globals.push(...[...ctx.scope.globals].filter(([, g]) => g).map(([n, g]) => {
1729
+ const ty = ctx.scope.globalTypes.get(n) ?? g.type
1730
+ return ['global', `$${n}`,
1731
+ ...(g.export ? [['export', `"${g.export}"`]] : []),
1732
+ g.mut ? ['mut', ty] : ty,
1733
+ [`${ty}.const`, g.init]]
1734
+ }))
1076
1735
 
1077
1736
  // Data segments (after emit — string literals append to ctx.runtime.data / strPool during emit)
1078
1737
  // Active segment at address 0 — skipped for shared memory (would collide across modules)
@@ -1124,26 +1783,6 @@ export default function compile(ast, profiler) {
1124
1783
  if (restParamFuncs.length)
1125
1784
  sec.customs.push(['@custom', '"jz:rest"', `"${JSON.stringify(restParamFuncs).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`])
1126
1785
 
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
1786
  // Custom section: per-export externref param positions. interop.js reads
1148
1787
  // this to pass JS arguments straight through at those positions (no
1149
1788
  // `mem.wrapVal`, no SSO encoding). Format: { name, p, d? } where p lists
@@ -1158,12 +1797,22 @@ export default function compile(ast, profiler) {
1158
1797
  f._exportExtParams.forEach((b, i) => {
1159
1798
  if (!b) return
1160
1799
  p.push(i)
1161
- if (typeof b === 'object' && b.def != null) d[i] = b.def
1800
+ // String-key the index: object property keys are conceptually strings (JSON renders
1801
+ // `{"0":…}` either way), and the self-host kernel's objects don't enumerate a numeric
1802
+ // key — `d[0]=…` stores but Object.keys(d) misses it, so the `d` map would read empty
1803
+ // and the default never reach the jz:extparam section. Same coercion as the optimize
1804
+ // LEVEL_PRESETS lookup. (Native is unaffected: numeric keys auto-stringify.)
1805
+ if (typeof b === 'object' && b.def != null) d[String(i)] = b.def
1162
1806
  })
1163
1807
  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 })
1808
+ // Build each export entry as a direct literal — no `entry.d = d` after the fact and no
1809
+ // `{...entry, name}` spread. The self-host kernel's fixed-schema objects don't enumerate
1810
+ // a key added to a non-empty literal (JSON.stringify/spread would silently drop a post-hoc
1811
+ // `d`), and spreading a 3-key literal mis-resolves the merged schema there. Constructing
1812
+ // the final shape directly sidesteps both.
1813
+ const hasDefaults = Object.keys(d).length > 0
1814
+ for (const exportName of exportNamesOf(f.name))
1815
+ extExports.push(hasDefaults ? { name: exportName, p, d } : { name: exportName, p })
1167
1816
  }
1168
1817
  if (extExports.length)
1169
1818
  sec.customs.push(['@custom', '"jz:extparam"', `"${JSON.stringify(extExports).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`])
@@ -1192,7 +1841,8 @@ export default function compile(ast, profiler) {
1192
1841
  const { callCount } = treeshake(
1193
1842
  [{ arr: sec.stdlib }, { arr: sec.funcs }, { arr: sec.start }],
1194
1843
  [...sec.start, ...sec.elem, ...sec.customs, ...sec.extStdlib, ...sec.imports, ...sec.tags],
1195
- { removeDead: !optCfg || optCfg.treeshake !== false, globals: sec.globals }
1844
+ { removeDead: !optCfg || optCfg.treeshake !== false, globals: sec.globals, userGlobals: ctx.scope.userGlobals,
1845
+ userFuncs: new Set(ctx.func.list.map(f => `$${f.name}`)) }
1196
1846
  )
1197
1847
 
1198
1848
  pruneUnusedThrowRuntime(sec)