jz 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +56 -9
  2. package/bench/README.md +121 -50
  3. package/bench/bench.svg +27 -27
  4. package/cli.js +3 -1
  5. package/dist/interop.js +1 -1
  6. package/dist/jz.js +7541 -6178
  7. package/index.js +165 -74
  8. package/interop.js +189 -17
  9. package/jzify/arguments.js +32 -1
  10. package/jzify/async.js +409 -0
  11. package/jzify/classes.js +136 -18
  12. package/jzify/generators.js +639 -0
  13. package/jzify/hoist-vars.js +73 -1
  14. package/jzify/index.js +123 -4
  15. package/jzify/names.js +1 -0
  16. package/jzify/transform.js +239 -1
  17. package/layout.js +48 -3
  18. package/module/array.js +318 -43
  19. package/module/atomics.js +144 -0
  20. package/module/collection.js +1429 -155
  21. package/module/core.js +431 -40
  22. package/module/date.js +142 -120
  23. package/module/fs.js +144 -0
  24. package/module/function.js +6 -3
  25. package/module/index.js +4 -1
  26. package/module/json.js +270 -49
  27. package/module/math.js +1032 -145
  28. package/module/number.js +532 -163
  29. package/module/object.js +353 -95
  30. package/module/regex.js +157 -7
  31. package/module/schema.js +100 -2
  32. package/module/string.js +428 -93
  33. package/module/typedarray.js +264 -72
  34. package/module/web.js +36 -0
  35. package/package.json +5 -5
  36. package/src/abi/string.js +82 -11
  37. package/src/ast.js +11 -5
  38. package/src/autoload.js +28 -1
  39. package/src/bridge.js +7 -2
  40. package/src/compile/analyze-scans.js +22 -7
  41. package/src/compile/analyze.js +136 -30
  42. package/src/compile/dyn-closure-tables.js +277 -0
  43. package/src/compile/emit-assign.js +281 -15
  44. package/src/compile/emit.js +1055 -70
  45. package/src/compile/index.js +277 -29
  46. package/src/compile/infer.js +42 -4
  47. package/src/compile/inplace-store.js +329 -0
  48. package/src/compile/loop-recurrence.js +167 -0
  49. package/src/compile/narrow.js +555 -18
  50. package/src/compile/peel-stencil.js +5 -0
  51. package/src/compile/plan/index.js +45 -3
  52. package/src/compile/plan/inline.js +8 -1
  53. package/src/compile/plan/literals.js +39 -0
  54. package/src/compile/plan/scope.js +430 -23
  55. package/src/compile/program-facts.js +732 -38
  56. package/src/ctx.js +64 -9
  57. package/src/helper-counters.js +7 -1
  58. package/src/ir.js +113 -5
  59. package/src/kind-traits.js +66 -3
  60. package/src/kind.js +84 -12
  61. package/src/op-policy.js +7 -4
  62. package/src/optimize/index.js +1179 -704
  63. package/src/optimize/recurse.js +2 -2
  64. package/src/optimize/vectorize.js +982 -67
  65. package/src/prepare/index.js +809 -67
  66. package/src/prepare/math-kernel.js +331 -0
  67. package/src/prepare/pre-eval.js +714 -0
  68. package/src/snapshot.js +194 -0
  69. package/src/type.js +1170 -56
  70. package/src/wat/assemble.js +403 -65
  71. package/src/wat/codegen.js +74 -14
  72. package/transform.js +113 -4
  73. package/wasi.js +3 -0
@@ -27,9 +27,11 @@
27
27
 
28
28
  import parseWat from 'watr/parse'
29
29
  import { ctx, err, inc, resolveIncludes, PTR, LAYOUT, declGlobal } from '../ctx.js'
30
+ import { i64Hex } from '../../layout.js'
30
31
  import { T, isBlockBody, isReassigned, refsName, REFS_IN_EXPR, returnExprs } from '../ast.js'
31
32
  import { valTypeOf } from '../kind.js'
32
33
  import { intLiteralValue } from '../static.js'
