jz 0.4.0 → 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
 
@@ -197,21 +202,245 @@ function refreshCallerLocals(callerCtx) {
197
202
  // this, post-G `refreshCallerLocals` still walks bodies with arr untyped, the
198
203
  // length stays f64, and any callee taking that length never gets an i32 param
199
204
  // (heapsort→siftDown's `end`). analyzeFuncForEmit re-seeds + re-invalidates at
200
- // emit time, so this transient repByLocal doesn't leak past narrowing.
201
- ctx.func.repByLocal = new Map()
202
- for (const p of func.sig.params) if (p.ptrKind != null) ctx.func.repByLocal.set(p.name, { val: p.ptrKind })
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 })
203
208
  invalidateLocalsCache(func.body)
204
- const fresh = analyzeLocals(func.body)
209
+ const fresh = analyzeBody(func.body).locals
205
210
  for (const p of func.sig.params) if (!fresh.has(p.name)) fresh.set(p.name, p.type)
206
211
  callerCtx.get(func).callerLocals = fresh
207
212
  }
208
- ctx.func.repByLocal = null
213
+ ctx.func.localReps = null
209
214
  }
210
215
 
211
216
  function resetParamWasmFacts(paramReps) {
212
217
  for (const m of paramReps.values()) for (const r of m.values()) r.wasm = undefined
213
218
  }
