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
@@ -8,6 +8,7 @@
8
8
  * which mutate the AST as their final step):
9
9
  *
10
10
  * - `inferModuleLetTypes` — module-level `let` typed-array union
11
+ * - `inferModuleGlobalValTypes` — module-global VAL-kind from an all-writers scan
11
12
  * - `unboxConstTypedGlobals` — const typed-array → unboxed i32 offset
12
13
  * - `inferModuleIntGlobals` — purpose-focused f64→i32 numeric demotion
13
14
  * - `flattenFuncNamespaces` — `f.prop` slot SROA + dead-write drop
@@ -20,12 +21,14 @@
20
21
  */
21
22
 
22
23
  import { ctx, warn, declGlobal } from '../../ctx.js'
23
- import { ASSIGN_OPS, T, refsAny } from '../../ast.js'
24
+ import { ASSIGN_OPS, T, refsAny, extractParams, classifyParam, collectParamNames } from '../../ast.js'
24
25
  import { VAL, updateGlobalRep } from '../../reps.js'
25
26
  import { typedElemCtor, ternaryCtorOfRhs, MIXED_CTORS } from '../../type.js'
27
+ import { inferSchemaId } from '../infer.js'
28
+ import { valTypeOf } from '../../kind.js'
26
29
  import { typedElemAux } from '../../../layout.js'
27
30
  import { MAX_CLOSURE_ARITY, UNDEF_NAN } from '../../ir.js'
28
- import { analyzeFuncNamespaces } from '../analyze.js'
31
+ import { analyzeFuncNamespaces, analyzeBody } from '../analyze.js'
29
32
  import { invalidateProgramFactsCache } from '../program-facts.js'
30
33
 
31
34
  // `scanGlobalValueFacts` was deleted — prepare's depth-0 catch (calling
