jz 0.4.0 → 0.5.1

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,13 +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,
34
- isBlockBody,
31
+ T, VAL, analyzeBody,
32
+ unboxablePtrs, cseSafeLoadBases, typedElemAux, invalidateLocalsCache,
33
+ boxedCaptures, updateRep,
34
+ isBlockBody, analyzeStructInline,
35
35
  } from './analyze.js'
36
+ import { inferLocals } from './infer.js'
36
37
  import { optimizeFunc, treeshake } from './optimize.js'
37
38
  import { emit, emitter, emitFlat, emitBody } from './emit.js'
39
+ import { emitCharDecompPrologue, JSS_IMPORT_SIGS } from './abi/string.js'
38
40
  import {
39
41
  typed, asF64, asI32, asPtrOffset, asParamType, toI32, asI64, fromI64,
40
42
  NULL_NAN, UNDEF_NAN, NULL_WAT, UNDEF_WAT, NULL_IR, UNDEF_IR, nullExpr, undefExpr,
@@ -45,7 +47,9 @@ import {
45
47
  isGlobal, isConst, boxedAddr, readVar, writeVar, isNullish, isUndef,
46
48
  slotAddr, elemLoad, elemStore, arrayLoop, allocPtr,
47
49
  multiCount, loopTop, flat, reconstructArgsWithSpreads,
48
- valKindToPtr, findBodyStart,
50
+ valKindToPtr, findBodyStart, tcoTailRewrite,
51
+ boolBoxIR,
52
+ I32_MIN, I32_MAX,
49
53
  } from './ir.js'
50
54
  import plan from './plan.js'
51
55
  import {
@@ -53,6 +57,53 @@ import {
53
57
  pullStdlib, syncImports, optimizeModule, stripStaticDataPrefix,
54
58
  } from './assemble.js'
55
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
+
56
107
  const timePhase = (profiler, name, fn) => profiler ? profiler.time(name, fn) : fn()
57
108
 
58
109
  // Per-compile func name set + map live on ctx.func.names / ctx.func.map,
@@ -63,7 +114,7 @@ const timePhase = (profiler, name, fn) => profiler ? profiler.time(name, fn) : f
63
114
  // buildArrayWithSpreads) moved to src/emit.js.
64
115
 
65
116
  // AST-analysis primitives (staticObjectProps, paramReps lattice helpers,
66
- // inferArg* cross-call inference, collectProgramFacts) moved to src/analyze.js.
117
+ // infer* cross-call inference, collectProgramFacts) moved to src/analyze.js.
67
118
 
68
119
  /**
69
120
  * Boundary-wrap predicate: exports whose body-driven result OR any param narrowed
@@ -77,69 +128,22 @@ const timePhase = (profiler, name, fn) => profiler ? profiler.time(name, fn) : f
77
128
  * fractional Number gets the same truncation it would get from `arr[n]`).
78
129
  */
79
130
  const isBoundaryWrapped = (func) => {
80
- 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
81
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
82
136
  return func.sig.params.some(p => p.type !== 'f64' || p.ptrKind != null)
83
137
  }
84
138
 
85
- /**
86
- * Tail-call rewrite: walks tail positions of an emitted IR tree and replaces
87
- * direct `(call $name args...)` ops with `(return_call $name args...)`.
88
- *
89
- * Tail positions, recursively from the IR root:
90
- * - the root itself (function's terminal value-producing expression)
91
- * - both arms of `(if (result T) cond (then ...) (else ...))`
92
- * - last instruction of `(block (result T) ...)`
93
- *
94
- * Only fires when caller and callee result types match — if they didn't match,
95
- * `asParamType`/`asPtrOffset` would have wrapped the call in a conversion op,
96
- * pushing the `call` away from the tail position. We don't recurse into
97
- * arithmetic / select / loop ops: their results aren't standalone-tail control
98
- * transfers.
99
- *
100
- * Mirrors the existing `'return'` op handler in emit.js (which already does
101
- * TCO when the return statement is explicit). This pass closes the gap for
102
- * expression-bodied arrows like `(n, acc) => n <= 0 ? acc : sum(n-1, acc+n)`
103
- * — the AST has no `return` keyword so the emit-time handler never fires.
104
- */
105
- const tcoTailRewrite = (ir, resultType) => {
106
- if (ctx.transform.noTailCall || ctx.func.inTry) return ir
107
- if (!Array.isArray(ir)) return ir
108
- const op = ir[0]
109
- if (op === 'call' && typeof ir[1] === 'string') {
110
- // IR call name is `$name`; func.map keys are bare `name`.
111
- const calleeName = ir[1].startsWith('$') ? ir[1].slice(1) : ir[1]
112
- const callee = ctx.func.map.get(calleeName)
113
- if (!callee || callee.raw) return ir
114
- const calleeRT = callee.sig?.results?.[0] ?? 'f64'
115
- if (calleeRT !== resultType) return ir
116
- return typed(['return_call', ...ir.slice(1)], resultType)
117
- }
118
- if (op === 'if' && Array.isArray(ir[1]) && ir[1][0] === 'result') {
119
- let changed = false
120
- const newIr = ir.slice()
121
- for (let i = 3; i < newIr.length; i++) {
122
- const arm = newIr[i]
123
- if (Array.isArray(arm) && (arm[0] === 'then' || arm[0] === 'else') && arm.length > 1) {
124
- const last = arm[arm.length - 1]
125
- const rewritten = tcoTailRewrite(last, resultType)
126
- if (rewritten !== last) {
127
- newIr[i] = [...arm.slice(0, -1), rewritten]
128
- changed = true
129
- }
130
- }
131
- }
132
- return changed ? typed(newIr, ir.type) : ir
133
- }
134
- if (op === 'block' && ir.length > 1) {
135
- const last = ir[ir.length - 1]
136
- const rewritten = tcoTailRewrite(last, resultType)
137
- if (rewritten !== last) return typed([...ir.slice(0, -1), rewritten], ir.type)
138
- }
139
- return ir
140
- }
141
-
142
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
143
147
  if (!ctx.runtime.throws) return
144
148
 
145
149
  if (!ctx.scope.globals.has('__jz_last_err_bits'))
@@ -150,17 +154,110 @@ const ensureThrowRuntime = (sec) => {
150
154
  sec.tags.push(['export', '"__jz_last_err_bits"', ['global', '$__jz_last_err_bits']])
151
155
  }
152
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')
177
+ }
178
+
153
179
  // === Module compilation ===
154
180
 
155
181
  const cloneRepMap = map => map ? new Map([...map].map(([k, v]) => [k, { ...v }])) : null
156
182
 
157
- 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 } = {}) {
158
238
  ctx.func.stack = []
159
- ctx.func.uniq = 0
160
- ctx.func.current = func.sig
161
- ctx.func.body = func.body
162
- ctx.func.directClosures = null
239
+ ctx.func.uniq = uniq
240
+ ctx.func.current = sig
241
+ ctx.func.body = body
242
+ ctx.func.directClosures = directClosures
163
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
164
261
  }