214
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
+
215
444
  function createPhaseState() {
216
445
  const callerCtx = buildCallerCtx()
217
446
  const elemCtx = new Map()
@@ -269,13 +498,13 @@ export default function narrowSignatures(programFacts, ast) {
269
498
  // D: Call-site type propagation — infer param types from how functions are called.
270
499
  // Drives off `callSites` collected during the ProgramFacts walk; no AST re-walking.
271
500
  // For non-exported internal functions, if all call sites agree on a param's type,
272
- // 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.
273
502
  // Also infer i32/f64 WASM type — when all call sites pass i32 for a param, specialize
274
503
  // sig.params[k].type to i32 (no default, no rest, not exported, not value-used).
275
504
  // Also propagate schema ID — when all call sites pass objects with the same schema,
276
505
  // bind the callee's param to that schema so `p.x` becomes a direct slot load.
277
- // Inference helpers (inferArgType/inferArgSchema/inferArgArr*/inferArgTypedCtor)
278
- // 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.
279
508
  // Per-caller analysis is stable across fixpoint iterations — precompute once.
280
509
  // callerCtx[null] (top-level) uses module globals for both locals and valTypes.
281
510
  const phase = createPhaseState()
@@ -286,7 +515,7 @@ export default function narrowSignatures(programFacts, ast) {
286
515
  else if (Array.isArray(arg) && arg[0] == null && typeof arg[1] === 'number') raw = arg[1]
287
516
  else if (Array.isArray(arg) && arg[0] === 'u-' && typeof arg[1] === 'number') raw = -arg[1]
288
517
  else if (typeof arg === 'string' && ctx.scope.constInts?.has(arg)) raw = ctx.scope.constInts.get(arg)
289
- 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
290
519
  }
291
520
 
292
521
  const runCallsiteLattice = (rules) => {
@@ -303,7 +532,7 @@ export default function narrowSignatures(programFacts, ast) {
303
532
  callerLocals: ctxEntry.callerLocals,
304
533
  callerValTypes: ctxEntry.callerValTypes,
305
534
  callerParamFacts(key) {
306
- if (!paramFacts.has(key)) paramFacts.set(key, callerParamFactMap(paramReps, callerFunc, key))
535
+ if (!paramFacts.has(key)) paramFacts.set(key, paramFactsOf(paramReps, callerFunc, key))
307
536
  return paramFacts.get(key)
308
537
  },
309
538
  }
@@ -317,13 +546,13 @@ export default function narrowSignatures(programFacts, ast) {
317
546
  }
318
547
 
319
548
  const poison = field => r => { r[field] = null }
320
- // Default-aware val inference. Adds two fallbacks beyond inferArgType's
549
+ // Default-aware val inference. Adds two fallbacks beyond inferValType's
321
550
  // body-local `callerValTypes` lookup so a hot recursive helper like
322
551
  // `uleb(n, buffer = []) { ... return uleb(n, buffer) }` resolves the
323
552
  // recursive `buffer` arg to VAL.ARRAY (via callerParamFacts on iter 2,
324
553
  // or via the caller's own default expression on iter 1).
325
554
  const inferValAtSite = (arg, state) => {
326
- const v = inferArgType(arg, state.callerValTypes)
555
+ const v = inferValType(arg, state.callerValTypes)
327
556
  if (v != null) return v
328
557
  if (typeof arg !== 'string') return null
329
558
  const fromParam = state.callerParamFacts('val')?.get(arg)
@@ -361,7 +590,7 @@ export default function narrowSignatures(programFacts, ast) {
361
590
  else if (r.wasm !== wt) r.wasm = null
362
591
  },
363
592
  },
364
- mergeRule('schemaId', (arg, _k, state) => inferArgSchema(arg, state.callerParamFacts('schemaId'))),
593
+ mergeRule('schemaId', (arg, _k, state) => inferSchemaId(arg, state.callerParamFacts('schemaId'))),
365
594
  {
366
595
  missing: poison('intConst'),
367
596
  apply(r, arg, k, state) {
@@ -389,8 +618,8 @@ export default function narrowSignatures(programFacts, ast) {
389
618
  do { changed = false; runCallsiteLattice([soft]) } while (changed)
390
619
  runCallsiteLattice([mergeRule(field, infer)])
391
620
  }
392
- const runArrFixpoint = () => runArrElemFixpoint('arrayElemSchema', inferArgArrElemSchema, phase.callerElems('arrElemSchemas'))
393
- 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'))
394
623
  runFixpoint()
395
624
  runFixpoint()
396
625
 
@@ -418,97 +647,12 @@ export default function narrowSignatures(programFacts, ast) {
418
647
  // - exclude rest position (array pack/unpack stays f64).
419
648
  applyPointerParamAbi(paramReps, valueUsed)
420
649
 
421
- // E: Result-type monomorphization narrow sig.results[0] to 'i32' when body only
422
- // produces i32 values. Fixpoint: a call to another narrowed func now contributes i32;
423
- // iterate until stable so chains of i32-only helpers all narrow together.
424
- // Safety: skip exported (JS boundary preserves number semantics), value-used (closure
425
- // trampolines assume f64 result), raw WAT, multi-value. `undefined` return = skip.
426
- // exprType already consults ctx.func.map for narrowed user-function results
427
- // (analyze.js exprType `()` branch), plus the Math.imul/Math.clz32/charCodeAt
428
- // stdlib subset and primitive-op rules. Earlier we had a local shim here that
429
- // shadowed exprType's stdlib rules with `return 'f64'` for any non-user call;
430
- // unifying through exprType lets a single rule (math.imul → i32) flow through
431
- // to mix-style helpers (`(h, x) => Math.imul(h ^ (x|0), C)`) and unblocks the
432
- // E-phase result narrowing on every call site that consumes them.
433
- const exprTypeWithCalls = exprType
434
- // Body-driven: safe for exports — the result type is determined by what the body
435
- // computes, not by what JS callers might pass. JS-visible f64 ABI is restored at
436
- // the boundary via a synthesized wrapper (see synthesizeBoundaryWrappers below).
437
- // 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).
438
653
  const funcsWithNarrowableResult = narrowableFuncs(valueUsed)
439
- let changed = true
440
- while (changed) {
441
- changed = false
442
- for (const func of funcsWithNarrowableResult) {
443
- if (func.sig.results[0] === 'i32') continue
444
- const body = func.body
445
- // Bare `return;` produces undef (f64) — narrowing to i32 would lose that.
446
- if (isBlockBody(body) && hasBareReturn(body)) continue
447
- const exprs = returnExprs(body)
448
- if (!exprs.length) continue
449
- // Narrow result to i32 when every return-tail types as i32 (incl. bitwise ops, `|0`,
450
- // and `>>>`). When ANY tail is `>>>` (unsigned uint32 idiom) we still narrow, but tag
451
- // `sig.unsignedResult` so the call-site rebox uses `f64.convert_i32_u` — preserving
452
- // the [0, 2^32) range that `(x >>> 0)` carries through hash/CRC finalizers.
453
- const anyUnsigned = exprs.some(e => Array.isArray(e) && e[0] === '>>>')
454
- const savedCurrent = ctx.func.current
455
- ctx.func.current = func.sig
456
- const locals = isBlockBody(body) ? analyzeLocals(body) : new Map()
457
- for (const p of func.sig.params) if (!locals.has(p.name)) locals.set(p.name, p.type)
458
- const allI32 = exprs.every(e => exprTypeWithCalls(e, locals) === 'i32')
459
- ctx.func.current = savedCurrent
460
- if (allI32) {
461
- func.sig.results = ['i32']
462
- if (anyUnsigned) func.sig.unsignedResult = true
463
- changed = true
464
- }
465
- }
466
- }
467
-
468
- // E2: VAL-type result inference — if a function always returns the same VAL kind,
469
- // record it so callers inherit that type (enables static dispatch on .length, .[],
470
- // .prop through a call chain). Fixpoint propagates through helper chains.
471
- // Safety: skip exported (host sees raw f64), value-used (indirect call signature).
472
- // Shim so calls to already-typed funcs contribute their result type.
473
- const valTypeOfWithCalls = (expr, localValTypes) => {
474
- if (expr == null) return null
475
- if (typeof expr === 'string') return localValTypes?.get(expr) || ctx.scope.globalValTypes?.get(expr) || null
476
- if (!Array.isArray(expr)) return valTypeOf(expr)
477
- const [op, ...args] = expr
478
- if (op === '()' && typeof args[0] === 'string') {
479
- const f = ctx.func.map.get(args[0])
480
- if (f?.valResult) return f.valResult
481
- }
482
- if (op === '?:') {
483
- const a = valTypeOfWithCalls(args[1], localValTypes), b = valTypeOfWithCalls(args[2], localValTypes)
484
- return a && a === b ? a : null
485
- }
486
- if (op === '&&' || op === '||') {
487
- const a = valTypeOfWithCalls(args[0], localValTypes), b = valTypeOfWithCalls(args[1], localValTypes)
488
- return a && a === b ? a : null
489
- }
490
- return valTypeOf(expr)
491
- }
492
- // Body-driven valResult inference: same safety analysis as numeric narrowing
493
- // above — exports OK because boundary wrapper restores f64 ABI for JS callers.
494
- changed = true
495
- while (changed) {
496
- changed = false
497
- for (const func of funcsWithNarrowableResult) {
498
- if (func.valResult) continue
499
- const body = func.body
500
- const isBlock = isBlockBody(body)
501
- if (isBlock && hasBareReturn(body)) continue
502
- const exprs = returnExprs(body)
503
- if (!exprs.length) continue
504
- const localValTypes = isBlock ? analyzeBody(body).valTypes : new Map()
505
- // Params of this function contribute no known VAL type yet (paramReps may help later).
506
- const vt0 = valTypeOfWithCalls(exprs[0], localValTypes)
507
- if (!vt0) continue
508
- const allSame = exprs.every(e => valTypeOfWithCalls(e, localValTypes) === vt0)
509
- if (allSame) { func.valResult = vt0; changed = true }
510
- }
511
- }
654
+ narrowI32Results(funcsWithNarrowableResult)
655
+ narrowValResults(funcsWithNarrowableResult)
512
656
 
513
657
  // Now that E2 set `valResult` on funcs, narrow per-func `arrayElemSchema` for
514
658
  // VAL.ARRAY-returning funcs (via push observations + call chains). Then re-run the
@@ -542,141 +686,9 @@ export default function narrowSignatures(programFacts, ast) {
542
686
  // through helper chains: `init()→main→runKernel`.
543
687
  runArrValTypeFixpoint()
544
688
  runArrValTypeFixpoint()
545
- // E3: Result-type pointer narrowing — when valResult is a non-ambiguous pointer kind
546
- // with constant aux, narrow sig.results[0] from f64 to i32 and tag sig.ptrKind/.ptrAux.
547
- // Eliminates the f64.reinterpret_i64+i64.or rebox at every return and the
548
- // i32.wrap_i64+i64.reinterpret_f64 unbox at every callsite that uses the value as a
549
- // pointer (load .[], .length, .prop slot dispatch).
550
- // - SET/MAP/BUFFER: aux always 0 — no per-callsite aux preservation needed.
551
- // - OBJECT: aux is schema-id; narrow only when all return exprs share a constant
552
- // schema (literal `{a,b,c}`, schemaId-bound param, module-bound var, or call to
553
- // another OBJECT-narrowed func). Caller picks aux up via callIR.ptrAux → readVar →
554
- // repByLocal.schemaId, restoring property-slot dispatch through the call boundary.
555
- // Safety: ARRAY forwards on realloc (no narrowing). STRING dual-encoded SSO/heap.
556
- // CLOSURE/TYPED also carry meaningful aux — TYPED narrowing is a follow-up. Body must
557
- // be a guaranteed-return form — fallthrough fallback i32.const 0 would be a valid
558
- // offset 0 of the narrowed kind, not undefined.
559
- const PTR_RESULT_KINDS_NOAUX = new Set([VAL.SET, VAL.MAP, VAL.BUFFER])
560
- // Schema-id inference for a return expression. Returns id (number), or null if unknown
561
- // / not constant. Mirrors inferArgSchema but extends with calls to already-narrowed
562
- // OBJECT-result funcs (fixpoint propagation through helper chains).
563
- const schemaIdOfReturn = (expr, paramSchemasMap) => {
564
- if (typeof expr === 'string') {
565
- if (paramSchemasMap?.has(expr)) return paramSchemasMap.get(expr)
566
- if (ctx.schema.vars.has(expr)) return ctx.schema.vars.get(expr)
567
- return null
568
- }
569
- if (!Array.isArray(expr)) return null
570
- const [op, ...args] = expr
571
- if (op === '{}') {
572
- // Object literal: bail to null on block body, dynamic key, or spread.
573
- const parsed = staticObjectProps(args)
574
- return parsed ? ctx.schema.register(parsed.names) : null
575
- }
576
- if (op === '()' && typeof args[0] === 'string') {
577
- const f = ctx.func.map.get(args[0])
578
- if (f?.valResult === VAL.OBJECT && f.sig.ptrAux != null) return f.sig.ptrAux
579
- return null
580
- }
581
- if (op === '?:') {
582
- const a = schemaIdOfReturn(args[1], paramSchemasMap)
583
- const b = schemaIdOfReturn(args[2], paramSchemasMap)
584
- return a != null && a === b ? a : null
585
- }
586
- if (op === '&&' || op === '||') {
587
- const a = schemaIdOfReturn(args[0], paramSchemasMap)
588
- const b = schemaIdOfReturn(args[1], paramSchemasMap)
589
- return a != null && a === b ? a : null
590
- }
591
- return null
592
- }
593
- // Per-body local elemAux map: scans `let/const x = new TypedArray(...)` decls so
594
- // a return like `let a = new Float64Array(...); return a` resolves to a constant
595
- // aux. Result calls + ?: are handled inline in typedAuxOfReturn.
596
- const localElemAuxMap = (body) => {
597
- const m = new Map()
598
- const walk = (n) => {
599
- if (!Array.isArray(n)) return
600
- const op = n[0]
601
- if (op === '=>') return
602
- if ((op === 'let' || op === 'const') && n.length > 1) {
603
- for (let i = 1; i < n.length; i++) {
604
- const a = n[i]
605
- if (Array.isArray(a) && a[0] === '=' && typeof a[1] === 'string') {
606
- const aux = typedElemAux(typedElemCtor(a[2]))
607
- if (aux != null) m.set(a[1], aux)
608
- }
609
- }
610
- }
611
- for (let i = 1; i < n.length; i++) walk(n[i])
612
- }
613
- walk(body)
614
- return m
615
- }
616
- const typedAuxOfReturn = (expr, localElemMap) => {
617
- if (typeof expr === 'string') return localElemMap?.get(expr) ?? null
618
- if (!Array.isArray(expr)) return null
619
- const [op, ...args] = expr
620
- if (op === '()' && typeof args[0] === 'string') {
621
- if (args[0].startsWith('new.')) {
622
- const ctor = typedElemCtor(expr)
623
- return ctor != null ? typedElemAux(ctor) : null
624
- }
625
- const f = ctx.func.map.get(args[0])
626
- if (f?.valResult === VAL.TYPED && f.sig.ptrAux != null) return f.sig.ptrAux
627
- return null
628
- }
629
- if (op === '?:') {
630
- const a = typedAuxOfReturn(args[1], localElemMap)
631
- const b = typedAuxOfReturn(args[2], localElemMap)
632
- return a != null && a === b ? a : null
633
- }
634
- if (op === '&&' || op === '||') {
635
- const a = typedAuxOfReturn(args[0], localElemMap)
636
- const b = typedAuxOfReturn(args[1], localElemMap)
637
- return a != null && a === b ? a : null
638
- }
639
- return null
640
- }
641
- // Fixpoint: a chain `outer → inner → {a,b}` needs inner to narrow first so outer's
642
- // call to inner contributes a known schema-id.
643
- let narrowChanged = true
644
- while (narrowChanged) {
645
- narrowChanged = false
646
- for (const func of funcsWithNarrowableResult) {
647
- if (!func.valResult) continue
648
- if (func.sig.results[0] !== 'f64') continue
649
- const isBlock = isBlockBody(func.body)
650
- if (isBlock && !alwaysReturns(func.body)) continue
651
- if (PTR_RESULT_KINDS_NOAUX.has(func.valResult)) {
652
- func.sig.results = ['i32']
653
- func.sig.ptrKind = func.valResult
654
- narrowChanged = true
655
- continue
656
- }
657
- const exprs = returnExprs(func.body)
658
- if (!exprs.length) continue
659
- if (func.valResult === VAL.OBJECT) {
660
- const paramSchemasMap = callerParamFactMap(paramReps, func, 'schemaId')
661
- const sid0 = schemaIdOfReturn(exprs[0], paramSchemasMap)
662
- if (sid0 == null) continue
663
- if (!exprs.every(e => schemaIdOfReturn(e, paramSchemasMap) === sid0)) continue
664
- func.sig.results = ['i32']
665
- func.sig.ptrKind = VAL.OBJECT
666
- func.sig.ptrAux = sid0
667
- narrowChanged = true
668
- } else if (func.valResult === VAL.TYPED) {
669
- const localMap = isBlock ? localElemAuxMap(func.body) : null
670
- const aux0 = typedAuxOfReturn(exprs[0], localMap)
671
- if (aux0 == null) continue
672
- if (!exprs.every(e => typedAuxOfReturn(e, localMap) === aux0)) continue
673
- func.sig.results = ['i32']
674
- func.sig.ptrKind = VAL.TYPED
675
- func.sig.ptrAux = aux0
676
- narrowChanged = true
677
- }
678
- }
679
- }
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)
680
692
 
681
693
  // F: Cross-call typed-array element ctor propagation. Runs AFTER E3 so that
682
694
  // calls to user functions returning a TYPED-narrowed pointer (with constant
@@ -685,8 +697,10 @@ export default function narrowSignatures(programFacts, ast) {
685
697
  // for their own params and `arr[i]` reads emit a direct `f64.load` instead of
686
698
  // the runtime `__is_str_key + __typed_idx` dispatch — closes the largest
687
699
  // chunk of the JS→wasm gap on f64-heavy hot loops.
688
- // (Helpers `inferArgTypedCtor`/`ctorFromElemAux` live in analyze.js so the
689
- // 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.)
690
704
  // Per-caller typed-elem map, recomputed now that E3 has tagged helper sigs.
691
705
  // Cache invalidation: analyzeBody.typedElems reads `ctx.func.map.get(...).sig.ptrKind`
692
706
  // for `let x = mkInput(...)` decls; entries cached during the initial walk
@@ -697,7 +711,7 @@ export default function narrowSignatures(programFacts, ast) {
697
711
  // its own callees (e.g. if `outer(buf)` calls `inner(buf)` and we learn `buf`
698
712
  // for outer, the second pass picks it up for inner). Reuses runArrElemFixpoint
699
713
  // (same shape — field/inferFn/elemsCtxMap parameterization).
700
- const runTypedFixpoint = () => runArrElemFixpoint('typedCtor', inferArgTypedCtor, callerTypedCtx)
714
+ const runTypedFixpoint = () => runArrElemFixpoint('typedCtor', inferTypedCtor, callerTypedCtx)
701
715
  runTypedFixpoint()
702
716
  runTypedFixpoint()
703
717
 
@@ -714,7 +728,7 @@ export default function narrowSignatures(programFacts, ast) {
714
728
  // H: Post-F/G re-fixpoint — propagates VAL kinds through bimorphic call sites
715
729
  // where ptrKind narrowed but ptrAux disagreed (e.g. `sum(f64arr)` and `sum(i32arr)`
716
730
  // → both VAL.TYPED, different ctors). Without this, callerValTypes carries no entry
717
- // 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
718
732
  // sticky null. With ptrKind enriching callerValTypes, sum's arr gets val=TYPED in
719
733
  // its rep, letting array.js skip __is_str_key + __str_idx dispatch on `arr[i]`.
720
734
  enrichCallerValTypesFromPointerParams(callerCtx)
@@ -742,6 +756,174 @@ export default function narrowSignatures(programFacts, ast) {
742
756
  // so r.wasm flips to 'i32' here — but narrowing now breaks the clone path
743
757
  // that still needs to mint per-ctor sigs with ptrKind=TYPED, ptrAux=ctor-aux.
744
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
+ }
745
927
  }
746
928
 
747
929
  /**
@@ -789,7 +971,7 @@ export function specializeBimorphicTyped(programFacts) {
789
971
  // (so transitive `sum(arr)` inside a func that took `arr` from above resolves).
790
972
  const callerTypedParamsCtx = new Map()
791
973
  for (const func of ctx.func.list) {
792
- const m = callerParamFactMap(paramReps, func, 'typedCtor') || null
974
+ const m = paramFactsOf(paramReps, func, 'typedCtor') || null
793
975
  let acc = m
794
976
  if (func.sig?.params) for (const p of func.sig.params) {
795
977
  if (p.ptrKind === VAL.TYPED && p.ptrAux != null) {
@@ -834,7 +1016,7 @@ export function specializeBimorphicTyped(programFacts) {
834
1016
  const combo = []
835
1017
  for (const k of bimorphic) {
836
1018
  if (k >= site.argList.length) { abort = true; break }
837
- const c = inferArgTypedCtor(site.argList[k], callerTypedElems, callerTypedParams)
1019
+ const c = inferTypedCtor(site.argList[k], callerTypedElems, callerTypedParams)
838
1020
  if (c == null || typedElemAux(c) == null) { abort = true; break }
839
1021
  combo.push(c)
840
1022
  }