jz 0.8.1 → 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 (72) 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 -6271
  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 +299 -44
  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 +892 -32
  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 +301 -84
  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 +71 -5
  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 +979 -54
  45. package/src/compile/index.js +271 -29
  46. package/src/compile/infer.js +34 -3
  47. package/src/compile/inplace-store.js +329 -0
  48. package/src/compile/narrow.js +543 -15
  49. package/src/compile/peel-stencil.js +5 -0
  50. package/src/compile/plan/index.js +45 -3
  51. package/src/compile/plan/inline.js +8 -1
  52. package/src/compile/plan/literals.js +39 -0
  53. package/src/compile/plan/scope.js +430 -23
  54. package/src/compile/program-facts.js +732 -38
  55. package/src/ctx.js +64 -9
  56. package/src/helper-counters.js +7 -1
  57. package/src/ir.js +113 -5
  58. package/src/kind-traits.js +66 -3
  59. package/src/kind.js +84 -12
  60. package/src/op-policy.js +7 -4
  61. package/src/optimize/index.js +1060 -750
  62. package/src/optimize/recurse.js +2 -2
  63. package/src/optimize/vectorize.js +972 -67
  64. package/src/prepare/index.js +792 -63
  65. package/src/prepare/math-kernel.js +331 -0
  66. package/src/prepare/pre-eval.js +714 -0
  67. package/src/snapshot.js +194 -0
  68. package/src/type.js +1170 -56
  69. package/src/wat/assemble.js +403 -65
  70. package/src/wat/codegen.js +74 -14
  71. package/transform.js +113 -4
  72. 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,
@@ -43,6 +45,9 @@ import { narrowBoundedSquare } from './loop-square.js'
43
45
  import { unrollRecurrence } from './loop-recurrence.js'
44
46
  import { peelClampedStencil } from './peel-stencil.js'
45
47
  import { cseLoads } from './cse-load.js'
48
+ import {
49
+ scanDynClosureTableCandidates, recordParamClosureDefault, recordDirectReturnClosure, resolveDynFnTables,
50
+ } from './dyn-closure-tables.js'
46
51
 
47
52
  // Monotonic across all functions so a CSE temp never collides (even after later inlining).
48
53
  let __cseCtr = 0
@@ -50,7 +55,7 @@ const freshCseName = () => `${T}cse${__cseCtr++}`
50
55
  import { emit, emitter, emitVoid, emitBlockBody } from './emit.js'
51
56
  import { emitCharDecompPrologue, JSS_IMPORT_SIGS } from '../abi/string.js'
52
57
  import {
53
- typed, asF64, asI32, asPtrOffset, asParamType, toI32, asI64, fromI64,
58
+ typed, asF64, asI32, asPtrOffset, asParamType, toI32, asI64, fromI64, ptrTypeEq,
54
59
  NULL_NAN, UNDEF_NAN, NULL_WAT, UNDEF_WAT, NULL_IR, UNDEF_IR, nullExpr, undefExpr,
55
60
  MAX_CLOSURE_ARITY,
56
61
  mkPtrIR,
@@ -67,7 +72,7 @@ import plan from './plan/index.js'
67
72
  import { foldStaticConstAggregates } from './plan/literals.js'
68
73
  import {
69
74
  buildStartFn, dedupClosureBodies, finalizeClosureTable,
70
- pullStdlib, syncImports, optimizeModule, stripStaticDataPrefix, hoistConstGlobalInits, stripDeadElTable,
75
+ pullStdlib, syncImports, optimizeModule, stripStaticDataPrefix, hoistConstGlobalInits, stripDeadLazyTables,
71
76
  } from '../wat/assemble.js'
72
77
  import { instrumentHelperCallsites } from '../helper-counters.js'
73
78
 
@@ -393,6 +398,8 @@ function enterFunc(sig, body, { uniq = 0, directClosures = null } = {}) {
393
398
  ctx.func.directClosures = directClosures
394
399
  ctx.func.localProps = null
395
400
  ctx.func.charDecomp = null
401
+ ctx.func.charDecompGlobals = false // only emitFunc's named path drains — it re-arms
402
+ ctx.func.probeHoist = null
396
403
  if (ctx.transform.optimize) {
397
404
  scanAndTagNonEscapingClosures(body)
398
405
  }
@@ -443,21 +450,68 @@ function analyzeFuncForEmit(func, programFacts) {
443
450
  ctx.func.boxed = new Map()
444
451
  ctx.func.localReps = null
445
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
446
456
 
447
457
  const _reps = paramReps.get(name)
448
458
  if (_reps) {
449
459
  for (const [k, r] of _reps) {
450
460
  if (k >= sig.params.length) continue
451
461
  const pname = sig.params[k].name
452
- 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) {
453
484
  if (!ctx.types.typedElem) ctx.types.typedElem = new Map()
454
485
  if (!ctx.types.typedElem.has(pname)) ctx.types.typedElem.set(pname, r.typedCtor)
455
486
  updateRep(pname, { val: VAL.TYPED })
456
487
  }
457
- 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 })
458
489
  if (r.arrayElemSchema != null) updateRep(pname, { arrayElemSchema: r.arrayElemSchema })
459
490
  if (r.arrayElemValType != null) updateRep(pname, { arrayElemValType: r.arrayElemValType })
460
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 })
461
515
  }