165
262
 
166
263
  function analyzeFuncForEmit(func, programFacts) {
@@ -168,11 +265,11 @@ function analyzeFuncForEmit(func, programFacts) {
168
265
  if (func.raw) return null
169
266
 
170
267
  const { name, body, sig } = func
171
- enterFunc(func)
268
+ enterFunc(sig, body)
172
269
 
173
270
  const block = isBlockBody(body)
174
271
  ctx.func.boxed = new Map()
175
- ctx.func.repByLocal = null
272
+ ctx.func.localReps = null
176
273
  ctx.types.typedElem = ctx.scope.globalTypedElem ? new Map(ctx.scope.globalTypedElem) : null
177
274
 
178
275
  const _reps = paramReps.get(name)
@@ -185,41 +282,52 @@ function analyzeFuncForEmit(func, programFacts) {
185
282
  if (!ctx.types.typedElem.has(pname)) ctx.types.typedElem.set(pname, r.typedCtor)
186
283
  updateRep(pname, { val: VAL.TYPED })
187
284
  }
188
- 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 })
189
286
  if (r.arrayElemSchema != null) updateRep(pname, { arrayElemSchema: r.arrayElemSchema })
190
287
  if (r.arrayElemValType != null) updateRep(pname, { arrayElemValType: r.arrayElemValType })