34
+ import { intCertainMap, typedStaticLen } from '../type.js'
33
35
  import {
34
36
  analyzeBody, unboxablePtrs, cseSafeLoadBases, boxedCaptures,
35
37
  analyzeStructInline, invalidateLocalsCache,
@@ -40,8 +42,12 @@ import { inferLocals } from './infer.js'
40
42
  import { optimizeFunc, treeshake } from '../optimize/index.js'
41
43
  import { strengthReduceLoopDivMod } from './loop-divmod.js'
42
44
  import { narrowBoundedSquare } from './loop-square.js'
45
+ import { unrollRecurrence } from './loop-recurrence.js'
43
46
  import { peelClampedStencil } from './peel-stencil.js'
44
47
  import { cseLoads } from './cse-load.js'
48
+ import {
49
+ scanDynClosureTableCandidates, recordParamClosureDefault, recordDirectReturnClosure, resolveDynFnTables,
50
+ } from './dyn-closure-tables.js'
45
51
 
46
52
  // Monotonic across all functions so a CSE temp never collides (even after later inlining).
47
53
  let __cseCtr = 0
@@ -49,7 +55,7 @@ const freshCseName = () => `${T}cse${__cseCtr++}`
49
55
  import { emit, emitter, emitVoid, emitBlockBody } from './emit.js'
50
56
  import { emitCharDecompPrologue, JSS_IMPORT_SIGS } from '../abi/string.js'
51
57
  import {
52
- typed, asF64, asI32, asPtrOffset, asParamType, toI32, asI64, fromI64,
58
+ typed, asF64, asI32, asPtrOffset, asParamType, toI32, asI64, fromI64, ptrTypeEq,
53
59
  NULL_NAN, UNDEF_NAN, NULL_WAT, UNDEF_WAT, NULL_IR, UNDEF_IR, nullExpr, undefExpr,
54
60
  MAX_CLOSURE_ARITY,
55
61
  mkPtrIR,
@@ -66,7 +72,7 @@ import plan from './plan/index.js'
66
72
  import { foldStaticConstAggregates } from './plan/literals.js'
67
73
  import {
68
74
  buildStartFn, dedupClosureBodies, finalizeClosureTable,
69
- pullStdlib, syncImports, optimizeModule, stripStaticDataPrefix, hoistConstGlobalInits, stripDeadElTable,
75
+ pullStdlib, syncImports, optimizeModule, stripStaticDataPrefix, hoistConstGlobalInits, stripDeadLazyTables,
70
76
  } from '../wat/assemble.js'
71
77
  import { instrumentHelperCallsites } from '../helper-counters.js'
72
78
 
@@ -392,6 +398,8 @@ function enterFunc(sig, body, { uniq = 0, directClosures = null } = {}) {
392
398
  ctx.func.directClosures = directClosures
393
399
  ctx.func.localProps = null
394
400
  ctx.func.charDecomp = null
401
+ ctx.func.charDecompGlobals = false // only emitFunc's named path drains — it re-arms
402
+ ctx.func.probeHoist = null
395
403
  if (ctx.transform.optimize) {
396
404
  scanAndTagNonEscapingClosures(body)
397
405
  }
@@ -426,6 +434,11 @@ function analyzeFuncForEmit(func, programFacts) {
426
434
  // so the sieve's product/counter chain carries i32 instead of f64. Before analyze so the
427
435
  // Math.imul typed/narrows like any i32. Off at L0 / `loopSquare:false`.
428
436
  if (_o && _o.loopSquare !== false && isBlockBody(func.body)) func.body = narrowBoundedSquare(func.body)
437
+ // Array-recurrence unroll: a unit-stride DP/scan that reads arr[j-1] and writes arr[j] carries
438
+ // its value through memory (store→load) and re-pays loop overhead per cell — both of which V8
439
+ // hides but Cranelift/baseline don't. Scalar-replace the recurrence + unroll ×2 (clang's fix).
440
+ // Off at L0 / `unrollRecurrence:false`.
441
+ if (_o && _o.unrollRecurrence !== false && isBlockBody(func.body)) func.body = unrollRecurrence(func.body)
429
442
  // Edge-clamp peeling: split a clamped stencil loop into clamp-free interior + edges
430
443
  // (the interior then lifts to SIMD). Before analyze so the new loops are analyzed.
431
444
  if (_o && _o.clampPeel !== false && isBlockBody(func.body)) func.body = peelClampedStencil(func.body)
@@ -437,21 +450,68 @@ function analyzeFuncForEmit(func, programFacts) {
437
450
  ctx.func.boxed = new Map()
438
451
  ctx.func.localReps = null
439
452
  ctx.types.typedElem = ctx.scope.globalTypedElem ? new Map(ctx.scope.globalTypedElem) : null
453
+ // typedLen mirrors typedElem's per-function lifecycle EXACTLY — a stale entry from a
454
+ // sibling function's same-named local would prove a wrong bound (names are per-function).
455
+ ctx.types.typedLen = ctx.scope.globalTypedLen ? new Map(ctx.scope.globalTypedLen) : null
440
456
 
441
457
  const _reps = paramReps.get(name)
442
458
  if (_reps) {
443
459
  for (const [k, r] of _reps) {
444
460
  if (k >= sig.params.length) continue
445
461
  const pname = sig.params[k].name
446
- if (r.typedCtor) {
462
+ // r.val/r.typedCtor describe the CALLER's argument — the param's value AT
463
+ // ENTRY, before this function's own body runs. A param the body reassigns
464
+ // (`opts = normalize(opts)`) no longer necessarily holds that entry-time
465
+ // kind past the write, so seeding it here is only sound when the body
466
+ // never writes the name. Without this guard the stale entry-time kind
467
+ // stands unchallenged: analyzeBody's OWN valType tracker (below, `bodyFacts.
468
+ // valTypes`) starts with no memory of this pre-seeded value (it's a fresh
469
+ // Map, not the shared ctx.func.localReps store), so when the reassignment's
470
+ // RHS type can't be resolved (e.g. a call to a function whose own valResult
471
+ // never converges), makeValTracker's poison path requires a PRIOR value
472
+ // in ITS OWN map to fire — there isn't one — so it neither confirms nor
473
+ // invalidates the seed, and the merge loop below never touches the name at
474
+ // all. A hardcoded-wrong kind then rides every read of that binding for the
475
+ // rest of the function (watr's own `optimize()`: opts's param-fact kind is
476
+ // VAL.OBJECT from callers that pass object literals; `opts = normalize(opts)`
477
+ // reassigns it to normalize's actual return — a HASH in the schema-less-
478
+ // spread shape — but the stale OBJECT kind survives, so `emitTypeTag` bakes
479
+ // a hardcoded `(i32.const PTR.OBJECT)` tag into `opts.inlineOnce`'s dyn-get
480
+ // dispatch instead of reading the receiver's true runtime tag, and the probe
481
+ // walks a schema this HASH was never shaped as — a silent miss, not a trap).
482
+ const reassigned = isReassigned(body, pname)
483
+ if (r.typedCtor && !reassigned) {
447
484
  if (!ctx.types.typedElem) ctx.types.typedElem = new Map()
448
485
  if (!ctx.types.typedElem.has(pname)) ctx.types.typedElem.set(pname, r.typedCtor)
449
486
  updateRep(pname, { val: VAL.TYPED })
450
487
  }
451
- if (r.val && !ctx.func.localReps?.get(pname)?.val) updateRep(pname, { val: r.val })
488
+ if (r.val && !reassigned && !ctx.func.localReps?.get(pname)?.val) updateRep(pname, { val: r.val })
452
489
  if (r.arrayElemSchema != null) updateRep(pname, { arrayElemSchema: r.arrayElemSchema })
453
490
  if (r.arrayElemValType != null) updateRep(pname, { arrayElemValType: r.arrayElemValType })
454
491
  if (r.intConst != null) updateRep(pname, { intConst: r.intConst })
492
+ // Cross-function never-relocation proof (analyzeParamNeverGrown) — the
493
+ // raw-base array read (module/array.js arrBase) keys off this rep.
494
+ if (r.neverGrown) updateRep(pname, { neverGrown: true })
495
+ }
496
+ }
497
+ // Caller-side nullability: a NO-DEFAULT param observes the UNDEF pad whenever a
498
+ // site omits its position (narrow's missing rule poisons r.val) or when callers
499
+ // are unknown (exported / value-used — no fact at all). A later body write
500
+ // (`nbar = 4` inside `if (nbar == null)`) sets val=NUMBER, which used to
501
+ // constant-fold the very null-check guarding it — under-arity callers then read
502
+ // the raw UNDEF box as NaN (window-function's taylor manual-default idiom).
503
+ // `nullable` only suppresses the nullish-compare FOLD; arithmetic typing keeps.
504
+ // `r.nullable` alongside a SETTLED val is narrow.js's BIGINT re-derivation:
505
+ // a `c ? BigInt(x) : null` site proves the kind yet still passes null — the
506
+ // callee's `x == null` sentinel must stay a live bit-compare.
507
+ {
508
+ const restIdx = func.rest ? sig.params.length - 1 : -1
509
+ for (let k = 0; k < sig.params.length; k++) {
510
+ if (k === restIdx) continue // rest arrays are never undefined
511
+ const pname = sig.params[k].name
512
+ if (func.defaults?.[pname] != null) continue // default fires on the UNDEF pad
513
+ const r = _reps?.get(k)
514
+ if (!r || r.val == null || r.nullable) updateRep(pname, { nullable: true })
455
515
  }
456
516
  }
457
517
  // Trust numeric export params. An exported f64 param used only in numeric
@@ -496,7 +556,32 @@ function analyzeFuncForEmit(func, programFacts) {
496
556
  const bodyFacts = block ? analyzeBody(body) : null
497
557
  ctx.func.locals = bodyFacts ? bodyFacts.locals : new Map()
498
558
  if (bodyFacts?.valTypes) {
499
- for (const [name, vt] of bodyFacts.valTypes) updateRep(name, { val: vt })
559
+ // A PARAMETER name has no `let`/`const` declaration node inside body for
560
+ // analyzeBody's own tracker to seed a baseline "unknown" observation from
561
+ // (makeValTracker's poison logic needs a PRIOR value in ITS OWN map to
562
+ // detect a conflict — see that function's doc). So when a parameter is
563
+ // reassigned only CONDITIONALLY (`if (typeof opts === 'string' && …) opts
564
+ // = { profile: … }` — watr's own normalize()), the tracker's first (and
565
+ // only) observation is that ONE branch's type, with no competing
566
+ // observation for the other, equally-reachable path where the param keeps
567
+ // its original, caller-supplied value — a path this walk never visits
568
+ // because there's no assignment node ON it to visit. The merge below would
569
+ // then adopt the conditional branch's type as if it held on EVERY path.
570
+ // Trust it only when the param's own call-site-proven entry type (_reps,
571
+ // the fixpoint-settled cross-call-site fact — unlike this per-body walk,
572
+ // it already answers "what can this param be at entry, always") agrees:
573
+ // if it does, both the reassigned and the original-value paths carry the
574
+ // same kind, so unconditional-adoption is sound; if it's absent or
575
+ // different, the conditional branch's type does NOT generalize and must
576
+ // not override the (correctly) unresolved entry-time kind.
577
+ const paramIdx = block ? new Map(sig.params.map((p, i) => [p.name, i])) : null
578
+ for (const [name, vt] of bodyFacts.valTypes) {
579
+ if (paramIdx?.has(name)) {
580
+ const entryVal = _reps?.get(paramIdx.get(name))?.val
581
+ if (entryVal !== vt) continue
582
+ }
583
+ updateRep(name, { val: vt })
584
+ }
500
585
  }
501
586
  // Never-relocated array bindings — the `[]` reader skips the forwarding follow.
502
587
  if (bodyFacts?.neverGrown) for (const name of bodyFacts.neverGrown) updateRep(name, { neverGrown: true })
@@ -524,6 +609,17 @@ function analyzeFuncForEmit(func, programFacts) {
524
609
  .filter(p => !ctx.func.localReps?.get(p.name)?.val)
525
610
  .map(p => p.name)
526
611
  inferLocals(body, candidates)
612
+ // analyzeBody's locals slice (line above bodyFacts) ran BEFORE inferLocals
613
+ // bound elem-alias schema ids (`const p = ps[i]` → p.schemaId via
614
+ // analyzeValTypes). With strict-int32 slots in the program, re-derive the
615
+ // widths so exprType's slotI32CertainAt consult resolves through p — then
616
+ // `const x = hitX ? p.x : nx` declares i32 and the raw i32 slot load lands
617
+ // without an f64 round-trip. Gated: programs without strict slots skip the
618
+ // extra walk.
619
+ if (block && ctx.schema.slotI32Certain?.size) {
620
+ invalidateLocalsCache(body)
621
+ ctx.func.locals = analyzeBody(body).locals
622
+ }
527
623
  if (block) {
528
624
  boxedCaptures(body)
529
625
  // Lower provably-monomorphic pointer locals to i32 offset storage.
@@ -567,15 +663,30 @@ function analyzeFuncForEmit(func, programFacts) {
567
663
  : new Set()
568
664
 
569
665
  // Closure-capture narrowing: a boxed var whose every defining RHS — owner
570
- // body AND nested arrows, the narrower's intCertain contract is integer-
571
- // valued keeps its CELL in i32, so readVar/writeVar skip the f64↔i32
572
- // round-trip per access. Params are excluded: their cell is seeded from the
573
- // raw f64 param value, which would desync an i32-read cell. Same asm.js-style
574
- // range contract as plain intCertain locals.
666
+ // body AND nested arrows is integer-valued keeps its CELL in i32, so
667
+ // readVar/writeVar skip the f64↔i32 round-trip per access. Params are
668
+ // excluded: their cell is seeded from the raw f64 param value, which would
669
+ // desync an i32-read cell. Same asm.js-style range contract as plain
670
+ // intCertain locals.
671
+ //
672
+ // `ctx.func.localReps.get(name).intCertain` (forward-propagated in analyze.js
673
+ // via the plain, single-arg `intCertainMap(body)`) only sees defs in THIS
674
+ // scope's own top level — correct for an ordinary local (it can't be
675
+ // assigned from inside a nested arrow without becoming a capture) but blind
676
+ // to the writes that make a name "boxed" in the first place: `let env = 0;
677
+ // let set = () => { env = 1.5 }` has no top-level def contradicting `env`'s
678
+ // integer init, so it read back intCertain=true and the cell stayed i32,
679
+ // silently truncating every closure-body float write. Recompute instead with
680
+ // `capturedNames` — collectIntDefs' arrow-descending mode — scoped to just
681
+ // the boxed names, so their nested-arrow write sites join the SAME fixpoint.
575
682
  const cellTypes = new Set()
576
- for (const name of ctx.func.boxed.keys()) {
577
- if (sig.params.some(p => p.name === name)) continue
578
- if (ctx.func.localReps?.get(name)?.intCertain === true) cellTypes.add(name)
683
+ const boxedNames = new Set(ctx.func.boxed.keys())
684
+ if (boxedNames.size) {
685
+ const capturedIntCertain = intCertainMap(body, boxedNames)
686
+ for (const name of boxedNames) {
687
+ if (sig.params.some(p => p.name === name)) continue
688
+ if (capturedIntCertain.get(name) === true) cellTypes.add(name)
689
+ }
579
690
  }
580
691
 
581
692
  // Snapshot each param's JS-boundary carrier while reps are live — synthesizeBoundaryWrappers
@@ -614,6 +725,7 @@ function analyzeFuncForEmit(func, programFacts) {
614
725
  cseLoadBases,
615
726
  distinctParams: func.distinctParams || null,
616
727
  typedElem: ctx.types.typedElem ? new Map(ctx.types.typedElem) : null,
728
+ typedLen: ctx.types.typedLen ? new Map(ctx.types.typedLen) : null,
617
729
  localReps: cloneRepMap(ctx.func.localReps),
618
730
  }
619
731
  }
@@ -996,6 +1108,9 @@ function emitFunc(func, funcFacts, programFacts) {
996
1108
  const _reps = paramReps.get(name)
997
1109
 
998
1110
  enterFunc(sig, body)
1111
+ // Only this path drains charDecomp prologues (collectParamInits below) —
1112
+ // the shape-1b global-receiver decomposition may mint only here.
1113
+ ctx.func.charDecompGlobals = true
999
1114
  const block = funcFacts.block
1000
1115
  ctx.func.locals = new Map(funcFacts.locals)
1001
1116
  ctx.func.boxed = new Map(funcFacts.boxed)
@@ -1004,6 +1119,8 @@ function emitFunc(func, funcFacts, programFacts) {
1004
1119
  ctx.func.sliceViews = new Set(funcFacts.sliceViews)
1005
1120
  ctx.func.localReps = cloneRepMap(funcFacts.localReps)
1006
1121
  ctx.types.typedElem = funcFacts.typedElem ? new Map(funcFacts.typedElem) : null
1122
+ ctx.types.typedLen = funcFacts.typedLen ? new Map(funcFacts.typedLen)
1123
+ : ctx.scope.globalTypedLen ? new Map(ctx.scope.globalTypedLen) : null
1007
1124
 
1008
1125
  // D: Apply call-site param facts (only if body analysis didn't already set them).
1009
1126
  // Schema bindings additionally write into ctx.schema.vars so prop-access dispatch
@@ -1013,13 +1130,20 @@ function emitFunc(func, funcFacts, programFacts) {
1013
1130
  for (const [k, r] of _reps) {
1014
1131
  if (k >= sig.params.length) continue
1015
1132
  const pname = sig.params[k].name
1016
- if (r.val && !ctx.func.localReps?.get(pname)?.val) updateRep(pname, { val: r.val })
1017
- if (r.typedCtor) {
1133
+ // Same entry-vs-body-reassignment hazard analyzeFuncForEmit guards against
1134
+ // (see its comment): r.val/r.typedCtor/r.schemaId describe the CALLER's
1135
+ // argument, sound only while the body never writes the name. This step
1136
+ // duplicates that seeding (funcFacts.localReps already carries whatever
1137
+ // analyzeFuncForEmit settled, guarded — but re-applying the UNGUARDED
1138
+ // call-site fact here would undo it) so it needs the identical guard.
1139
+ const reassigned = isReassigned(body, pname)
1140
+ if (r.val && !reassigned && !ctx.func.localReps?.get(pname)?.val) updateRep(pname, { val: r.val })
1141
+ if (r.typedCtor && !reassigned) {
1018
1142
  if (!ctx.types.typedElem) ctx.types.typedElem = new Map()
1019
1143
  if (!ctx.types.typedElem.has(pname)) ctx.types.typedElem.set(pname, r.typedCtor)
1020
1144
  if (!ctx.func.localReps?.get(pname)?.val) updateRep(pname, { val: VAL.TYPED })
1021
1145
  }
1022
- if (r.schemaId != null && !exported && !ctx.schema.vars.has(pname)) {
1146
+ if (r.schemaId != null && !reassigned && !exported && !ctx.schema.vars.has(pname)) {
1023
1147
  ctx.schema.vars.set(pname, r.schemaId)
1024
1148
  updateRep(pname, { schemaId: r.schemaId })
1025
1149
  }
@@ -1064,9 +1188,16 @@ function emitFunc(func, funcFacts, programFacts) {
1064
1188
  // externref so no `ref.is_null` branch is needed.
1065
1189
  if (p?.jsstring && p.jsstringDefault != null) continue
1066
1190
  const t = p?.type || 'f64'
1191
+ // emit(defVal) ONCE, before branching on t — same self-host miscompile class as
1192
+ // emit.js's 'return' handler. See .work/todo.md (groundtruth archive).
1193
+ const emittedDefVal = emit(defVal)
1194
+ // dyn-closure-tables.js: a default value that's provably a closure literal
1195
+ // (e.g. subscript's `dispatch(ops, tail, fn = (a, …) => {…})`) is the fact
1196
+ // proveClosureFactory needs to see through `dispatch`'s forwarded return.
1197
+ recordParamClosureDefault(name, pname, emittedDefVal)
1067
1198
  defaultInits.set(pname,
1068
1199
  ['if', isUndef(typed(['local.get', `$${pname}`], 'f64')),
1069
- ['then', ['local.set', `$${pname}`, t === 'f64' ? asF64(emit(defVal)) : asI32(emit(defVal))]]])
1200
+ ['then', ['local.set', `$${pname}`, t === 'f64' ? asF64(emittedDefVal) : asI32(emittedDefVal)]]])
1070
1201
  }
1071
1202
 
1072
1203
  // Box params that are mutably captured: allocate cell, copy param value
@@ -1102,6 +1233,28 @@ function emitFunc(func, funcFacts, programFacts) {
1102
1233
  // in-bounds char wrongly decodes as the OOB NaN.
1103
1234
  const collectParamInits = () => {
1104
1235
  const inits = []
1236
+ // Global-receiver decompositions (shape 1b) come first: a param default
1237
+ // like `(c = cur.charCodeAt(0))` must see the global's prologue locals
1238
+ // populated. Globals are readable at entry, so nothing precedes them.
1239
+ if (ctx.func.charDecomp) for (const dec of ctx.func.charDecomp.values())
1240
+ if (dec.global) inits.push(...emitCharDecompPrologue(dec))
1241
+ // Hoisted method-override probes (emit.js tryGenericEmitter): the probe's
1242
+ // answer is loop-invariant for a stable-global receiver — resolve it once.
1243
+ // Mirrors sidecarOverride's arm: primitives (real numbers, strings) can
1244
+ // never carry an own override, so only NaN-boxed non-STRING receivers probe.
1245
+ if (ctx.func.probeHoist) for (const ph of ctx.func.probeHoist.values()) {
1246
+ const g = () => ['i64.reinterpret_f64', ph.recvIR()]
1247
+ inits.push(
1248
+ ['local.set', `$${ph.ovr}`, ['if', ['result', 'f64'],
1249
+ ['i32.and',
1250
+ ['f64.ne', ph.recvIR(), ph.recvIR()],
1251
+ ['i64.ne',
1252
+ ['i64.and', g(), ['i64.const', i64Hex(BigInt(LAYOUT.TAG_MASK) << BigInt(LAYOUT.TAG_SHIFT))]],
1253
+ ['i64.const', i64Hex(BigInt(PTR.STRING) << BigInt(LAYOUT.TAG_SHIFT))]]],
1254
+ ['then', ['f64.reinterpret_i64', ['call', '$__dyn_get_expr', g(), ph.keyIR()]]],
1255
+ ['else', undefExpr()]]],
1256
+ ['local.set', `$${ph.is}`, ptrTypeEq(['local.get', `$${ph.ovr}`], PTR.CLOSURE)])
1257
+ }
1105
1258
  for (const p of sig.params) {
1106
1259
  const di = defaultInits.get(p.name)
1107
1260
  if (di) inits.push(di)
@@ -1135,6 +1288,10 @@ function emitFunc(func, funcFacts, programFacts) {
1135
1288
  fn.push(...paramInits, ...boxedParamInits, ...preboxedLocalInits, ...values)
1136
1289
  } else {
1137
1290
  const ir = emit(body)
1291
+ // dyn-closure-tables.js: an expression-bodied function whose return value
1292
+ // is unconditionally a closure literal (e.g. `mk = (n) => (x) => x + n`) —
1293
+ // a direct-return closure factory, no defaulted-param indirection needed.
1294
+ recordDirectReturnClosure(name, ir)
1138
1295
  const paramInits = collectParamInits()
1139
1296
  for (const [l, t] of ctx.func.locals) fn.push(['local', dollar(l), t])
1140
1297
  const finalIR = sig.ptrKind != null ? asPtrOffset(ir, sig.ptrKind) : asParamType(ir, sig.results[0])
@@ -1259,14 +1416,18 @@ function synthesizeBoundaryWrappers() {
1259
1416
  const ptrType = valKindToPtr(sig.ptrKind)
1260
1417
  body = toI64(mkPtrIR(ptrType, sig.ptrAux ?? 0, callIR))
1261
1418
  } else if (resultBool) {
1262
- // The inner func returns a clean 0/1 boolean carrier never NaN. The i32
1263
- // carrier already takes truthyIR's identity path; the f64 carrier would
1264
- // otherwise fall through to the full __is_truthy NaN-discrimination, every
1265
- // arm of which is dead for a boolean. Pull the bit out with one f64.ne so
1266
- // boolBoxIR boxes `4|bit` straight into the TRUE_NAN/FALSE_NAN atom.
1267
- const carrier = sig.results[0] === 'i32'
1268
- ? typed(callIR, 'i32')
1269
- : typed(['f64.ne', callIR, ['f64.const', 0]], 'i32')
1419
+ // The i32 carrier is a clean 0/1 truthyIR's identity path boxes it
1420
+ // straight into the TRUE_NAN/FALSE_NAN atom. The f64 carrier is NOT
1421
+ // provably raw: a BOOL-valued result may already be the atom box
1422
+ // (JSON.parse("false") returns FALSE_NAN a bare f64.ne(v,0) reads any
1423
+ // atom as truthy). __is_truthy normalizes both representations; this is
1424
+ // the cold host boundary, the call costs nothing that matters.
1425
+ let carrier
1426
+ if (sig.results[0] === 'i32') carrier = typed(callIR, 'i32')
1427
+ else {
1428
+ inc('__is_truthy')
1429
+ carrier = typed(['call', '$__is_truthy', toI64(callIR)], 'i32')
1430
+ }
1270
1431
  body = toI64(boolBoxIR(carrier))
1271
1432
  } else if (resultBigint || resultDynamic) {
1272
1433
  // BigInt rides the i64-reinterpret-f64 carrier internally; a dynamic result is already an
@@ -1322,6 +1483,12 @@ function emitClosureBody(cb) {
1322
1483
  } else {
1323
1484
  ctx.types.typedElem = prevTypedElems
1324
1485
  }
1486
+ // Static typed lengths ride the same channel (module globals + per-body): the
1487
+ // typedIdxProven bounds proof reads the merged view.
1488
+ const globalTL = ctx.scope.globalTypedLen
1489
+ ctx.types.typedLen = cb.typedLens
1490
+ ? (globalTL ? new Map([...globalTL, ...cb.typedLens]) : new Map(cb.typedLens))
1491
+ : (globalTL ? new Map(globalTL) : null)
1325
1492
  // In closure bodies, boxed captures use the original name as both var and cell local
1326
1493
  ctx.func.boxed = cb.boxed ? new Map([...cb.boxed].map(v => [v, v])) : new Map()
1327
1494
  // i32-narrowed cells: the owner decided the cell width (funcFacts.cellTypes);
@@ -1673,11 +1840,30 @@ export default function compile(ast, profiler) {
1673
1840
  // Cache integer values for cross-call const-arg propagation: `f(N)` where
1674
1841
  // `const N = 8` should observe the param as intConst=8.
1675
1842
  if (isInt) (ctx.scope.constInts ||= new Map()).set(name, v)
1843
+ // Cache EVERY folded value (fractional too) so readVar substitutes the
1844
+ // literal at each read site — compile-time paths (emitPow's constant
1845
+ // non-integer exponent → exp(c·log x), narrowing, the vectorizer) see
1846
+ // through the global where V8 would only fold it at runtime. colorpq's
1847
+ // PQ exponents (nv = 2610/16384, p = 1.7·2523/32) rode global.get into
1848
+ // the generic runtime-exponent $math.pow because of exactly this gap.
1849
+ ;(ctx.scope.constNums ||= new Map()).set(name, v)
1676
1850
  }
1677
1851
  }
1678
1852
  }
1679
1853
  }
1680
1854
 
1855
+ // Typed-ctor sizes parked at prepare (`new T(CIN*H*W)` — names only now folded):
1856
+ // re-run the static-len derivation with constInts populated. Feeds the interval
1857
+ // proof's receiver lengths (typedIdxProven class 5).
1858
+ if (ctx.scope.pendingTypedLens) {
1859
+ for (const [name, rhs] of ctx.scope.pendingTypedLens) {
1860
+ const len = typedStaticLen(rhs)
1861
+ if (len != null && ctx.scope.globalTypedElem?.has(name))
1862
+ (ctx.scope.globalTypedLen ||= new Map()).set(name, len)
1863
+ }
1864
+ ctx.scope.pendingTypedLens = null
1865
+ }
1866
+
1681
1867
  // Whole-program constant fold of module-scope aggregate literals — `var x=[1,2,3];
1682
1868
  // y=x[0]` → `y=1`, dropping the array (no data segment, no __arr_idx_known) when
1683
1869
  // every reference is a static read. The scalar analog of the constInts fold above.
@@ -1685,6 +1871,13 @@ export default function compile(ast, profiler) {
1685
1871
 
1686
1872
  const programFacts = timePhase(profiler, 'plan', () => plan(ast, profiler))
1687
1873
 
1874
+ // Same-body indirect devirt (dyn-closure-tables.js): which module globals are
1875
+ // structurally safe candidate closure tables (never alias/escape) — the
1876
+ // write-family + call-site facts gathered during emission below only fire
1877
+ // for names in this set. Post-plan so the scan sees the AST shapes that will
1878
+ // actually emit.
1879
+ if (ctx.transform.optimize) ctx.scope.dynFnTableCandidates = scanDynClosureTableCandidates(ast)
1880
+
1688
1881
  // Inspect sink: editor hosts opt in via { inspect: true } to read inferred shapes.
1689
1882
  // Initialized here (post-plan) so paramReps and schema.list are stable, populated
1690
1883
  // per-function below as funcFacts settle. Bytes themselves are unchanged.
@@ -1800,6 +1993,12 @@ export default function compile(ast, profiler) {
1800
1993
 
1801
1994
  timePhase(profiler, 'buildStart', () => buildStartFn(ast, sec, closureFuncs, compilePendingClosures))
1802
1995
 
1996
+ // dyn-closure-tables.js: every function AND module init has now emitted, so
1997
+ // callSites/paramClosureDefaults/directReturnClosures are complete — resolve
1998
+ // each candidate table's write family and (if monomorphic) hand it to
1999
+ // devirtConstFnArrayCalls via ctx.scope.constFnArrays, same as a const array.
2000
+ if (ctx.transform.optimize) timePhase(profiler, 'resolveDynFnTables', () => resolveDynFnTables(programFacts))
2001
+
1803
2002
  // Host globals (globalThis/process/WebAssembly/…) referenced as values are
1804
2003
  // recorded in ctx.core.hostGlobals during emit; register them as env imports
1805
2004
  // now (assembly owns ctx.module.imports). Drained after buildStartFn so a
@@ -1848,10 +2047,10 @@ export default function compile(ast, profiler) {
1848
2047
  [`${ty}.const`, g.init]]
1849
2048
  }))
1850
2049
 
1851
- // Drop the Eisel-Lemire decimal table if no live code parses decimals at runtime — must
1852
- // run after sec.globals/funcs are final (exact reachability) and before the data segment
1853
- // below serializes ctx.runtime.data. See stripDeadElTable.
1854
- stripDeadElTable(sec)
2050
+ // Drop the lazy conversion tables (EL decimal→f64, Ryū float→decimal) whose owner
2051
+ // functions no live code calls — must run after sec.globals/funcs are final (exact
2052
+ // reachability) and before the data segment below serializes ctx.runtime.data.
2053
+ stripDeadLazyTables(sec)
1855
2054
 
1856
2055
  // Data segments (after emit — string literals append to ctx.runtime.data / strPool during emit)
1857
2056
  // Active segment at address 0 — skipped for shared memory (would collide across modules)
@@ -1866,6 +2065,28 @@ export default function compile(ast, profiler) {
1866
2065
  }
1867
2066
  if (ctx.runtime.data && !ctx.memory.shared)
1868
2067
  sec.data.push(['data', ['i32.const', 0], '"' + escBytes(ctx.runtime.data) + '"'])
2068
+ // Shared memory: no active segment at 0 (instances would collide) — ship the
2069
+ // static region (static strings + lazy conversion tables) as a PASSIVE segment,
2070
+ // memory.init it into __alloc'd space at start, and rebase its consumers:
2071
+ // $__staticBase for __static_str, plus each surviving table global (their
2072
+ // declared inits hold offsets WITHIN the region — see injectTable/strip).
2073
+ else if (ctx.runtime.data && ctx.memory.shared && ctx.scope.globals.has('__staticBase')) {
2074
+ const len = ctx.runtime.data.length
2075
+ sec.data.push(['data', '$__staticData', '"' + escBytes(ctx.runtime.data) + '"'])
2076
+ const inits = [
2077
+ ['global.set', '$__staticBase', ['call', '$__alloc', ['i32.const', len]]],
2078
+ ['memory.init', '$__staticData', ['global.get', '$__staticBase'], ['i32.const', 0], ['i32.const', len]],
2079
+ ['data.drop', '$__staticData'],
2080
+ ...(ctx.runtime.lazySpans || []).map(t => ['global.set', `$${t.global}`,
2081
+ ['i32.add', ['global.get', '$__staticBase'], ['i32.const', ctx.scope.globals.get(t.global)?.init || 0]]]),
2082
+ ]
2083
+ let startFn = sec.start.find(n => Array.isArray(n) && n[0] === 'func' && n[1] === '$__start')
2084
+ if (!startFn) sec.start.push(startFn = ['func', '$__start'], ['start', '$__start'])
2085
+ // insert after the local decls, before any init code (module init may stringify)
2086
+ let at = 2
2087
+ while (at < startFn.length && Array.isArray(startFn[at]) && startFn[at][0] === 'local') at++
2088
+ startFn.splice(at, 0, ...inits)
2089
+ }
1869
2090
  // Passive segment for shared-memory string literals (copied via memory.init at runtime)
1870
2091
  if (ctx.runtime.strPool)
1871
2092
  sec.data.push(['data', '$__strPool', '"' + escBytes(ctx.runtime.strPool) + '"'])
@@ -1984,6 +2205,33 @@ export default function compile(ast, profiler) {
1984
2205
 
1985
2206
  pruneUnusedThrowRuntime(sec)
1986
2207
 
2208
+ // WASI reactor convention: the p1 ABI forbids WASI calls inside the wasm start
2209
+ // section — a JS shim literally cannot serve them (fd_write during `new
2210
+ // WebAssembly.Instance` has no memory to read; a top-level console.log/Date.now
2211
+ // crashed with "Cannot read properties of null"). Ship module init as the
2212
+ // standard `_initialize` export instead: hosts (jz/wasi + interop shims, node:wasi
2213
+ // initialize(), wasmtime) call it once memory is wired. Command entries
2214
+ // (`run`/`_start`) self-init through the same once-guard, so a runtime that
2215
+ // invokes a command without calling `_initialize` still runs init exactly once.
2216
+ if (ctx.transform.host === 'wasi') {
2217
+ const sFn = sec.start.find(n => Array.isArray(n) && n[0] === 'func')
2218
+ const sDirIdx = sec.start.findIndex(n => Array.isArray(n) && n[0] === 'start')
2219
+ if (sFn) {
2220
+ sec.globals.push(['global', '$__init_done', ['mut', 'i32'], ['i32.const', 0]])
2221
+ let at = 2
2222
+ while (at < sFn.length && Array.isArray(sFn[at]) && sFn[at][0] === 'local') at++
2223
+ sFn.splice(at, 0,
2224
+ ['if', ['global.get', '$__init_done'], ['then', ['return']]],
2225
+ ['global.set', '$__init_done', ['i32.const', 1]])
2226
+ sFn.splice(2, 0, ['export', '"_initialize"'])
2227
+ if (sDirIdx !== -1) sec.start.splice(sDirIdx, 1)
2228
+ for (const name of wasiCommandExports) {
2229
+ const w = sec.funcs.find(f => Array.isArray(f) && f[1] === `$${name}$wasi`)
2230
+ if (w) w.splice(w.findIndex(n => Array.isArray(n) && n[0] === 'drop'), 0, ['call', '$__start'])
2231
+ }
2232
+ }
2233
+ }
2234
+
1987
2235
  // Reorder non-import funcs by call count: hot callees get low LEB128 indices.
1988
2236
  // `call $f` encodes funcidx as ULEB128 (1 B for idx < 128, 2 B for idx < 16384).
1989
2237
  // On watr self-host this saves ~6 KB (hot specialized helpers migrate to idx < 128).
@@ -47,7 +47,8 @@ import { ctx } from '../ctx.js'
47
47
  import { collectParamNames, ASSIGN_OPS } from '../ast.js'
48
48
  import { analyzeValTypes, analyzeIntCertain } from './analyze.js'
49
49
  import { staticObjectProps, staticArrayElems } from '../static.js'
50
- import { typedElemCtor } from '../type.js'
50
+ import { isNullishLit } from '../ir.js'
51
+ import { typedElemCtor, typedStaticLen } from '../type.js'
51
52
  import { ctorFromElemAux } from '../../layout.js'
52
53
  import { shapeOfObjectLiteralAst, valTypeOf } from '../kind.js'
53
54
  import { includeForStringValue } from '../autoload.js'
@@ -130,11 +131,18 @@ export const inferParams = (body, candidates) => {
130
131
  // regardless of prior evidence — a later method call can't re-induce a shape
131
132
  // already contradicted by an earlier scalar assignment.
132
133
 
134
+ // Methods that exist ONLY on String.prototype — seeing one on a bare binding proves
135
+ // it is a string. `indexOf`/`includes`/`lastIndexOf`/`concat`/`slice`/`at` are NOT here:
136
+ // Array.prototype has them too, so the receiver is genuinely ambiguous (and the argument
137
+ // can't disambiguate — String coerces it, Arrays hold strings). Those keep the runtime
138
+ // __ptr_type fork, which is correct for both; forcing `lastIndexOf` to string here used to
139
+ // miscompile `arr.lastIndexOf(x)` to -1. A string-using param still narrows via any of the
140
+ // real discriminators below (charCodeAt, a string assignment, a string-passing call site).
133
141
  const STRING_ONLY_METHODS = new Set([
134
142
  'charCodeAt', 'charAt', 'codePointAt', 'startsWith', 'endsWith',
135
143
  'toUpperCase', 'toLowerCase', 'toLocaleLowerCase', 'normalize', 'localeCompare',
136
144
  'padStart', 'padEnd', 'repeat', 'trimStart', 'trimEnd', 'trim',
137
- 'matchAll', 'match', 'replace', 'replaceAll', 'split', 'lastIndexOf',
145
+ 'matchAll', 'match', 'replace', 'replaceAll', 'split',
138
146
  ])
139
147
  const ARRAY_ONLY_POISON = new Set([
140
148
  'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'fill', 'reverse',
@@ -334,13 +342,32 @@ export const inferLocals = (body, candidates) => {
334
342
  // confirmed prepare's depth-0 catch is a strict superset.
335
343
  export function recordGlobalRep(name, expr) {
336
344
  if (typeof name !== 'string') return
345
+ // A nullish decl init makes the binding NULLABLE program-wide — any later
346
+ // val-kind claim (typed promote, pointer-ABI assignment) must keep an
347
+ // `x === null` compare honest. This is THE plan-cache memo idiom
348
+ // (`let lastPlan = null` + `if (lastPlan === null) lastPlan = make()`):
349
+ // without the flag, strictSentinel folds the guard to a constant and the
350
+ // memo never fills (silently wrong values, not a trap).
351
+ if (isNullishLit(expr) || expr === 'null' || expr === 'undefined')
352
+ updateGlobalRep(name, { nullable: true })
337
353
  const vt = valTypeOf(expr)
338
354
  if (vt) {
339
355
  ;(ctx.scope.globalValTypes ||= new Map()).set(name, vt)
340
356
  if (vt === VAL.REGEX && ctx.runtime.regex) ctx.runtime.regex.vars.set(name, expr)
341
357
  }
342
358
  const ctor = typedElemCtor(expr)
343
- if (ctor) (ctx.scope.globalTypedElem ||= new Map()).set(name, ctor)
359
+ if (ctor) {
360
+ ;(ctx.scope.globalTypedElem ||= new Map()).set(name, ctor)
361
+ const len = typedStaticLen(expr)
362
+ if (len != null) (ctx.scope.globalTypedLen ||= new Map()).set(name, len)
363
+ else {
364
+ ctx.scope.globalTypedLen?.delete(name)
365
+ // `new T(CIN*H*W)` — the size is a const expression whose names fold AFTER
366
+ // prepare (the compile-time constInts fixpoint). Park the rhs; the fold's tail
367
+ // re-runs typedStaticLen over these (see "Pre-fold const globals").
368
+ ;(ctx.scope.pendingTypedLens ||= new Map()).set(name, expr)
369
+ }
370
+ }
344
371
  // Module-level const array literal with a uniform element val-type (e.g. a numeric
345
372
  // table `const FREQS = [261.63, …]`): record it so `FREQS[i]` reads in any using
346
373
  // function are typed (NUMBER) rather than untyped. Without this an untyped element
@@ -496,7 +523,7 @@ export function inferArrElemValType(expr, callerArrElemVals, callerArrValParams)
496
523
  /** Infer typed-array ctor (`new.Float64Array` etc.) of an arg expression at a call site.
497
524
  * Sources: caller's body-local typedElems, caller's typed params, literal `new TypedArray(...)`,
498
525
  * calls to typed-narrowed user funcs. Returns null when the ctor can't be determined. */
499
- export function inferTypedCtor(expr, callerTypedElems, callerTypedParams) {
526
+ export function inferTypedCtor(expr, callerTypedElems, callerTypedParams, callerSids) {
500
527
  if (typeof expr === 'string') {
501
528
  if (callerTypedElems?.has(expr)) return callerTypedElems.get(expr)
502
529
  if (callerTypedParams?.has(expr)) return callerTypedParams.get(expr)
@@ -508,5 +535,16 @@ export function inferTypedCtor(expr, callerTypedElems, callerTypedParams) {
508
535
  const f = ctx.func.map?.get(expr[1])
509
536
  if (f?.sig?.ptrKind === VAL.TYPED && f.sig.ptrAux != null) return ctorFromElemAux(f.sig.ptrAux)
510
537
  }
538
+ // Field provenance: `plan.twRe` where the receiver's schema slot holds one
539
+ // typed-array kind program-wide (observeProgramSlots) and the prop is never
540
+ // written — the write gate lives inside slotTypedCtorBySid. The receiver's
541
+ // schema comes from the caller's per-body localSids when provided (narrow's
542
+ // arg lattice — live reps aren't trustworthy there), else the idOf chain.
543
+ if (Array.isArray(expr) && (expr[0] === '.' || expr[0] === '?.') &&
544
+ typeof expr[1] === 'string' && typeof expr[2] === 'string' && ctx.schema?.slotTypedCtorAt) {
545
+ const sid = callerSids?.get(expr[1])
546
+ if (sid != null) return ctx.schema.slotTypedCtorBySid(sid, expr[2])
547
+ return ctx.schema.slotTypedCtorAt(expr[1], expr[2])
548
+ }
511
549
  return null
512
550
  }