jz 0.8.1 → 0.9.1

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 +23 -23
  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
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Same-body indirect devirt for closure tables built IMPERATIVELY.
3
+ *
4
+ * devirtConstFnArrayCalls (optimize/index.js) devirtualizes `constOps[idx](args)`
5
+ * when `constOps` is a module-const ARRAY LITERAL of capture-free arrows — the
6
+ * candidate set is known the moment the literal emits (module/array.js tags
7
+ * `.fnElements`). subscript's operator/token dispatch table (the jessie bench's
8
+ * `lookup` array) doesn't qualify: it's built imperatively (`lookup[c] = …`
9
+ * inside `register`, once per operator registration, at module-init time) and
10
+ * every element is a CLOSURE WITH CAPTURES, not a capture-free literal.
11
+ *
12
+ * But every value ever written into `lookup` traces back to ONE lexical arrow.
13
+ * subscript's shape: `dispatch(ops, tail, fn = (a, …) => {…}) => (fn.ops = ops,
14
+ * fn.tail = tail, fn)`. `fn`'s default is a single `=>` node — closure.make sees
15
+ * it once, at `dispatch`'s own emit time, and gives it one funcIdx forever.
16
+ * Every call to `dispatch` (both ternary arms in `register`) omits `fn`, so the
17
+ * default always fires; `dispatch` returns `fn` unmodified. Different calls get
18
+ * different `ops`/`tail` (different captured ENV), but the closure BODY
19
+ * (funcIdx) is the same wasm function every time. Proven program-wide — every
20
+ * write into the table resolves to the same funcIdx, and the table never
21
+ * escapes or aliases — the read-then-call at the use site (`table[idx](args)`,
22
+ * or subscript's `(fn = table[idx]) && fn(args)` guarded idiom) can skip
23
+ * call_indirect for a direct call, guarded by a RUNTIME funcIdx check whose
24
+ * false arm is the untouched original call_indirect — semantics are unchanged
25
+ * if the proof is ever wrong, the slot is empty, or the table diverges through
26
+ * an alias devirtConstFnArrayCalls's own guard-rewrite already defends against.
27
+ *
28
+ * This module gathers the facts (program-wide, fail-closed) and feeds the SAME
29
+ * `ctx.scope.constFnArrays` map devirtConstFnArrayCalls already reads — a
30
+ * monomorphic dynamic table is indistinguishable, at rewrite time, from a
31
+ * monomorphic const array. No changes to the rewrite itself.
32
+ *
33
+ * Three phases, wired from compile/index.js:
34
+ * 1. scanDynClosureTableCandidates (pre-emit, source AST) — which module
35
+ * globals are structurally safe candidates (never alias/escape).
36
+ * 2. recordDynFnTableWrite / recordParamClosureDefault /
37
+ * recordDirectReturnClosure — called from emit-assign.js and
38
+ * compile/index.js as functions emit, accumulating write-family + closure-
39
+ * factory facts.
40
+ * 3. resolveDynFnTables (post-emit, once every function + module init has
41
+ * emitted) — resolves each candidate's write family; a table whose every
42
+ * write agrees on one funcIdx populates ctx.scope.constFnArrays.
43
+ *
44
+ * @module compile/dyn-closure-tables
45
+ */
46
+ import { ctx } from '../ctx.js'
47
+ import { isReassigned } from '../ast.js'
48
+ import { scanBindingUses, USE } from './analyze-scans.js'
49
+
50
+ // A candidate table may safely appear as: a `V[idx]` READ (any key — call
51
+ // sites read-then-call, `.length`, comparisons, whatever) or a PLAIN
52
+ // (non-compound) `V[idx] = RHS` WRITE with a COMPUTED index. Anything else —
53
+ // aliasing (`let b = V`), a call argument, a return, a `.`-property write, a
54
+ // compound/delete element write, mention inside a nested closure, a bare
55
+ // comparison — disqualifies. Default-deny, mirrors scanNeverGrown/
56
+ // scanFlatObjects (analyze-scans.js): any use kind not explicitly allowed here
57
+ // poisons the candidate.
58
+ const safeTableUse = (u) =>
59
+ u.kind === USE.MEMBER_R || (u.kind === USE.MEMBER_W && !u.compound && u.computed)
60
+
61
+ const isEmptyArrayLit = (rhs) =>
62
+ Array.isArray(rhs) && ((rhs[0] === '[' && rhs.length === 1) || (rhs[0] === '[]' && rhs.length <= 2))
63
+
64
+ /** Program-wide structural safety pre-scan (source AST, pre-emit). A candidate
65
+ * is a GLOBAL `let`/`const` declared exactly once, bound to a fresh empty
66
+ * array, whose every occurrence — module top level, every function body,
67
+ * every function's param-default expressions — is one of the safe shapes
68
+ * above. Returns `Set<name>`. Called once, early (before any function
69
+ * emits), from compile/index.js; the result is consulted (read-only) by
70
+ * emit-assign.js's write recorder and emit.js's guarded-dispatch call-site
71
+ * tagger. */
72
+ export function scanDynClosureTableCandidates(ast) {
73
+ // Every top-level AST root: the entry module's own `ast`, plus one root per
74
+ // bundled dependency module — `import`-ed files' top-level statements live
75
+ // in ctx.module.moduleInits, NOT `ast` (see plan/scope.js), so subscript's
76
+ // `export let … lookup = [] …` (declared in its own parse.js) is invisible
77
+ // to a scan of `ast` alone.
78
+ const topRoots = [ast, ...(ctx.module.moduleInits || [])]
79
+
80
+ // Pass 1: declarations only happen at module scope — find every
81
+ // `let`/`const V = []` global across every top-level root.
82
+ const candidates = new Set()
83
+ for (const root of topRoots) {
84
+ for (const [name, s] of scanBindingUses(root))
85
+ if (s.decls === 1 && ctx.scope.globals?.has(name) && isEmptyArrayLit(s.initRhs)) candidates.add(name)
86
+ }
87
+ if (!candidates.size) return candidates
88
+
89
+ // Pass 2: every USE of a candidate, anywhere in the program, must be safe.
90
+ // `trackNames` makes scanBindingUses report on these globals even in bodies
91
+ // that never declare them (the normal case — a global's uses are scattered
92
+ // across every function that touches it, not just its declaring scope).
93
+ const bodies = [...topRoots]
94
+ for (const func of ctx.func.list) {
95
+ if (func.body && !func.raw) bodies.push(func.body)
96
+ if (func.defaults) for (const dv of Object.values(func.defaults)) bodies.push(dv)
97
+ }
98
+ for (const body of bodies) {
99
+ const uses = scanBindingUses(body, candidates)
100
+ for (const name of candidates) {
101
+ if (!candidates.has(name)) continue
102
+ const s = uses.get(name)
103
+ if (s && !s.uses.every(safeTableUse)) candidates.delete(name)
104
+ }
105
+ }
106
+ return candidates
107
+ }
108
+
109
+ // Comma-sequence tail: `(a, b, c)` evaluates to `c`. Unwraps to the value an
110
+ // expression-bodied arrow (or a `return` statement) actually produces —
111
+ // subscript's `dispatch` returns `(fn.ops = ops, fn.tail = tail, fn)`.
112
+ const commaTail = (e) => (Array.isArray(e) && e[0] === ',' ? commaTail(e[e.length - 1]) : e)
113
+
114
+ // Every `return <expr>` reachable in `body` without descending into a nested
115
+ // `=>` — same extraction module/function.js's closureReturnExprs uses for its
116
+ // own single-purpose "does every return produce a plain number" check — or,
117
+ // for an expression-bodied function (no `{}` block), the body itself is the
118
+ // sole "return." `null` means "doesn't end in an explicit return" (may fall
119
+ // off the end) — unprovable either way, the caller treats that as failure.
120
+ function extractReturnExprs(body) {
121
+ if (!Array.isArray(body) || body[0] !== '{}') return [body]
122
+ const stmts = Array.isArray(body[1]) && body[1][0] === ';' ? body[1].slice(1) : [body[1]]
123
+ const last = stmts[stmts.length - 1]
124
+ if (!Array.isArray(last) || last[0] !== 'return') return null
125
+ const rets = []
126
+ let ok = true
127
+ const walk = (n) => {
128
+ if (!ok || !Array.isArray(n) || n[0] === '=>') return
129
+ if (n[0] === 'return') { if (n.length < 2) ok = false; else rets.push(n[1]); return }
130
+ for (let i = 1; i < n.length; i++) walk(n[i])
131
+ }
132
+ for (const s of stmts) walk(s)
133
+ return ok ? rets : null
134
+ }
135
+
136
+ /** Record fact: the default value of `funcName`'s param `pname` is provably a
137
+ * closure of body `{name, idx}`. Called from compile/index.js's per-param
138
+ * default-init emission (`emittedDefVal = emit(defVal)`) — the exact point a
139
+ * default arrow's closure.make call resolves its funcIdx. Complete once every
140
+ * function has emitted (before resolveDynFnTables consumes it). */
141
+ export function recordParamClosureDefault(funcName, pname, emittedDefVal) {
142
+ if (emittedDefVal?.closureBodyName == null || emittedDefVal?.closureFuncIdx == null) return
143
+ ;(ctx.scope.paramClosureDefaults ||= new Map()).set(`${funcName}#${pname}`,
144
+ { name: emittedDefVal.closureBodyName, idx: emittedDefVal.closureFuncIdx })
145
+ }
146
+
147
+ /** Record fact: `funcName`'s (expression-bodied) return value is
148
+ * UNCONDITIONALLY a closure of body `{name, idx}`. Called from
149
+ * compile/index.js right after `const ir = emit(body)` for a non-block
150
+ * function body. Sound by construction: a branch inside the expression
151
+ * (ternary/`&&`/`||`) emits as an `if`/`select` wrapper, which never carries
152
+ * `.closureBodyName` forward, so this only fires when the WHOLE body
153
+ * statically reduces to one closure.make call. */
154
+ export function recordDirectReturnClosure(funcName, ir) {
155
+ if (ir?.closureBodyName == null || ir?.closureFuncIdx == null) return
156
+ ;(ctx.scope.directReturnClosures ||= new Map()).set(funcName, { name: ir.closureBodyName, idx: ir.closureFuncIdx })
157
+ }
158
+
159
+ // One write-site RHS, classified from its SOURCE shape (+ the top-level
160
+ // emitted value, when available): a closure literal directly (`.closureBodyName`
161
+ // on `val`, the emit()-produced value — only meaningful for the RHS's OWN top
162
+ // node, never a sub-arm: emitting a ternary collapses both arms into one IR
163
+ // node and no per-arm tag survives that far), or a direct call to a plain user
164
+ // function (resolved later — resolveDynFnTables → proveClosureFactory). `null`
165
+ // = unrecognized, the caller poisons.
166
+ const classifyWriteRhs = (node, val) => {
167
+ if (val?.closureBodyName != null && val?.closureFuncIdx != null)
168
+ return { kind: 'direct', name: val.closureBodyName, idx: val.closureFuncIdx }
169
+ if (Array.isArray(node) && node[0] === '()' && typeof node[1] === 'string' && ctx.func.names.has(node[1]))
170
+ return { kind: 'call', callee: node[1] }
171
+ return null
172
+ }
173
+
174
+ /** Record fact: emitting `arr[idx] = val` (source RHS `rhsNode`, emitted value
175
+ * `emittedVal`) wrote into candidate table `name`. Called from
176
+ * emit-assign.js's emitElementAssign, gated on `name` being a proven-safe
177
+ * candidate (scanDynClosureTableCandidates). A ternary RHS (subscript's
178
+ * `register` idiom: `lookup[c] = fn?.ops ? dispatch(A) : dispatch(B)`)
179
+ * classifies each arm independently on source shape alone — SOURCE alone
180
+ * (not the collapsed emitted value) is all a ternary arm has to offer, so
181
+ * only the "call to a known function" shape is provable there; a bare
182
+ * closure-literal arm falls through to poison (see classifyWriteRhs). Any
183
+ * other RHS shape poisons the table PERMANENTLY (poison fixpoint — mirrors
184
+ * analyzeSchemaSlotIntCertain's program-facts.js global poison semantics:
185
+ * once poisoned, stays poisoned; never re-examined). */
186
+ export function recordDynFnTableWrite(name, rhsNode, emittedVal) {
187
+ const facts = (ctx.scope.dynFnTableWrites ||= new Map())
188
+ let rec = facts.get(name)
189
+ if (!rec) { rec = { writes: [], poisoned: false }; facts.set(name, rec) }
190
+ if (rec.poisoned) return
191
+ if (Array.isArray(rhsNode) && rhsNode[0] === '?:') {
192
+ const wa = classifyWriteRhs(rhsNode[2], null), wb = classifyWriteRhs(rhsNode[3], null)
193
+ if (wa && wb) { rec.writes.push(wa, wb); return }
194
+ rec.poisoned = true
195
+ return
196
+ }
197
+ const w = classifyWriteRhs(rhsNode, emittedVal)
198
+ if (w) { rec.writes.push(w); return }
199
+ rec.poisoned = true
200
+ }
201
+
202
+ /** Prove `calleeName` (a plain user function) ALWAYS returns a closure of one
203
+ * statically-known body, regardless of how it's called or what it captures.
204
+ * Two independently-sufficient shapes:
205
+ *
206
+ * 1. Direct — recordDirectReturnClosure already proved the function's own
207
+ * return value is unconditionally a closure literal.
208
+ *
209
+ * 2. Forwarded default — every return reduces (after unwrapping a trailing
210
+ * comma-sequence) to a bare parameter P; P is never reassigned; P's
211
+ * default value is provably a closure (recordParamClosureDefault); and
212
+ * every call site of `calleeName`, program-wide, passes fewer args than
213
+ * P's position — so the default ALWAYS fires. Matches subscript's
214
+ * `dispatch(ops, tail, fn = (a, …) => {…})`, called only as
215
+ * `dispatch(a, b)`. Requires `calleeName` never escapes as a bare value
216
+ * reference or module export — either could hide an uncounted call site
217
+ * that supplies P explicitly, breaking the "default always fires" proof.
218
+ *
219
+ * Memoized (a callee can be the shared factory behind many writes). Returns
220
+ * `{name, idx}` or null — the caller poisons the whole write family on null,
221
+ * same as any other unprovable write. */
222
+ function proveClosureFactory(calleeName, programFacts, cache) {
223
+ if (cache.has(calleeName)) return cache.get(calleeName)
224
+ cache.set(calleeName, null) // reentrancy guard
225
+ let verdict = ctx.scope.directReturnClosures?.get(calleeName) || null
226
+ if (!verdict) {
227
+ const fn = ctx.func.map?.get(calleeName)
228
+ if (fn && !fn.raw && fn.body && fn.defaults && !fn.exported && !programFacts.valueUsed?.has(calleeName)) {
229
+ const rets = extractReturnExprs(fn.body)
230
+ if (rets && rets.length) {
231
+ for (const pname of Object.keys(fn.defaults)) {
232
+ if (!rets.every(r => commaTail(r) === pname)) continue
233
+ if (isReassigned(fn.body, pname)) continue
234
+ const fact = ctx.scope.paramClosureDefaults?.get(`${calleeName}#${pname}`)
235
+ if (!fact) continue
236
+ const paramIdx = fn.sig.params.findIndex(p => p.name === pname)
237
+ if (paramIdx < 0) continue
238
+ const sites = (programFacts.callSites || []).filter(cs => cs.callee === calleeName)
239
+ if (!sites.length || !sites.every(cs => cs.argList.length <= paramIdx)) continue
240
+ verdict = fact
241
+ break
242
+ }
243
+ }
244
+ }
245
+ }
246
+ cache.set(calleeName, verdict)
247
+ return verdict
248
+ }
249
+
250
+ /** Post-emission resolution: for every candidate table with a recorded write
251
+ * family, resolve each write (direct, or through a proven closure-factory
252
+ * call) and — iff every write agrees on ONE funcIdx — hand it to
253
+ * devirtConstFnArrayCalls through the SAME `ctx.scope.constFnArrays` map the
254
+ * const-literal-array path populates. No changes needed to the rewrite or to
255
+ * call-site tagging: emitGenericClosureCall (emit.js) already tags every
256
+ * `V[idx](args)` site (and the `(fn = V[idx]) && fn(args)` guarded idiom,
257
+ * tagged separately at the `&&` node) regardless of how V was populated.
258
+ *
259
+ * Must run after every function AND module init has emitted —
260
+ * callSites/paramClosureDefaults/directReturnClosures are only complete
261
+ * then. Called once from compile/index.js, right after buildStartFn. */
262
+ export function resolveDynFnTables(programFacts) {
263
+ const writeFacts = ctx.scope.dynFnTableWrites
264
+ if (!writeFacts || !writeFacts.size) return
265
+ const cache = new Map()
266
+ for (const [name, rec] of writeFacts) {
267
+ if (rec.poisoned || !rec.writes.length) continue
268
+ let common = null, ok = true
269
+ for (const w of rec.writes) {
270
+ const resolved = w.kind === 'direct' ? { name: w.name, idx: w.idx } : proveClosureFactory(w.callee, programFacts, cache)
271
+ if (!resolved) { ok = false; break }
272
+ if (common == null) common = resolved
273
+ else if (common.idx !== resolved.idx) { ok = false; break }
274
+ }
275
+ if (ok && common) (ctx.scope.constFnArrays ||= new Map()).set(name, [{ idx: common.idx, name: common.name }])
276
+ }
277
+ }
@@ -10,21 +10,28 @@
10
10
  * @module compile/emit-assign