191
288
  if (r.intConst != null) updateRep(pname, { intConst: r.intConst })
192
289
  }
193
290
  }
194
- // Drop any earlier-cached analyzeLocals for this body — narrowSignatures called
195
- // it before our pre-seed, when params still had no inferred VAL.TYPED, so the
196
- // 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.
197
295
  invalidateLocalsCache(body)
198
- ctx.func.locals = block ? analyzeLocals(body) : new Map()
199
- // Usage-based VAL.STRING inference for params not already typed by paramReps.
200
- // Descends into nested closures so a param used as STRING only inside an inner
201
- // arrow (e.g. parseLevel's `str` capture in watr) still gets seeded — the
202
- // closure capture path then propagates VAL.STRING via captureValTypes.
203
- if (block) {
204
- const candidates = sig.params
205
- .filter(p => !ctx.func.repByLocal?.get(p.name)?.val)
206
- .map(p => p.name)
207
- if (candidates.length) {
208
- const inferred = inferStringParams(body, candidates)
209
- for (const [n, vt] of inferred) updateRep(n, { val: vt })
210
- }
211
- }
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)
212
322
  if (block) {
213
- analyzeValTypes(body)
214
- analyzeIntCertain(body)
215
- analyzeBoxedCaptures(body)
323
+ boxedCaptures(body)
216
324
  // Lower provably-monomorphic pointer locals to i32 offset storage.
217
325
  // VAL.TYPED unbox requires a known element ctor (aux byte) — without it,
218
326
  // the use site can't pick the right i32.store{8,16}/i32.store width and
219
327
  // the rebox path can't reconstruct the NaN-box. Heterogeneous decls (two
220
328
  // `let arr = ...` with different ctors, or a multi-ctor ternary) leave
221
329
  // typedElem unset; skip unbox so reads/writes go through `__typed_set_idx`.
222
- const unbox = analyzePtrUnboxable(body, ctx.func.locals, ctx.func.boxed)
330
+ const unbox = unboxablePtrs(body, ctx.func.locals, ctx.func.boxed)
223
331
  if (unbox.size > 0) {
224
332
  for (const [n, kind] of unbox) {
225
333
  const fields = { ptrKind: kind }
@@ -234,7 +342,7 @@ function analyzeFuncForEmit(func, programFacts) {
234
342
  }
235
343
  }
236
344
  // Pointer-ABI params (from narrowing loop above): params already have type='i32' and
237
- // 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.
238
346
  // Boxed capture still works: the boxed-init path (below) uses a ptrKind-tagged local.get
239
347
  // so asF64 reboxes to NaN-form before f64.store to the cell.
240
348
  for (const p of sig.params) {
@@ -244,12 +352,21 @@ function analyzeFuncForEmit(func, programFacts) {
244
352
  updateRep(p.name, fields)
245
353
  }
246
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
+
247
361
  return {
248
362
  block,
249
363
  locals: new Map(ctx.func.locals),
250
364
  boxed: new Map(ctx.func.boxed),
365
+ flatObjects: new Map(ctx.func.flatObjects),
366
+ sliceViews: new Set(ctx.func.sliceViews),
367
+ cseLoadBases,
251
368
  typedElem: ctx.types.typedElem ? new Map(ctx.types.typedElem) : null,
252
- repByLocal: cloneRepMap(ctx.func.repByLocal),
369
+ localReps: cloneRepMap(ctx.func.localReps),
253
370
  }
254
371
  }
255
372
 
@@ -269,11 +386,13 @@ function emitFunc(func, funcFacts, programFacts) {
269
386
  const multi = sig.results.length > 1
270
387
  const _reps = paramReps.get(name)
271
388
 
272
- enterFunc(func)
389
+ enterFunc(sig, body)
273
390
  const block = funcFacts.block
274
391
  ctx.func.locals = new Map(funcFacts.locals)
275
392
  ctx.func.boxed = new Map(funcFacts.boxed)
276
- 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)
277
396
  ctx.types.typedElem = funcFacts.typedElem ? new Map(funcFacts.typedElem) : null
278
397
 
279
398
  // D: Apply call-site param facts (only if body analysis didn't already set them).
@@ -284,11 +403,11 @@ function emitFunc(func, funcFacts, programFacts) {
284
403
  for (const [k, r] of _reps) {
285
404
  if (k >= sig.params.length) continue
286
405
  const pname = sig.params[k].name
287
- 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 })
288
407
  if (r.typedCtor) {
289
408
  if (!ctx.types.typedElem) ctx.types.typedElem = new Map()
290
409
  if (!ctx.types.typedElem.has(pname)) ctx.types.typedElem.set(pname, r.typedCtor)
291
- 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 })
292
411
  }
