jz 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/compile.js CHANGED
@@ -28,12 +28,15 @@
28
28
  import { parse as parseWat } from 'watr'
29
29
  import { ctx, err, inc, resolveIncludes, PTR, LAYOUT } from './ctx.js'
30
30
  import {
31
- T, VAL, analyzeValTypes, analyzeIntCertain, analyzeLocals,
32
- analyzePtrUnboxable, typedElemAux, invalidateLocalsCache,
33
- analyzeBoxedCaptures, updateRep, inferStringParams,
31
+ T, VAL, analyzeBody,
32
+ unboxablePtrs, cseSafeLoadBases, typedElemAux, invalidateLocalsCache,
33
+ boxedCaptures, updateRep,
34
+ isBlockBody, analyzeStructInline,
34
35
  } from './analyze.js'
36
+ import { inferLocals } from './infer.js'
35
37
  import { optimizeFunc, treeshake } from './optimize.js'
36
38
  import { emit, emitter, emitFlat, emitBody } from './emit.js'
39
+ import { emitCharDecompPrologue, JSS_IMPORT_SIGS } from './abi/string.js'
37
40
  import {
38
41
  typed, asF64, asI32, asPtrOffset, asParamType, toI32, asI64, fromI64,
39
42
  NULL_NAN, UNDEF_NAN, NULL_WAT, UNDEF_WAT, NULL_IR, UNDEF_IR, nullExpr, undefExpr,
@@ -44,7 +47,9 @@ import {
44
47
  isGlobal, isConst, boxedAddr, readVar, writeVar, isNullish, isUndef,
45
48
  slotAddr, elemLoad, elemStore, arrayLoop, allocPtr,
46
49
  multiCount, loopTop, flat, reconstructArgsWithSpreads,
47
- valKindToPtr, findBodyStart,
50
+ valKindToPtr, findBodyStart, tcoTailRewrite,
51
+ boolBoxIR,
52
+ I32_MIN, I32_MAX,
48
53
  } from './ir.js'
49
54
  import plan from './plan.js'
50
55
  import {
@@ -52,6 +57,53 @@ import {
52
57
  pullStdlib, syncImports, optimizeModule, stripStaticDataPrefix,
53
58
  } from './assemble.js'
54
59
 
60
+ // =============================================================================
61
+ // Single-source export semantics
62
+ // =============================================================================
63
+ // Two distinct concepts that callers used to conflate:
64
+ //
65
+ // 1. `f.exported` — *syntactic* inline-export form, snapshot at `defFunc`
66
+ // time (prepare.js). True iff the func decl carried the inline `export`
67
+ // keyword AND `ctx.func.exports[name]` was already populated by parent
68
+ // decl processing. Only the inline-emit gate below (`(func (export "name") ...)`)
69
+ // should read it — that emit path requires the inline-syntax invariant
70
+ // to avoid duplicate-export collisions with sec.customs.
71
+ //
72
+ // 2. `isExported(f)` — *semantic* "is this func reachable from JS via any
73
+ // export?". Covers the four forms equally:
74
+ // • inline: `export function foo` → exports[foo]=true
75
+ // • non-aliased: `function foo; export { foo }` → exports[foo]='foo'
76
+ // • aliased: `function foo; export { foo as bar }` → exports[bar]='foo'
77
+ // • default-by-name: `function foo; export default foo` → exports['default']='foo'
78
+ // Every public-ABI gate (boundary wrap, rest-param packing, i64 ABI,
79
+ // cross-call signature narrowing) should consult this.
80
+
81
+ /** Semantic export predicate. Use everywhere the question is "should this
82
+ * func behave as part of the public ABI?" — boundary-wrap, rest-pack,
83
+ * i64-ABI, sig-narrowing gates.
84
+ *
85
+ * `f.exported` short-circuits the inline-export case (no map walk needed);
86
+ * the value-scan picks up `export { f }` / `export { f as g }` / `export
87
+ * default f` where the source name appears as a *value* keyed under the
88
+ * public name. */
89
+ const isExported = f => {
90
+ if (f.exported) return true
91
+ for (const val of Object.values(ctx.func.exports)) {
92
+ if (val === f.name) return true
93
+ }
94
+ return false
95
+ }
96
+
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) {
101
+ 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
104
+ }
105
+ }
106
+
55
107
  const timePhase = (profiler, name, fn) => profiler ? profiler.time(name, fn) : fn()
56
108
 
57
109
  // Per-compile func name set + map live on ctx.func.names / ctx.func.map,
@@ -62,7 +114,7 @@ const timePhase = (profiler, name, fn) => profiler ? profiler.time(name, fn) : f
62
114
  // buildArrayWithSpreads) moved to src/emit.js.
63
115
 
64
116
  // AST-analysis primitives (staticObjectProps, paramReps lattice helpers,
65
- // inferArg* cross-call inference, collectProgramFacts) moved to src/analyze.js.
117
+ // infer* cross-call inference, collectProgramFacts) moved to src/analyze.js.
66
118
 
67
119
  /**
68
120
  * Boundary-wrap predicate: exports whose body-driven result OR any param narrowed
@@ -76,79 +128,136 @@ const timePhase = (profiler, name, fn) => profiler ? profiler.time(name, fn) : f
76
128
  * fractional Number gets the same truncation it would get from `arr[n]`).
77
129
  */
78
130
  const isBoundaryWrapped = (func) => {
79
- if (!func.exported || func.raw || func.sig.results.length !== 1) return false
131
+ if (!isExported(func) || func.raw || func.sig.results.length !== 1) return false
80
132
  if (func.sig.results[0] !== 'f64' || func.sig.ptrKind != null) return true
133
+ // A boolean result rides the 0/1 number carrier internally; the export thunk
134
+ // boxes it to the TRUE_NAN/FALSE_NAN atom so the host sees a real boolean.
135
+ if (func.valResult === VAL.BOOL) return true
81
136
  return func.sig.params.some(p => p.type !== 'f64' || p.ptrKind != null)
82
137
  }
83
138
 