462
516
  }
463
517
  // Trust numeric export params. An exported f64 param used only in numeric
@@ -502,7 +556,32 @@ function analyzeFuncForEmit(func, programFacts) {
502
556
  const bodyFacts = block ? analyzeBody(body) : null
503
557
  ctx.func.locals = bodyFacts ? bodyFacts.locals : new Map()
504
558
  if (bodyFacts?.valTypes) {
505
- 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
+ }
506
585
  }
507
586
  // Never-relocated array bindings — the `[]` reader skips the forwarding follow.
508
587
  if (bodyFacts?.neverGrown) for (const name of bodyFacts.neverGrown) updateRep(name, { neverGrown: true })
@@ -530,6 +609,17 @@ function analyzeFuncForEmit(func, programFacts) {
530
609
  .filter(p => !ctx.func.localReps?.get(p.name)?.val)
531
610
  .map(p => p.name)
532
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
+ }
533
623
  if (block) {
534
624
  boxedCaptures(body)
535
625
  // Lower provably-monomorphic pointer locals to i32 offset storage.
@@ -573,15 +663,30 @@ function analyzeFuncForEmit(func, programFacts) {
573
663
  : new Set()
574
664
 
575
665
  // Closure-capture narrowing: a boxed var whose every defining RHS — owner
576
- // body AND nested arrows, the narrower's intCertain contract is integer-
577
- // valued keeps its CELL in i32, so readVar/writeVar skip the f64↔i32
578
- // round-trip per access. Params are excluded: their cell is seeded from the
579
- // raw f64 param value, which would desync an i32-read cell. Same asm.js-style
580
- // 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.
581
682
  const cellTypes = new Set()
582
- for (const name of ctx.func.boxed.keys()) {
583
- if (sig.params.some(p => p.name === name)) continue
584
- 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
+ }
585
690
  }
586
691
 
587
692
  // Snapshot each param's JS-boundary carrier while reps are live — synthesizeBoundaryWrappers
@@ -620,6 +725,7 @@ function analyzeFuncForEmit(func, programFacts) {
620
725
  cseLoadBases,
621
726
  distinctParams: func.distinctParams || null,
622
727
  typedElem: ctx.types.typedElem ? new Map(ctx.types.typedElem) : null,
728
+ typedLen: ctx.types.typedLen ? new Map(ctx.types.typedLen) : null,
623
729
  localReps: cloneRepMap(ctx.func.localReps),
624
730
  }
625
731
  }