293
412
  if (r.schemaId != null && !exported && !ctx.schema.vars.has(pname)) {
294
413
  ctx.schema.vars.set(pname, r.schemaId)
@@ -298,7 +417,18 @@ function emitFunc(func, funcFacts, programFacts) {
298
417
  }
299
418
 
300
419
  const fn = ['func', `$${name}`]
301
- // 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
302
432
  // wrapper ($${name}$exp) that reboxes the narrowed result back to f64.
303
433
  if (exported && !isBoundaryWrapped(func)) fn.push(['export', `"${name}"`])
304
434
  fn.push(...sig.params.map(p => ['param', `$${p.name}`, p.type]))
@@ -306,19 +436,26 @@ function emitFunc(func, funcFacts, programFacts) {
306
436
 
307
437
  // Default params: ES spec says default applies only when arg is `undefined`
308
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.
309
442
  const defaults = func.defaults || {}
310
- const defaultInits = []
443
+ const defaultInits = new Map()
311
444
  for (const [pname, defVal] of Object.entries(defaults)) {
312
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
313
451
  const t = p?.type || 'f64'
314
- defaultInits.push(
452
+ defaultInits.set(pname,
315
453
  ['if', isUndef(typed(['local.get', `$${pname}`], 'f64')),
316
454
  ['then', ['local.set', `$${pname}`, t === 'f64' ? asF64(emit(defVal)) : asI32(emit(defVal))]]])
317
455
  }
318
456
 
319
457
  // Box params that are mutably captured: allocate cell, copy param value
320
458
  const boxedParamInits = []
321
- const preboxedLocalInits = []
322
459
  ctx.func.preboxed = new Set()
323
460
  const paramNames = new Set(sig.params.map(p => p.name))
324
461
  for (const p of sig.params) {
@@ -333,31 +470,58 @@ function emitFunc(func, funcFacts, programFacts) {
333
470
  ['f64.store', ['local.get', `$${cell}`], asF64(lget)])
334
471
  }
335
472
  }
336
- for (const [name, cell] of ctx.func.boxed) {
337
- if (paramNames.has(name)) continue
338
- ctx.func.locals.set(cell, 'i32')
339
- ctx.func.preboxed.add(name)
340
- preboxedLocalInits.push(
341
- ['local.set', `$${cell}`, ['call', '$__alloc', ['i32.const', 8]]],
342
- ['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
343
497
  }
344
498
 
345
499
  if (block) {
346
500
  const stmts = emitBody(body)
501
+ const paramInits = collectParamInits()
347
502
  for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
348
503
  // I: Skip trailing fallback when last statement is return (unreachable code)
349
504
  const lastStmt = stmts.at(-1)
350
505
  const endsWithReturn = lastStmt && (lastStmt[0] === 'return' || lastStmt[0] === 'return_call')
351
- 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)
352
514
  } else if (multi && body[0] === '[') {
353
515
  const values = body.slice(1).map(e => asF64(emit(e)))
516
+ const paramInits = collectParamInits()
354
517
  for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
355
- fn.push(...boxedParamInits, ...preboxedLocalInits, ...values)
518
+ fn.push(...paramInits, ...boxedParamInits, ...preboxedLocalInits, ...values)
356
519
  } else {
357
520
  const ir = emit(body)
521
+ const paramInits = collectParamInits()
358
522
  for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
359
523
  const finalIR = sig.ptrKind != null ? asPtrOffset(ir, sig.ptrKind) : asParamType(ir, sig.results[0])
360
- fn.push(...defaultInits, ...boxedParamInits, ...preboxedLocalInits, tcoTailRewrite(finalIR, sig.results[0]))
524
+ fn.push(...paramInits, ...boxedParamInits, ...preboxedLocalInits, tcoTailRewrite(finalIR, sig.results[0]))
361
525
  }
362
526
 
363
527
  // Restore schema.vars so param bindings don't leak to next function.
@@ -373,7 +537,7 @@ function emitFunc(func, funcFacts, programFacts) {
373
537
  * - takes i64 params always — JS-side carrier is BigInt that reinterprets to
374
538
  * f64 NaN-box bits. i64 dodges V8's spec-permitted NaN canonicalization at
375
539
  * the wasm↔JS boundary (see ToJSValue / ToWebAssemblyValue). Host wrap()
376
- * in src/host.js pairs by converting BigInt↔f64 via reinterpret bits.
540
+ * in interop.js pairs by converting BigInt↔f64 via reinterpret bits.
377
541
  * - converts each narrowed param at the call: f64 → i32 (truncate-sat) for
378
542
  * numeric narrowed, f64 → i32-offset (`i32.wrap_i64 + i64.reinterpret_f64`)
379
543
  * for pointer narrowed. The reinterpret happens once at param decode and
@@ -400,13 +564,31 @@ function synthesizeBoundaryWrappers() {
400
564
  // actually crosses the boundary (param.ptrKind set, or result with
401
565
  // sig.ptrKind set). Numeric narrowing (i32 trunc-sat / convert) keeps f64
402
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.
403
568
  const paramI64 = sig.params.map(p => p.ptrKind != null)
404
- const resultI64 = sig.ptrKind != null
405
- const wrapNode = ['func', `$${name}$exp`, ['export', `"${name}"`]]
406
- 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
+ })
407
587
  wrapNode.push(['result', resultI64 ? 'i64' : 'f64'])
408
588
  const args = sig.params.map((p, i) => {
409
589
  const get = ['local.get', `$${p.name}`]
590
+ // jsstring: externref flows through unchanged — inner func also takes externref.
591
+ if (p.jsstring) return get
410
592
  if (p.ptrKind != null) {
411
593
  // ptrKind: i64 carrier carries NaN-box bits → wrap to i32 offset
412
594
  return ['i32.wrap_i64', get]
@@ -420,6 +602,10 @@ function synthesizeBoundaryWrappers() {
420
602
  if (sig.ptrKind != null) {
421
603
  const ptrType = valKindToPtr(sig.ptrKind)
422
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
423
609
  } else if (sig.results[0] === 'i32') {
424
610
  body = [sig.unsignedResult ? 'f64.convert_i32_u' : 'f64.convert_i32_s', callIR]
425
611
  } else {
@@ -428,6 +614,16 @@ function synthesizeBoundaryWrappers() {
428
614
  wrapNode.push(resultI64 ? ['i64.reinterpret_f64', body] : body)
429
615
  func._exportUsesI64 = resultI64 || paramI64.some(Boolean)
430
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
431
627
  wrappers.push(wrapNode)
432
628
  }
433
629
  return wrappers
@@ -441,7 +637,7 @@ function synthesizeBoundaryWrappers() {
441
637
  * so any closure can be invoked via call_indirect on $ftN. This function
442
638
  * builds one body fn given the body record (cb) created by ctx.closure.make.
443
639
  *
444
- * Mutates ctx.func.* per-body state (locals, boxed, repByLocal) and
640
+ * Mutates ctx.func.* per-body state (locals, boxed, localReps) and
445
641
  * ctx.schema.vars / ctx.types.typedElem (restored on exit so capture-binding
446
642
  * leaks don't poison the next body). Returns the WAT IR for the func node.
447
643
  */
@@ -450,8 +646,9 @@ function emitClosureBody(cb) {
450
646
  const prevTypedElems = ctx.types.typedElem
451
647
  // Reset per-function state for closure body
452
648
  ctx.func.locals = new Map()
453
- ctx.func.repByLocal = null
649
+ ctx.func.localReps = null
454
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 })
455
652
  if (cb.valTypes) for (const [name, vt] of cb.valTypes) updateRep(name, { val: vt })
456
653
  if (cb.schemaVars) {
457
654
  ctx.schema.vars = new Map([...prevSchemaVars, ...cb.schemaVars])
@@ -469,18 +666,24 @@ function emitClosureBody(cb) {
469
666
  ctx.func.boxed = cb.boxed ? new Map([...cb.boxed].map(v => [v, v])) : new Map()
470
667
  const parentBoxedCaptures = new Set(cb.boxed || [])
471
668
  ctx.func.preboxed = new Set()
472
- ctx.func.stack = []
473
- ctx.func.uniq = Math.max(ctx.func.uniq, 100) // avoid label collisions
474
- ctx.func.body = cb.body
475
- // Seed direct-call dispatch for captured const-bound closures (A3 across capture boundary).
476
- // closure.make snapshotted the parent's directClosures for each capture; here we restore
477
- // them so calls to a captured `peek` lower to `call $closureN` instead of call_indirect.
478
- 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]
479
674
  // Uniform convention: (env f64, argc i32, a0..a{width-1} f64) → f64
480
675
  const W = ctx.closure.width ?? MAX_CLOSURE_ARITY
481
676
  const paramDecls = [{ name: '__env', type: 'f64' }, { name: '__argc', type: 'i32' }]
482
677
  for (let i = 0; i < W; i++) paramDecls.push({ name: `__a${i}`, type: 'f64' })
483
- 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
+ })
484
687
 
485
688
  const fn = ['func', `$${cb.name}`]
486
689
  fn.push(['param', '$__env', 'f64'])
@@ -502,24 +705,16 @@ function emitClosureBody(cb) {
502
705
  let bodyIR
503
706
  if (block) {
504
707
  invalidateLocalsCache(cb.body)
505
- for (const [k, v] of analyzeLocals(cb.body)) if (!ctx.func.locals.has(k)) ctx.func.locals.set(k, v)
506
- // 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.
507
710
  // (Captures already have their parent's val type via cb.valTypes above.)
508
- {
509
- const candidates = cb.params.filter(p => !ctx.func.repByLocal?.get(p)?.val)
510
- if (candidates.length) {
511
- const inferred = inferStringParams(cb.body, candidates)
512
- for (const [n, vt] of inferred) updateRep(n, { val: vt })
513
- }
514
- }
515
- analyzeValTypes(cb.body)
516
- analyzeIntCertain(cb.body)
711
+ inferLocals(cb.body, cb.params.filter(p => !ctx.func.localReps?.get(p)?.val))
517
712
  // Detect captures from deeper nested arrows that mutate this body's locals/params/captures
518
- analyzeBoxedCaptures(cb.body)
713
+ boxedCaptures(cb.body)
519
714
  for (const name of ctx.func.boxed.keys()) {
520
715
  if (parentBoxedCaptures.has(name) && ctx.func.locals.get(name) === 'f64') ctx.func.locals.set(name, 'i32')
521
716
  }
522
- const unbox = analyzePtrUnboxable(cb.body, ctx.func.locals, ctx.func.boxed)
717
+ const unbox = unboxablePtrs(cb.body, ctx.func.locals, ctx.func.boxed)
523
718
  for (const [name, kind] of unbox) {
524
719
  if (cb.params.includes(name) || cb.captures.includes(name)) continue
525
720
  const fields = { ptrKind: kind }
@@ -561,17 +756,27 @@ function emitClosureBody(cb) {
561
756
  ctx.func.locals.set(ctx.func.boxed.get(name), 'i32')
562
757
  ctx.func.preboxed.add(name)
563
758
  }
564
- const preboxedLocalInits = []
565
- for (const [name, cell] of ctx.func.boxed) {
566
- if (boxedCaptureNames.has(name) || boxedValueCaptureNames.has(name) || boxedParamNames.has(name)) continue
567
- ctx.func.locals.set(cell, 'i32')
568
- ctx.func.preboxed.add(name)
569
- preboxedLocalInits.push(
570
- ['local.set', `$${cell}`, ['call', '$__alloc', ['i32.const', 8]]],
571
- ['f64.store', ['local.get', `$${cell}`], nullExpr()])
572
- }
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))
573
763
 
574
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
+
575
780
  for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
576
781
 
577
782
  // Load captures from env: boxed → i32.load (raw cell pointer), immutable → f64.load value.
@@ -644,17 +849,7 @@ function emitClosureBody(cb) {
644
849
 
645
850
  // Default params for closures (check sentinel after unpack)
646
851
  // Only `undefined` triggers default per spec — `null`/`0`/`false` pass through.
647
- if (cb.defaults) {
648
- for (const [pname, defVal] of Object.entries(cb.defaults)) {
649
- if (boxedParamNames.has(pname)) {
650
- fn.push(['if', isUndef(['f64.load', boxedAddr(pname)]),
651
- ['then', ['f64.store', boxedAddr(pname), asF64(emit(defVal))]]])
652
- } else {
653
- fn.push(['if', isUndef(['local.get', `$${pname}`]),
654
- ['then', ['local.set', `$${pname}`, asF64(emit(defVal))]]])
655
- }
656
- }
657
- }
852
+ fn.push(...defaultParamInits)
658
853
  fn.push(...preboxedLocalInits)
659
854
  fn.push(...bodyIR)
660
855
  // I: Skip trailing fallback when last statement is return
@@ -700,7 +895,11 @@ export default function compile(ast, profiler) {
700
895
  err(`'${name}' conflicts with a compiler internal — choose a different name`)
701
896
 
702
897
  // Pre-fold const globals: evaluate constant initializers before function compilation
703
- // 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.
704
903
  if (ast) {
705
904
  const evalConst = n => {
706
905
  if (typeof n === 'number') return n
@@ -719,8 +918,10 @@ export default function compile(ast, profiler) {
719
918
  if (op === '>>') return va >> vb; if (op === '>>>') return va >>> vb
720
919
  return null
721
920
  }
722
- const stmts = Array.isArray(ast) && ast[0] === ';' ? ast.slice(1)
723
- : 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))
724
925
  for (const s of stmts) {
725
926
  if (!Array.isArray(s) || s[0] !== 'const') continue
726
927
  for (const decl of s.slice(1)) {
@@ -729,7 +930,7 @@ export default function compile(ast, profiler) {
729
930
  if (!ctx.scope.globals.has(name) || !ctx.scope.consts?.has(name)) continue
730
931
  const v = evalConst(init)
731
932
  if (v == null || !isFinite(v)) continue
732
- const isInt = Number.isInteger(v) && v >= -2147483648 && v <= 2147483647
933
+ const isInt = Number.isInteger(v) && v >= I32_MIN && v <= I32_MAX
733
934
  ctx.scope.globals.set(name, isInt
734
935
  ? `(global $${name} i32 (i32.const ${v}))`
735
936
  : `(global $${name} f64 (f64.const ${v}))`)
@@ -743,8 +944,22 @@ export default function compile(ast, profiler) {
743
944
 
744
945
  const programFacts = timePhase(profiler, 'plan', () => plan(ast))
745
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
+
746
952
  const funcFacts = new Map()
747
- 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)
748
963
  const funcs = ctx.func.list.map(func => emitFunc(func, funcFacts.get(func), programFacts))
749
964
  funcs.push(...synthesizeBoundaryWrappers())
750
965
 
@@ -759,10 +974,28 @@ export default function compile(ast, profiler) {
759
974
  }
760
975
  compilePendingClosures()
761
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
+
762
995
  // Build module sections — named slots, assembled at the end (no index bookkeeping)
763
996
  const sec = {
764
997
  extStdlib: [], // external stdlib (imports that must precede all other imports)
765
- imports: [...ctx.module.imports],
998
+ imports: [...jssImports, ...ctx.module.imports],
766
999
  types: [], // function types for call_indirect
767
1000
  memory: [], // memory declaration
768
1001
  data: [], // data segment (filled after emit)
@@ -834,10 +1067,10 @@ export default function compile(ast, profiler) {
834
1067
 
835
1068
  stripStaticDataPrefix(sec)
836
1069
 
837
- optimizeModule(sec)
838
-
839
1070
  ensureThrowRuntime(sec)
840
1071
 
1072
+ optimizeModule(sec)
1073
+
841
1074
  // Populate globals (after __start — const folding may update declarations)
842
1075
  sec.globals.push(...[...ctx.scope.globals.values()].filter(g => g).map(g => parseWat(g)))
843
1076
 
@@ -875,9 +1108,19 @@ export default function compile(ast, profiler) {
875
1108
  sec.customs.push(['@custom', '"jz:schema"', bytes])
876
1109
  }
877
1110
 
878
- // Custom section: rest params for exported functions (JS-side wrapping)
879
- const restParamFuncs = ctx.func.list.filter(f => f.exported && f.rest)
880
- .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
+ }
881
1124
  if (restParamFuncs.length)
882
1125
  sec.customs.push(['@custom', '"jz:rest"', `"${JSON.stringify(restParamFuncs).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`])
883
1126
 
@@ -889,20 +1132,42 @@ export default function compile(ast, profiler) {
889
1132
  // stay as Numbers on the JS side.
890
1133
  const i64Exports = []
891
1134
  for (const f of ctx.func.list) {
892
- if (!f.exported || !isBoundaryWrapped(f) || !f._exportUsesI64) continue
1135
+ if (!isExported(f) || !isBoundaryWrapped(f) || !f._exportUsesI64) continue
893
1136
  const p = []
894
1137
  f._exportI64Sig.params.forEach((b, i) => { if (b) p.push(i) })
895
1138
  const r = f._exportI64Sig.result ? 1 : 0
896
- i64Exports.push({ name: f.name, p, r })
897
- // Aliases (export { foo as bar }) re-export the same wrapper under a
898
- // different JS-visible name; list each alias too so wrap() finds it.
899
- for (const [alias, val] of Object.entries(ctx.func.exports)) {
900
- if (val === f.name && alias !== f.name) i64Exports.push({ name: alias, p, r })
901
- }
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 })
902
1143
  }
903
1144
  if (i64Exports.length)
904
1145
  sec.customs.push(['@custom', '"jz:i64exp"', `"${JSON.stringify(i64Exports).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`])
905
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
+
906
1171
  // Named export aliases: export { name } or export { source as alias }
907
1172
  for (const [name, val] of Object.entries(ctx.func.exports)) {
908
1173
  if (wasiCommandExports.has(name)) continue
@@ -926,10 +1191,12 @@ export default function compile(ast, profiler) {
926
1191
  const optCfg = ctx.transform.optimize
927
1192
  const { callCount } = treeshake(
928
1193
  [{ arr: sec.stdlib }, { arr: sec.funcs }, { arr: sec.start }],
929
- [...sec.start, ...sec.elem, ...sec.customs, ...sec.extStdlib, ...sec.imports],
1194
+ [...sec.start, ...sec.elem, ...sec.customs, ...sec.extStdlib, ...sec.imports, ...sec.tags],
930
1195
  { removeDead: !optCfg || optCfg.treeshake !== false, globals: sec.globals }
931
1196
  )
932
1197
 
1198
+ pruneUnusedThrowRuntime(sec)
1199
+
933
1200
  // Reorder non-import funcs by call count: hot callees get low LEB128 indices.
934
1201
  // `call $f` encodes funcidx as ULEB128 (1 B for idx < 128, 2 B for idx < 16384).
935
1202
  // On watr self-host this saves ~6 KB (hot specialized helpers migrate to idx < 128).