jz 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -9
- package/bench/README.md +121 -50
- package/bench/bench.svg +27 -27
- package/cli.js +3 -1
- package/dist/interop.js +1 -1
- package/dist/jz.js +7541 -6178
- package/index.js +165 -74
- package/interop.js +189 -17
- package/jzify/arguments.js +32 -1
- package/jzify/async.js +409 -0
- package/jzify/classes.js +136 -18
- package/jzify/generators.js +639 -0
- package/jzify/hoist-vars.js +73 -1
- package/jzify/index.js +123 -4
- package/jzify/names.js +1 -0
- package/jzify/transform.js +239 -1
- package/layout.js +48 -3
- package/module/array.js +318 -43
- package/module/atomics.js +144 -0
- package/module/collection.js +1429 -155
- package/module/core.js +431 -40
- package/module/date.js +142 -120
- package/module/fs.js +144 -0
- package/module/function.js +6 -3
- package/module/index.js +4 -1
- package/module/json.js +270 -49
- package/module/math.js +1032 -145
- package/module/number.js +532 -163
- package/module/object.js +353 -95
- package/module/regex.js +157 -7
- package/module/schema.js +100 -2
- package/module/string.js +428 -93
- package/module/typedarray.js +264 -72
- package/module/web.js +36 -0
- package/package.json +5 -5
- package/src/abi/string.js +82 -11
- package/src/ast.js +11 -5
- package/src/autoload.js +28 -1
- package/src/bridge.js +7 -2
- package/src/compile/analyze-scans.js +22 -7
- package/src/compile/analyze.js +136 -30
- package/src/compile/dyn-closure-tables.js +277 -0
- package/src/compile/emit-assign.js +281 -15
- package/src/compile/emit.js +1055 -70
- package/src/compile/index.js +277 -29
- package/src/compile/infer.js +42 -4
- package/src/compile/inplace-store.js +329 -0
- package/src/compile/loop-recurrence.js +167 -0
- package/src/compile/narrow.js +555 -18
- package/src/compile/peel-stencil.js +5 -0
- package/src/compile/plan/index.js +45 -3
- package/src/compile/plan/inline.js +8 -1
- package/src/compile/plan/literals.js +39 -0
- package/src/compile/plan/scope.js +430 -23
- package/src/compile/program-facts.js +732 -38
- package/src/ctx.js +64 -9
- package/src/helper-counters.js +7 -1
- package/src/ir.js +113 -5
- package/src/kind-traits.js +66 -3
- package/src/kind.js +84 -12
- package/src/op-policy.js +7 -4
- package/src/optimize/index.js +1179 -704
- package/src/optimize/recurse.js +2 -2
- package/src/optimize/vectorize.js +982 -67
- package/src/prepare/index.js +809 -67
- package/src/prepare/math-kernel.js +331 -0
- package/src/prepare/pre-eval.js +714 -0
- package/src/snapshot.js +194 -0
- package/src/type.js +1170 -56
- package/src/wat/assemble.js +403 -65
- package/src/wat/codegen.js +74 -14
- package/transform.js +113 -4
- 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
|
-
|
|
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
|
-
|
|
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)
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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)
|
|
393
|
-
|
|
394
|
-
|
|
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)
|
|
425
|
-
|
|
426
|
-
|
|
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') {
|