11
11
  */
12
12
 
13
- import { ctx, err, inc, warnDeopt, PTR } from '../ctx.js'
13
+ import { ctx, err, inc, warnDeopt, PTR, LAYOUT } from '../ctx.js'
14
14
  import { T } from '../ast.js'
15
- import { staticPropertyKey, staticIndexKey } from '../static.js'
15
+ import { staticPropertyKey, staticIndexKey, staticObjectProps } from '../static.js'
16
+ import { i64Hex, encodePtrHi } from '../../layout.js'
17
+ import { inplaceKey } from './inplace-store.js'
18
+ import { recordDynFnTableWrite } from './dyn-closure-tables.js'
16
19
  import { valTypeOf, shapeOf } from '../kind.js'
17
20
  import { VAL, lookupValType, repOf } from '../reps.js'
18
21
  import {
19
22
  typed, asF64, asI32, asI64, temp, tempI32, withTemp, block64,
20
23
  ptrOffsetIR, ptrTypeEq, boxedAddr, writeVar, isGlobal, isBoundName, isLiteralStr,
21
- usesDynProps, needsDynShadow, boolBoxIR,
24
+ usesDynProps, needsDynShadow, boolBoxIR, carrierF64, mkPtrIR, isNumericIR,
22
25
  } from '../ir.js'