84
- /**
85
- * Tail-call rewrite: walks tail positions of an emitted IR tree and replaces
86
- * direct `(call $name args...)` ops with `(return_call $name args...)`.
87
- *
88
- * Tail positions, recursively from the IR root:
89
- * - the root itself (function's terminal value-producing expression)
90
- * - both arms of `(if (result T) cond (then ...) (else ...))`
91
- * - last instruction of `(block (result T) ...)`
92
- *
93
- * Only fires when caller and callee result types match — if they didn't match,
94
- * `asParamType`/`asPtrOffset` would have wrapped the call in a conversion op,
95
- * pushing the `call` away from the tail position. We don't recurse into
96
- * arithmetic / select / loop ops: their results aren't standalone-tail control
97
- * transfers.
98
- *
99
- * Mirrors the existing `'return'` op handler in emit.js (which already does
100
- * TCO when the return statement is explicit). This pass closes the gap for
101
- * expression-bodied arrows like `(n, acc) => n <= 0 ? acc : sum(n-1, acc+n)`
102
- * the AST has no `return` keyword so the emit-time handler never fires.
103
- */
104
- const tcoTailRewrite = (ir, resultType) => {
105
- if (ctx.transform.noTailCall || ctx.func.inTry) return ir
106
- if (!Array.isArray(ir)) return ir
107
- const op = ir[0]
108
- if (op === 'call' && typeof ir[1] === 'string') {
109
- // IR call name is `$name`; func.map keys are bare `name`.
110
- const calleeName = ir[1].startsWith('$') ? ir[1].slice(1) : ir[1]
111
- const callee = ctx.func.map.get(calleeName)
112
- if (!callee || callee.raw) return ir
113
- const calleeRT = callee.sig?.results?.[0] ?? 'f64'
114
- if (calleeRT !== resultType) return ir
115
- return typed(['return_call', ...ir.slice(1)], resultType)
116
- }
117
- if (op === 'if' && Array.isArray(ir[1]) && ir[1][0] === 'result') {
118
- let changed = false
119
- const newIr = ir.slice()
120
- for (let i = 3; i < newIr.length; i++) {
121
- const arm = newIr[i]
122
- if (Array.isArray(arm) && (arm[0] === 'then' || arm[0] === 'else') && arm.length > 1) {
123
- const last = arm[arm.length - 1]
124
- const rewritten = tcoTailRewrite(last, resultType)
125
- if (rewritten !== last) {
126
- newIr[i] = [...arm.slice(0, -1), rewritten]
127
- changed = true
128
- }
129
- }
130
- }
131
- return changed ? typed(newIr, ir.type) : ir
132
- }
133
- if (op === 'block' && ir.length > 1) {
134
- const last = ir[ir.length - 1]
135
- const rewritten = tcoTailRewrite(last, resultType)
136
- if (rewritten !== last) return typed([...ir.slice(0, -1), rewritten], ir.type)
137
- }
138
- return ir
139
+ const ensureThrowRuntime = (sec) => {
140
+ // A pulled stdlib helper may throw $__jz_err even when no user `throw` set the
141
+ // flag (e.g. __to_num on a Symbol). Detect it from the included stdlib bodies
142
+ // so the $__jz_err tag is always present when something can raise it.
143
+ if (!ctx.runtime.throws && [...ctx.core.includes].some(n => {
144
+ const body = ctx.core.stdlib[n]
145
+ return typeof body === 'string' && body.includes('(throw ')
146
+ })) ctx.runtime.throws = true
147
+ if (!ctx.runtime.throws) return
148
+
149
+ 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))')
151
+ if (!sec.tags.some(t => Array.isArray(t) && t[0] === 'tag' && t[1] === '$__jz_err'))
152
+ sec.tags.push(['tag', '$__jz_err', ['param', 'f64']])
153
+ if (!sec.tags.some(t => Array.isArray(t) && t[0] === 'export' && t[1] === '"__jz_last_err_bits"'))
154
+ sec.tags.push(['export', '"__jz_last_err_bits"', ['global', '$__jz_last_err_bits']])
155
+ }
156
+
157
+ // Drop the $__jz_err tag + __jz_last_err_bits globals when optimization
158
+ // eliminated every actual throw site. ensureThrowRuntime runs before
159
+ // optimizeModule so dead-throw analysis can see the tag/global as live; once
160
+ // opt has finished, an unused tag still forces consumers (wasmtime, wasm2c) to
161
+ // enable the exceptions proposal just to parse the module. User-written
162
+ // throw/try/catch/finally is an ABI contract (JS-side may inspect
163
+ // __jz_last_err_bits), so `userThrows` keeps the runtime declared regardless;
164
+ // the prune fires only when `throws` was set purely by stdlib pattern matching
165
+ // or compiler-internal coercion sites.
166
+ const pruneUnusedThrowRuntime = (sec) => {
167
+ if (!ctx.runtime.throws || ctx.runtime.userThrows) return
168
+ const hasThrow = (n) => Array.isArray(n) && (n[0] === 'throw' || n.some(hasThrow))
169
+ for (const arr of [sec.funcs, sec.stdlib, sec.start])
170
+ for (const f of arr) if (hasThrow(f)) return
171
+ sec.tags = sec.tags.filter(t => !(Array.isArray(t) &&
172
+ ((t[0] === 'tag' && t[1] === '$__jz_err') ||
173
+ (t[0] === 'export' && t[1] === '"__jz_last_err_bits"'))))
174
+ sec.globals = sec.globals.filter(g => !(Array.isArray(g) &&
175
+ g[0] === 'global' && g[1] === '$__jz_last_err_bits'))
176
+ ctx.scope.globals.delete('__jz_last_err_bits')
139
177
  }
140
178
 
141
179
  // === Module compilation ===
142
180
 
143
181
  const cloneRepMap = map => map ? new Map([...map].map(([k, v]) => [k, { ...v }])) : null
144
182
 
145
- function enterFunc(func) {
183
+ /** Serialize a ValueRep entry into a plain object for inspect output.
184
+ * Omits undefined fields so consumers can JSON-stringify without noise. */
185
+ const repView = (rep) => {
186
+ if (!rep) return null
187
+ 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
+ }
191
+ return Object.keys(out).length ? out : null
192
+ }
193
+
194
+ /** Capture a function's inferred shape into ctx.inspect.functions. Called after
195
+ * analyzeFuncForEmit when transform.inspect is set — reads from funcFacts +
196
+ * programFacts.paramReps, never from the live ctx.func.* (which churns per emit). */
197
+ function captureFuncInspect(func, facts, programFacts) {
198
+ if (!ctx.inspect || func.raw) return
199
+ const { name, sig } = func
200
+ const reps = facts?.localReps
201
+ const paramNames = new Set(sig.params.map(p => p.name))
202
+ const params = sig.params.map(p => ({
203
+ name: p.name, type: p.type,
204
+ ...(p.ptrKind != null ? { ptrKind: p.ptrKind } : {}),
205
+ ...(p.ptrAux != null ? { ptrAux: p.ptrAux } : {}),
206
+ ...(repView(reps?.get(p.name)) || {}),
207
+ }))
208
+ const locals = {}
209
+ if (facts?.locals) {
210
+ for (const [lname, ltype] of facts.locals) {
211
+ if (paramNames.has(lname)) continue
212
+ const v = repView(reps?.get(lname))
213
+ locals[lname] = v ? { type: ltype, ...v } : { type: ltype }
214
+ }
215
+ }
216
+ const callerReps = {}
217
+ const cr = programFacts.paramReps?.get(name)
218
+ if (cr) for (const [idx, r] of cr) {
219
+ const v = repView(r)
220
+ if (v) callerReps[idx] = v
221
+ }
222
+ ctx.inspect.functions[name] = {
223
+ exported: isExported(func),
224
+ params,
225
+ results: sig.results.slice(),
226
+ ...(sig.ptrKind != null ? { resultPtrKind: sig.ptrKind } : {}),
227
+ ...(sig.ptrAux != null ? { resultPtrAux: sig.ptrAux } : {}),
228
+ locals,
229
+ ...(Object.keys(callerReps).length ? { callerReps } : {}),
230
+ }
231
+ }
232
+
233
+ // Reset per-function emit-frame state — the single source of frame entry.
234
+ // `emitFunc`, `analyzeFuncForEmit`, and `emitClosureBody` all route through
235
+ // here. Top-level funcs start `uniq` at 0; closures pass a higher base so
236
+ // their synthetic labels can't collide with the parent frame's.
237
+ function enterFunc(sig, body, { uniq = 0, directClosures = null } = {}) {
146
238
  ctx.func.stack = []
147
- ctx.func.uniq = 0
148
- ctx.func.current = func.sig
149
- ctx.func.body = func.body
150
- ctx.func.directClosures = null
239
+ ctx.func.uniq = uniq
240
+ ctx.func.current = sig
241
+ ctx.func.body = body
242
+ ctx.func.directClosures = directClosures
151
243
  ctx.func.localProps = null
244
+ ctx.func.charDecomp = null
245
+ }
246
+
247
+ // Allocate + null-init a heap cell for every boxed local that isn't seeded
248
+ // from an incoming param/capture value. Registers the cell as an i32 local
249
+ // and marks the name preboxed; `isSeeded(name)` skips the already-seeded.
250
+ function emitPreboxedLocalInits(isSeeded) {
251
+ const inits = []
252
+ for (const [name, cell] of ctx.func.boxed) {
253
+ if (isSeeded(name)) continue
254
+ ctx.func.locals.set(cell, 'i32')
255
+ ctx.func.preboxed.add(name)
256
+ inits.push(
257
+ ['local.set', `$${cell}`, ['call', '$__alloc', ['i32.const', 8]]],
258
+ ['f64.store', ['local.get', `$${cell}`], nullExpr()])
259
+ }
260
+ return inits
152
261
  }