@@ -102,6 +105,16 @@ export const inferModuleLetTypes = (ast) => {
102
105
  if (ctor === MIXED) { d.bad = true; return }
103
106
  if (ctor) { d.ctors.add(ctor); return }
104
107
  if (typeof rhs === 'string') { d.refs.add(key(rhs, sid)); return }
108
+ // Field provenance: a schema slot holding ONE typed kind program-wide (and a
109
+ // prop never written — both gates inside slotTypedCtorAt, which also fails
110
+ // closed before the slot census exists) contributes that kind as evidence.
111
+ if (Array.isArray(rhs) && (rhs[0] === '.' || rhs[0] === '?.') &&
112
+ typeof rhs[1] === 'string' && typeof rhs[2] === 'string') {
113
+ const fc = ctx.schema?.slotTypedCtorAt?.(rhs[1], rhs[2])
114
+ if (fc) { d.ctors.add(fc); return }
115
+ d.bad = true
116
+ return
117
+ }
105
118
  if (Array.isArray(rhs) && rhs[0] === '()') {
106
119
  const callee = rhs[1]
107
120
  // `recv.subarray(...)` / `recv.slice(...)` / `recv.map(...)` → inherit recv's ctor.
@@ -182,6 +195,253 @@ export const inferModuleLetTypes = (ast) => {
182
195
  }
183
196
  }
184
197
 
198
+ /** LATE field-provenance refinement — after narrow's return inference, module
199
+ * consts bound to returned objects resolve their SCHEMA: `const P = mk(n)`
200
+ * binds P's sid through mk's inferred return sid (`valResult`/`ptrAux`). A
201
+ * const's single init is its value on every read, so the binding is exactly as
202
+ * trustworthy as the return inference itself. The ctor side (a following
203
+ * `const T = P.wre`, memo globals, Map-cached plans) is the late
204
+ * inferModuleLetTypes re-run, whose field/`@map:` evidence needs these sids
205
+ * bound first (bench: provenance, fftplan). */
206
+ export const refineFieldProvenance = (ast) => {
207
+ if (!ctx.scope.userGlobals || !ctx.schema?.vars) return
208
+ const visitDecl = (node) => {
209
+ if (!Array.isArray(node)) return
210
+ if (node[0] === 'let' || node[0] === 'const') {
211
+ for (const d of node.slice(1)) {
212
+ if (!Array.isArray(d) || d[0] !== '=' || typeof d[1] !== 'string') continue
213
+ const name = d[1], rhs = d[2]
214
+ if (!ctx.scope.userGlobals.has(name) || !ctx.scope.consts?.has(name)) continue
215
+ if (!ctx.schema.vars.has(name) && !ctx.schema.poisoned?.has(name)) {
216
+ const sid = inferSchemaId(rhs, null)
217
+ if (sid != null) ctx.schema.vars.set(name, sid)
218
+ }
219
+ }
220
+ return
221
+ }
222
+ if (node[0] === ';' || node[0] === 'export') for (let i = 1; i < node.length; i++) visitDecl(node[i])
223
+ }
224
+ if (Array.isArray(ast)) {
225
+ if (ast[0] === ';') for (let i = 1; i < ast.length; i++) visitDecl(ast[i])
226
+ else visitDecl(ast)
227
+ }
228
+ }
229
+
230
+ // A module global whose every write anywhere in the program (any function body,
231
+ // any nesting/closure depth) agrees on one VAL.* kind — not just its depth-0
232
+ // initializer (recordGlobalRep's territory). The subscript/jessie shape this
233
+ // unblocks: `export let idx, cur, parse = s => (idx = 0, cur = s, …)` — `cur`
234
+ // is a parse-state global assigned ONLY inside `parse`'s body, so recordGlobalRep
235
+ // (depth-0 only) never proves it, every `cur.charCodeAt(i)` read in the scan
236
+ // loop stays on the durable-receiver override probe (sidecarOverride, ir.js —
237
+ // gated on `valTypeOf(receiver)`, which this pass feeds via `ctx.scope.
238
+ // globalValTypes`), and the probe's own call_indirect fail-closes the loop
239
+ // hoist. Proving `cur` STRING here removes the probe (emit.js's tryStaticDispatch
240
+ // fires once `vt` is known, ahead of the runtime-dispatch/sidecar strategies).
241
+ //
242
+ // Soundness is fail-closed and layered:
243
+ // - Host-writable escape: an exported MUTABLE global's wasm export lets the
244
+ // host `.value =` it with ANY bit pattern, invisible to this (or any) AST
245
+ // scan — excluded from candidacy outright (isHostWritableGlobal).
246
+ // - Shadowing: a write only counts when `name` is free (not a param/let/const/
247
+ // catch binding) at that point in ITS OWN enclosing function/closure scope —
248
+ // computed per scope via a dedicated bound-name collector, not reused from
249
+ // emit-time `ctx.func.locals` (not populated yet at plan time).
250
+ // - Unrecognized write shapes (compound-assign, `++`/`--`, a destructuring
251
+ // target — in practice already desugared to plain assigns by prepare, but
252
+ // defended anyway) poison the candidate outright: correctness-relevant
253
+ // mutations this scan can't classify must not be silently dropped.
254
+ // - Every other write's RHS is resolved via `valTypeOf` under a per-scope
255
+ // overlay (that scope's `let/const` locals from `analyzeBody`, plus — when
256
+ // `paramReps` is supplied — its OWN resolved param facts), so a plain
257
+ // parameter alias (`cur = s`) or a method result (`cur = s.slice(i)`)
258
+ // resolves the same way a body-local's does. A bare reference to ANOTHER
259
+ // candidate global, or a call to a user function, defers to a small
260
+ // alias-graph fixpoint (module globals + `@ret:fn` result nodes only —
261
+ // unlike inferModuleLetTypes's ctor lattice, ordinary locals need no
262
+ // fixpoint slot since the overlay already resolves them in one pass).
263
+ //
264
+ // Two call sites (mirrors recordGlobalRep's early landing spot, then widens
265
+ // once call-site param facts exist):
266
+ // 1. Here in plan(), alongside inferModuleLetTypes — before narrowSignatures
267
+ // reads `ctx.scope.globalValTypes` for its own callerValTypes seed, and
268
+ // early enough that a freshly-proven NUMBER global still reaches
269
+ // inferModuleIntGlobals's i32 candidacy check.
270
+ // 2. Again after narrowSignatures (plan/index.js) — `cur = s` needs `s`'s
271
+ // resolved param fact (`programFacts.paramReps`), which narrowSignatures'
272
+ // call-site fixpoint hasn't produced yet on pass 1. Idempotent: candidates
273
+ // already claimed (by recordGlobalRep, inferModuleLetTypes, or pass 1 of
274
+ // this same function) are skipped, so the rerun only picks up new proofs.
275
+ const GLOBAL_VT_CONFLICT = Symbol('global-vt-conflict')
276
+
277
+ // Exported mutable global — the wasm export lets the host assign it any value
278
+ // through `instance.exports.name.value = …`, a write no AST scan can see.
279
+ // `mut: false` (const) globals export as immutable wasm globals — the JS API
280
+ // throws on `.value =`, so a const export is safe regardless of `ctx.func.exports`.
281
+ const isHostWritableGlobal = (name) => {
282
+ const decl = ctx.scope.globals.get(name)
283
+ if (!decl?.mut) return false
284
+ for (const [exportName, val] of Object.entries(ctx.func.exports || {}))
285
+ if (val === name || (val === true && exportName === name)) return true
286
+ return false
287
+ }
288
+
289
+ const isNullishLit = (e) => e == null || e === 'undefined' || e === 'null'
290
+ || (Array.isArray(e) && e[0] == null && (e[1] === undefined || e[1] === null))
291
+
292
+ // A destructuring-ASSIGNMENT target (`[a,b] = …` / `({a} = …)`) — prepare
293
+ // desugars these to temp-based plain assigns before this pass ever runs (a
294
+ // module-global candidate never actually reaches this shape in practice), but
295
+ // a leaf write through one is a real mutation this scan doesn't classify, so
296
+ // it must poison rather than silently pass through. `'[]'` here is the
297
+ // pre-prepare pattern-or-index overload — length ≠ 3 rules out `recv[idx]`.
298
+ const isAssignPatternNode = (n) =>
299
+ Array.isArray(n) && (n[0] === '[' || n[0] === '{}' || (n[0] === '[]' && n.length !== 3))
300
+
301
+ export const inferModuleGlobalValTypes = (ast, paramReps) => {
302
+ if (!ctx.scope.userGlobals?.size) return
303
+
304
+ const candidates = new Set()
305
+ for (const name of ctx.scope.userGlobals) {
306
+ if (ctx.scope.globalValTypes?.get(name)) continue // already proven (recordGlobalRep / inferModuleLetTypes / a prior call)
307
+ if (ctx.func.names?.has(name)) continue // a function binding, not a data global
308
+ if (isHostWritableGlobal(name)) continue // host can write any bit pattern — no claim possible
309
+ candidates.add(name)
310
+ }
311
+ if (!candidates.size) return
312
+
313
+ const fnames = ctx.func.names || new Set()
314
+ // defs keys: bare candidate names (module-wide — globally unique) and
315
+ // `@ret:<fn>` virtual nodes (also globally unique). No scope qualification
316
+ // needed — everything that ISN'T a candidate-to-candidate or fn-return alias
317
+ // resolves synchronously via valTypeOf under the per-scope overlay below.
318
+ const defs = new Map()
319
+ const getDef = (k) => { let d = defs.get(k); if (!d) defs.set(k, d = { vals: new Set(), refs: new Set(), bad: false }); return d }
320
+
321
+ const observe = (name, rhs) => {
322
+ const d = getDef(name)
323
+ if (d.bad) return
324
+ if (isNullishLit(rhs)) return // no evidence either way
325
+ if (typeof rhs === 'string' && candidates.has(rhs)) { d.refs.add(rhs); return }
326
+ if (Array.isArray(rhs) && rhs[0] === '()' && typeof rhs[1] === 'string' && fnames.has(rhs[1])) {
327
+ d.refs.add('@ret:' + rhs[1]); return
328
+ }
329
+ const vt = valTypeOf(rhs)
330
+ if (vt) d.vals.add(vt)
331
+ else d.bad = true // unrecognized/computed shape — fail closed
332
+ }
333
+
334
+ // Positional (index-keyed) param names for a `=>` params node or a func.list
335
+ // signature — `null` slots (rest/destructured params) simply never resolve
336
+ // via paramReps, which is fine: they fall to the overlay's ordinary "unknown".
337
+ const paramNamesOf = (paramsNode) => extractParams(paramsNode).map(r => {
338
+ const c = classifyParam(r)
339
+ return (c.kind === 'plain' || c.kind === 'default') ? c.name : null
340
+ })
341
+
342
+ // Enter one function/arrow scope: `body` is walked for writes to `candidates`,
343
+ // with `paramNames` (positional) seeding the shadow set and — for a NAMED
344
+ // function with a resolved paramReps entry — the valType overlay too.
345
+ const walkFn = (body, paramNames, funcName) => {
346
+ // Shadow set: every name locally bound anywhere in THIS scope (params +
347
+ // every let/const/catch binding, at any nesting depth short of a nested
348
+ // `=>` — jz's own body-local analyses (findFreeVars, boxedCaptures) use
349
+ // the same "hoist let to function scope" convention, matching how prepare
350
+ // resolves same-name block shadowing). A write to a shadowed name is a
351
+ // local mutation, not a global one — it must not pollute the global's kind.
352
+ const bound = new Set()
353
+ for (const p of paramNames) if (p) bound.add(p)
354
+ const collectDecls = (node) => {
355
+ if (!Array.isArray(node)) return
356
+ const op = node[0]
357
+ if (op === '=>') return
358
+ if (op === 'let' || op === 'const') collectParamNames(node.slice(1), bound)
359
+ if (op === 'catch' && typeof node[2] === 'string') bound.add(node[2])
360
+ for (let i = 1; i < node.length; i++) collectDecls(node[i])
361
+ }
362
+ collectDecls(body)
363
+
364
+ // Overlay: this scope's own let/const locals (analyzeBody — the same
365
+ // per-function local analysis emit.js seeds from) plus, for a named
366
+ // function once paramReps is populated (pass 2, post-narrowSignatures),
367
+ // its resolved param facts — so `cur = s` resolves `s` exactly like a
368
+ // local alias would, via the same valTypeOf call sites use everywhere else.
369
+ const overlay = new Map(analyzeBody(body).valTypes)
370
+ if (funcName && paramReps) {
371
+ const reps = paramReps.get(funcName)
372
+ if (reps) for (const [idx, r] of reps)
373
+ if (r.val && paramNames[idx] != null && !overlay.has(paramNames[idx])) overlay.set(paramNames[idx], r.val)
374
+ }
375
+
376
+ const prevOverlay = ctx.func.localValTypesOverlay
377
+ ctx.func.localValTypesOverlay = overlay
378
+ try { walkStmts(body, bound, funcName) }
379
+ finally { ctx.func.localValTypesOverlay = prevOverlay }
380
+ }
381
+
382
+ const walkStmts = (node, bound, retFn) => {
383
+ if (!Array.isArray(node)) return
384
+ const op = node[0]
385
+ if (op === '=>') { walkFn(node[2], paramNamesOf(node[1]), null); return }
386
+ if (op === 'return' && retFn && node[1] !== undefined) observe('@ret:' + retFn, node[1])
387
+ if (op === '=' && node.length >= 3) {
388
+ const t = node[1]
389
+ if (typeof t === 'string') { if (candidates.has(t) && !bound.has(t)) observe(t, node[2]) }
390
+ else if (isAssignPatternNode(t)) {
391
+ for (const n of collectParamNames([t])) if (candidates.has(n) && !bound.has(n)) getDef(n).bad = true
392
+ }
393
+ } else if (ASSIGN_OPS.has(op) && typeof node[1] === 'string') {
394
+ if (candidates.has(node[1]) && !bound.has(node[1])) getDef(node[1]).bad = true // compound-assign: can't classify the merged value — poison
395
+ } else if ((op === '++' || op === '--') && typeof node[1] === 'string') {
396
+ if (candidates.has(node[1]) && !bound.has(node[1])) getDef(node[1]).bad = true // ToNumeric mutation — poison (recordGlobalRep/inferModuleIntGlobals own the numeric-counter case)
397
+ }
398
+ for (let i = 1; i < node.length; i++) walkStmts(node[i], bound, retFn)
399
+ }
400
+
401
+ // Module-init-time code (ast + every bundled dependency's top-level init)
402
+ // is recordGlobalRep's territory ALREADY — including control-flow-nested
403
+ // assignments, verified empirically (an `if`-nested depth-0 `cur = 5` DOES
404
+ // land in `ctx.scope.globalValTypes` via prepare's own depth-0 walk). This
405
+ // walk exists only to reach closures DEFINED at module-init time (an inline
406
+ // `.forEach(x => { g = x })` at top level) whose BODIES don't run until
407
+ // called — invisible to the depth-0 walk, visible to this one.
408
+ const findArrows = (node) => {
409
+ if (!Array.isArray(node)) return
410
+ if (node[0] === '=>') { walkFn(node[2], paramNamesOf(node[1]), null); return }
411
+ for (let i = 1; i < node.length; i++) findArrows(node[i])
412
+ }
413
+ findArrows(ast)
414
+ if (ctx.module.moduleInits) for (const init of ctx.module.moduleInits) findArrows(init)
415
+ for (const f of ctx.func.list) {
416
+ if (!f.body || f.raw) continue
417
+ walkFn(f.body, (f.sig?.params || []).map(p => p.name), f.name)
418
+ }
419
+
420
+ // Least-fixed-point over the alias graph (candidate↔candidate refs + `@ret:`
421
+ // fn-result refs). bottom = null (no evidence), top = CONFLICT; a concrete
422
+ // VAL.* is a fixed point once every def and ref agree.
423
+ const join = (a, b) => a === GLOBAL_VT_CONFLICT || b === GLOBAL_VT_CONFLICT ? GLOBAL_VT_CONFLICT
424
+ : a == null ? b : b == null ? a : a === b ? a : GLOBAL_VT_CONFLICT
425
+ const state = new Map()
426
+ const refState = (r) => defs.has(r) ? (state.get(r) ?? null) : (ctx.scope.globalValTypes?.get(r) ?? null)
427
+ let changed = true
428
+ while (changed) {
429
+ changed = false
430
+ for (const [k, d] of defs) {
431
+ let cur = d.bad ? GLOBAL_VT_CONFLICT : null
432
+ if (cur !== GLOBAL_VT_CONFLICT) for (const v of d.vals) cur = join(cur, v)
433
+ if (cur !== GLOBAL_VT_CONFLICT) for (const r of d.refs) cur = join(cur, refState(r))
434
+ if (cur !== (state.get(k) ?? null)) { state.set(k, cur); changed = true }
435
+ }
436
+ }
437
+
438
+ for (const name of candidates) {
439
+ const vt = state.get(name)
440
+ if (!vt || vt === GLOBAL_VT_CONFLICT) continue
441
+ ;(ctx.scope.globalValTypes ||= new Map()).set(name, vt)
442
+ }
443
+ }
444
+
185
445
  export const unboxConstTypedGlobals = () => {
186
446
  if (!ctx.scope.globalTypedElem || !ctx.scope.consts) return
187
447
  for (const [name, ctor] of ctx.scope.globalTypedElem) {
@@ -252,10 +512,18 @@ export const inferModuleIntGlobals = (ast) => {
252
512
  // Does `e` provably evaluate to a non-integer? Integer-coercing ops (bitwise,
253
513
  // shifts) and comparisons launder any fraction; only the *value*-bearing
254
514
  // branches of ternary/logical ops carry it.
515
+ // Post-prepare, `Math.PI` / `Math.sqrt(x)` arrive as FLAT math keys — the bare
516
+ // string 'math.PI' in value position, 'math.sqrt' as a string callee — not the
517
+ // raw ['.','Math','sqrt'] shape. Missing them assumed INTEGER and i32-demoted
518
+ // module consts like `export const TWO_PI = Math.PI * 2` in DEP modules (their
519
+ // inits prep before this scan), truncating 6.283… → 6 at init.
520
+ const FRACTIONAL_MATH_CONSTS = new Set(['PI', 'E', 'LN2', 'LN10', 'LOG2E', 'LOG10E', 'SQRT2', 'SQRT1_2'])
521
+ const fractionalMathKey = (k) => typeof k === 'string' && k.startsWith('math.')
522
+ && (FRACTIONAL_MATH.has(k.slice(5)) || FRACTIONAL_MATH_CONSTS.has(k.slice(5)))
255
523
  const producesFraction = (e) => {
256
524
  if (e == null) return false
257
525
  if (typeof e === 'number') return !Number.isInteger(e)
258
- if (typeof e === 'string') return refIsFractional(e)
526
+ if (typeof e === 'string') return refIsFractional(e) || fractionalMathKey(e)
259
527
  if (!Array.isArray(e)) return false
260
528
  const op = e[0]
261
529
  if (op == null) return typeof e[1] === 'number' && !Number.isInteger(e[1])
@@ -267,6 +535,7 @@ export const inferModuleIntGlobals = (ast) => {
267
535
  const callee = e[1]
268
536
  if (Array.isArray(callee) && callee[0] === '?') return producesFraction(callee[2]) || producesFraction(callee[3])
269
537
  if (Array.isArray(callee) && callee[0] === '.' && callee[1] === 'Math' && FRACTIONAL_MATH.has(callee[2])) return true
538
+ if (fractionalMathKey(callee)) return true
270
539
  return false // unknown call → assume integer
271
540
  }
272
541
  for (let i = 1; i < e.length; i++) if (producesFraction(e[i])) return true
@@ -316,6 +585,10 @@ export const inferModuleIntGlobals = (ast) => {
316
585
  for (let i = 1; i < node.length; i++) walk(node[i], scope)
317
586
  }
318
587
  walk(ast, null)
588
+ // DEP-module top-level inits live in ctx.module.moduleInits, NOT the entry ast —
589
+ // without walking them a dep's `export const TWO_PI = Math.PI * 2` records no
590
+ // RHS at all and the integer default i32-demotes it (init truncated 6.283 → 6).
591
+ if (ctx.module.moduleInits) for (const init of ctx.module.moduleInits) walk(init, null)
319
592
  for (const f of ctx.func.list) {
320
593
  if (!f.body || f.raw) continue
321
594
  const params = new Set((f.sig?.params || []).map(p => p.name))
@@ -394,9 +667,22 @@ export const flattenFuncNamespaces = (ast) => {
394
667
  for (const prop of info.props) {
395
668
  if (ctx.func.multiProp.has(`${f}.${prop}`)) { plan(prop, { global: `${f}${T}${prop}` }); continue }
396
669
  const w = info.writes.get(prop)
397
- // Single write of the lifted `$f$prop`, never read as a value → drop it.
398
- if (w && w.length === 1 && w[0].atInit && w[0].rhs === `${f}$${prop}` && !info.valRead.has(prop))
399
- plan(prop, { drop: true })
670
+ // Single top-level write of the lifted `$f$prop` (the `f.prop = arrow`
671
+ // definition shape): calls to it already lower to a direct `call $f$prop`,
672
+ // which a global would demote to call_indirect — leave it alone; when it's
673
+ // additionally never read as a value, the write itself is dead → drop it.
674
+ if (w && w.length === 1 && w[0].atInit && w[0].rhs === `${f}$${prop}`) {
675
+ if (!info.valRead.has(prop)) plan(prop, { drop: true })
676
+ continue
677
+ }
678
+ // Everything else dissolves into a module global — the namespace is
679
+ // non-escaping, so every site is visible and the slot is a closed cell.
680
+ // This covers slots multiProp can't see: props reassigned only INSIDE
681
+ // function bodies (prepare's registry counts top-level lifts), and
682
+ // single-write non-function values. The layered-parser state pattern
683
+ // (subscript's parse.comment/newline/semi — read/written per token) was
684
+ // paying a __dyn_get/__dyn_set probe chain per site without this.
685
+ plan(prop, { global: `${f}${T}${prop}` })
400
686
  }
401
687
  }
402
688
  if (!flat.size) return false
@@ -409,7 +695,11 @@ export const flattenFuncNamespaces = (ast) => {
409
695
  typeof obj === 'string' && typeof prop === 'string' && flat.has(obj)
410
696
  ? flat.get(obj).get(prop) : undefined
411
697
  const isEmptySeq = (n) => Array.isArray(n) && n.length === 1 && n[0] === ';'
412
- const rewrite = (node) => {
698
+ // `stmt` node sits in statement position (a `;` sequence child). A dropped
699
+ // write there emits nothing; in EXPRESSION position (comma chain, init value,
700
+ // arrow body) `(f.p = v)` must still yield v — the lifted-closure reference —
701
+ // or the surrounding arity breaks (invalid wasm: values left on the stack).
702
+ const rewrite = (node, stmt = false) => {
413
703
  if (!Array.isArray(node)) return node
414
704
  const op = node[0]
415
705
  if (op === '.' || op === '?.') {
@@ -419,13 +709,13 @@ export const flattenFuncNamespaces = (ast) => {
419
709
  if (op === '=' && Array.isArray(node[1]) && (node[1][0] === '.' || node[1][0] === '?.')) {
420
710
  const d = decisionFor(node[1][1], node[1][2])
421
711
  if (d?.global) return ['=', d.global, rewrite(node[2])]
422
- if (d?.drop) return [';'] // dead write — emit nothing
712
+ if (d?.drop) return stmt ? [';'] : rewrite(node[2]) // dead write — statement: nothing; value: the rhs
423
713
  }
424
714
  const out = [op]
425
715
  // Filter dropped writes out of statement sequences (an empty `[';']` left in
426
716
  // a body would lower to an unrenderable node).
427
717
  for (let i = 1; i < node.length; i++) {
428
- const c = rewrite(node[i])
718
+ const c = rewrite(node[i], op === ';')
429
719
  if (op === ';' && isEmptySeq(c)) continue
430
720
  out.push(c)
431
721
  }
@@ -435,8 +725,16 @@ export const flattenFuncNamespaces = (ast) => {
435
725
  ast.length = 0
436
726
  for (let i = 0; i < newAst.length; i++) ast.push(newAst[i])
437
727
  invalidateProgramFactsCache(ast)
438
- for (const fn of ctx.func.list)
728
+ for (const fn of ctx.func.list) {
439
729
  if (fn.body && !fn.raw) fn.body = rewrite(fn.body)
730
+ // Default-param values are AST stored OUTSIDE fn.body (fn.defaults) — a
731
+ // closure default like subscript's `dispatch = (ops, tail, fn = (…) => {…
732
+ // parse.id(…) …})` reads func-props too. Missing them left the read on the
733
+ // dynamic __dyn_get path while every write had dissolved into the global —
734
+ // disjoint stores, so the read yielded undefined (the tokenizer's word-guard
735
+ // collapsed and `init` lexed as `in`+`it`).
736
+ if (fn.defaults) for (const k of Object.keys(fn.defaults)) fn.defaults[k] = rewrite(fn.defaults[k])
737
+ }
440
738
  // The defining `f.prop = …` writes live in moduleInits for bundled programs —
441
739
  // rewrite them too, or reads would resolve to an unwritten global.
442
740
  if (ctx.module.moduleInits)
@@ -482,10 +780,25 @@ export const devirtGlobalCalls = (ast) => {
482
780
  flatten(ast)
483
781
 
484
782
  const isGlobal = (s) => typeof s === 'string' && ctx.scope.globals.has(s)
485
- // `[target, rhs]` pairs for a `=` / `let` / `const` node assigning a global.
783
+
784
+ // A node that is ITSELF a compound-assign (`=`/`??=`/`||=`/…) directly
785
+ // targeting a global — the one-hop-deep shape a declarator's or a bare
786
+ // statement's own value position can be (chained assignment: `const x = (G
787
+ // = arrow)`; `const x = (G ??= Y)`, subscript asi.js's `parse._baseSpace ??=
788
+ // parse.space`). Both the poison scan and the env resolver special-case
789
+ // exactly this nesting; anything buried deeper (behind a comma-expression,
790
+ // ternary, call, …) stays outside what either tracks — the same shallow-
791
+ // recognition boundary this pass always had for plain `=`.
792
+ const chainedWrite = (n) => Array.isArray(n) && ASSIGN_OPS.has(n[0]) && typeof n[1] === 'string'
793
+
794
+ // `[target, valueNode]` pairs for a `=`/compound-assign / `let` / `const`
795
+ // node assigning a global. `valueNode` is either a plain value expression
796
+ // (declarator RHS) or — for a bare compound-assign statement — the whole
797
+ // write node, so resolveValue's chainedWrite branch can see the operator.
486
798
  const writesOf = (node) => {
487
799
  if (!Array.isArray(node)) return []
488
- if (node[0] === '=' && isGlobal(node[1])) return [[node[1], node[2]]]
800
+ if (chainedWrite(node) && isGlobal(node[1])) return [[node[1], node]]
801
+ if ((node[0] === '++' || node[0] === '--') && isGlobal(node[1])) return [[node[1], null]]
489
802
  if (node[0] === 'let' || node[0] === 'const') {
490
803
  const out = []
491
804
  for (let i = 1; i < node.length; i++) {
@@ -499,7 +812,11 @@ export const devirtGlobalCalls = (ast) => {
499
812
 
500
813
  // Poison a global assigned anywhere but an unconditional init statement — in a
501
814
  // function body, or nested in init control flow. Its value is then not a
502
- // fixed post-init constant.
815
+ // fixed post-init constant. A RHS that is itself a direct chainedWrite (one
816
+ // hop) inherits the OUTER statement's topInit instead of being forced
817
+ // conservative — `const asi = parse.asi = arrow` is ONE unconditional
818
+ // top-level statement; poisoning its inner target merely for being nested
819
+ // one assignment deep would be over-conservative, not sound-required.
503
820
  const poison = new Set()
504
821
  const scanWrites = (node, topInit) => {
505
822
  if (!Array.isArray(node)) return
@@ -511,15 +828,18 @@ export const devirtGlobalCalls = (ast) => {
511
828
  const d = node[i]
512
829
  if (Array.isArray(d) && d[0] === '=') {
513
830
  if (!topInit && isGlobal(d[1])) poison.add(d[1])
514
- scanWrites(d[2], false)
831
+ scanWrites(d[2], chainedWrite(d[2]) ? topInit : false)
515
832
  } else scanWrites(d, false)
516
833
  }
517
834
  return
518
835
  }
519
- if (op === '=') {
836
+ // Any assign-op (`=`, `??=`, `||=`, `+=`, …) or `++`/`--` on a global outside
837
+ // unconditional init poisons it — the house write predicate used throughout
838
+ // prepare/narrow/emit (`ASSIGN_OPS.has(op) || op==='++' || op==='--'`).
839
+ if (ASSIGN_OPS.has(op) || op === '++' || op === '--') {
520
840
  if (!topInit && isGlobal(node[1])) poison.add(node[1])
521
841
  scanWrites(node[1], false)
522
- scanWrites(node[2], false)
842
+ scanWrites(node[2], chainedWrite(node[2]) ? topInit : false)
523
843
  return
524
844
  }
525
845
  for (let i = 1; i < node.length; i++) scanWrites(node[i], false)
@@ -530,13 +850,94 @@ export const devirtGlobalCalls = (ast) => {
530
850
 
531
851
  // Resolve each global's value by a linear pass over init in execution order.
532
852
  const env = new Map()
533
- const evalFn = (rhs) =>
534
- typeof rhs !== 'string' ? null
535
- : fnNames.has(rhs) ? rhs
536
- : env.has(rhs) ? env.get(rhs)
537
- : null
853
+
854
+ // Free identifiers referenced by `node`, skipping property-name / literal-
855
+ // key positions (mirrors ast.js's REFS_IN_EXPR convention: `.`/`?.` only
856
+ // recurse the receiver, `:` only the value) and skipping anything bound by a
857
+ // `=>` param, `let`/`const`, or `catch` clause anywhere within — the same
858
+ // "hoist every local to function scope" approximation the other body-local
859
+ // scans in this file use (over-inclusive `bound` only makes lifting MORE
860
+ // conservative, never less sound).
861
+ const collectFreeIdents = (node, bound, out) => {
862
+ if (!Array.isArray(node)) return
863
+ const op = node[0]
864
+ if (op === '=>') { collectParamNames(extractParams(node[1]), bound); collectFreeIdents(node[2], bound, out); return }
865
+ if (op === 'str') return
866
+ if (op === '.' || op === '?.') { collectFreeIdents(node[1], bound, out); return }
867
+ if (op === ':') { collectFreeIdents(node[2], bound, out); return }
868
+ if (op === 'catch' && typeof node[2] === 'string') bound.add(node[2])
869
+ if (op === 'let' || op === 'const') collectParamNames(node.slice(1), bound)
870
+ for (let i = 1; i < node.length; i++) {
871
+ const c = node[i]
872
+ if (typeof c === 'string') { if (!bound.has(c)) out.add(c) }
873
+ else collectFreeIdents(c, bound, out)
874
+ }
875
+ }
876
+
877
+ // Lift a module-init-time arrow literal into a standalone top-level function
878
+ // so its resolved VALUE (the function name) can flow through env like any
879
+ // other devirt candidate — subscript's `parse.space = (r,e) => {…}` shape
880
+ // once flattenFuncNamespaces turns the write into a plain global assign, or
881
+ // an arrow reached one hop through a chainedWrite. Sound only when the arrow
882
+ // captures nothing from an enclosing function's locals: at true module-init
883
+ // depth that's automatic (no enclosing function exists at all — initStmts
884
+ // only holds top-level statements, and this walk never enters a `=>` body
885
+ // except the candidate's own), but an arrow nested inside another init
886
+ // expression is still checked, fail-closed — any free identifier that isn't
887
+ // a module global or a top-level function name aborts the lift, as does any
888
+ // non-plain (rest/default/destructured) param. The ORIGINAL arrow node is
889
+ // left untouched in place: this only ADDS a new function; whatever produced
890
+ // the arrow as a value (closure.make at its own AST position) keeps working
891
+ // unchanged for any other, non-call use of the same global.
892
+ const liftArrow = (node) => {
893
+ const params = []
894
+ for (const p of extractParams(node[1])) {
895
+ const c = classifyParam(p)
896
+ if (c.kind !== 'plain') return null
897
+ params.push(c.name)
898
+ }
899
+ const bound = new Set(params)
900
+ const free = new Set()
901
+ collectFreeIdents(node[2], bound, free)
902
+ for (const name of free) if (!ctx.scope.globals.has(name) && !fnNames.has(name)) return null
903
+
904
+ const name = `${T}devirt${ctx.func.uniq++}`
905
+ const funcInfo = { name, body: node[2], exported: false, sig: { params: params.map(n => ({ name: n, type: 'f64' })), results: ['f64'] } }
906
+ ctx.func.list.push(funcInfo)
907
+ ctx.func.map.set(name, funcInfo)
908
+ fnNames.add(name)
909
+ return name
910
+ }
911
+
912
+ // A write node's resolved value: `=` always takes the RHS; `??=`/`||=` keep
913
+ // G's prior env value when one was already recorded (sound because a prior
914
+ // value present in env is always a function — non-nullish AND truthy, so
915
+ // both operators short-circuit without evaluating the RHS at all) and fall
916
+ // back to resolving the RHS only when no prior write was seen (sound because
917
+ // an as-yet-unwritten SROA/module global's declared init is the shared
918
+ // undefined atom — nullish and falsy). Any OTHER compound op (`+=`, `&&=`, …)
919
+ // can't be interpreted here — recorded as a write with an unknown value
920
+ // rather than left silently unset, so a LATER `??=`/`||=` on the same global
921
+ // can't mistake "written but uninterpretable" for "never written".
922
+ const resolveWriteNode = (n) => {
923
+ const [op, g, v] = n
924
+ const val = op === '='
925
+ ? resolveValue(v)
926
+ : (op === '??=' || op === '||=')
927
+ ? (env.has(g) ? env.get(g) : resolveValue(v))
928
+ : null
929
+ env.set(g, val)
930
+ return val
931
+ }
932
+ const resolveValue = (v) => {
933
+ if (typeof v === 'string') return fnNames.has(v) ? v : env.has(v) ? env.get(v) : null
934
+ if (!Array.isArray(v)) return null
935
+ if (v[0] === '=>') return liftArrow(v)
936
+ if (chainedWrite(v) && isGlobal(v[1])) return resolveWriteNode(v)
937
+ return null
938
+ }
538
939
  for (const stmt of initStmts)
539
- for (const [g, rhs] of writesOf(stmt)) env.set(g, evalFn(rhs))
940
+ for (const [g, valueNode] of writesOf(stmt)) env.set(g, resolveValue(valueNode))
540
941
 
541
942
  const devirt = new Map()
542
943
  for (const [g, fn] of env)
@@ -646,4 +1047,10 @@ export const canSkipWholeProgramNarrowing = (programFacts) =>
646
1047
  !programFacts.anyDyn &&
647
1048
  programFacts.propMap.size === 0 &&
648
1049
  !programFacts.hasSchemaLiterals &&
649
- !ctx.closure.make
1050
+ !ctx.closure.make &&
1051
+ // Typed default-arg annotations (`arr = new Int32Array(0)`) feed the param
1052
+ // lattice even with zero call sites — a host-called SPMD kernel (Workers v1)
1053
+ // gets its pointer-ABI lane and Atomics receiver proof from exactly this.
1054
+ !ctx.func.list.some(f => f.defaults && Object.values(f.defaults).some(d =>
1055
+ Array.isArray(d) && d[0] === '()' && typeof d[1] === 'string' &&
1056
+ d[1].startsWith('new.') && d[1].endsWith('Array')))