23
26
  import { emit } from '../bridge.js'
24
27
 
25
28
 
26
29
  // Boxed-bool-aware store value: booleans persist as their tagged atom.
27
- const storedValue = (node) => valTypeOf(node) === VAL.BOOL ? boolBoxIR(emit(node)) : asF64(emit(node))
30
+ // emit(node) ONCE, before branching same self-host miscompile class as emit.js's
31
+ // 'return' handler (src/compile/emit.js): emit(node) called separately inline per
32
+ // ternary arm, wrapped by a DIFFERENT coercion (boolBoxIR vs asF64) per arm, is
33
+ // behaviorally identical in JS but self-host-fragile. See .work/todo.md (groundtruth archive).
34
+ const storedValue = (node) => carrierF64(node, emit(node))
28
35
 
29
36
  // Integer array-index key: '3' → 3; rejects non-canonical and 2³²−1.
30
37
  function arrayIndexKey(key) {
@@ -168,7 +175,224 @@ function emitPolymorphicElementStore(arrExpr, idxI32, valueExpr, arrVT, persist,
168
175
  * schema; typed-array element write before generic f64.store. */
169
176
  export { persistBindingPtr }
170
177
 
178
+ // `o[k] = f(o[k])` on a (possibly-)HASH receiver — the dictionary-counting idiom
179
+ // (histogram, wordcount, group-by). The generic lowering pays the string hash,
180
+ // probe, equality and dispatch TWICE per statement (a full dyn get plus a full
181
+ // dyn set). Fuse: __hash_slot probes ONCE (inserting `undefined` on miss — what
182
+ // the read of a missing key yields), the rhs computes against the loaded slot
183
+ // value, __slot_write stores back with the durable-heal protocol. Sound across
184
+ // growth (the receiver box never changes — forwarding header) and across memory
185
+ // growth (linear memory never moves). A non-HASH receiver at runtime returns
186
+ // slot 0 and takes the untouched generic path.
187
+ const _rmwStructEq = (a, b) => a === b ||
188
+ (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((x, i) => _rmwStructEq(x, b[i])))
189
+ // rhs allowlist: value ops only — a call could insert into the receiver (growing
190
+ // the table under the held slot address), an assignment or closure likewise.
191
+ const _rmwSafe = (n, readNode) => {
192
+ if (!Array.isArray(n)) return true
193
+ if (_rmwStructEq(n, readNode)) return true
194
+ const op = n[0]
195
+ if (op == null || op === 'str') return true
196
+ if (op === '()' || op === '=>' || op === 'new' || typeof op !== 'string') return false
197
+ if (op === '=' || op.endsWith('=') && op !== '==' && op !== '===' && op !== '!=' && op !== '!==' && op !== '<=' && op !== '>=') return false
198
+ if (op === '++' || op === '--') return false
199
+ for (let i = 1; i < n.length; i++) if (!_rmwSafe(n[i], readNode)) return false
200
+ return true
201
+ }
202
+ function tryHashRmwFusion(arr, idx, val) {
203
+ if (typeof arr !== 'string') return null
204
+ // valTypeOf consults the decl-site FLOW overlay, which stamps a dictionary-
205
+ // mode `{}` binding OBJECT (the literal node's kind) even though the
206
+ // dictionary lowering just repped it VAL.HASH — honor the rep, else the
207
+ // fusion never fires on exactly the bindings it exists for (`counts[w] =
208
+ // (counts[w]|0)+1` on a computed-key dictionary).
209
+ const at = repOf(arr)?.val === VAL.HASH ? VAL.HASH : valTypeOf(arr)
210
+ if (at !== VAL.HASH && at != null) return null
211
+ // A proven-string key probes directly; an unknown-typed name key takes the same
212
+ // __is_str_key routing __dyn_set uses (numeric keys → slot 0 → generic path).
213
+ const keyStr = (typeof idx === 'string' && valTypeOf(idx) === VAL.STRING) || isLiteralStr(idx)
214
+ const keyUnknown = typeof idx === 'string' && valTypeOf(idx) == null
215
+ if (!keyStr && !keyUnknown) return null
216
+ const readNode = ['[]', arr, idx]
217
+ let reads = 0
218
+ const scan = (n) => {
219
+ if (!Array.isArray(n)) return
220
+ if (n[0] === '[]' && _rmwStructEq(n, readNode)) { reads++; return }
221
+ for (let i = 1; i < n.length; i++) scan(n[i])
222
+ }
223
+ scan(val)
224
+ if (!reads || !_rmwSafe(val, readNode)) return null
225
+ const subst = (n) => !Array.isArray(n) ? n
226
+ : (n[0] === '[]' && _rmwStructEq(n, readNode)) ? oldT
227
+ : n.map((c, i) => i === 0 ? c : subst(c))
228
+ const oT = temp('rmo'), kT = temp('rmk'), oldT = temp('rmold'), resT = temp('rmres')
229
+ const slotT = tempI32('rms')
230
+ inc('__hash_slot', '__dyn_set')
231
+ const resIR = asF64(emit(subst(val)))
232
+ // Statically-numeric result (isNumericIR — the counting idiom's
233
+ // `(o[k]|0)+1`): a plain number is never an ephemeral pointer, so
234
+ // __slot_write's durable-heal barrier is provably dead — store bare and
235
+ // skip the call + per-token __is_eph_bits test it wraps.
236
+ const bare = isNumericIR(resIR)
237
+ if (!bare) inc('__slot_write')
238
+ const writeBack = bare
239
+ ? ['i64.store', ['local.get', `$${slotT}`], ['i64.reinterpret_f64', ['local.get', `$${resT}`]]]
240
+ : ['call', '$__slot_write', ['local.get', `$${slotT}`],
241
+ ['i64.reinterpret_f64', ['local.get', `$${resT}`]]]
242
+ return typed(['block', ['result', 'f64'],
243
+ ['local.set', `$${oT}`, asF64(emit(arr))],
244
+ ['local.set', `$${kT}`, asF64(emit(idx))],
245
+ // Unknown-typed key: the same __is_str_key routing __dyn_set uses, but
246
+ // inline — `f64.ne(k,k)` (only NaN patterns carry pointers) AND tag ==
247
+ // STRING is 6 ops against a per-token call in the counting idiom's loop.
248
+ ['local.set', `$${slotT}`, keyStr
249
+ ? ['call', '$__hash_slot',
250
+ ['i64.reinterpret_f64', ['local.get', `$${oT}`]],
251
+ ['i64.reinterpret_f64', ['local.get', `$${kT}`]]]
252
+ : ['if', ['result', 'i32'],
253
+ ['i32.and',
254
+ ['f64.ne', ['local.get', `$${kT}`], ['local.get', `$${kT}`]],
255
+ ['i64.eq',
256
+ ['i64.and', ['i64.shr_u', ['i64.reinterpret_f64', ['local.get', `$${kT}`]],
257
+ ['i64.const', String(LAYOUT.TAG_SHIFT)]], ['i64.const', String(LAYOUT.TAG_MASK)]],
258
+ ['i64.const', String(PTR.STRING)]]],
259
+ ['then', ['call', '$__hash_slot',
260
+ ['i64.reinterpret_f64', ['local.get', `$${oT}`]],
261
+ ['i64.reinterpret_f64', ['local.get', `$${kT}`]]]],
262
+ ['else', ['i32.const', 0]]]],
263
+ ['if', ['result', 'f64'], ['i32.eqz', ['local.get', `$${slotT}`]],
264
+ // non-HASH receiver: the generic dynamic write of the ORIGINAL rhs (its
265
+ // reads re-emit as ordinary dyn gets on the same pure receiver/key)
266
+ ['then', ['f64.reinterpret_i64', ['call', '$__dyn_set',
267
+ ['i64.reinterpret_f64', ['local.get', `$${oT}`]],
268
+ ['i64.reinterpret_f64', ['local.get', `$${kT}`]],
269
+ asI64(emit(val))]]],
270
+ ['else', typed(['block', ['result', 'f64'],
271
+ ['local.set', `$${oldT}`, ['f64.load', ['local.get', `$${slotT}`]]],
272
+ ['local.set', `$${resT}`, resIR],
273
+ writeBack,
274
+ ['local.get', `$${resT}`]], 'f64')]]], 'f64')
275
+ }
276
+
277
+ /** In-place replace-store: `arr[i] = {lit}` at a site the whole-program alias
278
+ * sweep proved safe (src/compile/inplace-store.js) overwrites the OLD
279
+ * element's payload slots instead of allocating a fresh object — the
280
+ * immutable-update idiom's per-step allocation churn goes to zero. Runtime
281
+ * guard: the old element's box must carry OBJECT tag + this literal's
282
+ * schemaId (one masked i64 compare — the emitSchemaSlotGuarded pattern);
283
+ * anything else (UNDEF from an out-of-bounds read, an alien schema, a
284
+ * non-object) takes the generic fresh-alloc arm, so semantics stay bit-exact.
285
+ * Literal values spill to temps FIRST — they may read the old element
286
+ * (`arr[i] = { x: p.y, y: p.x }` swaps). Fast-arm result is the old box:
287
+ * in place, the old object IS the new object (the array store is elided). */
288
+ function tryInplaceReplaceStore(arr, idx, val) {
289
+ if (!Array.isArray(val) || val[0] !== '{}' || typeof arr !== 'string') return null
290
+ // content key — see scanInplaceStores: node identity doesn't survive the
291
+ // per-function body transforms between the sweep and emit
292
+ const key = inplaceKey(arr, val)
293
+ const entry = ctx.schema.inplaceStores?.get(key)
294
+ if (!entry) return null
295
+ if (valTypeOf(arr) !== VAL.ARRAY) return null
296
+ const idxNumeric = (typeof idx === 'string' &&
297
+ (repOf(idx)?.intCertain === true || repOf(idx)?.val === VAL.NUMBER)) || valTypeOf(idx) === VAL.NUMBER
298
+ if (!idxNumeric) return null
299
+ const parsed = staticObjectProps(val.slice(1))
300
+ if (!parsed || !parsed.values.every(v => valTypeOf(v) === VAL.NUMBER)) return null
301
+ const sid = ctx.schema.register(parsed.names)
302
+ const schema = ctx.schema.list?.[sid]
303
+ const ops = ctx.abi.object?.ops
304
+ if (!schema || !ops) return null
305
+ // Target-binding reuse (sweep-proven): the tracked alias `const p = arr[i]`
306
+ // IS the current element — skip the `__arr_idx` re-read (forwarding follow +
307
+ // bounds check per store) and guard/overwrite through the binding directly.
308
+ const aliasOk = entry.alias && typeof idx === 'string' && idx === entry.idx
309
+ && !ctx.func.boxed?.has(entry.alias)
310
+ const aliasType = aliasOk ? ctx.func.locals.get(entry.alias) : null
311
+ const vTs = parsed.values.map(() => temp('ipv'))
312
+ const slots = parsed.names.map(nm => schema.indexOf(nm))
313
+ // Strongest form: unboxablePtrs already narrowed the alias to a raw OBJECT
314
+ // pointer of THIS schema — the runtime guard is statically discharged, the
315
+ // whole store is bare slot overwrites (the immutable-update idiom's floor:
316
+ // spill values, N stores, done).
317
+ if (aliasType === 'i32' && repOf(entry.alias)?.ptrKind === VAL.OBJECT
318
+ && (ctx.schema.vars.get(entry.alias) ?? repOf(entry.alias)?.schemaId) === sid) {
319
+ return typed(['block', ['result', 'f64'],
320
+ ...parsed.values.map((v, i) => ['local.set', `$${vTs[i]}`, storedValue(v)]),
321
+ ...slots.map((slot, i) => ops.store(['local.get', `$${entry.alias}`], slot, ['local.get', `$${vTs[i]}`])),
322
+ mkPtrIR(PTR.OBJECT, sid, ['local.get', `$${entry.alias}`])], 'f64')
323
+ }
324
+ const reuse = aliasOk && aliasType === 'f64' ? ['local.get', `$${entry.alias}`] : null
325
+ inc('__alloc_hdr')
326
+ if (!reuse) inc('__arr_idx')
327
+ const kT = tempI32('ipk'), eT = temp('ipe'), oT = tempI32('ipo'), hT = tempI32('iph')
328
+ const bitsE = () => ['i64.reinterpret_f64', ['local.get', `$${eT}`]]
329
+ const fast = ['block', ['result', 'f64'],
330
+ ['local.set', `$${oT}`, ['i32.wrap_i64', bitsE()]],
331
+ ...slots.map((slot, i) => ops.store(['local.get', `$${oT}`], slot, ['local.get', `$${vTs[i]}`])),
332
+ ['local.get', `$${eT}`]]
333
+ const slow = ['block', ['result', 'f64'],
334
+ ['local.set', `$${hT}`, ['call', '$__alloc_hdr', ['i32.const', 0], ['i32.const', ops.allocSlots(schema.length)]]],
335
+ ...slots.map((slot, i) => ops.store(['local.get', `$${hT}`], slot, ['local.get', `$${vTs[i]}`])),
336
+ storeArrayPayload(asF64(emit(arr)), ['f64.convert_i32_s', ['local.get', `$${kT}`]],
337
+ mkPtrIR(PTR.OBJECT, sid, ['local.get', `$${hT}`]), persistBinding(arr))]
338
+ return typed(['block', ['result', 'f64'],
339
+ ...parsed.values.map((v, i) => ['local.set', `$${vTs[i]}`, storedValue(v)]),
340
+ ['local.set', `$${kT}`, asI32(emit(idx))],
341
+ ['local.set', `$${eT}`, reuse ?? ['call', '$__arr_idx', asI64(emit(arr)), ['local.get', `$${kT}`]]],
342
+ ['if', ['result', 'f64'],
343
+ ['i64.eq',
344
+ ['i64.and', bitsE(), ['i64.const', '0xFFFFFFFF00000000']],
345
+ ['i64.const', i64Hex(BigInt(encodePtrHi(PTR.OBJECT, sid)) << 32n)]],
346
+ ['then', fast],
347
+ ['else', slow]]], 'f64')
348
+ }
349
+
171
350
  export function emitElementAssign(arr, idx, val) {
351
+ // 0. `obj.prop[idx] = val` where `obj`'s type is fully unknown (so `obj`
352
+ // could be a host EXTERNAL object at runtime) — `__ext_prop` (interop.js)
353
+ // always re-marshals a FRESH, disconnected copy of a container-valued
354
+ // property (`wrapVal` deep-copies an array into fresh wasm memory, no
355
+ // identity preserved with the host), so an index-write through THAT read —
356
+ // whichever branch below performs it — mutates a copy nobody keeps; the
357
+ // host object's own property is never touched, and a later read starts
358
+ // over from the original, unmutated value. Recurse first with `arr`
359
+ // replaced by a temp holding the (already-read) property value: every
360
+ // existing branch (ARRAY/TYPED/HASH/OBJECT/polymorphic) applies to it
361
+ // unchanged, including array-grow relocation (persistBinding keeps the temp
362
+ // current). Then write the (possibly-relocated) mutated container back onto
363
+ // the SAME property via `__hash_set` — the same general dynamic-property-set
364
+ // primitive a plain `obj.prop = val` on an unknown-type receiver already
365
+ // uses below, whose own type guard (genUpsertGrow, module/collection.js)
366
+ // dispatches HASH natively, EXTERNAL to `__ext_set`, anything else to
367
+ // `__dyn_set` — so a genuinely native (non-external) receiver, whose
368
+ // property read already returned the live pointer with nothing to write
369
+ // back, just re-stores the same pointer (idempotent). `mem.read` (interop.js)
370
+ // already recursively decodes an ARRAY pointer back to a real JS array on
371
+ // the host side, so this round-trips correctly, including one level of
372
+ // array-of-arrays nesting.
373
+ if (Array.isArray(arr) && arr[0] === '.' && typeof arr[2] === 'string' && valTypeOf(arr[1]) == null) {
374
+ const [, obj, prop] = arr
375
+ const objTmp = temp('eao'), arrTmp = temp('eaf'), resultTmp = temp('ear')
376
+ ctx.func.locals.set(objTmp, 'f64')
377
+ ctx.func.locals.set(arrTmp, 'f64')
378
+ ctx.func.locals.set(resultTmp, 'f64')
379
+ if (ctx.transform.host !== 'wasi') ctx.features.external = true
380
+ inc('__hash_set')
381
+ const storeIR = emitElementAssign(arrTmp, idx, val)
382
+ return block64(
383
+ ['local.set', `$${objTmp}`, asF64(emit(obj))],
384
+ ['local.set', `$${arrTmp}`, asF64(emit(['.', objTmp, prop]))],
385
+ ['local.set', `$${resultTmp}`, storeIR],
386
+ ['drop', ['call', '$__hash_set',
387
+ ['i64.reinterpret_f64', ['local.get', `$${objTmp}`]],
388
+ asI64(emit(['str', prop])),
389
+ ['i64.reinterpret_f64', ['local.get', `$${arrTmp}`]]]],
390
+ ['local.get', `$${resultTmp}`])
391
+ }
392
+ const rmw = ctx.transform.optimize ? tryHashRmwFusion(arr, idx, val) : null
393
+ if (rmw) return rmw
394
+ const inplace = ctx.transform.optimize ? tryInplaceReplaceStore(arr, idx, val) : null
395
+ if (inplace) return inplace
172
396
  // _expect is clobbered by every sub-emit() — capture statement-position hint
173
397
  // up front so the typed-array element-write path can elide the value materialize.
174
398
  const void_ = ctx.func._expect === 'void'
@@ -182,7 +406,18 @@ export function emitElementAssign(arr, idx, val) {
182
406
  const useRuntimeKeyDispatch = !idxNumericName &&
183
407
  (keyType == null || (typeof idx === 'string' && keyType !== VAL.STRING))
184
408
  const keyExpr = asF64(emit(idx))
185
- const valueExpr = asF64(emit(val))
409
+ // Boxed-bool-aware: `o[k] = false` through every receiver path (slot, SRoA,
410
+ // array payload, __dyn_set) keeps boolean identity. The one representation-
411
+ // sensitive consumer is the typed-array route — __typed_set_idx ToNumbers
412
+ // NaN-boxed values (spec ToNumber for typed element writes), so a boxed
413
+ // bool stores 0/1 there, never raw atom bits.
414
+ const valueExpr = storedValue(val)
415
+ // dyn-closure-tables.js: `arr[idx] = val` into a proven-safe candidate closure
416
+ // table — record this write's provenance (direct closure literal, or a call
417
+ // to a function resolveDynFnTables can later prove is a closure factory) for
418
+ // the program-wide same-body devirt proof. A no-op for every other array.
419
+ if (typeof arr === 'string' && ctx.scope.dynFnTableCandidates?.has(arr))
420
+ recordDynFnTableWrite(arr, val, valueExpr)
186
421
  // Literal string key, or schema-known object receiver with a static key expression.
187
422
  const litKey = isLiteralStr(idx) ? idx[1]
188
423
  : typeof arr === 'string' && lookupValType(arr) === VAL.OBJECT ? staticPropertyKey(idx)
@@ -199,11 +434,20 @@ export function emitElementAssign(arr, idx, val) {
199
434
  ['local.get', `$${t}`]])
200
435
  }
201
436
  // 2. Schema field literal key → direct payload-slot write.
437
+ // SHADOW CONTRACT (same as the dot-path arms): when the module may read this
438
+ // object dynamically, the mint seeded a props sidecar that __dyn_get probes
439
+ // BEFORE the schema slots — a slot-only write here is masked by the stale
440
+ // seed (this dropped `it['@@iterator'] = fn` for prehashed dot reads).
202
441
  if (litKey != null && typeof arr === 'string' && ctx.schema.slotOf) {
203
442
  const slot = ctx.schema.slotOf(arr, litKey)
204
- if (slot >= 0) return withTemp(valueExpr, t => [
205
- ctx.abi.object.ops.store(ptrOffsetIR(asF64(emit(arr)), lookupValType(arr) || VAL.OBJECT), slot, ['local.get', `$${t}`]),
206
- ['local.get', `$${t}`]])
443
+ if (slot >= 0) {
444
+ const shadow = needsDynShadow(arr)
445
+ if (shadow) inc('__dyn_set')
446
+ return withTemp(valueExpr, t => [
447
+ ctx.abi.object.ops.store(ptrOffsetIR(asF64(emit(arr)), lookupValType(arr) || VAL.OBJECT), slot, ['local.get', `$${t}`]),
448
+ ...(shadow ? [['drop', ['call', '$__dyn_set', ['i64.reinterpret_f64', asF64(emit(arr))], asI64(emit(['str', litKey])), ['i64.reinterpret_f64', ['local.get', `$${t}`]]]]] : []),
449
+ ['local.get', `$${t}`]])
450
+ }
207
451
  }
208
452
  // 3. Known-ARRAY receiver + literal numeric key → __arr_set_idx_ptr.
209
453
  const arrIndex = litKey != null ? arrayIndexKey(litKey) : null
@@ -265,7 +509,10 @@ export function emitElementAssign(arr, idx, val) {
265
509
  // large i. Route to __dyn_set (the per-OBJECT propsPtr hash sidecar), mirroring
266
510
  // emitPropertyAssign's OBJECT dot-write path; __dyn_get reads it back. This closes
267
511
  // the `o.prop=v` vs `o[expr]=v` asymmetry that faulted the self-host.
268
- if (knownArrVT === VAL.OBJECT) return dynSetCall(arr, keyExpr, valueExpr)
512
+ // A known-HASH receiver (dictionary-mode `{}`) is the same class: a raw
513
+ // indexed store would scribble into probe-table slots — ToPropertyKey says
514
+ // o[97] addresses the '97' string slot. __dyn_set stringifies and probes.
515
+ if (knownArrVT === VAL.OBJECT || knownArrVT === VAL.HASH) return dynSetCall(arr, keyExpr, valueExpr)
269
516
 
270
517
  // A receiver "may be an OBJECT/HASH at runtime" unless the analyzer has proven it
271
518
  // is an indexable array/typed candidate (`rep.notString`, set by infer.js for
@@ -384,14 +631,26 @@ export function emitPropertyAssign(obj, prop, val) {
384
631
  // in ctx.schema.vars under the name — so schema.slotOf(name) misses and the write
385
632
  // would fall to __dyn_set (propsPtr) while the READ resolves the slot via ptrAux,
386
633
  // targeting different memory (write lost). Match the read.
634
+ // SHADOW CONTRACT: when the module may read this object dynamically
635
+ // (needsDynShadow), the literal mint seeded a props sidecar with each schema
636
+ // key's INITIAL value, and __dyn_get probes that sidecar BEFORE the schema
637
+ // slots — so a slot-only write here is invisible to dyn reads (the stale
638
+ // sidecar copy masks it; this silently dropped `p.then = closure` in the
639
+ // async runtime whenever any `x[expr]` appeared in the module). Mirror into
640
+ // __dyn_set exactly like the named-receiver schema arm below.
387
641
  {
388
642
  const vaProbe = emit(obj)
389
643
  if (vaProbe?.ptrKind === VAL.OBJECT && vaProbe.ptrAux != null) {
390
644
  const sch = ctx.schema.list[vaProbe.ptrAux]
391
645
  const si = sch ? sch.indexOf(prop) : -1
392
- if (si >= 0) return withTemp(storedValue(val), t => [
393
- ctx.abi.object.ops.store(ptrOffsetIR(asF64(emit(obj)), VAL.OBJECT), si, ['local.get', `$${t}`]),
394
- ['local.get', `$${t}`]])
646
+ if (si >= 0) {
647
+ const shadow = needsDynShadow(typeof obj === 'string' ? obj : null)
648
+ if (shadow) inc('__dyn_set')
649
+ return withTemp(storedValue(val), t => [
650
+ ctx.abi.object.ops.store(ptrOffsetIR(asF64(emit(obj)), VAL.OBJECT), si, ['local.get', `$${t}`]),
651
+ ...(shadow ? [['drop', ['call', '$__dyn_set', ['i64.reinterpret_f64', asF64(emit(obj))], asI64(emit(['str', prop])), ['i64.reinterpret_f64', ['local.get', `$${t}`]]]]] : []),
652
+ ['local.get', `$${t}`]])
653
+ }
395
654
  }
396
655
  }
397
656
  // Schema-based object → f64.store at fixed offset.
@@ -421,9 +680,16 @@ export function emitPropertyAssign(obj, prop, val) {
421
680
  const sh = shapeOf(obj)
422
681
  if (sh?.val === VAL.OBJECT && sh.names) {
423
682
  const i = sh.names.indexOf(prop)
424
- if (i >= 0) return withTemp(storedValue(val), t => [
425
- ctx.abi.object.ops.store(ptrOffsetIR(asF64(emit(obj)), VAL.OBJECT), i, ['local.get', `$${t}`]),
426
- ['local.get', `$${t}`]])
683
+ if (i >= 0) {
684
+ // Same SHADOW CONTRACT as the ptrAux arm above: a slot-only write on a
685
+ // shadowed object is masked by the mint-seeded sidecar for dyn reads.
686
+ const shadow = needsDynShadow(null)
687
+ if (shadow) inc('__dyn_set')
688
+ return withTemp(storedValue(val), t => [
689
+ ctx.abi.object.ops.store(ptrOffsetIR(asF64(emit(obj)), VAL.OBJECT), i, ['local.get', `$${t}`]),
690
+ ...(shadow ? [['drop', ['call', '$__dyn_set', ['i64.reinterpret_f64', asF64(emit(obj))], asI64(emit(['str', prop])), ['i64.reinterpret_f64', ['local.get', `$${t}`]]]]] : []),
691
+ ['local.get', `$${t}`]])
692
+ }
427
693
  }
428
694
  }
429
695
  if (typeof obj === 'string') {