153
262
 
154
263
  function analyzeFuncForEmit(func, programFacts) {
@@ -156,11 +265,11 @@ function analyzeFuncForEmit(func, programFacts) {
156
265
  if (func.raw) return null
157
266
 
158
267
  const { name, body, sig } = func
159
- enterFunc(func)
268
+ enterFunc(sig, body)
160
269
 
161
- const block = Array.isArray(body) && body[0] === '{}' && body[1]?.[0] !== ':'
270
+ const block = isBlockBody(body)
162
271
  ctx.func.boxed = new Map()
163
- ctx.func.repByLocal = null
272
+ ctx.func.localReps = null
164
273
  ctx.types.typedElem = ctx.scope.globalTypedElem ? new Map(ctx.scope.globalTypedElem) : null
165
274
 
166
275
  const _reps = paramReps.get(name)
@@ -173,41 +282,52 @@ function analyzeFuncForEmit(func, programFacts) {
173
282
  if (!ctx.types.typedElem.has(pname)) ctx.types.typedElem.set(pname, r.typedCtor)
174
283
  updateRep(pname, { val: VAL.TYPED })
175
284
  }
176
- if (r.val && !ctx.func.repByLocal?.get(pname)?.val) updateRep(pname, { val: r.val })
285
+ if (r.val && !ctx.func.localReps?.get(pname)?.val) updateRep(pname, { val: r.val })
177
286
  if (r.arrayElemSchema != null) updateRep(pname, { arrayElemSchema: r.arrayElemSchema })
178
287
  if (r.arrayElemValType != null) updateRep(pname, { arrayElemValType: r.arrayElemValType })
179
288
  if (r.intConst != null) updateRep(pname, { intConst: r.intConst })
180
289
  }
181
290
  }
182
- // Drop any earlier-cached analyzeLocals for this body — narrowSignatures called
183
- // it before our pre-seed, when params still had no inferred VAL.TYPED, so the
184
- // cached widths reflect the pre-narrow state. Re-walk now with reps in place.
291
+ // Drop any earlier-cached analyzeBody.locals slice for this body —
292
+ // narrowSignatures called it before our pre-seed, when params still had no
293
+ // inferred VAL.TYPED, so the cached widths reflect the pre-narrow state.
294
+ // Re-walk now with reps in place.
185
295
  invalidateLocalsCache(body)