@@ -1002,6 +1108,9 @@ function emitFunc(func, funcFacts, programFacts) {
1002
1108
  const _reps = paramReps.get(name)
1003
1109
 
1004
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
1005
1114
  const block = funcFacts.block
1006
1115
  ctx.func.locals = new Map(funcFacts.locals)
1007
1116
  ctx.func.boxed = new Map(funcFacts.boxed)
@@ -1010,6 +1119,8 @@ function emitFunc(func, funcFacts, programFacts) {
1010
1119
  ctx.func.sliceViews = new Set(funcFacts.sliceViews)
1011
1120
  ctx.func.localReps = cloneRepMap(funcFacts.localReps)
1012
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
1013
1124
 
1014
1125
  // D: Apply call-site param facts (only if body analysis didn't already set them).
1015
1126
  // Schema bindings additionally write into ctx.schema.vars so prop-access dispatch
@@ -1019,13 +1130,20 @@ function emitFunc(func, funcFacts, programFacts) {
1019
1130
  for (const [k, r] of _reps) {
1020
1131
  if (k >= sig.params.length) continue
1021
1132
  const pname = sig.params[k].name
1022
- if (r.val && !ctx.func.localReps?.get(pname)?.val) updateRep(pname, { val: r.val })
1023
- 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) {
1024
1142
  if (!ctx.types.typedElem) ctx.types.typedElem = new Map()
1025
1143
  if (!ctx.types.typedElem.has(pname)) ctx.types.typedElem.set(pname, r.typedCtor)
1026
1144
  if (!ctx.func.localReps?.get(pname)?.val) updateRep(pname, { val: VAL.TYPED })
1027
1145
  }
1028
- if (r.schemaId != null && !exported && !ctx.schema.vars.has(pname)) {
1146
+ if (r.schemaId != null && !reassigned && !exported && !ctx.schema.vars.has(pname)) {
1029
1147
  ctx.schema.vars.set(pname, r.schemaId)
1030
1148
  updateRep(pname, { schemaId: r.schemaId })
1031
1149
  }
@@ -1070,9 +1188,16 @@ function emitFunc(func, funcFacts, programFacts) {
1070
1188
  // externref so no `ref.is_null` branch is needed.
1071
1189
  if (p?.jsstring && p.jsstringDefault != null) continue
1072
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)
1073
1198
  defaultInits.set(pname,
1074
1199
  ['if', isUndef(typed(['local.get', `$${pname}`], 'f64')),
1075
- ['then', ['local.set', `$${pname}`, t === 'f64' ? asF64(emit(defVal)) : asI32(emit(defVal))]]])
1200
+ ['then', ['local.set', `$${pname}`, t === 'f64' ? asF64(emittedDefVal) : asI32(emittedDefVal)]]])
1076
1201
  }
1077
1202
 
1078
1203
  // Box params that are mutably captured: allocate cell, copy param value
@@ -1108,6 +1233,28 @@ function emitFunc(func, funcFacts, programFacts) {
1108
1233
  // in-bounds char wrongly decodes as the OOB NaN.
1109
1234
  const collectParamInits = () => {
1110
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
+ }
1111
1258
  for (const p of sig.params) {
1112
1259
  const di = defaultInits.get(p.name)
1113
1260
  if (di) inits.push(di)
@@ -1141,6 +1288,10 @@ function emitFunc(func, funcFacts, programFacts) {
1141
1288
  fn.push(...paramInits, ...boxedParamInits, ...preboxedLocalInits, ...values)
1142
1289
  } else {
1143
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)
1144
1295
  const paramInits = collectParamInits()
1145
1296
  for (const [l, t] of ctx.func.locals) fn.push(['local', dollar(l), t])
1146
1297
  const finalIR = sig.ptrKind != null ? asPtrOffset(ir, sig.ptrKind) : asParamType(ir, sig.results[0])
@@ -1265,14 +1416,18 @@ function synthesizeBoundaryWrappers() {
1265
1416
  const ptrType = valKindToPtr(sig.ptrKind)
1266
1417
  body = toI64(mkPtrIR(ptrType, sig.ptrAux ?? 0, callIR))
1267
1418
  } else if (resultBool) {
1268
- // The inner func returns a clean 0/1 boolean carrier never NaN. The i32
1269
- // carrier already takes truthyIR's identity path; the f64 carrier would
1270
- // otherwise fall through to the full __is_truthy NaN-discrimination, every
1271
- // arm of which is dead for a boolean. Pull the bit out with one f64.ne so
1272
- // boolBoxIR boxes `4|bit` straight into the TRUE_NAN/FALSE_NAN atom.
1273
- const carrier = sig.results[0] === 'i32'
1274
- ? typed(callIR, 'i32')
1275
- : 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
+ }
1276
1431
  body = toI64(boolBoxIR(carrier))
1277
1432
  } else if (resultBigint || resultDynamic) {
1278
1433
  // BigInt rides the i64-reinterpret-f64 carrier internally; a dynamic result is already an
@@ -1328,6 +1483,12 @@ function emitClosureBody(cb) {
1328
1483
  } else {
1329
1484
  ctx.types.typedElem = prevTypedElems
1330
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)
1331
1492
  // In closure bodies, boxed captures use the original name as both var and cell local
1332
1493
  ctx.func.boxed = cb.boxed ? new Map([...cb.boxed].map(v => [v, v])) : new Map()
1333
1494
  // i32-narrowed cells: the owner decided the cell width (funcFacts.cellTypes);
@@ -1679,11 +1840,30 @@ export default function compile(ast, profiler) {
1679
1840
  // Cache integer values for cross-call const-arg propagation: `f(N)` where
1680
1841
  // `const N = 8` should observe the param as intConst=8.
1681
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)
1682
1850
  }
1683
1851
  }
1684
1852
  }
1685
1853
  }
1686
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
+
1687
1867
  // Whole-program constant fold of module-scope aggregate literals — `var x=[1,2,3];
1688
1868
  // y=x[0]` → `y=1`, dropping the array (no data segment, no __arr_idx_known) when
1689
1869
  // every reference is a static read. The scalar analog of the constInts fold above.
