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/narrow.js CHANGED
@@ -8,17 +8,22 @@
8
8
  */
9
9
 
10
10
  import { ctx } from './ctx.js'
11
- import { isLiteralStr } from './ir.js'
11
+ import { isLiteralStr, I32_MIN, I32_MAX } from './ir.js'
12
12
  import {
13
13
  VAL,
14
- analyzeBody, analyzeLocals,
15
- callerParamFactMap, clearStickyNull, ensureParamRep, mergeParamFact,
16
- exprType, findMutations, hasBareReturn, inferArgArrElemSchema,
17
- inferArgArrElemValType, inferArgSchema, inferArgType, inferArgTypedCtor,
14
+ analyzeBody,
15
+ paramFactsOf,
16
+ exprType, findMutations, hasBareReturn,
18
17
  invalidateLocalsCache, invalidateValTypesCache, isBlockBody, alwaysReturns,
19
18
  narrowReturnArrayElems, observeProgramSlots, returnExprs, staticObjectProps,
19
+ scanBoundedLoops,
20
20
  typedElemAux, typedElemCtor, ctorFromElemAux, valTypeOf,
21
21
  } from './analyze.js'
22
+ import {
23
+ clearStickyNull, ensureParamRep, mergeParamFact,
24
+ inferArrElemSchema, inferArrElemValType,
25
+ inferSchemaId, inferValType, inferTypedCtor,
26
+ } from './infer.js'
22
27
 
23
28
  const PTR_ABI_KINDS = new Set([VAL.OBJECT, VAL.SET, VAL.MAP, VAL.BUFFER])
24
29
 