186
- ctx.func.locals = block ? analyzeLocals(body) : new Map()
187
- // Usage-based VAL.STRING inference for params not already typed by paramReps.
188
- // Descends into nested closures so a param used as STRING only inside an inner
189
- // arrow (e.g. parseLevel's `str` capture in watr) still gets seeded — the
190
- // closure capture path then propagates VAL.STRING via captureValTypes.
191
- if (block) {
192
- const candidates = sig.params
193
- .filter(p => !ctx.func.repByLocal?.get(p.name)?.val)
194
- .map(p => p.name)
195
- if (candidates.length) {
196
- const inferred = inferStringParams(body, candidates)
197
- for (const [n, vt] of inferred) updateRep(n, { val: vt })
198
- }
199
- }
296
+ const bodyFacts = block ? analyzeBody(body) : null
297
+ ctx.func.locals = bodyFacts ? bodyFacts.locals : new Map()
298
+ // Proven uint32 accumulator locals readVar tags reads `.unsigned` so the
299
+ // f64 round-trip widens with convert_i32_u (not _s).
300
+ if (bodyFacts?.unsignedLocals) for (const n of bodyFacts.unsignedLocals) updateRep(n, { unsigned: true })
301
+ // SRoA flat-object bindings — `let o = {...}` dissolved into `o#i` field
302
+ // locals. Consumed by the codegen flat hooks (emitDecl, `.`/`[]` read+write).
303
+ ctx.func.flatObjects = bodyFacts ? bodyFacts.flatObjects : new Map()
304
+ // No-copy slice views — `let t = s.slice(...)` bindings proven non-escaping.
305
+ // Consumed by emitDecl to lower the initializer to a SLICE_BIT view.
306
+ ctx.func.sliceViews = bodyFacts ? bodyFacts.sliceViews : new Set()
307
+ // Usage-based shape inference (STRING / ARRAY) for params not already typed
308
+ // by paramReps. Descends into nested closures so a param used in a definite
309
+ // shape only inside an inner arrow (e.g. parseLevel's `str` capture in watr)
310
+ // still gets seeded — the closure capture path then propagates the VAL via
311
+ // captureValTypes.
312
+ //
313
+ // `inferLocals` is body-shape-agnostic — it walks any AST node, so we run it
314
+ // for expression-bodied arrows too (`(s) => s.charCodeAt(0) + s.length` gets
315
+ // `s: VAL.STRING` via methodEvidence the same way the block-bodied variant
316
+ // does). Only `boxedCaptures` / `unboxablePtrs` stay gated:
317
+ // both need `ctx.func.locals` populated, which only block bodies produce.
318
+ const candidates = sig.params
319
+ .filter(p => !ctx.func.localReps?.get(p.name)?.val)
320
+ .map(p => p.name)
321
+ inferLocals(body, candidates)
200
322
  if (block) {
201
- analyzeValTypes(body)
202
- analyzeIntCertain(body)
203
- analyzeBoxedCaptures(body)
323
+ boxedCaptures(body)
204
324
  // Lower provably-monomorphic pointer locals to i32 offset storage.
205
325
  // VAL.TYPED unbox requires a known element ctor (aux byte) — without it,
206
326
  // the use site can't pick the right i32.store{8,16}/i32.store width and
207
327
  // the rebox path can't reconstruct the NaN-box. Heterogeneous decls (two
208
328
  // `let arr = ...` with different ctors, or a multi-ctor ternary) leave
209
329
  // typedElem unset; skip unbox so reads/writes go through `__typed_set_idx`.
210
- const unbox = analyzePtrUnboxable(body, ctx.func.locals, ctx.func.boxed)
330
+ const unbox = unboxablePtrs(body, ctx.func.locals, ctx.func.boxed)
211
331
  if (unbox.size > 0) {
212
332
  for (const [n, kind] of unbox) {
213
333
  const fields = { ptrKind: kind }
@@ -222,7 +342,7 @@ function analyzeFuncForEmit(func, programFacts) {
222
342
  }
223
343
  }
224
344
  // Pointer-ABI params (from narrowing loop above): params already have type='i32' and
225
- // ptrKind set. Register them in ctx.func.repByLocal so readVar tags local.gets correctly.
345
+ // ptrKind set. Register them in ctx.func.localReps so readVar tags local.gets correctly.
226
346
  // Boxed capture still works: the boxed-init path (below) uses a ptrKind-tagged local.get
227
347
  // so asF64 reboxes to NaN-form before f64.store to the cell.
228
348
  for (const p of sig.params) {
@@ -232,12 +352,21 @@ function analyzeFuncForEmit(func, programFacts) {
232
352
  updateRep(p.name, fields)
233
353
  }
234
354
 
355
+ // CSE-safe load bases — pointer locals whose memory reads `cseScalarLoad`
356
+ // may scalar-replace. Computed last: needs every `let`/param ptrKind in place.
357
+ const cseLoadBases = block
358
+ ? cseSafeLoadBases(body, ctx.func.locals, ctx.func.localReps)
359
+ : new Set()
360
+
235
361
  return {
236
362
  block,
237
363
  locals: new Map(ctx.func.locals),
238
364
  boxed: new Map(ctx.func.boxed),
365
+ flatObjects: new Map(ctx.func.flatObjects),
366
+ sliceViews: new Set(ctx.func.sliceViews),
367
+ cseLoadBases,
239
368
  typedElem: ctx.types.typedElem ? new Map(ctx.types.typedElem) : null,
240
- repByLocal: cloneRepMap(ctx.func.repByLocal),
369
+ localReps: cloneRepMap(ctx.func.localReps),
241
370
  }
242
371
  }
243
372
 
@@ -257,11 +386,13 @@ function emitFunc(func, funcFacts, programFacts) {
257
386
  const multi = sig.results.length > 1
258
387
  const _reps = paramReps.get(name)
259
388
 
260
- enterFunc(func)
389
+ enterFunc(sig, body)
261
390
  const block = funcFacts.block
262
391
  ctx.func.locals = new Map(funcFacts.locals)
263
392
  ctx.func.boxed = new Map(funcFacts.boxed)
264
- ctx.func.repByLocal = cloneRepMap(funcFacts.repByLocal)
393
+ ctx.func.flatObjects = new Map(funcFacts.flatObjects)
394
+ ctx.func.sliceViews = new Set(funcFacts.sliceViews)
395
+ ctx.func.localReps = cloneRepMap(funcFacts.localReps)
265
396
  ctx.types.typedElem = funcFacts.typedElem ? new Map(funcFacts.typedElem) : null
266
397
 
267
398
  // D: Apply call-site param facts (only if body analysis didn't already set them).
@@ -272,11 +403,11 @@ function emitFunc(func, funcFacts, programFacts) {
272
403
  for (const [k, r] of _reps) {
273
404
  if (k >= sig.params.length) continue
274
405
  const pname = sig.params[k].name
275
- if (r.val && !ctx.func.repByLocal?.get(pname)?.val) updateRep(pname, { val: r.val })
406
+ if (r.val && !ctx.func.localReps?.get(pname)?.val) updateRep(pname, { val: r.val })
276
407
  if (r.typedCtor) {
277
408
  if (!ctx.types.typedElem) ctx.types.typedElem = new Map()
278
409
  if (!ctx.types.typedElem.has(pname)) ctx.types.typedElem.set(pname, r.typedCtor)
279
- if (!ctx.func.repByLocal?.get(pname)?.val) updateRep(pname, { val: VAL.TYPED })
410
+ if (!ctx.func.localReps?.get(pname)?.val) updateRep(pname, { val: VAL.TYPED })
280
411
  }
281
412
  if (r.schemaId != null && !exported && !ctx.schema.vars.has(pname)) {
282
413
  ctx.schema.vars.set(pname, r.schemaId)
@@ -286,7 +417,18 @@ function emitFunc(func, funcFacts, programFacts) {
286
417
  }
287
418
 
288
419
  const fn = ['func', `$${name}`]
289
- // Boundary-wrapped exports defer the export attribute to a synthesized
420
+ // Stamp the emit-side CSE soundness whitelist onto the func node (expando
421
+ // watr print/compile ignore non-index props). `cseScalarLoad` reads it; absent
422
+ // it the pass is a no-op. `$`-prefixed to match WAT local names directly.
423
+ if (funcFacts.cseLoadBases?.size)
424
+ fn.cseLoadBases = new Set([...funcFacts.cseLoadBases].map(n => `$${n}`))
425
+ // Inline `(export ...)` attribute only for the syntactic inline-export
426
+ // form (`export function foo`, snapshot in `func.exported` at defFunc
427
+ // time). Re-exports (`function foo; export { foo }`) and aliases (`export
428
+ // { foo as bar }`) flow through sec.customs below — emitting an inline
429
+ // attribute under the internal symbol would collide with the customs
430
+ // entry on the same name, or leak the internal symbol publicly.
431
+ // Boundary-wrapped exports also defer the attribute to the synthesized
290
432
  // wrapper ($${name}$exp) that reboxes the narrowed result back to f64.
291
433
  if (exported && !isBoundaryWrapped(func)) fn.push(['export', `"${name}"`])
292
434
  fn.push(...sig.params.map(p => ['param', `$${p.name}`, p.type]))
@@ -294,19 +436,26 @@ function emitFunc(func, funcFacts, programFacts) {
294
436
 
295
437
  // Default params: ES spec says default applies only when arg is `undefined`
296
438
  // (or missing). `null`, `0`, `false`, etc. all skip the default.
439
+ // Emitted here (registers any `charCodeAt` decomposition the default's
440
+ // initializer triggers) but keyed by param name — final ordering vs the
441
+ // charDecomp prologue is resolved in `collectParamInits` below.
297
442
  const defaults = func.defaults || {}
298
- const defaultInits = []
443
+ const defaultInits = new Map()
299
444
  for (const [pname, defVal] of Object.entries(defaults)) {
300
445
  const p = sig.params.find(p => p.name === pname)
446
+ // jsstring-carrier params with string-literal defaults skip wasm-side
447
+ // substitution — the interop wrapper applies the default JS-side (the
448
+ // value rides through `jz:extparam`). The wasm side never sees a null
449
+ // externref so no `ref.is_null` branch is needed.
450
+ if (p?.jsstring && p.jsstringDefault != null) continue
301
451
  const t = p?.type || 'f64'
302
- defaultInits.push(
452
+ defaultInits.set(pname,
303
453
  ['if', isUndef(typed(['local.get', `$${pname}`], 'f64')),
304
454
  ['then', ['local.set', `$${pname}`, t === 'f64' ? asF64(emit(defVal)) : asI32(emit(defVal))]]])
305
455
  }
306
456
 
307
457
  // Box params that are mutably captured: allocate cell, copy param value
308
458
  const boxedParamInits = []
309
- const preboxedLocalInits = []
310
459
  ctx.func.preboxed = new Set()
311
460
  const paramNames = new Set(sig.params.map(p => p.name))
312
461
  for (const p of sig.params) {
@@ -321,31 +470,58 @@ function emitFunc(func, funcFacts, programFacts) {
321
470
  ['f64.store', ['local.get', `$${cell}`], asF64(lget)])
322
471
  }
323
472
  }
324
- for (const [name, cell] of ctx.func.boxed) {
325
- if (paramNames.has(name)) continue
326
- ctx.func.locals.set(cell, 'i32')
327
- ctx.func.preboxed.add(name)
328
- preboxedLocalInits.push(
329
- ['local.set', `$${cell}`, ['call', '$__alloc', ['i32.const', 8]]],
330
- ['f64.store', ['local.get', `$${cell}`], nullExpr()])
473
+ // Remaining boxed locals (non-params) get a fresh null-init cell.
474
+ const preboxedLocalInits = emitPreboxedLocalInits(name => paramNames.has(name))
475
+
476
+ // Drain `ctx.func.charDecomp` after body emit: any param `charCodeAt` use
477
+ // registered a decomposition request that needs a function-entry prologue
478
+ // initialising its four i32 locals (base / len / sso / loadbase). Locals
479
+ // themselves were already added to `ctx.func.locals` during emit so they
480
+ // appear in the local-decl block below.
481
+ //
482
+ // Interleave with the per-param default inits in `sig.params` order so each
483
+ // param's prologue runs *after* that param's own default init (the prologue
484
+ // reads the param's final value) and *before* any later param's default
485
+ // init — a default like `c = op.charCodeAt(0)` must see `op`'s prologue
486
+ // locals already populated, else its bounds check reads len=0 and the
487
+ // in-bounds char wrongly decodes as the OOB NaN.
488
+ const collectParamInits = () => {
489
+ const inits = []
490
+ for (const p of sig.params) {
491
+ const di = defaultInits.get(p.name)
492
+ if (di) inits.push(di)
493
+ const dec = ctx.func.charDecomp?.get(p.name)
494
+ if (dec) inits.push(...emitCharDecompPrologue(dec))
495
+ }
496
+ return inits
331
497
  }
332
498
 
333
499
  if (block) {
334
500
  const stmts = emitBody(body)
501
+ const paramInits = collectParamInits()
335
502
  for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
336
503
  // I: Skip trailing fallback when last statement is return (unreachable code)
337
504
  const lastStmt = stmts.at(-1)
338
505
  const endsWithReturn = lastStmt && (lastStmt[0] === 'return' || lastStmt[0] === 'return_call')
339
- fn.push(...defaultInits, ...boxedParamInits, ...preboxedLocalInits, ...stmts, ...(endsWithReturn ? [] : sig.results.map(t => [`${t}.const`, 0])))
506
+ // Implicit fall-through return is `undefined` per JS spec, not 0 — same as
507
+ // the closure path below. A reachable fall-through forces an f64 result
508
+ // (it must carry undefined); concretely-typed results keep the `.const 0`
509
+ // form since they can only be reached via explicit typed returns.
510
+ const fallthrough = endsWithReturn ? []
511
+ : sig.results.length === 1 && sig.results[0] === 'f64' ? [undefExpr()]
512
+ : sig.results.map(t => [`${t}.const`, 0])
513
+ fn.push(...paramInits, ...boxedParamInits, ...preboxedLocalInits, ...stmts, ...fallthrough)
340
514
  } else if (multi && body[0] === '[') {
341
515
  const values = body.slice(1).map(e => asF64(emit(e)))
516
+ const paramInits = collectParamInits()
342
517
  for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
343
- fn.push(...boxedParamInits, ...preboxedLocalInits, ...values)
518
+ fn.push(...paramInits, ...boxedParamInits, ...preboxedLocalInits, ...values)
344
519
  } else {
345
520
  const ir = emit(body)
521
+ const paramInits = collectParamInits()
346
522
  for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
347
523
  const finalIR = sig.ptrKind != null ? asPtrOffset(ir, sig.ptrKind) : asParamType(ir, sig.results[0])
348
- fn.push(...defaultInits, ...boxedParamInits, ...preboxedLocalInits, tcoTailRewrite(finalIR, sig.results[0]))
524
+ fn.push(...paramInits, ...boxedParamInits, ...preboxedLocalInits, tcoTailRewrite(finalIR, sig.results[0]))
349
525
  }
350
526
 
351
527
  // Restore schema.vars so param bindings don't leak to next function.
@@ -361,7 +537,7 @@ function emitFunc(func, funcFacts, programFacts) {
361
537
  * - takes i64 params always — JS-side carrier is BigInt that reinterprets to
362
538
  * f64 NaN-box bits. i64 dodges V8's spec-permitted NaN canonicalization at
363
539
  * the wasm↔JS boundary (see ToJSValue / ToWebAssemblyValue). Host wrap()
364
- * in src/host.js pairs by converting BigInt↔f64 via reinterpret bits.
540
+ * in interop.js pairs by converting BigInt↔f64 via reinterpret bits.
365
541
  * - converts each narrowed param at the call: f64 → i32 (truncate-sat) for
366
542
  * numeric narrowed, f64 → i32-offset (`i32.wrap_i64 + i64.reinterpret_f64`)
367
543
  * for pointer narrowed. The reinterpret happens once at param decode and
@@ -375,7 +551,8 @@ function emitFunc(func, funcFacts, programFacts) {
375
551
  *
376
552
  * Result rebox cases (then reinterpret to i64 at the boundary):
377
553
  * - sig.ptrKind != null → mkPtrIR(ptrKind, ptrAux ?? 0, callIR)
378
- * - sig.results[0] = i32 → f64.convert_i32_s(callIR)
554
+ * - sig.results[0] = i32 → f64.convert_i32_s(callIR), or `_u` when
555
+ * sig.unsignedResult (preserves `(x >>> 0)` ∈ [0, 2³²))
379
556
  * - sig.results[0] = f64 → callIR (some params narrowed but result stayed f64)
380
557
  */
381
558
  function synthesizeBoundaryWrappers() {
@@ -387,13 +564,31 @@ function synthesizeBoundaryWrappers() {
387
564
  // actually crosses the boundary (param.ptrKind set, or result with
388
565
  // sig.ptrKind set). Numeric narrowing (i32 trunc-sat / convert) keeps f64
389
566
  // so callers seeing the raw export get a plain Number for numerics.
567
+ // jsstring params bypass both i64 and f64 — they flow as externref end-to-end.
390
568
  const paramI64 = sig.params.map(p => p.ptrKind != null)
391
- const resultI64 = sig.ptrKind != null
392
- const wrapNode = ['func', `$${name}$exp`, ['export', `"${name}"`]]
393
- sig.params.forEach((p, i) => wrapNode.push(['param', `$${p.name}`, paramI64[i] ? 'i64' : 'f64']))
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.
572
+ const resultBool = func.valResult === VAL.BOOL && sig.ptrKind == null
573
+ const resultI64 = sig.ptrKind != null || resultBool
574
+ // Inline `(export ...)` attribute only when the func decl carried the
575
+ // inline-export keyword (`export function foo`). For re-exports
576
+ // (`function foo; export { foo as bar }`) the `name` is the *internal*
577
+ // symbol; sec.customs holds the JS-visible export pointing at this
578
+ // wrapper. Emitting an inline attribute here under the internal name
579
+ // would leak the symbol publicly and collide with the customs entry.
580
+ const wrapNode = func.exported
581
+ ? ['func', `$${name}$exp`, ['export', `"${name}"`]]
582
+ : ['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])
586
+ })
394
587
  wrapNode.push(['result', resultI64 ? 'i64' : 'f64'])
395
588
  const args = sig.params.map((p, i) => {
396
589
  const get = ['local.get', `$${p.name}`]
590
+ // jsstring: externref flows through unchanged — inner func also takes externref.
591
+ if (p.jsstring) return get
397
592
  if (p.ptrKind != null) {
398
593
  // ptrKind: i64 carrier carries NaN-box bits → wrap to i32 offset
399
594
  return ['i32.wrap_i64', get]
@@ -407,14 +602,28 @@ function synthesizeBoundaryWrappers() {
407
602
  if (sig.ptrKind != null) {
408
603
  const ptrType = valKindToPtr(sig.ptrKind)
409
604
  body = mkPtrIR(ptrType, sig.ptrAux ?? 0, callIR)
605
+ } 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
410
609
  } else if (sig.results[0] === 'i32') {
411
- body = ['f64.convert_i32_s', callIR]
610
+ body = [sig.unsignedResult ? 'f64.convert_i32_u' : 'f64.convert_i32_s', callIR]
412
611
  } else {
413
612
  body = callIR
414
613
  }
415
614
  wrapNode.push(resultI64 ? ['i64.reinterpret_f64', body] : body)
416
615
  func._exportUsesI64 = resultI64 || paramI64.some(Boolean)
417
616
  func._exportI64Sig = { params: paramI64, result: resultI64 }
617
+ // Track externref param positions so interop.js can pass JS values
618
+ // raw (skipping `mem.wrapVal`) at those slots. Today this only fires
619
+ // for `jsstring`-tagged params; future externref carriers wire here too.
620
+ // `extParams` is per-slot: false (non-ext) | { def: '...' }-bearing object
621
+ // for jsstring params with a JS-side default substitution.
622
+ const extParams = sig.params.map(p => {
623
+ if (!p.jsstring) return false
624
+ return p.jsstringDefault != null ? { def: p.jsstringDefault } : true
625
+ })
626
+ if (extParams.some(Boolean)) func._exportExtParams = extParams
418
627
  wrappers.push(wrapNode)
419
628
  }
420
629
  return wrappers
@@ -428,7 +637,7 @@ function synthesizeBoundaryWrappers() {
428
637
  * so any closure can be invoked via call_indirect on $ftN. This function
429
638
  * builds one body fn given the body record (cb) created by ctx.closure.make.
430
639
  *
431
- * Mutates ctx.func.* per-body state (locals, boxed, repByLocal) and
640
+ * Mutates ctx.func.* per-body state (locals, boxed, localReps) and
432
641
  * ctx.schema.vars / ctx.types.typedElem (restored on exit so capture-binding
433
642
  * leaks don't poison the next body). Returns the WAT IR for the func node.
434
643
  */
@@ -437,8 +646,9 @@ function emitClosureBody(cb) {
437
646
  const prevTypedElems = ctx.types.typedElem
438
647
  // Reset per-function state for closure body
439
648
  ctx.func.locals = new Map()
440
- ctx.func.repByLocal = null
649
+ ctx.func.localReps = null
441
650
  if (cb.intConsts) for (const [name, v] of cb.intConsts) updateRep(name, { intConst: v })
651
+ if (cb.intCertain) for (const name of cb.intCertain) updateRep(name, { intCertain: true })
442
652
  if (cb.valTypes) for (const [name, vt] of cb.valTypes) updateRep(name, { val: vt })
443
653
  if (cb.schemaVars) {
444
654
  ctx.schema.vars = new Map([...prevSchemaVars, ...cb.schemaVars])
@@ -456,18 +666,24 @@ function emitClosureBody(cb) {
456
666
  ctx.func.boxed = cb.boxed ? new Map([...cb.boxed].map(v => [v, v])) : new Map()
457
667
  const parentBoxedCaptures = new Set(cb.boxed || [])
458
668
  ctx.func.preboxed = new Set()
459
- ctx.func.stack = []
460
- ctx.func.uniq = Math.max(ctx.func.uniq, 100) // avoid label collisions
461
- ctx.func.body = cb.body
462
- // Seed direct-call dispatch for captured const-bound closures (A3 across capture boundary).
463
- // closure.make snapshotted the parent's directClosures for each capture; here we restore
464
- // them so calls to a captured `peek` lower to `call $closureN` instead of call_indirect.
465
- ctx.func.directClosures = cb.directClosures ? new Map(cb.directClosures) : null
669
+ // Bare `;`-sequence bodies (no enclosing `{}`) reach us when callers built a
670
+ // statement list directly wrap into a block body so the multi-stmt path
671
+ // runs (otherwise emit returns an untyped list and asF64 wraps it with
672
+ // `f64.convert_i32_s`, yielding invalid WAT).
673
+ if (Array.isArray(cb.body) && cb.body[0] === ';') cb.body = ['{}', cb.body]
466
674
  // Uniform convention: (env f64, argc i32, a0..a{width-1} f64) → f64
467
675
  const W = ctx.closure.width ?? MAX_CLOSURE_ARITY
468
676
  const paramDecls = [{ name: '__env', type: 'f64' }, { name: '__argc', type: 'i32' }]
469
677
  for (let i = 0; i < W; i++) paramDecls.push({ name: `__a${i}`, type: 'f64' })
470
- ctx.func.current = { params: paramDecls, results: ['f64'] }
678
+ // Enter the closure frame. uniq 100 keeps synthetic labels from colliding
679
+ // with the parent. directClosures: closure.make snapshotted the parent's
680
+ // direct-call map for each capture, so a call to a captured const closure
681
+ // still lowers to `call $closureN` instead of call_indirect (A3 across the
682
+ // capture boundary).
683
+ enterFunc({ params: paramDecls, results: ['f64'] }, cb.body, {
684
+ uniq: Math.max(ctx.func.uniq, 100),
685
+ directClosures: cb.directClosures ? new Map(cb.directClosures) : null,
686
+ })
471
687
 
472
688
  const fn = ['func', `$${cb.name}`]
473
689
  fn.push(['param', '$__env', 'f64'])
@@ -485,28 +701,20 @@ function emitClosureBody(cb) {
485
701
  }
486
702
 
487
703
  // Emit body
488
- const block = Array.isArray(cb.body) && cb.body[0] === '{}' && cb.body[1]?.[0] !== ':'
704
+ const block = isBlockBody(cb.body)
489
705
  let bodyIR
490
706
  if (block) {
491
707
  invalidateLocalsCache(cb.body)
492
- for (const [k, v] of analyzeLocals(cb.body)) if (!ctx.func.locals.has(k)) ctx.func.locals.set(k, v)
493
- // Usage-based STRING inference for closure params not seeded by captureValTypes.
708
+ for (const [k, v] of analyzeBody(cb.body).locals) if (!ctx.func.locals.has(k)) ctx.func.locals.set(k, v)
709
+ // Usage-based shape inference for closure params not seeded by captureValTypes.
494
710
  // (Captures already have their parent's val type via cb.valTypes above.)
495
- {
496
- const candidates = cb.params.filter(p => !ctx.func.repByLocal?.get(p)?.val)
497
- if (candidates.length) {
498
- const inferred = inferStringParams(cb.body, candidates)
499
- for (const [n, vt] of inferred) updateRep(n, { val: vt })
500
- }
501
- }
502
- analyzeValTypes(cb.body)
503
- analyzeIntCertain(cb.body)
711
+ inferLocals(cb.body, cb.params.filter(p => !ctx.func.localReps?.get(p)?.val))
504
712
  // Detect captures from deeper nested arrows that mutate this body's locals/params/captures
505
- analyzeBoxedCaptures(cb.body)
713
+ boxedCaptures(cb.body)
506
714
  for (const name of ctx.func.boxed.keys()) {
507
715
  if (parentBoxedCaptures.has(name) && ctx.func.locals.get(name) === 'f64') ctx.func.locals.set(name, 'i32')
508
716
  }
509
- const unbox = analyzePtrUnboxable(cb.body, ctx.func.locals, ctx.func.boxed)
717
+ const unbox = unboxablePtrs(cb.body, ctx.func.locals, ctx.func.boxed)
510
718
  for (const [name, kind] of unbox) {
511
719
  if (cb.params.includes(name) || cb.captures.includes(name)) continue
512
720
  const fields = { ptrKind: kind }
@@ -548,17 +756,27 @@ function emitClosureBody(cb) {
548
756
  ctx.func.locals.set(ctx.func.boxed.get(name), 'i32')
549
757
  ctx.func.preboxed.add(name)
550
758
  }
551
- const preboxedLocalInits = []
552
- for (const [name, cell] of ctx.func.boxed) {
553
- if (boxedCaptureNames.has(name) || boxedValueCaptureNames.has(name) || boxedParamNames.has(name)) continue
554
- ctx.func.locals.set(cell, 'i32')
555
- ctx.func.preboxed.add(name)
556
- preboxedLocalInits.push(
557
- ['local.set', `$${cell}`, ['call', '$__alloc', ['i32.const', 8]]],
558
- ['f64.store', ['local.get', `$${cell}`], nullExpr()])
559
- }
759
+ // Boxed locals that aren't captures or params get a fresh null-init cell;
760
+ // captures/params already carry their incoming value.
761
+ const preboxedLocalInits = emitPreboxedLocalInits(name =>
762
+ boxedCaptureNames.has(name) || boxedValueCaptureNames.has(name) || boxedParamNames.has(name))
560
763
 
561
764
  // Insert locals (captures + params + declared)
765
+ // Build default-param initializer IR before local declarations are emitted:
766
+ // default expressions can allocate temporaries (for example `param = []`).
767
+ const defaultParamInits = []
768
+ if (cb.defaults) {
769
+ for (const [pname, defVal] of Object.entries(cb.defaults)) {
770
+ if (boxedParamNames.has(pname)) {
771
+ defaultParamInits.push(['if', isUndef(['f64.load', boxedAddr(pname)]),
772
+ ['then', ['f64.store', boxedAddr(pname), asF64(emit(defVal))]]])
773
+ } else {
774
+ defaultParamInits.push(['if', isUndef(['local.get', `$${pname}`]),
775
+ ['then', ['local.set', `$${pname}`, asF64(emit(defVal))]]])
776
+ }
777
+ }
778
+ }
779
+
562
780
  for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
563
781
 
564
782
  // Load captures from env: boxed → i32.load (raw cell pointer), immutable → f64.load value.
@@ -612,7 +830,7 @@ function emitClosureBody(cb) {
612
830
  ['then', ['local.set', `$${restLen}`, ['i32.const', restSlots]]]])
613
831
  fn.push(['local.set', `$${restOff}`,
614
832
  ['call', '$__alloc_hdr',
615
- ['local.get', `$${restLen}`], ['local.get', `$${restLen}`], ['i32.const', 8]]])
833
+ ['local.get', `$${restLen}`], ['local.get', `$${restLen}`]]])
616
834
  for (let i = 0; i < restSlots; i++) {
617
835
  fn.push(['if', ['i32.gt_s', ['local.get', `$${restLen}`], ['i32.const', i]],
618
836
  ['then', ['f64.store',
@@ -631,17 +849,7 @@ function emitClosureBody(cb) {
631
849
 
632
850
  // Default params for closures (check sentinel after unpack)
633
851
  // Only `undefined` triggers default per spec — `null`/`0`/`false` pass through.
634
- if (cb.defaults) {
635
- for (const [pname, defVal] of Object.entries(cb.defaults)) {
636
- if (boxedParamNames.has(pname)) {
637
- fn.push(['if', isUndef(['f64.load', boxedAddr(pname)]),
638
- ['then', ['f64.store', boxedAddr(pname), asF64(emit(defVal))]]])
639
- } else {
640
- fn.push(['if', isUndef(['local.get', `$${pname}`]),
641
- ['then', ['local.set', `$${pname}`, asF64(emit(defVal))]]])
642
- }
643
- }
644
- }
852
+ fn.push(...defaultParamInits)
645
853
  fn.push(...preboxedLocalInits)
646
854
  fn.push(...bodyIR)
647
855
  // I: Skip trailing fallback when last statement is return
@@ -687,7 +895,11 @@ export default function compile(ast, profiler) {
687
895
  err(`'${name}' conflicts with a compiler internal — choose a different name`)
688
896
 
689
897
  // Pre-fold const globals: evaluate constant initializers before function compilation
690
- // so functions see the correct global types (i32 vs f64).
898
+ // so functions see the correct global types (i32 vs f64). Covers the main module
899
+ // and every bundled sub-module — a sub-module's top-level `const SPACE = 32` lands
900
+ // in `moduleInits` (emitted from __start), not `ast`, so without this it stays a
901
+ // `(mut f64)` global. Folding it makes the scanner's char-code constants immutable
902
+ // globals V8 constant-folds at each read site.
691
903
  if (ast) {
692
904
  const evalConst = n => {
693
905
  if (typeof n === 'number') return n
@@ -706,8 +918,10 @@ export default function compile(ast, profiler) {
706
918
  if (op === '>>') return va >> vb; if (op === '>>>') return va >>> vb
707
919
  return null
708
920
  }
709
- const stmts = Array.isArray(ast) && ast[0] === ';' ? ast.slice(1)
710
- : Array.isArray(ast) && ast[0] === 'const' ? [ast] : []
921
+ const topStmts = n => Array.isArray(n) && n[0] === ';' ? n.slice(1)
922
+ : Array.isArray(n) && n[0] === 'const' ? [n] : []
923
+ const stmts = [...topStmts(ast)]
924
+ for (const mi of ctx.module.moduleInits || []) stmts.push(...topStmts(mi))
711
925
  for (const s of stmts) {
712
926
  if (!Array.isArray(s) || s[0] !== 'const') continue
713
927
  for (const decl of s.slice(1)) {
@@ -716,7 +930,7 @@ export default function compile(ast, profiler) {
716
930
  if (!ctx.scope.globals.has(name) || !ctx.scope.consts?.has(name)) continue
717
931
  const v = evalConst(init)
718
932
  if (v == null || !isFinite(v)) continue
719
- const isInt = Number.isInteger(v) && v >= -2147483648 && v <= 2147483647
933
+ const isInt = Number.isInteger(v) && v >= I32_MIN && v <= I32_MAX
720
934
  ctx.scope.globals.set(name, isInt
721
935
  ? `(global $${name} i32 (i32.const ${v}))`
722
936
  : `(global $${name} f64 (f64.const ${v}))`)
@@ -730,8 +944,22 @@ export default function compile(ast, profiler) {
730
944
 
731
945
  const programFacts = timePhase(profiler, 'plan', () => plan(ast))
732
946
 
947
+ // Inspect sink: editor hosts opt in via { inspect: true } to read inferred shapes.
948
+ // Initialized here (post-plan) so paramReps and schema.list are stable, populated
949
+ // per-function below as funcFacts settle. Bytes themselves are unchanged.
950
+ if (ctx.transform.inspect) ctx.inspect = { functions: {}, schemas: ctx.schema.list.map(s => s.slice()) }
951
+
733
952
  const funcFacts = new Map()
734
- for (const func of ctx.func.list) if (!func.raw) funcFacts.set(func, analyzeFuncForEmit(func, programFacts))
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
+ }
959
+ // Whole-program SRoA: pick the schemas whose `Array<S>` instances use the
960
+ // `structInline` carrier. Runs once the per-function reps have settled (they
961
+ // are codegen truth) and before any function is emitted.
962
+ analyzeStructInline(funcFacts, programFacts)
735
963
  const funcs = ctx.func.list.map(func => emitFunc(func, funcFacts.get(func), programFacts))
736
964
  funcs.push(...synthesizeBoundaryWrappers())
737
965
 
@@ -746,10 +974,28 @@ export default function compile(ast, profiler) {
746
974
  }
747
975
  compilePendingClosures()
748
976
 
977
+ // `wasm:js-string` imports — drained from `ctx.core.jsstring`, one
978
+ // `(import …)` per builtin referenced by emitted code. Engines with
979
+ // js-string-builtins support intercept the namespace; engines without
980
+ // fall back to JS-side polyfills wired in interop.js. The import nodes
981
+ // precede user imports so the host providing them sees them first.
982
+ const jssImports = []
983
+ if (ctx.core.jsstring?.size) {
984
+ for (const name of ctx.core.jsstring) {
985
+ const sig = JSS_IMPORT_SIGS[name]
986
+ if (!sig) continue // unknown builtin — silently skip (defensive)
987
+ const funcNode = ['func', `$__jss_${name}`,
988
+ ...sig.params.map(t => ['param', t]),
989
+ ['result', sig.result],
990
+ ]
991
+ jssImports.push(['import', '"wasm:js-string"', `"${name}"`, funcNode])
992
+ }
993
+ }
994
+
749
995
  // Build module sections — named slots, assembled at the end (no index bookkeeping)
750
996
  const sec = {
751
997
  extStdlib: [], // external stdlib (imports that must precede all other imports)
752
- imports: [...ctx.module.imports],
998
+ imports: [...jssImports, ...ctx.module.imports],
753
999
  types: [], // function types for call_indirect
754
1000
  memory: [], // memory declaration
755
1001
  data: [], // data segment (filled after emit)
@@ -775,12 +1021,6 @@ export default function compile(ast, profiler) {
775
1021
 
776
1022
  // Memory section deferred — emitted after resolveIncludes() when __alloc is needed
777
1023
 
778
- if (ctx.runtime.throws) {
779
- ctx.scope.globals.set('__jz_last_err_bits', '(global $__jz_last_err_bits (mut i64) (i64.const 0))')
780
- sec.tags.push(['tag', '$__jz_err', ['param', 'f64']])
781
- sec.tags.push(['export', '"__jz_last_err_bits"', ['global', '$__jz_last_err_bits']])
782
- }
783
-
784
1024
  if (ctx.closure.table?.length)
785
1025
  sec.table.push(['table', ['export', '"__jz_table"'], ctx.closure.table.length, 'funcref'])
786
1026
 
@@ -827,6 +1067,8 @@ export default function compile(ast, profiler) {
827
1067
 
828
1068
  stripStaticDataPrefix(sec)
829
1069
 
1070
+ ensureThrowRuntime(sec)
1071
+
830
1072
  optimizeModule(sec)
831
1073
 
832
1074
  // Populate globals (after __start — const folding may update declarations)
@@ -866,9 +1108,19 @@ export default function compile(ast, profiler) {
866
1108
  sec.customs.push(['@custom', '"jz:schema"', bytes])
867
1109
  }
868
1110
 
869
- // Custom section: rest params for exported functions (JS-side wrapping)
870
- const restParamFuncs = ctx.func.list.filter(f => f.exported && f.rest)
871
- .map(f => ({ name: f.name, fixed: f.sig.params.length - 1 }))
1111
+ // Custom section: rest params for exported functions (JS-side wrapping).
1112
+ // Entry per JS-visible export name (not per internal func name) — host's
1113
+ // interop.js wrap() keys by export name. Aliased re-export
1114
+ // (`function foo (...rest); export { foo as bar }`) needs `bar` in the
1115
+ // list; otherwise JS pads the missing args with UNDEF_NAN and the
1116
+ // VAL.ARRAY narrow path reads i32 at `__ptr_offset(UNDEF_NAN) - 8`, hitting
1117
+ // OOB instead of the polymorphic length-check fallback's tag-aware return-0.
1118
+ const restParamFuncs = []
1119
+ for (const f of ctx.func.list) {
1120
+ if (!isExported(f) || !f.rest) continue
1121
+ const fixed = f.sig.params.length - 1
1122
+ for (const exportName of exportNamesOf(f.name)) restParamFuncs.push({ name: exportName, fixed })
1123
+ }
872
1124
  if (restParamFuncs.length)
873
1125
  sec.customs.push(['@custom', '"jz:rest"', `"${JSON.stringify(restParamFuncs).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`])
874
1126
 
@@ -880,20 +1132,42 @@ export default function compile(ast, profiler) {
880
1132
  // stay as Numbers on the JS side.
881
1133
  const i64Exports = []
882
1134
  for (const f of ctx.func.list) {
883
- if (!f.exported || !isBoundaryWrapped(f) || !f._exportUsesI64) continue
1135
+ if (!isExported(f) || !isBoundaryWrapped(f) || !f._exportUsesI64) continue
884
1136
  const p = []
885
1137
  f._exportI64Sig.params.forEach((b, i) => { if (b) p.push(i) })
886
1138
  const r = f._exportI64Sig.result ? 1 : 0
887
- i64Exports.push({ name: f.name, p, r })
888
- // Aliases (export { foo as bar }) re-export the same wrapper under a
889
- // different JS-visible name; list each alias too so wrap() finds it.
890
- for (const [alias, val] of Object.entries(ctx.func.exports)) {
891
- if (val === f.name && alias !== f.name) i64Exports.push({ name: alias, p, r })
892
- }
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 })
893
1143
  }
894
1144
  if (i64Exports.length)
895
1145
  sec.customs.push(['@custom', '"jz:i64exp"', `"${JSON.stringify(i64Exports).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`])
896
1146
 
1147
+ // Custom section: per-export externref param positions. interop.js reads
1148
+ // this to pass JS arguments straight through at those positions (no
1149
+ // `mem.wrapVal`, no SSO encoding). Format: { name, p, d? } where p lists
1150
+ // 0-based externref param indices and d (optional) is a map idx→default
1151
+ // string for jsstring-carrier params whose default-substitution happens
1152
+ // JS-side. Empty list emits nothing.
1153
+ const extExports = []
1154
+ for (const f of ctx.func.list) {
1155
+ if (!isExported(f) || !isBoundaryWrapped(f) || !f._exportExtParams) continue
1156
+ const p = []
1157
+ const d = {}
1158
+ f._exportExtParams.forEach((b, i) => {
1159
+ if (!b) return
1160
+ p.push(i)
1161
+ if (typeof b === 'object' && b.def != null) d[i] = b.def
1162
+ })
1163
+ 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 })
1167
+ }
1168
+ if (extExports.length)
1169
+ sec.customs.push(['@custom', '"jz:extparam"', `"${JSON.stringify(extExports).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`])
1170
+
897
1171
  // Named export aliases: export { name } or export { source as alias }
898
1172
  for (const [name, val] of Object.entries(ctx.func.exports)) {
899
1173
  if (wasiCommandExports.has(name)) continue
@@ -917,10 +1191,12 @@ export default function compile(ast, profiler) {
917
1191
  const optCfg = ctx.transform.optimize
918
1192
  const { callCount } = treeshake(
919
1193
  [{ arr: sec.stdlib }, { arr: sec.funcs }, { arr: sec.start }],
920
- [...sec.start, ...sec.elem, ...sec.customs, ...sec.extStdlib, ...sec.imports],
921
- { removeDead: !optCfg || optCfg.treeshake !== false }
1194
+ [...sec.start, ...sec.elem, ...sec.customs, ...sec.extStdlib, ...sec.imports, ...sec.tags],
1195
+ { removeDead: !optCfg || optCfg.treeshake !== false, globals: sec.globals }
922
1196
  )
923
1197
 
1198
+ pruneUnusedThrowRuntime(sec)
1199
+
924
1200
  // Reorder non-import funcs by call count: hot callees get low LEB128 indices.
925
1201
  // `call $f` encodes funcidx as ULEB128 (1 B for idx < 128, 2 B for idx < 16384).
926
1202
  // On watr self-host this saves ~6 KB (hot specialized helpers migrate to idx < 128).