@@ -1691,6 +1871,13 @@ export default function compile(ast, profiler) {
1691
1871
 
1692
1872
  const programFacts = timePhase(profiler, 'plan', () => plan(ast, profiler))
1693
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
+
1694
1881
  // Inspect sink: editor hosts opt in via { inspect: true } to read inferred shapes.
1695
1882
  // Initialized here (post-plan) so paramReps and schema.list are stable, populated
1696
1883
  // per-function below as funcFacts settle. Bytes themselves are unchanged.
@@ -1806,6 +1993,12 @@ export default function compile(ast, profiler) {
1806
1993
 
1807
1994
  timePhase(profiler, 'buildStart', () => buildStartFn(ast, sec, closureFuncs, compilePendingClosures))
1808
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
+
1809
2002
  // Host globals (globalThis/process/WebAssembly/…) referenced as values are
1810
2003
  // recorded in ctx.core.hostGlobals during emit; register them as env imports
1811
2004
  // now (assembly owns ctx.module.imports). Drained after buildStartFn so a
@@ -1854,10 +2047,10 @@ export default function compile(ast, profiler) {
1854
2047
  [`${ty}.const`, g.init]]
1855
2048
  }))
1856
2049
 
1857
- // Drop the Eisel-Lemire decimal table if no live code parses decimals at runtime — must
1858
- // run after sec.globals/funcs are final (exact reachability) and before the data segment
1859
- // below serializes ctx.runtime.data. See stripDeadElTable.
1860
- 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)
1861
2054
 
1862
2055
  // Data segments (after emit — string literals append to ctx.runtime.data / strPool during emit)
1863
2056
  // Active segment at address 0 — skipped for shared memory (would collide across modules)
@@ -1872,6 +2065,28 @@ export default function compile(ast, profiler) {
1872
2065
  }
1873
2066
  if (ctx.runtime.data && !ctx.memory.shared)
1874
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
+ }
1875
2090
  // Passive segment for shared-memory string literals (copied via memory.init at runtime)
1876
2091
  if (ctx.runtime.strPool)
1877
2092
  sec.data.push(['data', '$__strPool', '"' + escBytes(ctx.runtime.strPool) + '"'])
@@ -1990,6 +2205,33 @@ export default function compile(ast, profiler) {
1990
2205
 
1991
2206
  pruneUnusedThrowRuntime(sec)
1992
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
+
1993
2235
  // Reorder non-import funcs by call count: hot callees get low LEB128 indices.
1994
2236
  // `call $f` encodes funcidx as ULEB128 (1 B for idx < 128, 2 B for idx < 16384).
1995
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'
@@ -341,13 +342,32 @@ export const inferLocals = (body, candidates) => {
341
342
  // confirmed prepare's depth-0 catch is a strict superset.
342
343
  export function recordGlobalRep(name, expr) {
343
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 })
344
353
  const vt = valTypeOf(expr)
345
354
  if (vt) {
346
355
  ;(ctx.scope.globalValTypes ||= new Map()).set(name, vt)
347
356
  if (vt === VAL.REGEX && ctx.runtime.regex) ctx.runtime.regex.vars.set(name, expr)
348
357
  }
349
358
  const ctor = typedElemCtor(expr)
350
- 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
+ }
351
371
  // Module-level const array literal with a uniform element val-type (e.g. a numeric
352
372
  // table `const FREQS = [261.63, …]`): record it so `FREQS[i]` reads in any using
353
373
  // function are typed (NUMBER) rather than untyped. Without this an untyped element
@@ -503,7 +523,7 @@ export function inferArrElemValType(expr, callerArrElemVals, callerArrValParams)
503
523
  /** Infer typed-array ctor (`new.Float64Array` etc.) of an arg expression at a call site.
504
524
  * Sources: caller's body-local typedElems, caller's typed params, literal `new TypedArray(...)`,
505
525
  * calls to typed-narrowed user funcs. Returns null when the ctor can't be determined. */
506
- export function inferTypedCtor(expr, callerTypedElems, callerTypedParams) {
526
+ export function inferTypedCtor(expr, callerTypedElems, callerTypedParams, callerSids) {
507
527
  if (typeof expr === 'string') {
508
528
  if (callerTypedElems?.has(expr)) return callerTypedElems.get(expr)
509
529
  if (callerTypedParams?.has(expr)) return callerTypedParams.get(expr)
@@ -515,5 +535,16 @@ export function inferTypedCtor(expr, callerTypedElems, callerTypedParams) {
515
535
  const f = ctx.func.map?.get(expr[1])
516
536
  if (f?.sig?.ptrKind === VAL.TYPED && f.sig.ptrAux != null) return ctorFromElemAux(f.sig.ptrAux)
517
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
+ }
518
549
  return null
519
550
  }