@@ -192,17 +197,250 @@ function enrichCallerValTypesFromPointerParams(callerCtx) {
192
197
  function refreshCallerLocals(callerCtx) {
193
198
  for (const func of ctx.func.list) {
194
199
  if (!func.body || func.raw) continue
200
+ // Seed pointer-narrowed params' val-kind so analyzeBody recognises e.g.
201
+ // `n = arr.length` (arr a TYPED/BUFFER pointer param) as an i32 local — without
202
+ // this, post-G `refreshCallerLocals` still walks bodies with arr untyped, the
203
+ // length stays f64, and any callee taking that length never gets an i32 param
204
+ // (heapsort→siftDown's `end`). analyzeFuncForEmit re-seeds + re-invalidates at
205
+ // emit time, so this transient localReps doesn't leak past narrowing.
206
+ ctx.func.localReps = new Map()
207
+ for (const p of func.sig.params) if (p.ptrKind != null) ctx.func.localReps.set(p.name, { val: p.ptrKind })
195
208
  invalidateLocalsCache(func.body)
196
- const fresh = analyzeLocals(func.body)
209
+ const fresh = analyzeBody(func.body).locals
197
210
  for (const p of func.sig.params) if (!fresh.has(p.name)) fresh.set(p.name, p.type)
198
211
  callerCtx.get(func).callerLocals = fresh
199
212
  }
213
+ ctx.func.localReps = null
200
214
  }
201
215
 
202
216
  function resetParamWasmFacts(paramReps) {
203
217
  for (const m of paramReps.values()) for (const r of m.values()) r.wasm = undefined
204
218
  }
205
219
 
220
+ /**
221
+ * Phase E: numeric result narrowing.
222
+ *
223
+ * For every narrowable func whose body returns only i32-typed expressions,
224
+ * narrow sig.results[0] to 'i32'. An *unsigned* tail flips sig.unsignedResult so
225
+ * the call-site rebox uses f64.convert_i32_u and preserves [0, 2^32) range.
226
+ * A tail is unsigned when it is a top-level `(x >>> 0)` OR a call to a function
227
+ * already narrowed `unsignedResult` — the latter propagates the flag through
228
+ * helper chains (`const u = x => (x|0)>>>0; const main = x => u(x)`), which a
229
+ * literal-`>>>`-only check would miss, reboxing main's result signed and
230
+ * silently turning `4294967295` into `-1`.
231
+ *
232
+ * Sign must be consistent across *all* tails: the same i32 bit pattern maps to
233
+ * two different JS numbers under signed vs unsigned conversion, so a function
234
+ * mixing signed (`x|0`) and unsigned (`x>>>0`) tails cannot be reboxed with a
235
+ * single boundary flag. Such functions are left at f64 — the body then converts
236
+ * each tail with its own sign. (Pre-fix, a top-level `>>>` next to a signed tail
237
+ * narrowed unsigned and corrupted the signed branch.)
238
+ *
239
+ * Fixpoint: a call to another narrowed func contributes i32; iterate until
240
+ * stable so chains of i32-only helpers all narrow together. exprType already
241
+ * consults ctx.func.map for narrowed user-function results plus the
242
+ * Math.imul/Math.clz32/charCodeAt stdlib subset.
243
+ *
244
+ * Safe for exports — boundary wrapper restores the f64 JS ABI. `return;`
245
+ * (bare) is preserved as f64; multi-value / raw / value-used are skipped by
246
+ * the narrowable filter.
247
+ */
248
+ function narrowI32Results(funcs) {
249
+ // A return tail is unsigned-valued when it is a top-level `>>>` or a call to
250
+ // a function already proven unsignedResult. Other i32 tails are signed.
251
+ const isUnsignedTail = (e) => Array.isArray(e) && (
252
+ e[0] === '>>>' ||
253
+ (e[0] === '()' && typeof e[1] === 'string' && ctx.func.map?.get(e[1])?.sig?.unsignedResult === true)
254
+ )
255
+ let changed = true
256
+ while (changed) {
257
+ changed = false
258
+ for (const func of funcs) {
259
+ if (func.sig.results[0] === 'i32') continue
260
+ const body = func.body
261
+ if (isBlockBody(body) && hasBareReturn(body)) continue
262
+ const exprs = returnExprs(body)
263
+ if (!exprs.length) continue
264
+ const savedCurrent = ctx.func.current
265
+ ctx.func.current = func.sig
266
+ const locals = isBlockBody(body) ? analyzeBody(body).locals : new Map()
267
+ for (const p of func.sig.params) if (!locals.has(p.name)) locals.set(p.name, p.type)
268
+ const allI32 = exprs.every(e => exprType(e, locals) === 'i32')
269
+ const anyUnsigned = exprs.some(isUnsignedTail)
270
+ const allUnsigned = exprs.every(isUnsignedTail)
271
+ ctx.func.current = savedCurrent
272
+ // Narrow only when sign is consistent (all-signed or all-unsigned tails).
273
+ if (allI32 && (!anyUnsigned || allUnsigned)) {
274
+ func.sig.results = ['i32']
275
+ if (allUnsigned) func.sig.unsignedResult = true
276
+ changed = true
277
+ }
278
+ }
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Phase E2: VAL-kind result inference.
284
+ *
285
+ * When every return-tail resolves to the same VAL.* kind, record it on
286
+ * func.valResult so call-site valTypeOf inherits it (enables static dispatch
287
+ * on .length / [i] / .prop through the call chain). Fixpoint propagates
288
+ * through helper chains. Exports are safe — same boundary-wrapper guarantee
289
+ * as numeric narrowing.
290
+ */
291
+ function narrowValResults(funcs) {
292
+ const valTypeOfWithCalls = (expr, localValTypes) => {
293
+ if (expr == null) return null
294
+ if (typeof expr === 'string') return localValTypes?.get(expr) || ctx.scope.globalValTypes?.get(expr) || null
295
+ if (!Array.isArray(expr)) return valTypeOf(expr)
296
+ const [op, ...args] = expr
297
+ if (op === '()' && typeof args[0] === 'string') {
298
+ const f = ctx.func.map.get(args[0])
299
+ if (f?.valResult) return f.valResult
300
+ }
301
+ if (op === '?:') {
302
+ const a = valTypeOfWithCalls(args[1], localValTypes), b = valTypeOfWithCalls(args[2], localValTypes)
303
+ return a && a === b ? a : null
304
+ }
305
+ if (op === '&&' || op === '||') {
306
+ const a = valTypeOfWithCalls(args[0], localValTypes), b = valTypeOfWithCalls(args[1], localValTypes)
307
+ return a && a === b ? a : null
308
+ }
309
+ return valTypeOf(expr)
310
+ }
311
+ let changed = true
312
+ while (changed) {
313
+ changed = false
314
+ for (const func of funcs) {
315
+ if (func.valResult) continue
316
+ const body = func.body
317
+ const isBlock = isBlockBody(body)
318
+ if (isBlock && hasBareReturn(body)) continue
319
+ const exprs = returnExprs(body)
320
+ if (!exprs.length) continue
321
+ const localValTypes = isBlock ? analyzeBody(body).valTypes : new Map()
322
+ const vt0 = valTypeOfWithCalls(exprs[0], localValTypes)
323
+ if (!vt0) continue
324
+ const allSame = exprs.every(e => valTypeOfWithCalls(e, localValTypes) === vt0)
325
+ if (allSame) { func.valResult = vt0; changed = true }
326
+ }
327
+ }
328
+ }
329
+
330
+ const PTR_RESULT_KINDS_NOAUX = new Set([VAL.SET, VAL.MAP, VAL.BUFFER])
331
+
332
+ // Per-body local elemAux map: scans `let/const x = new TypedArray(...)` decls so a return
333
+ // like `let a = new Float64Array(...); return a` resolves to a constant aux.
334
+ function localElemAuxMap(body) {
335
+ const m = new Map()
336
+ const walk = (n) => {
337
+ if (!Array.isArray(n)) return
338
+ const op = n[0]
339
+ if (op === '=>') return
340
+ if ((op === 'let' || op === 'const') && n.length > 1) {
341
+ for (let i = 1; i < n.length; i++) {
342
+ const a = n[i]
343
+ if (Array.isArray(a) && a[0] === '=' && typeof a[1] === 'string') {
344
+ const aux = typedElemAux(typedElemCtor(a[2]))
345
+ if (aux != null) m.set(a[1], aux)
346
+ }
347
+ }
348
+ }
349
+ for (let i = 1; i < n.length; i++) walk(n[i])
350
+ }
351
+ walk(body)
352
+ return m
353
+ }
354
+
355
+ function typedAuxOfReturn(expr, localElemMap) {
356
+ if (typeof expr === 'string') return localElemMap?.get(expr) ?? null
357
+ if (!Array.isArray(expr)) return null
358
+ const [op, ...args] = expr
359
+ if (op === '()' && typeof args[0] === 'string') {
360
+ if (args[0].startsWith('new.')) {
361
+ const ctor = typedElemCtor(expr)
362
+ return ctor != null ? typedElemAux(ctor) : null
363
+ }
364
+ const f = ctx.func.map.get(args[0])
365
+ if (f?.valResult === VAL.TYPED && f.sig.ptrAux != null) return f.sig.ptrAux
366
+ return null
367
+ }
368
+ if (op === '?:') {
369
+ const a = typedAuxOfReturn(args[1], localElemMap)
370
+ const b = typedAuxOfReturn(args[2], localElemMap)
371
+ return a != null && a === b ? a : null
372
+ }
373
+ if (op === '&&' || op === '||') {
374
+ const a = typedAuxOfReturn(args[0], localElemMap)
375
+ const b = typedAuxOfReturn(args[1], localElemMap)
376
+ return a != null && a === b ? a : null
377
+ }
378
+ return null
379
+ }
380
+
381
+ /**
382
+ * Phase E3: pointer result narrowing.
383
+ *
384
+ * For narrowable funcs whose valResult is a non-ambiguous pointer kind with a
385
+ * constant aux, narrow sig.results[0] from f64 to i32 and tag sig.ptrKind/.ptrAux.
386
+ * Eliminates the f64.reinterpret_i64+i64.or rebox at every return and the
387
+ * matching unbox dance at every call site that uses the value as a pointer.
388
+ *
389
+ * Aux strategy:
390
+ * - SET/MAP/BUFFER: aux always 0 — no per-callsite preservation needed.
391
+ * - OBJECT: aux is schema-id; narrow only when all return exprs share a constant
392
+ * schema (literal, schemaId-bound param, module-bound var, or call to another
393
+ * OBJECT-narrowed func). Caller picks aux up via callIR.ptrAux → readVar →
394
+ * localReps.schemaId, restoring property-slot dispatch through the call boundary.
395
+ * - TYPED: aux is elem-type; require all return tails to agree on a single aux.
396
+ *
397
+ * Skipped: ARRAY forwards on realloc, STRING dual-encoded SSO/heap, CLOSURE
398
+ * (aux carries funcIdx for call_indirect). Body must guarantee-return so the
399
+ * fallthrough fallback can't produce a wrong-typed undef.
400
+ *
401
+ * Fixpoint: a chain `outer → inner → {a,b}` needs inner to narrow first so
402
+ * outer's call to inner contributes a known schema-id.
403
+ */
404
+ function narrowPointerResults(funcs, paramReps) {
405
+ let changed = true
406
+ while (changed) {
407
+ changed = false
408
+ for (const func of funcs) {
409
+ if (!func.valResult) continue
410
+ if (func.sig.results[0] !== 'f64') continue
411
+ const isBlock = isBlockBody(func.body)
412
+ if (isBlock && !alwaysReturns(func.body)) continue
413
+ if (PTR_RESULT_KINDS_NOAUX.has(func.valResult)) {
414
+ func.sig.results = ['i32']
415
+ func.sig.ptrKind = func.valResult
416
+ changed = true
417
+ continue
418
+ }
419
+ const exprs = returnExprs(func.body)
420
+ if (!exprs.length) continue
421
+ if (func.valResult === VAL.OBJECT) {
422
+ const paramSchemasMap = paramFactsOf(paramReps, func, 'schemaId')
423
+ const sid0 = inferSchemaId(exprs[0], paramSchemasMap)
424
+ if (sid0 == null) continue
425
+ if (!exprs.every(e => inferSchemaId(e, paramSchemasMap) === sid0)) continue
426
+ func.sig.results = ['i32']
427
+ func.sig.ptrKind = VAL.OBJECT
428
+ func.sig.ptrAux = sid0
429
+ changed = true
430
+ } else if (func.valResult === VAL.TYPED) {
431
+ const localMap = isBlock ? localElemAuxMap(func.body) : null
432
+ const aux0 = typedAuxOfReturn(exprs[0], localMap)
433
+ if (aux0 == null) continue
434
+ if (!exprs.every(e => typedAuxOfReturn(e, localMap) === aux0)) continue
435
+ func.sig.results = ['i32']
436
+ func.sig.ptrKind = VAL.TYPED
437
+ func.sig.ptrAux = aux0
438
+ changed = true
439
+ }
440
+ }
441
+ }
442
+ }
443
+
206
444
  function createPhaseState() {
207
445
  const callerCtx = buildCallerCtx()
208
446
  const elemCtx = new Map()
@@ -260,13 +498,13 @@ export default function narrowSignatures(programFacts, ast) {
260
498
  // D: Call-site type propagation — infer param types from how functions are called.
261
499
  // Drives off `callSites` collected during the ProgramFacts walk; no AST re-walking.
262
500
  // For non-exported internal functions, if all call sites agree on a param's type,
263
- // seed the param's val rep (ctx.func.repByLocal) during per-function compilation.
501
+ // seed the param's val rep (ctx.func.localReps) during per-function compilation.
264
502
  // Also infer i32/f64 WASM type — when all call sites pass i32 for a param, specialize
265
503
  // sig.params[k].type to i32 (no default, no rest, not exported, not value-used).
266
504
  // Also propagate schema ID — when all call sites pass objects with the same schema,
267
505
  // bind the callee's param to that schema so `p.x` becomes a direct slot load.
268
- // Inference helpers (inferArgType/inferArgSchema/inferArgArr*/inferArgTypedCtor)
269
- // live in analyze.js — pure AST→fact resolvers shared across fixpoint phases.
506
+ // Inference helpers (inferValType/inferSchemaId/inferArr*/inferTypedCtor)
507
+ // live in infer.js — pure AST→fact resolvers shared across fixpoint phases.
270
508
  // Per-caller analysis is stable across fixpoint iterations — precompute once.
271
509
  // callerCtx[null] (top-level) uses module globals for both locals and valTypes.
272
510
  const phase = createPhaseState()
@@ -277,7 +515,7 @@ export default function narrowSignatures(programFacts, ast) {
277
515
  else if (Array.isArray(arg) && arg[0] == null && typeof arg[1] === 'number') raw = arg[1]
278
516
  else if (Array.isArray(arg) && arg[0] === 'u-' && typeof arg[1] === 'number') raw = -arg[1]
279
517
  else if (typeof arg === 'string' && ctx.scope.constInts?.has(arg)) raw = ctx.scope.constInts.get(arg)
280
- return (raw != null && Number.isInteger(raw) && raw >= -2147483648 && raw <= 2147483647) ? raw : null
518
+ return (raw != null && Number.isInteger(raw) && raw >= I32_MIN && raw <= I32_MAX) ? raw : null
281
519
  }
282
520
 
283
521
  const runCallsiteLattice = (rules) => {
@@ -294,7 +532,7 @@ export default function narrowSignatures(programFacts, ast) {
294
532
  callerLocals: ctxEntry.callerLocals,
295
533
  callerValTypes: ctxEntry.callerValTypes,
296
534
  callerParamFacts(key) {
297
- if (!paramFacts.has(key)) paramFacts.set(key, callerParamFactMap(paramReps, callerFunc, key))
535
+ if (!paramFacts.has(key)) paramFacts.set(key, paramFactsOf(paramReps, callerFunc, key))
298
536
  return paramFacts.get(key)
299
537
  },
300
538
  }
@@ -308,13 +546,13 @@ export default function narrowSignatures(programFacts, ast) {
308
546
  }
309
547
 
310
548
  const poison = field => r => { r[field] = null }
311
- // Default-aware val inference. Adds two fallbacks beyond inferArgType's
549
+ // Default-aware val inference. Adds two fallbacks beyond inferValType's
312
550
  // body-local `callerValTypes` lookup so a hot recursive helper like
313
551
  // `uleb(n, buffer = []) { ... return uleb(n, buffer) }` resolves the
314
552
  // recursive `buffer` arg to VAL.ARRAY (via callerParamFacts on iter 2,
315
553
  // or via the caller's own default expression on iter 1).
316
554
  const inferValAtSite = (arg, state) => {
317
- const v = inferArgType(arg, state.callerValTypes)
555
+ const v = inferValType(arg, state.callerValTypes)
318
556
  if (v != null) return v
319
557
  if (typeof arg !== 'string') return null
320
558
  const fromParam = state.callerParamFacts('val')?.get(arg)
@@ -352,7 +590,7 @@ export default function narrowSignatures(programFacts, ast) {
352
590
  else if (r.wasm !== wt) r.wasm = null
353
591
  },
354
592
  },
355
- mergeRule('schemaId', (arg, _k, state) => inferArgSchema(arg, state.callerParamFacts('schemaId'))),
593
+ mergeRule('schemaId', (arg, _k, state) => inferSchemaId(arg, state.callerParamFacts('schemaId'))),
356
594
  {
357
595
  missing: poison('intConst'),
358
596
  apply(r, arg, k, state) {
@@ -361,13 +599,27 @@ export default function narrowSignatures(programFacts, ast) {
361
599
  },
362
600
  },
363
601
  ])
602
+ // Transitive ctor/schema propagation down call chains. A naive single-pass
603
+ // mergeRule poisons a callee's param on the *first* sweep if the caller's own
604
+ // param (the very thing that supplies the ctor) hasn't been typed yet — and the
605
+ // poison is sticky, so later sweeps can't recover. Two-pass was the old patch;
606
+ // it still loses any chain deeper than `main→f→g→h` (e.g. heapsort's siftDown).
607
+ // Fix: iterate a *soft* merge — propagate known ctors, treat "can't tell yet"
608
+ // as skip (no poison) — to a fixpoint, then one *hard* validating sweep that
609
+ // poisons params whose call sites still can't be proven (genuinely-untyped args).
364
610
  const runArrElemFixpoint = (field, inferFn, elemsCtxMap) => {
365
- runCallsiteLattice([mergeRule(field, (arg, _k, state) =>
366
- inferFn(arg, elemsCtxMap.get(state.callerFunc), state.callerParamFacts(field))
367
- )])
611
+ const infer = (arg, _k, state) => inferFn(arg, elemsCtxMap.get(state.callerFunc), state.callerParamFacts(field))
612
+ let changed
613
+ const bump = (r, v) => { if (v == null || r[field] === null) return; const b = r[field]; mergeParamFact(r, field, v); if (r[field] !== b) changed = true }
614
+ const soft = {
615
+ missing(r, k, state) { const def = defaultArg(state, k); if (def != null) bump(r, infer(def, k, state)) },
616
+ apply(r, arg, k, state) { bump(r, infer(arg, k, state)) },
617
+ }
618
+ do { changed = false; runCallsiteLattice([soft]) } while (changed)
619
+ runCallsiteLattice([mergeRule(field, infer)])
368
620
  }
369
- const runArrFixpoint = () => runArrElemFixpoint('arrayElemSchema', inferArgArrElemSchema, phase.callerElems('arrElemSchemas'))
370
- const runArrValTypeFixpoint = () => runArrElemFixpoint('arrayElemValType', inferArgArrElemValType, phase.callerElems('arrElemValTypes'))
621
+ const runArrFixpoint = () => runArrElemFixpoint('arrayElemSchema', inferArrElemSchema, phase.callerElems('arrElemSchemas'))
622
+ const runArrValTypeFixpoint = () => runArrElemFixpoint('arrayElemValType', inferArrElemValType, phase.callerElems('arrElemValTypes'))
371
623
  runFixpoint()
372
624
  runFixpoint()
373
625
 
@@ -395,93 +647,12 @@ export default function narrowSignatures(programFacts, ast) {
395
647
  // - exclude rest position (array pack/unpack stays f64).
396
648
  applyPointerParamAbi(paramReps, valueUsed)
397
649
 
398
- // E: Result-type monomorphization narrow sig.results[0] to 'i32' when body only
399
- // produces i32 values. Fixpoint: a call to another narrowed func now contributes i32;
400
- // iterate until stable so chains of i32-only helpers all narrow together.
401
- // Safety: skip exported (JS boundary preserves number semantics), value-used (closure
402
- // trampolines assume f64 result), raw WAT, multi-value. `undefined` return = skip.
403
- // exprType already consults ctx.func.map for narrowed user-function results
404
- // (analyze.js exprType `()` branch), plus the Math.imul/Math.clz32/charCodeAt
405
- // stdlib subset and primitive-op rules. Earlier we had a local shim here that
406
- // shadowed exprType's stdlib rules with `return 'f64'` for any non-user call;
407
- // unifying through exprType lets a single rule (math.imul → i32) flow through
408
- // to mix-style helpers (`(h, x) => Math.imul(h ^ (x|0), C)`) and unblocks the
409
- // E-phase result narrowing on every call site that consumes them.
410
- const exprTypeWithCalls = exprType
411
- // Body-driven: safe for exports — the result type is determined by what the body
412
- // computes, not by what JS callers might pass. JS-visible f64 ABI is restored at
413
- // the boundary via a synthesized wrapper (see synthesizeBoundaryWrappers below).
414
- // Shared pool for E (numeric), E2 (valType) and E3 (ptr) narrowing — same predicate.
650
+ // E + E2: body-driven result-type inference. Pool of narrowable funcs is shared
651
+ // across the i32/VAL/pointer passes same predicate (non-raw, non-value-used,
652
+ // single-result).
415
653
  const funcsWithNarrowableResult = narrowableFuncs(valueUsed)
416
- let changed = true
417
- while (changed) {
418
- changed = false
419
- for (const func of funcsWithNarrowableResult) {
420
- if (func.sig.results[0] === 'i32') continue
421
- const body = func.body
422
- // Bare `return;` produces undef (f64) — narrowing to i32 would lose that.
423
- if (isBlockBody(body) && hasBareReturn(body)) continue
424
- const exprs = returnExprs(body)
425
- if (!exprs.length) continue
426
- // Skip narrowing when any return-tail is `>>>` (unsigned uint32). Narrowing to i32
427
- // loses the unsigned interpretation: the wrapper rebox via `f64.convert_i32_s` would
428
- // sign-flip values with bit 31 set, breaking the canonical `(x >>> 0)` uint32 idiom.
429
- // A future pass could track sig.unsignedResult and emit `f64.convert_i32_u` instead.
430
- if (exprs.some(e => Array.isArray(e) && e[0] === '>>>')) continue
431
- const savedCurrent = ctx.func.current
432
- ctx.func.current = func.sig
433
- const locals = isBlockBody(body) ? analyzeLocals(body) : new Map()
434
- for (const p of func.sig.params) if (!locals.has(p.name)) locals.set(p.name, p.type)
435
- const allI32 = exprs.every(e => exprTypeWithCalls(e, locals) === 'i32')
436
- ctx.func.current = savedCurrent
437
- if (allI32) { func.sig.results = ['i32']; changed = true }
438
- }
439
- }
440
-
441
- // E2: VAL-type result inference — if a function always returns the same VAL kind,
442
- // record it so callers inherit that type (enables static dispatch on .length, .[],
443
- // .prop through a call chain). Fixpoint propagates through helper chains.
444
- // Safety: skip exported (host sees raw f64), value-used (indirect call signature).
445
- // Shim so calls to already-typed funcs contribute their result type.
446
- const valTypeOfWithCalls = (expr, localValTypes) => {
447
- if (expr == null) return null
448
- if (typeof expr === 'string') return localValTypes?.get(expr) || ctx.scope.globalValTypes?.get(expr) || null
449
- if (!Array.isArray(expr)) return valTypeOf(expr)
450
- const [op, ...args] = expr
451
- if (op === '()' && typeof args[0] === 'string') {
452
- const f = ctx.func.map.get(args[0])
453
- if (f?.valResult) return f.valResult
454
- }
455
- if (op === '?:') {
456
- const a = valTypeOfWithCalls(args[1], localValTypes), b = valTypeOfWithCalls(args[2], localValTypes)
457
- return a && a === b ? a : null
458
- }
459
- if (op === '&&' || op === '||') {
460
- const a = valTypeOfWithCalls(args[0], localValTypes), b = valTypeOfWithCalls(args[1], localValTypes)
461
- return a && a === b ? a : null
462
- }
463
- return valTypeOf(expr)
464
- }
465
- // Body-driven valResult inference: same safety analysis as numeric narrowing
466
- // above — exports OK because boundary wrapper restores f64 ABI for JS callers.
467
- changed = true
468
- while (changed) {
469
- changed = false
470
- for (const func of funcsWithNarrowableResult) {
471
- if (func.valResult) continue
472
- const body = func.body
473
- const isBlock = isBlockBody(body)
474
- if (isBlock && hasBareReturn(body)) continue
475
- const exprs = returnExprs(body)
476
- if (!exprs.length) continue
477
- const localValTypes = isBlock ? analyzeBody(body).valTypes : new Map()
478
- // Params of this function contribute no known VAL type yet (paramReps may help later).
479
- const vt0 = valTypeOfWithCalls(exprs[0], localValTypes)
480
- if (!vt0) continue
481
- const allSame = exprs.every(e => valTypeOfWithCalls(e, localValTypes) === vt0)
482
- if (allSame) { func.valResult = vt0; changed = true }
483
- }
484
- }
654
+ narrowI32Results(funcsWithNarrowableResult)
655
+ narrowValResults(funcsWithNarrowableResult)
485
656
 
486
657
  // Now that E2 set `valResult` on funcs, narrow per-func `arrayElemSchema` for
487
658
  // VAL.ARRAY-returning funcs (via push observations + call chains). Then re-run the
@@ -515,141 +686,9 @@ export default function narrowSignatures(programFacts, ast) {
515
686
  // through helper chains: `init()→main→runKernel`.
516
687
  runArrValTypeFixpoint()
517
688
  runArrValTypeFixpoint()
518
- // E3: Result-type pointer narrowing — when valResult is a non-ambiguous pointer kind
519
- // with constant aux, narrow sig.results[0] from f64 to i32 and tag sig.ptrKind/.ptrAux.
520
- // Eliminates the f64.reinterpret_i64+i64.or rebox at every return and the
521
- // i32.wrap_i64+i64.reinterpret_f64 unbox at every callsite that uses the value as a
522
- // pointer (load .[], .length, .prop slot dispatch).
523
- // - SET/MAP/BUFFER: aux always 0 — no per-callsite aux preservation needed.
524
- // - OBJECT: aux is schema-id; narrow only when all return exprs share a constant
525
- // schema (literal `{a,b,c}`, schemaId-bound param, module-bound var, or call to
526
- // another OBJECT-narrowed func). Caller picks aux up via callIR.ptrAux → readVar →
527
- // repByLocal.schemaId, restoring property-slot dispatch through the call boundary.
528
- // Safety: ARRAY forwards on realloc (no narrowing). STRING dual-encoded SSO/heap.
529
- // CLOSURE/TYPED also carry meaningful aux — TYPED narrowing is a follow-up. Body must
530
- // be a guaranteed-return form — fallthrough fallback i32.const 0 would be a valid
531
- // offset 0 of the narrowed kind, not undefined.
532
- const PTR_RESULT_KINDS_NOAUX = new Set([VAL.SET, VAL.MAP, VAL.BUFFER])
533
- // Schema-id inference for a return expression. Returns id (number), or null if unknown
534
- // / not constant. Mirrors inferArgSchema but extends with calls to already-narrowed
535
- // OBJECT-result funcs (fixpoint propagation through helper chains).
536
- const schemaIdOfReturn = (expr, paramSchemasMap) => {
537
- if (typeof expr === 'string') {
538
- if (paramSchemasMap?.has(expr)) return paramSchemasMap.get(expr)
539
- if (ctx.schema.vars.has(expr)) return ctx.schema.vars.get(expr)
540
- return null
541
- }
542
- if (!Array.isArray(expr)) return null
543
- const [op, ...args] = expr
544
- if (op === '{}') {
545
- // Object literal: bail to null on block body, dynamic key, or spread.
546
- const parsed = staticObjectProps(args)
547
- return parsed ? ctx.schema.register(parsed.names) : null
548
- }
549
- if (op === '()' && typeof args[0] === 'string') {
550
- const f = ctx.func.map.get(args[0])
551
- if (f?.valResult === VAL.OBJECT && f.sig.ptrAux != null) return f.sig.ptrAux
552
- return null
553
- }
554
- if (op === '?:') {
555
- const a = schemaIdOfReturn(args[1], paramSchemasMap)
556
- const b = schemaIdOfReturn(args[2], paramSchemasMap)
557
- return a != null && a === b ? a : null
558
- }
559
- if (op === '&&' || op === '||') {
560
- const a = schemaIdOfReturn(args[0], paramSchemasMap)
561
- const b = schemaIdOfReturn(args[1], paramSchemasMap)
562
- return a != null && a === b ? a : null
563
- }
564
- return null
565
- }
566
- // Per-body local elemAux map: scans `let/const x = new TypedArray(...)` decls so
567
- // a return like `let a = new Float64Array(...); return a` resolves to a constant
568
- // aux. Result calls + ?: are handled inline in typedAuxOfReturn.
569
- const localElemAuxMap = (body) => {
570
- const m = new Map()
571
- const walk = (n) => {
572
- if (!Array.isArray(n)) return
573
- const op = n[0]
574
- if (op === '=>') return
575
- if ((op === 'let' || op === 'const') && n.length > 1) {
576
- for (let i = 1; i < n.length; i++) {
577
- const a = n[i]
578
- if (Array.isArray(a) && a[0] === '=' && typeof a[1] === 'string') {
579
- const aux = typedElemAux(typedElemCtor(a[2]))
580
- if (aux != null) m.set(a[1], aux)
581
- }
582
- }
583
- }
584
- for (let i = 1; i < n.length; i++) walk(n[i])
585
- }
586
- walk(body)
587
- return m
588
- }
589
- const typedAuxOfReturn = (expr, localElemMap) => {
590
- if (typeof expr === 'string') return localElemMap?.get(expr) ?? null
591
- if (!Array.isArray(expr)) return null
592
- const [op, ...args] = expr
593
- if (op === '()' && typeof args[0] === 'string') {
594
- if (args[0].startsWith('new.')) {
595
- const ctor = typedElemCtor(expr)
596
- return ctor != null ? typedElemAux(ctor) : null
597
- }
598
- const f = ctx.func.map.get(args[0])
599
- if (f?.valResult === VAL.TYPED && f.sig.ptrAux != null) return f.sig.ptrAux
600
- return null
601
- }
602
- if (op === '?:') {
603
- const a = typedAuxOfReturn(args[1], localElemMap)
604
- const b = typedAuxOfReturn(args[2], localElemMap)
605
- return a != null && a === b ? a : null
606
- }
607
- if (op === '&&' || op === '||') {
608
- const a = typedAuxOfReturn(args[0], localElemMap)
609
- const b = typedAuxOfReturn(args[1], localElemMap)
610
- return a != null && a === b ? a : null
611
- }
612
- return null
613
- }
614
- // Fixpoint: a chain `outer → inner → {a,b}` needs inner to narrow first so outer's
615
- // call to inner contributes a known schema-id.
616
- let narrowChanged = true
617
- while (narrowChanged) {
618
- narrowChanged = false
619
- for (const func of funcsWithNarrowableResult) {
620
- if (!func.valResult) continue
621
- if (func.sig.results[0] !== 'f64') continue
622
- const isBlock = isBlockBody(func.body)
623
- if (isBlock && !alwaysReturns(func.body)) continue
624
- if (PTR_RESULT_KINDS_NOAUX.has(func.valResult)) {
625
- func.sig.results = ['i32']
626
- func.sig.ptrKind = func.valResult
627
- narrowChanged = true
628
- continue
629
- }
630
- const exprs = returnExprs(func.body)
631
- if (!exprs.length) continue
632
- if (func.valResult === VAL.OBJECT) {
633
- const paramSchemasMap = callerParamFactMap(paramReps, func, 'schemaId')
634
- const sid0 = schemaIdOfReturn(exprs[0], paramSchemasMap)
635
- if (sid0 == null) continue
636
- if (!exprs.every(e => schemaIdOfReturn(e, paramSchemasMap) === sid0)) continue
637
- func.sig.results = ['i32']
638
- func.sig.ptrKind = VAL.OBJECT
639
- func.sig.ptrAux = sid0
640
- narrowChanged = true
641
- } else if (func.valResult === VAL.TYPED) {
642
- const localMap = isBlock ? localElemAuxMap(func.body) : null
643
- const aux0 = typedAuxOfReturn(exprs[0], localMap)
644
- if (aux0 == null) continue
645
- if (!exprs.every(e => typedAuxOfReturn(e, localMap) === aux0)) continue
646
- func.sig.results = ['i32']
647
- func.sig.ptrKind = VAL.TYPED
648
- func.sig.ptrAux = aux0
649
- narrowChanged = true
650
- }
651
- }
652
- }
689
+ // E3: pointer-kind result narrowing — once valResult is set, lift the wasm
690
+ // return type to i32 + ptrKind/ptrAux when aux is statically resolvable.
691
+ narrowPointerResults(funcsWithNarrowableResult, paramReps)
653
692
 
654
693
  // F: Cross-call typed-array element ctor propagation. Runs AFTER E3 so that
655
694
  // calls to user functions returning a TYPED-narrowed pointer (with constant
@@ -658,8 +697,10 @@ export default function narrowSignatures(programFacts, ast) {
658
697
  // for their own params and `arr[i]` reads emit a direct `f64.load` instead of
659
698
  // the runtime `__is_str_key + __typed_idx` dispatch — closes the largest
660
699
  // chunk of the JS→wasm gap on f64-heavy hot loops.
661
- // (Helpers `inferArgTypedCtor`/`ctorFromElemAux` live in analyze.js so the
662
- // bimorphic-typed specialization pass below can reuse them.)
700
+ // (Helper `inferTypedCtor` lives in src/infer.js the call-site mirror
701
+ // of body-walk evidence and is reused by the bimorphic-typed
702
+ // specialization pass below; `ctorFromElemAux` stays in analyze.js next
703
+ // to its encode/decode partner.)
663
704
  // Per-caller typed-elem map, recomputed now that E3 has tagged helper sigs.
664
705
  // Cache invalidation: analyzeBody.typedElems reads `ctx.func.map.get(...).sig.ptrKind`
665
706
  // for `let x = mkInput(...)` decls; entries cached during the initial walk
@@ -670,7 +711,7 @@ export default function narrowSignatures(programFacts, ast) {
670
711
  // its own callees (e.g. if `outer(buf)` calls `inner(buf)` and we learn `buf`
671
712
  // for outer, the second pass picks it up for inner). Reuses runArrElemFixpoint
672
713
  // (same shape — field/inferFn/elemsCtxMap parameterization).
673
- const runTypedFixpoint = () => runArrElemFixpoint('typedCtor', inferArgTypedCtor, callerTypedCtx)
714
+ const runTypedFixpoint = () => runArrElemFixpoint('typedCtor', inferTypedCtor, callerTypedCtx)
674
715
  runTypedFixpoint()
675
716
  runTypedFixpoint()
676
717
 
@@ -687,7 +728,7 @@ export default function narrowSignatures(programFacts, ast) {
687
728
  // H: Post-F/G re-fixpoint — propagates VAL kinds through bimorphic call sites
688
729
  // where ptrKind narrowed but ptrAux disagreed (e.g. `sum(f64arr)` and `sum(i32arr)`
689
730
  // → both VAL.TYPED, different ctors). Without this, callerValTypes carries no entry
690
- // for caller's params, so inferArgType returns null and paramReps[callee][k].val is
731
+ // for caller's params, so inferValType returns null and paramReps[callee][k].val is
691
732
  // sticky null. With ptrKind enriching callerValTypes, sum's arr gets val=TYPED in
692
733
  // its rep, letting array.js skip __is_str_key + __str_idx dispatch on `arr[i]`.
693
734
  enrichCallerValTypesFromPointerParams(callerCtx)
@@ -715,6 +756,174 @@ export default function narrowSignatures(programFacts, ast) {
715
756
  // so r.wasm flips to 'i32' here — but narrowing now breaks the clone path
716
757
  // that still needs to mint per-ctor sigs with ptrKind=TYPED, ptrAux=ctor-aux.
717
758
  applyI32ParamSpecialization(paramReps, valueUsed, { skipTyped: true })
759
+
760
+ // J: jsstring boundary opt-in — for exported funcs with a string param whose
761
+ // every use is mappable to a wasm:js-string builtin, flip the param's wasm
762
+ // slot from f64 (nanbox SSO carrier) to externref so the JS host passes the
763
+ // native string directly. Zero copy, zero transcoding. See applyJsstringBoundaryCarrier.
764
+ if (jsstringEnabled()) applyJsstringBoundaryCarrier(paramReps, valueUsed)
765
+ }
766
+
767
+ /** Gate the jsstring opt-in: enabled by default for JS hosts; disabled under
768
+ * WASI (env imports unavailable) or when explicitly opted out via the
769
+ * `optimize: { jsstring: false }` knob (used by side-by-side benchmarks). */
770
+ function jsstringEnabled() {
771
+ if (ctx.transform.host === 'wasi') return false
772
+ if (ctx.transform.optimize?.jsstring === false) return false
773
+ return true
774
+ }
775
+
776
+ /** Phase J standalone: runs even when `canSkipWholeProgramNarrowing` short-circuits
777
+ * the main narrow pass. The check is body-local and export-boundary-only, so call-
778
+ * site lattice isn't needed; just guard on host and run the use-scan. */
779
+ export function applyJsstringBoundaryCarrierStandalone(programFacts) {
780
+ if (!jsstringEnabled()) return
781
+ applyJsstringBoundaryCarrier(new Map(), programFacts.valueUsed)
782
+ }
783
+
784
+ /**
785
+ * Body-local boolean-result inference. `narrowValResults` is the general (any
786
+ * VAL.*) pass, but it lives inside whole-program narrowing, which is skipped for
787
+ * trivial leaf modules (no call sites). A boolean result is the one kind whose
788
+ * internal carrier (the 0/1 number) differs from its host-boundary carrier (the
789
+ * TRUE_NAN/FALSE_NAN atom), so an exported `(a) => a > 2` still needs its boundary
790
+ * box even on the skip path. This pass only ever *sets* valResult = VAL.BOOL, so
791
+ * it is safe to run unconditionally — pointer/array/number results are untouched.
792
+ */
793
+ export function narrowBoolResults() {
794
+ for (const func of ctx.func.list) {
795
+ if (func.raw || func.valResult || !func.body || func.sig.results.length !== 1) continue
796
+ const body = func.body
797
+ const isBlock = isBlockBody(body)
798
+ if (isBlock && hasBareReturn(body)) continue
799
+ const exprs = returnExprs(body)
800
+ if (!exprs.length) continue
801
+ const localValTypes = isBlock ? analyzeBody(body).valTypes : null
802
+ const vt = e => typeof e === 'string'
803
+ ? (localValTypes?.get(e) || ctx.scope.globalValTypes?.get(e) || null)
804
+ : valTypeOf(e)
805
+ if (exprs.every(e => vt(e) === VAL.BOOL)) func.valResult = VAL.BOOL
806
+ }
807
+ }
808
+
809
+ // ── jsstring boundary carrier ───────────────────────────────────────────────
810
+ //
811
+ // Mappable use of an exported string param:
812
+ // - `s.length` → wasm:js-string.length
813
+ // - `s.charCodeAt(idx)` → wasm:js-string.charCodeAt — but ONLY when the
814
+ // index is provably in-bounds (scanBoundedLoops).
815
+ // The builtin traps on OOB; JS semantics return
816
+ // NaN. The only way to preserve JS semantics with
817
+ // zero overhead is to refuse non-bounded use.
818
+ // Anything else (concat, indexing `s[i]`, regex, hash key, passing to a non-
819
+ // externref param, reassignment, closure capture, `==` with anything, …) is a
820
+ // fallback trigger and disqualifies the param.
821
+
822
+ const JSS_OK_PROPS = new Set(['length', 'charCodeAt'])
823
+
824
+ /**
825
+ * Decide whether `name` (an exported func's STRING-shaped param) can flow
826
+ * through the boundary as `externref`. Walk the body once: every leaf
827
+ * occurrence of `name` must be the receiver of `.length` (always safe) or
828
+ * `.charCodeAt` whose callee node lives in `safeCC` (provably bounded).
829
+ * Reassignment / `++` / `--` / closure capture all reject conservatively.
830
+ *
831
+ * Returns `{ ok, stringDiscriminating }` — `stringDiscriminating` is true iff
832
+ * we saw at least one string-only use (`.charCodeAt`); the caller uses this
833
+ * to gate on whether the body actually proves the param is a string, since
834
+ * `.length` alone is polymorphic across string/array/typed-array.
835
+ */
836
+ function paramAllUsesJsstringMappable(body, name, safeCC) {
837
+ if (body == null) return { ok: false, stringDiscriminating: false }
838
+ let ok = true
839
+ let stringDiscriminating = false
840
+ const walk = (node) => {
841
+ if (!ok) return
842
+ if (typeof node === 'string') {
843
+ // A bare reference to the param outside an eligible `[., name, prop]`
844
+ // callee slot is a fallback trigger — the param escaped to a non-
845
+ // mappable use.
846
+ if (node === name) ok = false
847
+ return
848
+ }
849
+ if (!Array.isArray(node)) return
850
+ const op = node[0]
851
+ if (op === '=>') {
852
+ // Nested arrow may shadow `name` with its own param; if it doesn't, any
853
+ // capture of the outer `name` is an escape we can't track — reject.
854
+ const params = node[1]
855
+ const shadowed = Array.isArray(params)
856
+ ? params.some(p => (typeof p === 'string' && p === name) ||
857
+ (Array.isArray(p) && p[1] === name))
858
+ : params === name
859
+ if (!shadowed) ok = false
860
+ return
861
+ }
862
+ if ((op === '=' || op === '+=' || op === '-=' || op === '*=' || op === '/=' ||
863
+ op === '%=' || op === '&=' || op === '|=' || op === '^=' ||
864
+ op === '>>=' || op === '<<=' || op === '>>>=' ||
865
+ op === '||=' || op === '&&=' || op === '??=' ||
866
+ op === '++' || op === '--') && node[1] === name) {
867
+ ok = false
868
+ return
869
+ }
870
+ if (op === '.' && node[1] === name && JSS_OK_PROPS.has(node[2])) {
871
+ // .length is always safe but polymorphic across string/array/typed.
872
+ // .charCodeAt is only safe when the loop-bounds analysis proves the
873
+ // index in-bounds (this dotted callee node is exactly the one collected
874
+ // by scanBoundedLoops) — but it IS string-discriminating.
875
+ if (node[2] === 'length') return
876
+ if (safeCC.has(node)) { stringDiscriminating = true; return }
877
+ ok = false
878
+ return
879
+ }
880
+ // For `['()', callee, ...args]` with callee = ['.', name, 'charCodeAt'],
881
+ // `name` is in the callee receiver slot (validated by the branch above).
882
+ // The `.length` case is just a member read — no `()` wrapping.
883
+ for (let i = 1; i < node.length; i++) walk(node[i])
884
+ }
885
+ walk(body)
886
+ return { ok, stringDiscriminating }
887
+ }
888
+
889
+ function applyJsstringBoundaryCarrier(paramReps, valueUsed) {
890
+ for (const func of ctx.func.list) {
891
+ if (func.raw || !func.exported) continue
892
+ if (!func.body) continue
893
+ if (func.rest) continue // rest position stays packed-array
894
+ if (valueUsed.has(func.name)) continue // value-used → callers may pass non-string
895
+ // Pre-compute the in-bounds .charCodeAt callee nodes once per body.
896
+ const safeCC = new Set()
897
+ scanBoundedLoops(func.body, safeCC)
898
+ const reps = paramReps.get(func.name)
899
+ for (let k = 0; k < func.sig.params.length; k++) {
900
+ const p = func.sig.params[k]
901
+ if (p.type !== 'f64' || p.ptrKind != null) continue
902
+ // String-literal defaults (`s = ''`, `s = 'default'`) are both string-
903
+ // discrimination proof AND substituted JS-side by the interop wrapper —
904
+ // see `jz:extparam` def field. Non-string defaults still disqualify:
905
+ // the wasm side has no way to materialise an arbitrary externref default
906
+ // at boundary-check time without a host import.
907
+ const defVal = func.defaults?.[p.name]
908
+ if (defVal != null && !isLiteralStr(defVal)) continue
909
+ const { ok: usesOk, stringDiscriminating } = paramAllUsesJsstringMappable(func.body, p.name, safeCC)
910
+ if (!usesOk) continue
911
+ const r = reps?.get(k)
912
+ // Skip if any rep says non-STRING (`r.val` set to ARRAY/TYPED at any
913
+ // call site rules out jsstring).
914
+ if (r && r.val != null && r.val !== VAL.STRING) continue
915
+ // Discrimination signal: either a string-discriminating body use
916
+ // (`.charCodeAt`), a call-site proof (`r.val === STRING`), or an
917
+ // explicit string-literal default (the source intent declaration).
918
+ const hasStringDefault = defVal != null && isLiteralStr(defVal)
919
+ if (!stringDiscriminating && r?.val !== VAL.STRING && !hasStringDefault) continue
920
+ p.type = 'externref'
921
+ p.jsstring = true
922
+ // Record the literal default so compile.js can omit wasm-side substitution
923
+ // (the JS-side interop wrapper applies the default on `undefined`).
924
+ if (hasStringDefault) p.jsstringDefault = defVal[1]
925
+ }
926
+ }
718
927
  }
719
928
 
720
929
  /**
@@ -762,7 +971,7 @@ export function specializeBimorphicTyped(programFacts) {
762
971
  // (so transitive `sum(arr)` inside a func that took `arr` from above resolves).
763
972
  const callerTypedParamsCtx = new Map()
764
973
  for (const func of ctx.func.list) {
765
- const m = callerParamFactMap(paramReps, func, 'typedCtor') || null
974
+ const m = paramFactsOf(paramReps, func, 'typedCtor') || null
766
975
  let acc = m
767
976
  if (func.sig?.params) for (const p of func.sig.params) {
768
977
  if (p.ptrKind === VAL.TYPED && p.ptrAux != null) {
@@ -807,7 +1016,7 @@ export function specializeBimorphicTyped(programFacts) {
807
1016
  const combo = []
808
1017
  for (const k of bimorphic) {
809
1018
  if (k >= site.argList.length) { abort = true; break }
810
- const c = inferArgTypedCtor(site.argList[k], callerTypedElems, callerTypedParams)
1019
+ const c = inferTypedCtor(site.argList[k], callerTypedElems, callerTypedParams)
811
1020
  if (c == null || typedElemAux(c) == null) { abort = true; break }
812
1021
  combo.push(c)
813
1022
  }