jz 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +56 -9
  2. package/bench/README.md +121 -50
  3. package/bench/bench.svg +27 -27
  4. package/cli.js +3 -1
  5. package/dist/interop.js +1 -1
  6. package/dist/jz.js +7541 -6178
  7. package/index.js +165 -74
  8. package/interop.js +189 -17
  9. package/jzify/arguments.js +32 -1
  10. package/jzify/async.js +409 -0
  11. package/jzify/classes.js +136 -18
  12. package/jzify/generators.js +639 -0
  13. package/jzify/hoist-vars.js +73 -1
  14. package/jzify/index.js +123 -4
  15. package/jzify/names.js +1 -0
  16. package/jzify/transform.js +239 -1
  17. package/layout.js +48 -3
  18. package/module/array.js +318 -43
  19. package/module/atomics.js +144 -0
  20. package/module/collection.js +1429 -155
  21. package/module/core.js +431 -40
  22. package/module/date.js +142 -120
  23. package/module/fs.js +144 -0
  24. package/module/function.js +6 -3
  25. package/module/index.js +4 -1
  26. package/module/json.js +270 -49
  27. package/module/math.js +1032 -145
  28. package/module/number.js +532 -163
  29. package/module/object.js +353 -95
  30. package/module/regex.js +157 -7
  31. package/module/schema.js +100 -2
  32. package/module/string.js +428 -93
  33. package/module/typedarray.js +264 -72
  34. package/module/web.js +36 -0
  35. package/package.json +5 -5
  36. package/src/abi/string.js +82 -11
  37. package/src/ast.js +11 -5
  38. package/src/autoload.js +28 -1
  39. package/src/bridge.js +7 -2
  40. package/src/compile/analyze-scans.js +22 -7
  41. package/src/compile/analyze.js +136 -30
  42. package/src/compile/dyn-closure-tables.js +277 -0
  43. package/src/compile/emit-assign.js +281 -15
  44. package/src/compile/emit.js +1055 -70
  45. package/src/compile/index.js +277 -29
  46. package/src/compile/infer.js +42 -4
  47. package/src/compile/inplace-store.js +329 -0
  48. package/src/compile/loop-recurrence.js +167 -0
  49. package/src/compile/narrow.js +555 -18
  50. package/src/compile/peel-stencil.js +5 -0
  51. package/src/compile/plan/index.js +45 -3
  52. package/src/compile/plan/inline.js +8 -1
  53. package/src/compile/plan/literals.js +39 -0
  54. package/src/compile/plan/scope.js +430 -23
  55. package/src/compile/program-facts.js +732 -38
  56. package/src/ctx.js +64 -9
  57. package/src/helper-counters.js +7 -1
  58. package/src/ir.js +113 -5
  59. package/src/kind-traits.js +66 -3
  60. package/src/kind.js +84 -12
  61. package/src/op-policy.js +7 -4
  62. package/src/optimize/index.js +1179 -704
  63. package/src/optimize/recurse.js +2 -2
  64. package/src/optimize/vectorize.js +982 -67
  65. package/src/prepare/index.js +809 -67
  66. package/src/prepare/math-kernel.js +331 -0
  67. package/src/prepare/pre-eval.js +714 -0
  68. package/src/snapshot.js +194 -0
  69. package/src/type.js +1170 -56
  70. package/src/wat/assemble.js +403 -65
  71. package/src/wat/codegen.js +74 -14
  72. package/transform.js +113 -4
  73. package/wasi.js +3 -0
package/module/object.js CHANGED
@@ -7,12 +7,13 @@
7
7
  * @module object
8
8
  */
9
9
 
10
- import { typed, asF64, asI64, NULL_NAN, UNDEF_NAN, temp, tempI32, tempI64, block64, ptrTypeEq, dispatchByPtrType, allocPtr, needsDynShadow, mkPtrIR, extractF64Bits, appendStaticSlots, slotAddr, elemLoad, elemStore, boolBoxIR } from '../src/ir.js'
10
+ import { typed, asF64, asI64, NULL_NAN, UNDEF_NAN, temp, tempI32, tempI64, block64, ptrTypeEq, dispatchByPtrType, allocPtr, needsDynShadow, mkPtrIR, extractF64Bits, appendStaticSlots, slotAddr, elemLoad, elemStore, boolBoxIR, carrierF64 } from '../src/ir.js'
11
11
  import { emit } from '../src/bridge.js'
12
12
  import { staticArrayPtr } from './array.js'
13
13
  import { valTypeOf, shapeOf } from '../src/kind.js'
14
14
  import { VAL, lookupValType, repOf, updateRep } from '../src/reps.js'
15
15
  import { ctx, err, inc, PTR, LAYOUT, declGlobal } from '../src/ctx.js'
16
+ import { isReassigned } from '../src/ast.js'
16
17
 
17
18
  // Object.prototype.toString tag per value category. Matches what JS engines
18
19
  // return for primitive/built-in types; canonicalized from
@@ -39,7 +40,18 @@ const objectToStringTagForVal = (obj) => {
39
40
  return val ? OBJECT_TO_STRING_TAGS[val] : null
40
41
  }
41
42
 
42
- const storedValue = (node) => valTypeOf(node) === VAL.BOOL ? boolBoxIR(emit(node)) : asF64(emit(node))
43
+ // emit(node) ONCE, before branching same self-host miscompile class as emit.js's
44
+ // 'return' handler (src/compile/emit.js): emit(node) called separately inline per
45
+ // ternary arm, wrapped by a DIFFERENT coercion (boolBoxIR vs asF64) per arm, is
46
+ // behaviorally identical in JS but self-host-fragile. See .work/todo.md (groundtruth archive).
47
+ const storedValue = (node) => carrierF64(node, emit(node))
48
+
49
+ // Array-IR twin of collection.js's heapResetWat (WAT-string form) — both MUST
50
+ // gate identically. True when `off` is ephemeral (>= the post-init high-water
51
+ // mark): only then does a receiver's off-16 header sidecar hold the live
52
+ // dyn-prop truth. See collection.js's heapResetWat for the full durable-
53
+ // receiver policy rationale.
54
+ const heapResetIR = () => ctx.scope.globals.has('__heap_reset') ? ['global.get', '$__heap_reset'] : ['i32.const', 0]
43
55
 
44
56
  export default (ctx) => {
45
57
  inc('__mkptr', '__alloc', '__alloc_hdr', '__ptr_offset', '__len', '__ptr_type')
@@ -58,6 +70,20 @@ export default (ctx) => {
58
70
  // schema-slot writes to offsets >= 8 land out-of-bounds.
59
71
  const target = takeLiteralTarget()
60
72
  const merged = target ? ctx.schema.resolve(target) : null
73
+ // Dictionary mode: a `{}` whose binding takes ONLY computed-key access (no
74
+ // static prop ever — merged schema empty) is a string-keyed dictionary, not
75
+ // a fixed-shape record. Represent it as a real HASH so every get/set is one
76
+ // strict probe instead of the OBJECT dyn-sidecar detour, and the RMW fusion
77
+ // (`o[k] = f(o[k])`, emit-assign.js) can hold a slot address. The same
78
+ // heuristic V8 uses to send such objects to dictionary mode. Sound: all dyn
79
+ // paths dispatch on the runtime tag, and mem.Hash marshals it as a plain
80
+ // object at the boundary.
81
+ if (target && !merged?.length && ctx.types.dynWriteVars?.has(target)) {
82
+ ctx.module.include('collection')
83
+ inc('__hash_new_small')
84
+ updateRep(target, { val: VAL.HASH })
85
+ return typed(['call', '$__hash_new_small'], 'f64')
86
+ }
61
87
  // Register the empty schema so schemaId always indexes a real schema.list
62
88
  // entry — __json_obj and dyn-get load keys via $__schema_tbl[sid] and would
63
89
  // crash on an unregistered id 0 (table left uninitialized when list empty).
@@ -117,24 +143,38 @@ export default (ctx) => {
117
143
  // `map.get(k).n++` that no alias analysis could attribute — so a literal
118
144
  // whose schema intersects it allocates per-evaluation instead.
119
145
  const neverWritten = names.every(n => !ctx.module.writtenProps?.has(n))
120
- if (neverWritten && values.length >= 2 && values.length === schema.length && !ctx.memory.shared) {
146
+ // `!shadow`: a computed-key write on the target (`o[k]=v`) mutates the object
147
+ // a shared static instance would leak call N's writes into call N+1. The old
148
+ // shadow-mirror masked this by re-storing literal values through __dyn_set's
149
+ // schema-arm on every evaluation (an accidental reset that still leaked
150
+ // runtime-ADDED keys); with the mirror gone (tier 2), mutable literals must
151
+ // allocate fresh per evaluation — the runtime path below.
152
+ if (neverWritten && !shadow && values.length >= 2 && values.length === schema.length && !ctx.memory.shared) {
121
153
  const emitted = values.map(storedValue)
122
154
  // asF64 folds i32.const → f64.const so int-literal values also qualify.
123
155
  const slots = emitted.map(v => extractF64Bits(v))
124
156
  if (slots.every(b => b !== null)) {
125
157
  // Reorder into schema-slot order before laying out the static segment.
126
- const ordered = emitted.map(() => null), orderedBits = emitted.map(() => null)
127
- for (let i = 0; i < values.length; i++) { ordered[slotOf(i)] = emitted[i]; orderedBits[slotOf(i)] = slots[i] }
128
- const off = appendStaticSlots(orderedBits)
129
- const staticPtr = mkPtrIR(PTR.OBJECT, schemaId, off)
130
- if (!shadow) return staticPtr
131
- inc('__dyn_set')
132
- const body = [['local.set', `$${ptr}`, staticPtr]]
133
- for (let i = 0; i < schema.length; i++)
134
- body.push(['drop', ['call', '$__dyn_set', ['i64.reinterpret_f64', ['local.get', `$${ptr}`]],
135
- asI64(emit(['str', String(schema[i])])), asI64(ordered[i])]])
136
- body.push(['local.get', `$${ptr}`])
137
- return typed(['block', ['result', 'f64'], ...body], 'f64')
158
+ const orderedBits = emitted.map(() => null)
159
+ for (let i = 0; i < values.length; i++) orderedBits[slotOf(i)] = slots[i]
160
+ // Full 16-byte __alloc_hdr-shaped header ([props:8]=0, len=0, cap=slots):
161
+ // dyn machinery reads the off-16 props word — a headerless static object
162
+ // aliased whatever data preceded it (the durable-dangler garbage class),
163
+ // and a runtime dyn-set/delete on the shared instance now has a real,
164
+ // writable slot to install a sidecar into. No runtime shadow mirror
165
+ // either way (tier 2): dyn READS of schema props resolve through the
166
+ // schema-arm (__schema_tbl itself static data now), so the old
167
+ // per-prop __dyn_set mirror was pure init cost — the block it emitted
168
+ // also made the literal non-const, forcing every ENCLOSING literal
169
+ // (`const A = [{…}, {…}]`) to build at runtime.
170
+ if (!ctx.runtime.data) ctx.runtime.data = ''
171
+ while (ctx.runtime.data.length % 8 !== 0) ctx.runtime.data += '\0'
172
+ const hdrOff = ctx.runtime.data.length
173
+ const hdr = new Uint8Array(16); const hdv = new DataView(hdr.buffer)
174
+ hdv.setInt32(12, ctx.abi.object.ops.allocSlots ? ctx.abi.object.ops.allocSlots(schema.length) : schema.length, true)
175
+ for (let i = 0; i < 16; i++) ctx.runtime.data += String.fromCharCode(hdr[i])
176
+ appendStaticSlots(orderedBits)
177
+ return mkPtrIR(PTR.OBJECT, schemaId, hdrOff + 16)
138
178
  }
139
179
  }
140
180
 
@@ -157,6 +197,11 @@ export default (ctx) => {
157
197
 
158
198
  // === Object static methods ===
159
199
 
200
+ // Object.freeze: identity passthrough — jz objects have no per-property
201
+ // metadata, so write-protection is not enforced (documented divergence).
202
+ // Call forms are folded away in prepare (which records the binding in
203
+ // ctx.runtime.frozenVars so isFrozen answers true for it); this emitter
204
+ // survives only for freeze-as-a-value (`arr.map(Object.freeze)`).
160
205
  ctx.core.emit['Object.freeze'] = (obj) => asF64(emit(obj))
161
206
 
162
207
  // Object.is(a, b) — SameValue, which on NaN-boxed f64 values is exact bit
@@ -180,14 +225,21 @@ export default (ctx) => {
180
225
  if (t === VAL.NUMBER || t === VAL.STRING || t === VAL.BIGINT) return { ext: 0, sealed: 1, frozen: 1 }
181
226
  return { ext: 1, sealed: 0, frozen: 0 }
182
227
  }
183
- const objQuery = (pick) => (obj) => {
184
- const v = pick(extKind(obj))
228
+ const objQuery = (pick, frozenAware = false) => (obj) => {
229
+ let v = pick(extKind(obj))
230
+ if (frozenAware) {
231
+ // A binding freeze() marked in prepare (composition unwraps to the same
232
+ // name there). Trustworthy only while never reassigned — a rebind points
233
+ // at a fresh, unfrozen object.
234
+ if (typeof obj === 'string' && ctx.runtime.frozenVars?.has(obj) &&
235
+ !isReassigned(ctx.func.body, obj)) v = 1
236
+ }
185
237
  if (obj == null) return typed(['f64.const', v], 'f64')
186
238
  return typed(['block', ['result', 'f64'], ['drop', asF64(emit(obj))], ['f64.const', v]], 'f64')
187
239
  }
188
240
  ctx.core.emit['Object.isExtensible'] = objQuery((k) => k.ext)
189
241
  ctx.core.emit['Object.isSealed'] = objQuery((k) => k.sealed)
190
- ctx.core.emit['Object.isFrozen'] = objQuery((k) => k.frozen)
242
+ ctx.core.emit['Object.isFrozen'] = objQuery((k) => k.frozen, true)
191
243
 
192
244
  // RequireObjectCoercible: Object.keys/values/entries reject null & undefined
193
245
  // with a TypeError. A literal lowers to a [null, value] node — so `null` is
@@ -229,10 +281,18 @@ export default (ctx) => {
229
281
  out.ptr], 'f64')
230
282
  }
231
283
 
232
- ctx.core.emit['Object.keys'] = (obj) => {
284
+ // Shared by Object.keys and __keys_ro. `ro` marks the for-in path: its result
285
+ // is read-only by construction (the lowering only reads ks[i]/ks.length), so
286
+ // the HASH arms may serve the shared enum-cache array (core.js __hash_keys_ro)
287
+ // instead of a fresh copy. Object.keys stays fresh — callers may mutate it.
288
+ const emitKeysGeneric = (obj, ro) => {
289
+ // Shared memory: cache globals are per-instance but the table is cross-thread,
290
+ // and shared's `__clear` (plain rewind, core.js) takes no reset injections —
291
+ // both break the cache's invalidation story. Serve the uncached path there.
292
+ if (ctx.memory.shared) ro = false
233
293
  const nullish = requireCoercible(obj)
234
294
  if (nullish) return nullish
235
- if (isHashTyped(obj)) return emitHashKeys(obj)
295
+ if (isHashTyped(obj)) return ro ? emitHashKeysRO(obj) : emitHashKeys(obj)
236
296
  if (arrayValType(obj)) return idxKeys(obj, '__len')
237
297
  if (stringValType(obj)) return idxKeys(obj, '__str_len')
238
298
  const schema = resolveSchema(obj)
@@ -240,11 +300,12 @@ export default (ctx) => {
240
300
  // computed writes (`o[k]=v`) also carries props in its per-object dyn HASH
241
301
  // that the schema omits. Enumerate those live (schema ∪ dyn props) — the gap
242
302
  // that blocked metacircularity (the compiler grows dicts then enumerates).
243
- if (schema && !mayHaveDynProps(obj)) return emitStringArray(schema)
303
+ if (schema && !mayHaveDynProps(obj) && !hasOutOfSchemaWrites(obj, schema)) return emitStringArray(schema)
244
304
  // Unknown receiver, or schema with possible dyn props: dispatch on ptr-type
245
305
  // at runtime (HASH probe table / OBJECT schema+dyn merge / else []).
246
- return emitRuntimeKeys(obj)
306
+ return emitRuntimeKeys(obj, ro)
247
307
  }
308
+ ctx.core.emit['Object.keys'] = (obj) => emitKeysGeneric(obj, false)
248
309
  ctx.core.emit['Object.getOwnPropertyNames'] = ctx.core.emit['Object.keys']
249
310
 
250
311
  // for-in's read-only key enumeration (src/prepare for…in lowering). Identical to
@@ -258,18 +319,20 @@ export default (ctx) => {
258
319
  // Object.keys (evaluates the receiver, full runtime enumeration).
259
320
  ctx.core.emit['__keys_ro'] = (obj) => {
260
321
  // Pool only when the receiver's enumerable key set is provably the static
261
- // schema: a bare var with NO computed-key writes (`o[k]=v`) those are the
262
- // only writes that add enumerable keys (computed reads / dot-adds don't).
322
+ // schema: a bare var with NO computed-key writes (`o[k]=v`) and no literal
323
+ // writes outside the schema either kind adds enumerable keys the pool
324
+ // would drop (computed writes via dynWriteVars; out-of-schema literal
325
+ // writes land in the dyn sidecar — see hasOutOfSchemaWrites).
263
326
  // `mayHaveDynProps` is too coarse here — it also flags computed-READ receivers,
264
327
  // and for-in's own `o[k]` read would otherwise veto its own pooling.
265
328
  if (typeof obj === 'string' && !ctx.types.dynWriteVars?.has(obj) && !isHashTyped(obj) && !arrayValType(obj) && !stringValType(obj)) {
266
329
  const schema = resolveSchema(obj)
267
- if (schema) {
330
+ if (schema && !hasOutOfSchemaWrites(obj, schema)) {
268
331
  const slots = schema.map(name => extractF64Bits(asF64(emit(['str', name]))))
269
332
  if (slots.every(b => b !== null)) return staticArrayPtr(slots)
270
333
  }
271
334
  }
272
- return ctx.core.emit['Object.keys'](obj)
335
+ return emitKeysGeneric(obj, true)
273
336
  }
274
337
 
275
338
  // Object.prototype.hasOwnProperty(key) — own-property presence check.
@@ -362,7 +425,7 @@ export default (ctx) => {
362
425
  if (arrayValType(obj)) { inc('__arr_from'); return typed(['call', '$__arr_from', asI64(emit(obj))], 'f64') }
363
426
  if (isHashTyped(obj)) return emitHashValues(obj)
364
427
  const schema = resolveSchema(obj)
365
- if (!schema || mayHaveDynProps(obj)) return emitRuntimeValues(obj)
428
+ if (!schema || mayHaveDynProps(obj) || hasOutOfSchemaWrites(obj, schema)) return emitRuntimeValues(obj)
366
429
  const va = asF64(emit(obj))
367
430
  const n = schema.length
368
431
  const t = temp('ov'), base = tempI32('vb')
@@ -425,7 +488,7 @@ export default (ctx) => {
425
488
  }
426
489
  if (isHashTyped(obj)) return emitHashEntries(obj)
427
490
  const schema = resolveSchema(obj)
428
- if (!schema || mayHaveDynProps(obj)) return emitRuntimeEntries(obj)
491
+ if (!schema || mayHaveDynProps(obj) || hasOutOfSchemaWrites(obj, schema)) return emitRuntimeEntries(obj)
429
492
  const va = asF64(emit(obj))
430
493
  const n = schema.length
431
494
  const t = temp('oe'), pair = tempI32('op'), base = tempI32('eb')
@@ -458,6 +521,8 @@ export default (ctx) => {
458
521
  }
459
522
  const boxedSchema = ['__inner__', ...allProps]
460
523
  const schemaId = ctx.schema.register(boxedSchema)
524
+ // Extern-write belt: source slot values copied in below, unseen by the censuses.
525
+ ctx.schema.externSlotSids?.add(schemaId)
461
526
  ctx.schema.vars.set(target, schemaId)
462
527
  // Emit-time rep mutation: Object.assign's target gains a freshly-registered
463
528
  // boxed-schema binding here; downstream `.prop` reads in the same emit pass
@@ -489,6 +554,11 @@ export default (ctx) => {
489
554
  const sourceSchemas = sources.map(resolveSchema)
490
555
  if (!tSchema) return emitObjectAssignDynamic(target, sources)
491
556
  if (sourceSchemas.some(s => !s)) return emitDynamicAssign(target, sources, sourceSchemas)
557
+ // Extern-write belt: cross-schema slot copies into the TARGET's sid below
558
+ // (plan's hazard scan marks the same target when it resolves it).
559
+ const tSid = typeof target === 'string'
560
+ ? (repOf(target)?.schemaId ?? ctx.schema.vars.get(target)) : null
561
+ if (tSid != null) ctx.schema.externSlotSids?.add(tSid)
492
562
  const t = temp('at'), s = temp('as')
493
563
  const tBase = tempI32('tb'), sBase2 = tempI32('sb')
494
564
  // When the target carries a dynamic-props shadow (needsDynShadow), reads of an
@@ -585,9 +655,9 @@ export default (ctx) => {
585
655
  ['f64.store',
586
656
  ['i32.sub', ['local.get', `$${dstOff}`], ['i32.const', 16]],
587
657
  ['f64.load', ['i32.sub', ['local.get', `$${srcOff}`], ['i32.const', 16]]]],
588
- ['call', '$__dyn_move',
658
+ ['drop', ['call', '$__dyn_move',
589
659
  ['local.get', `$${srcOff}`],
590
- ['local.get', `$${dstOff}`]],
660
+ ['local.get', `$${dstOff}`]]],
591
661
  ['local.get', `$${dst}`]], 'f64')
592
662
  }
593
663
  const schema = resolveSchema(proto)
@@ -610,9 +680,9 @@ export default (ctx) => {
610
680
  ['f64.store',
611
681
  ['i32.sub', ['local.get', `$${dstOff2}`], ['i32.const', 16]],
612
682
  ['f64.load', ['i32.sub', ['local.get', `$${srcOff2}`], ['i32.const', 16]]]],
613
- ['call', '$__dyn_move',
683
+ ['drop', ['call', '$__dyn_move',
614
684
  ['local.get', `$${srcOff2}`],
615
- ['local.get', `$${dstOff2}`]],
685
+ ['local.get', `$${dstOff2}`]]],
616
686
  ['local.get', `$${dst2}`]]],
617
687
  ['else', ['local.get', `$${value}`]]]] , 'f64')
618
688
  }
@@ -738,10 +808,35 @@ function emitObjectAssignDynamic(target, sources) {
738
808
  // them; callers route it through the runtime schema∪dyn-props merge instead.
739
809
  const mayHaveDynProps = (obj) => typeof obj === 'string' && !!ctx.types.dynKeyVars?.has(obj)
740
810
 
811
+ // A literal-key write of a key OUTSIDE the receiver's schema lands in the
812
+ // dyn-props sidecar (locals get no propMap/autoBox merge) — the static schema
813
+ // fold would silently drop it from enumeration, so such receivers must take
814
+ // the runtime merge path. Bare-var receivers only, mirroring dynWriteVars'
815
+ // per-name precision (expression receivers are runtime-dispatched anyway).
816
+ const hasOutOfSchemaWrites = (obj, schema) => {
817
+ if (typeof obj !== 'string') return false
818
+ const w = ctx.types.literalWriteKeys?.get(obj)
819
+ if (!w) return false
820
+ for (const k of w) if (!schema.includes(k)) return true
821
+ return false
822
+ }
823
+
741
824
  function resolveSchema(obj) {
742
825
  if (typeof obj === 'string') return ctx.schema.resolve(obj)
743
- if (Array.isArray(obj) && obj[0] === '{}')
744
- return obj.slice(1).filter(p => Array.isArray(p) && p[0] === ':').map(p => p[1])
826
+ if (Array.isArray(obj) && obj[0] === '{}') {
827
+ // Comma-grouped children arrive as ['{}', [',', p1, p2, …]] — same unwrap
828
+ // as the '{}' emitter's, or a grouped literal resolves to zero keys.
829
+ const props = obj.length === 2 && Array.isArray(obj[1]) && obj[1][0] === ','
830
+ ? obj[1].slice(1) : obj.slice(1)
831
+ // A spread-bearing literal's schema is the MERGE emitObjectSpread builds
832
+ // (or null when any source is unknown → HASH result, runtime enumeration).
833
+ // Filtering to ':'-entries here made Object.keys({ ...S, z: 9 }) fold to
834
+ // ['z'] while the object's real slot layout carried S's keys too — the
835
+ // spread's keys vanished from keys/values/entries/for-in/JSON while
836
+ // remaining readable as properties (watr-in-kernel's normalize() cfg).
837
+ if (props.some(p => Array.isArray(p) && p[0] === '...')) return spreadLiteralSchema(props)
838
+ return props.filter(p => Array.isArray(p) && p[0] === ':').map(p => p[1])
839
+ }
745
840
  // JSON-shape inferred: JSON.parse(constStr) call or `.prop`/`[i]` chain
746
841
  // resolving to a known OBJECT shape carries its key list as `names`.
747
842
  const sh = shapeOf(obj)
@@ -774,22 +869,32 @@ function takeLiteralTarget() {
774
869
  return frame.name
775
870
  }
776
871
 
872
+ // Merged static schema of a spread-bearing literal, or null when any spread
873
+ // source's key set is unknown at compile time (→ HASH result). The SINGLE
874
+ // source of truth for emitObjectSpread (which builds the object with exactly
875
+ // this slot layout) and resolveSchema (which keys/values/entries/for-in fold
876
+ // against) — the two MUST agree or enumeration drops the spread's keys.
877
+ function spreadLiteralSchema(props) {
878
+ const names = []
879
+ const add = n => { if (!names.includes(n)) names.push(n) }
880
+ for (const p of props) {
881
+ if (Array.isArray(p) && p[0] === '...') {
882
+ const s = spreadSourceSchema(p[1])
883
+ if (!s) return null
884
+ for (const n of s) add(n)
885
+ } else if (Array.isArray(p) && p[0] === ':') add(p[1])
886
+ }
887
+ return names
888
+ }
889
+
777
890
  function emitObjectSpread(props, spreadTarget = takeLiteralTarget()) {
778
891
  // Resolve every spread source's schema. A source with no static schema means
779
892
  // its full key set is unknown at compile time, so the merge result must be a
780
893
  // HASH (dynamic dict) — a fixed schema would silently drop the source's keys
781
894
  // it doesn't list. Only when EVERY source is known do we build the fixed-shape
782
895
  // OBJECT below.
783
- const allNames = []
784
- const addName = n => { if (!allNames.includes(n)) allNames.push(n) }
785
- let allKnown = true
786
- for (const p of props) {
787
- if (Array.isArray(p) && p[0] === '...') {
788
- const s = spreadSourceSchema(p[1])
789
- if (s) for (const n of s) addName(n)
790
- else allKnown = false
791
- } else if (Array.isArray(p) && p[0] === ':') addName(p[1])
792
- }
896
+ const allNames = spreadLiteralSchema(props)
897
+ const allKnown = allNames != null
793
898
  // Single unknown spread `{ ...src }` → shallow-clone src at runtime, preserving
794
899
  // its type (OBJECT→OBJECT, HASH→HASH). Aliasing src (the old shortcut) leaked
795
900
  // every later write to the result back into the source — a real correctness bug
@@ -804,6 +909,10 @@ function emitObjectSpread(props, spreadTarget = takeLiteralTarget()) {
804
909
  if (!allKnown) return emitDynamicSpread(props)
805
910
 
806
911
  const schemaId = ctx.schema.register(allNames)
912
+ // Extern-write belt: the spread slot-copies below write source-schema values
913
+ // into this sid outside the write censuses' view (collectSlotWriteHazards
914
+ // normally resolves the same merge at plan time; the belt covers divergence).
915
+ ctx.schema.externSlotSids?.add(schemaId)
807
916
  const schema = ctx.schema.list[schemaId]
808
917
  const t = tempI32('obj')
809
918
  const ptr = temp('objp')
@@ -918,6 +1027,28 @@ function emitHashKeys(obj) {
918
1027
  hashKeysFromTemp(t)], 'f64')
919
1028
  }
920
1029
 
1030
+ // for-in over a statically-HASH receiver: serve keys through the shared enum
1031
+ // cache (core.js __hash_keys_ro) — read-only by the for-in lowering's contract.
1032
+ function emitHashKeysRO(obj) {
1033
+ inc('__hash_keys_ro')
1034
+ declEnumcGlobals()
1035
+ return typed(['call', '$__hash_keys_ro', ['i64.reinterpret_f64', asF64(emit(obj))]], 'f64')
1036
+ }
1037
+
1038
+ // __hash_keys_ro's cache globals — declared at emit time so the helper's text
1039
+ // resolves even in builds where collection.js (which also declares them for its
1040
+ // delete-hook) never loads. enumcConsumed marks that some enumeration site can
1041
+ // FILL the cache this build (the OBJECT arm is inline IR — reachability can't
1042
+ // see it), so assemble.js knows to reset it in `__clear` (ABA guard).
1043
+ function declEnumcGlobals() {
1044
+ ctx.runtime.enumcConsumed = true
1045
+ if (!ctx.scope.globals.has('__enumc_off')) {
1046
+ declGlobal('__enumc_off', 'i32')
1047
+ declGlobal('__enumc_len', 'i32')
1048
+ declGlobal('__enumc_arr', 'f64')
1049
+ }
1050
+ }
1051
+
921
1052
  function emitHashValues(obj) {
922
1053
  const t = temp('hv')
923
1054
  return typed(['block', ['result', 'f64'],
@@ -1017,30 +1148,39 @@ function hashEntriesFromTemp(t) {
1017
1148
  // compile time or lazily at runtime by JSON.parse via __jp_schema_get); other
1018
1149
  // types (ARRAY, nullish, primitives) return an empty array. The empty-array
1019
1150
  // fallback is allocated in all arms for type uniformity at the if boundary.
1020
- function emitRuntimeKeys(obj) {
1151
+ function emitRuntimeKeys(obj, ro) {
1021
1152
  const t = temp('rk')
1022
1153
  return typed(['block', ['result', 'f64'],
1023
1154
  ['local.set', `$${t}`, asF64(emit(obj))],
1024
- runtimeKeysFromTemp(t, 'rk')], 'f64')
1155
+ runtimeKeysFromTemp(t, 'rk', ro)], 'f64')
1025
1156
  }
1026
1157
 
1027
- function runtimeKeysFromTemp(t, tag) {
1158
+ function runtimeKeysFromTemp(t, tag, ro) {
1159
+ if (ctx.memory.shared) ro = false // see emitKeysGeneric — no enum cache under shared memory
1028
1160
  inc('__ptr_type')
1029
1161
  // Ensure the schema table global exists even in programs that never use
1030
1162
  // JSON.parse or compile-time schemas — the OBJECT arm reads it at runtime
1031
- // and the watr resolver requires the symbol to be declared.
1163
+ // and the watr resolver requires the symbol to be declared. Declaring is not
1164
+ // enough: the OBJECT arm READS the table inline (no named helper to count),
1165
+ // so mark consumption for assemble.js's needsSchemaTbl gate or the table
1166
+ // stays 0 and enumeration silently yields zero schema keys.
1032
1167
  if (!ctx.scope.globals.has('__schema_tbl'))
1033
1168
  declGlobal('__schema_tbl', 'i32')
1169
+ ctx.runtime.schemaTblConsumed = true
1034
1170
  const tt = tempI32(`${tag}t`)
1035
1171
  const empty = allocPtr({ type: PTR.ARRAY, len: 0, tag: `${tag}e` })
1036
1172
  return ['block', ['result', 'f64'],
1037
1173
  ['local.set', `$${tt}`, ['call', '$__ptr_type', ['i64.reinterpret_f64', ['local.get', `$${t}`]]]],
1038
1174
  ['if', ['result', 'f64'],
1039
1175
  ['i32.eq', ['local.get', `$${tt}`], ['i32.const', PTR.HASH]],
1040
- ['then', hashKeysFromTemp(t)],
1176
+ // for-in (ro): serve the shared enum-cache array — see emitHashKeysRO.
1177
+ ['then', ro
1178
+ ? (inc('__hash_keys_ro'), declEnumcGlobals(),
1179
+ ['call', '$__hash_keys_ro', ['i64.reinterpret_f64', ['local.get', `$${t}`]]])
1180
+ : hashKeysFromTemp(t)],
1041
1181
  ['else', ['if', ['result', 'f64'],
1042
1182
  ['i32.eq', ['local.get', `$${tt}`], ['i32.const', PTR.OBJECT]],
1043
- ['then', objectKeysFromTemp(t)],
1183
+ ['then', objectKeysFromTemp(t, ro)],
1044
1184
  ['else', ['block', ['result', 'f64'], empty.init, empty.ptr]]]]]]
1045
1185
  }
1046
1186
 
@@ -1048,6 +1188,7 @@ function emitRuntimeValues(obj) {
1048
1188
  inc('__ptr_type')
1049
1189
  if (!ctx.scope.globals.has('__schema_tbl'))
1050
1190
  declGlobal('__schema_tbl', 'i32')
1191
+ ctx.runtime.schemaTblConsumed = true
1051
1192
  const t = temp('rv'), tt = tempI32('rvt')
1052
1193
  const empty = allocPtr({ type: PTR.ARRAY, len: 0, tag: 'rve' })
1053
1194
  return typed(['block', ['result', 'f64'],
@@ -1066,6 +1207,7 @@ function emitRuntimeEntries(obj) {
1066
1207
  inc('__ptr_type')
1067
1208
  if (!ctx.scope.globals.has('__schema_tbl'))
1068
1209
  declGlobal('__schema_tbl', 'i32')
1210
+ ctx.runtime.schemaTblConsumed = true
1069
1211
  const t = temp('re'), tt = tempI32('ret')
1070
1212
  const empty = allocPtr({ type: PTR.ARRAY, len: 0, tag: 'ree' })
1071
1213
  return typed(['block', ['result', 'f64'],
@@ -1098,17 +1240,88 @@ function emitRuntimeEntries(obj) {
1098
1240
  //
1099
1241
  // Callbacks receive the active locals as named fields so each variant can
1100
1242
  // reference what it needs without knowing the scaffold's layout.
1101
- function emitEnumerateObject(t, emitStaticStore, emitDynStore) {
1243
+ function emitEnumerateObject(t, emitStaticStore, emitDynStore, ro) {
1102
1244
  inc('__alloc_hdr', '__ptr_offset', '__coll_order')
1245
+ if (ro) declEnumcGlobals()
1246
+ // Durable-receiver global-table merge (see below) only when collection.js's
1247
+ // dyn-props machinery is actually part of this build — a program that never
1248
+ // writes a dynamic prop anywhere never loads collection.js, so __dyn_props/
1249
+ // __ihash_get_local wouldn't exist to reference.
1250
+ const hasDynProps = ctx.scope.globals.has('__dyn_props')
1251
+ if (hasDynProps) inc('__ihash_get_local', '__is_nullish')
1103
1252
  const sid = tempI32('oes'), src = tempI32('oesrc'), sn = tempI32('oen')
1104
- const base = tempI32('oebase'), props = tempI64('oepr'), poff = tempI32('oepo')
1105
- const pcap = tempI32('oepc'), dn = tempI32('oedn'), total = tempI32('oetot')
1253
+ const base = tempI32('oebase'), props = tempI64('oepr')
1254
+ // TWO dyn-prop sources for a DURABLE receiver: the off-16 sidecar (populated
1255
+ // if this receiver got dyn props DURING init, while __heap_reset was still
1256
+ // low) and the global __dyn_props table (populated by RUNTIME/post-init
1257
+ // writes — see collection.js's heapResetWat). Either, both, or neither may
1258
+ // be non-empty; enumerate whichever exist, deduping across schema ∪ global
1259
+ // ∪ sidecar (in that priority — global shadows a sidecar entry with the
1260
+ // same key, matching __dyn_get_t_h's read priority). An EPHEMERAL receiver
1261
+ // only ever uses the sidecar slot (poffG stays 0 for it).
1262
+ const poffG = tempI32('oepoG'), pcapG = tempI32('oepcG'), dnG = tempI32('oednG'), ordG = tempI32('oeordG')
1263
+ const poffS = tempI32('oepoS'), pcapS = tempI32('oepcS'), dnS = tempI32('oednS'), ordS = tempI32('oeordS')
1264
+ const total = tempI32('oetot')
1106
1265
  const out = tempI32('oeo'), i = tempI32('oei'), o = tempI32('oej')
1107
- const slot = tempI32('oesl'), ord = tempI32('oeord')
1266
+ const slot = tempI32('oesl')
1108
1267
  const j = tempI32('oej2'), skip = tempI32('oesk'), pair = tempI32('oep')
1109
1268
  const id = ctx.func.uniq++
1110
1269
  const env = { out, o, src, base, i, slot, pair }
1111
- return ['block', ['result', 'f64'],
1270
+ // for-in enum cache, OBJECT arm (see core.js __hash_keys_ro for the scheme).
1271
+ // Key = (sidecar off, sidecar len): the sidecar identifies the object (one
1272
+ // sidecar per object, offs unique), sid/schema are immutable per object, and
1273
+ // every other key-set change clears the cache — sidecar inserts change dnS
1274
+ // (natural miss), sidecar/global deletes and global dyn-prop inserts clear
1275
+ // __enumc_off at their (cold) sites. Checked BEFORE the global __dyn_props
1276
+ // probe, so a hit skips the ihash lookup too — sound because any global-side
1277
+ // structural change since fill cleared the cache. poffS≠0 guard: an empty
1278
+ // cache (off 0) must not match a sidecar-less object.
1279
+ const roHit = ro ? [['if', ['i32.and',
1280
+ ['i32.and',
1281
+ ['i32.ne', ['local.get', `$${poffS}`], ['i32.const', 0]],
1282
+ ['i32.eq', ['local.get', `$${poffS}`], ['global.get', '$__enumc_off']]],
1283
+ ['i32.eq', ['local.get', `$${dnS}`], ['global.get', '$__enumc_len']]],
1284
+ ['then', ['br', `$oed${id}`, ['global.get', '$__enumc_arr']]]]] : []
1285
+ // Dedup-and-store one dyn source's dn live slots (at poff/pcap, ord already
1286
+ // computed) against the schema (0..sn @ src) and, when `against` is given,
1287
+ // a second dyn source's already-walked ord array (0..dn2 @ ord2).
1288
+ const walkDyn = (label, dn, ord, against) => ['if', ['i32.ne', ['local.get', `$${dn}`], ['i32.const', 0]],
1289
+ ['then',
1290
+ ['local.set', `$${i}`, ['i32.const', 0]],
1291
+ ['block', `$${label}brk${id}`, ['loop', `$${label}loop${id}`,
1292
+ ['br_if', `$${label}brk${id}`, ['i32.ge_s', ['local.get', `$${i}`], ['local.get', `$${dn}`]]],
1293
+ ['local.set', `$${slot}`, ['i32.load', ['i32.add', ['local.get', `$${ord}`],
1294
+ ['i32.shl', ['local.get', `$${i}`], ['i32.const', 2]]]]],
1295
+ ['local.set', `$${skip}`, ['i32.const', 0]],
1296
+ ['local.set', `$${j}`, ['i32.const', 0]],
1297
+ ['block', `$${label}skbrk${id}`, ['loop', `$${label}skloop${id}`,
1298
+ ['br_if', `$${label}skbrk${id}`, ['i32.ge_s', ['local.get', `$${j}`], ['local.get', `$${sn}`]]],
1299
+ ['if', ['i64.eq',
1300
+ ['i64.load', ['i32.add', ['local.get', `$${slot}`], ['i32.const', 8]]],
1301
+ ['i64.load', ['i32.add', ['local.get', `$${src}`], ['i32.shl', ['local.get', `$${j}`], ['i32.const', 3]]]]],
1302
+ ['then', ['local.set', `$${skip}`, ['i32.const', 1]], ['br', `$${label}skbrk${id}`]]],
1303
+ ['local.set', `$${j}`, ['i32.add', ['local.get', `$${j}`], ['i32.const', 1]]],
1304
+ ['br', `$${label}skloop${id}`]]],
1305
+ ...(against ? [
1306
+ ['if', ['i32.eqz', ['local.get', `$${skip}`]],
1307
+ ['then',
1308
+ ['local.set', `$${j}`, ['i32.const', 0]],
1309
+ ['block', `$${label}gdbrk${id}`, ['loop', `$${label}gdloop${id}`,
1310
+ ['br_if', `$${label}gdbrk${id}`, ['i32.ge_s', ['local.get', `$${j}`], ['local.get', `$${against.dn}`]]],
1311
+ ['if', ['i64.eq',
1312
+ ['i64.load', ['i32.add', ['local.get', `$${slot}`], ['i32.const', 8]]],
1313
+ ['i64.load', ['i32.add', ['i32.load', ['i32.add', ['local.get', `$${against.ord}`],
1314
+ ['i32.shl', ['local.get', `$${j}`], ['i32.const', 2]]]], ['i32.const', 8]]]],
1315
+ ['then', ['local.set', `$${skip}`, ['i32.const', 1]], ['br', `$${label}gdbrk${id}`]]],
1316
+ ['local.set', `$${j}`, ['i32.add', ['local.get', `$${j}`], ['i32.const', 1]]],
1317
+ ['br', `$${label}gdloop${id}`]]]]]] : []),
1318
+ ['if', ['i32.eqz', ['local.get', `$${skip}`]],
1319
+ ['then',
1320
+ ...emitDynStore(env),
1321
+ ['local.set', `$${o}`, ['i32.add', ['local.get', `$${o}`], ['i32.const', 1]]]]],
1322
+ ['local.set', `$${i}`, ['i32.add', ['local.get', `$${i}`], ['i32.const', 1]]],
1323
+ ['br', `$${label}loop${id}`]]]]]
1324
+ return ['block', `$oed${id}`, ['result', 'f64'],
1112
1325
  // Static schema row: sid (AUX bits) → __schema_tbl[sid] → src offset; n@src-8.
1113
1326
  // __schema_tbl is omitted when every program schema is empty (dyn-only dicts);
1114
1327
  // guard the read so empty-table programs see sn=0 here and still enumerate
@@ -1124,13 +1337,20 @@ function emitEnumerateObject(t, emitStaticStore, emitDynStore) {
1124
1337
  ['i64.load', ['i32.add', ['global.get', '$__schema_tbl'], ['i32.shl', ['local.get', `$${sid}`], ['i32.const', 3]]]],
1125
1338
  ['i64.const', LAYOUT.OFFSET_MASK]]]],
1126
1339
  ['local.set', `$${sn}`, ['i32.load', ['i32.sub', ['local.get', `$${src}`], ['i32.const', 8]]]]]],
1127
- // Dyn-props: heap OBJECTs (base >= __heap_start) carry a HASH propsPtr at
1128
- // base-16 (0 when none). Static-segment objects have no header, so they
1129
- // contribute no dyn keys (poff stays 0).
1340
+ // Dyn-props: heap OBJECTs carry a HASH propsPtr either at base-16
1341
+ // (populated by an init-time write, or by any write at all on an
1342
+ // EPHEMERAL receiver one allocated after the post-init high-water
1343
+ // mark, per the durable-receiver policy) or in the global __dyn_props
1344
+ // table keyed by offset (populated by a RUNTIME/post-init write on a
1345
+ // DURABLE receiver; see collection.js's heapResetWat). Static-segment
1346
+ // objects (base < __heap_start) have no header at all and predate any
1347
+ // warm-reuse machinery, so they contribute no dyn keys either way.
1130
1348
  ['local.set', `$${base}`, ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['local.get', `$${t}`]]]],
1131
- ['local.set', `$${dn}`, ['i32.const', 0]],
1132
- ['local.set', `$${poff}`, ['i32.const', 0]],
1133
- ['if', ['i32.ge_u', ['local.get', `$${base}`], ['global.get', '$__heap_start']],
1349
+ ['local.set', `$${dnG}`, ['i32.const', 0]],
1350
+ ['local.set', `$${poffG}`, ['i32.const', 0]],
1351
+ ['local.set', `$${dnS}`, ['i32.const', 0]],
1352
+ ['local.set', `$${poffS}`, ['i32.const', 0]],
1353
+ ['if', ['i32.ge_u', ['local.get', `$${base}`], heapResetIR()],
1134
1354
  ['then',
1135
1355
  ['local.set', `$${props}`, ['i64.load', ['i32.sub', ['local.get', `$${base}`], ['i32.const', 16]]]],
1136
1356
  ['if', ['i32.eq',
@@ -1139,12 +1359,56 @@ function emitEnumerateObject(t, emitStaticStore, emitDynStore) {
1139
1359
  ['then',
1140
1360
  // Resolve forward chain — HASH may have forwarded on grow; the raw
1141
1361
  // propsPtr offset would point at the forward record, not live slots.
1142
- ['local.set', `$${poff}`, ['call', '$__ptr_offset', ['local.get', `$${props}`]]],
1143
- ['local.set', `$${pcap}`, ['i32.load', ['i32.sub', ['local.get', `$${poff}`], ['i32.const', 4]]]],
1144
- ['local.set', `$${dn}`, ['i32.load', ['i32.sub', ['local.get', `$${poff}`], ['i32.const', 8]]]]]]]],
1145
- // Over-allocate sn+dn; patch length to actual `o` post-dedup so removed
1146
- // shadow-mirror slots never expose garbage tails.
1147
- ['local.set', `$${total}`, ['i32.add', ['local.get', `$${sn}`], ['local.get', `$${dn}`]]],
1362
+ ['local.set', `$${poffS}`, ['call', '$__ptr_offset', ['local.get', `$${props}`]]],
1363
+ ['local.set', `$${pcapS}`, ['i32.load', ['i32.sub', ['local.get', `$${poffS}`], ['i32.const', 4]]]],
1364
+ ['local.set', `$${dnS}`, ['i32.load', ['i32.sub', ['local.get', `$${poffS}`], ['i32.const', 8]]]]]],
1365
+ ...roHit],
1366
+ ...(hasDynProps ? [['else',
1367
+ // Sidecar (init-time keys, if any) — only for genuinely heap-allocated
1368
+ // receivers (base >= __heap_start): static-segment objects have no
1369
+ // header at all, so the off-16 read would hit neighboring static data.
1370
+ // Durable words may carry the runtime-shadowed bit0 marker
1371
+ // (collection.js __dyn_set) — mask it out or the resolved sidecar off
1372
+ // is misaligned.
1373
+ ['if', ['i32.ge_u', ['local.get', `$${base}`], ['global.get', '$__heap_start']],
1374
+ ['then',
1375
+ ['local.set', `$${props}`, ['i64.and',
1376
+ ['i64.load', ['i32.sub', ['local.get', `$${base}`], ['i32.const', 16]]], ['i64.const', -2]]],
1377
+ ['if', ['i32.eq',
1378
+ ['i32.wrap_i64', ['i64.and', ['i64.shr_u', ['local.get', `$${props}`], ['i64.const', LAYOUT.TAG_SHIFT]], ['i64.const', LAYOUT.TAG_MASK]]],
1379
+ ['i32.const', PTR.HASH]],
1380
+ ['then',
1381
+ ['local.set', `$${poffS}`, ['call', '$__ptr_offset', ['local.get', `$${props}`]]],
1382
+ ['local.set', `$${pcapS}`, ['i32.load', ['i32.sub', ['local.get', `$${poffS}`], ['i32.const', 4]]]],
1383
+ ['local.set', `$${dnS}`, ['i32.load', ['i32.sub', ['local.get', `$${poffS}`], ['i32.const', 8]]]]]]]],
1384
+ ...roHit,
1385
+ // Global (runtime-written keys, if any) — NO heap_start gate: __dyn_set
1386
+ // routes writes on STATIC-SEGMENT receivers here too (they have no
1387
+ // header, so the global table is their only storage), and the probe is
1388
+ // keyed by offset, needing no header. Gating it on heap_start silently
1389
+ // dropped `o.zz = 3` on a data-segment literal from enumeration.
1390
+ ['if', ['f64.ne', ['global.get', '$__dyn_props'], ['f64.const', 0]],
1391
+ ['then',
1392
+ ['local.set', `$${props}`, ['call', '$__ihash_get_local',
1393
+ ['i64.reinterpret_f64', ['global.get', '$__dyn_props']],
1394
+ ['i64.reinterpret_f64', ['f64.convert_i32_s', ['local.get', `$${base}`]]]]],
1395
+ ['if', ['i32.eqz', ['call', '$__is_nullish', ['local.get', `$${props}`]]],
1396
+ ['then',
1397
+ ['local.set', `$${poffG}`, ['call', '$__ptr_offset', ['local.get', `$${props}`]]],
1398
+ ['local.set', `$${pcapG}`, ['i32.load', ['i32.sub', ['local.get', `$${poffG}`], ['i32.const', 4]]]],
1399
+ ['local.set', `$${dnG}`, ['i32.load', ['i32.sub', ['local.get', `$${poffG}`], ['i32.const', 8]]]]]]]]]] : [])],
1400
+ // for-in with no dyn sources at all: the enumeration IS the schema key
1401
+ // array, and __schema_tbl[sid] already holds it as a static jz array —
1402
+ // return it boxed directly. Read-only by for-in's contract, static by
1403
+ // construction: no alloc, no cache, no invalidation.
1404
+ ...(ro ? [['if', ['i32.and',
1405
+ ['i32.and', ['i32.eqz', ['local.get', `$${dnG}`]], ['i32.eqz', ['local.get', `$${dnS}`]]],
1406
+ ['i32.ne', ['local.get', `$${src}`], ['i32.const', 0]]],
1407
+ ['then', ['br', `$oed${id}`, mkPtrIR(PTR.ARRAY, 0, ['local.get', `$${src}`])]]]] : []),
1408
+ // Over-allocate sn+dnG+dnS; patch length to actual `o` post-dedup so
1409
+ // removed shadow-mirror/cross-source-duplicate slots never expose
1410
+ // garbage tails.
1411
+ ['local.set', `$${total}`, ['i32.add', ['local.get', `$${sn}`], ['i32.add', ['local.get', `$${dnG}`], ['local.get', `$${dnS}`]]]],
1148
1412
  ['local.set', `$${out}`, ['call', '$__alloc_hdr', ['local.get', `$${total}`], ['local.get', `$${total}`]]],
1149
1413
  ['local.set', `$${o}`, ['i32.const', 0]],
1150
1414
  // Static schema slots — no skip, schema keys are unique by construction.
@@ -1155,42 +1419,36 @@ function emitEnumerateObject(t, emitStaticStore, emitDynStore) {
1155
1419
  ['local.set', `$${o}`, ['i32.add', ['local.get', `$${o}`], ['i32.const', 1]]],
1156
1420
  ['local.set', `$${i}`, ['i32.add', ['local.get', `$${i}`], ['i32.const', 1]]],
1157
1421
  ['br', `$sloop${id}`]]],
1158
- // Dyn-prop slots in insertion order (__coll_order sorts the dn live 24-byte
1422
+ // Dyn-prop slots in insertion order (__coll_order sorts the live 24-byte
1159
1423
  // slots by packed seq; hash@+0, key@+8, value@+16). Skip entries whose key
1160
1424
  // is already in the schema — when an object literal has shadow=true (per
1161
1425
  // needsDynShadow), each schema key is mirrored into propsPtr at construction
1162
1426
  // so dyn-key reads hit the hash fast path; the mirror is not an enumeration
1163
- // entity, so we must not emit it twice.
1164
- ['if', ['i32.ne', ['local.get', `$${poff}`], ['i32.const', 0]],
1165
- ['then',
1166
- ['local.set', `$${ord}`, ['call', '$__coll_order', ['local.get', `$${poff}`], ['local.get', `$${pcap}`], ['i32.const', 24]]],
1167
- ['local.set', `$${i}`, ['i32.const', 0]],
1168
- ['block', `$dbrk${id}`, ['loop', `$dloop${id}`,
1169
- ['br_if', `$dbrk${id}`, ['i32.ge_s', ['local.get', `$${i}`], ['local.get', `$${dn}`]]],
1170
- ['local.set', `$${slot}`, ['i32.load', ['i32.add', ['local.get', `$${ord}`],
1171
- ['i32.shl', ['local.get', `$${i}`], ['i32.const', 2]]]]],
1172
- ['local.set', `$${skip}`, ['i32.const', 0]],
1173
- ['local.set', `$${j}`, ['i32.const', 0]],
1174
- ['block', `$skbrk${id}`, ['loop', `$skloop${id}`,
1175
- ['br_if', `$skbrk${id}`, ['i32.ge_s', ['local.get', `$${j}`], ['local.get', `$${sn}`]]],
1176
- ['if', ['i64.eq',
1177
- ['i64.load', ['i32.add', ['local.get', `$${slot}`], ['i32.const', 8]]],
1178
- ['i64.load', ['i32.add', ['local.get', `$${src}`], ['i32.shl', ['local.get', `$${j}`], ['i32.const', 3]]]]],
1179
- ['then', ['local.set', `$${skip}`, ['i32.const', 1]], ['br', `$skbrk${id}`]]],
1180
- ['local.set', `$${j}`, ['i32.add', ['local.get', `$${j}`], ['i32.const', 1]]],
1181
- ['br', `$skloop${id}`]]],
1182
- ['if', ['i32.eqz', ['local.get', `$${skip}`]],
1183
- ['then',
1184
- ...emitDynStore(env),
1185
- ['local.set', `$${o}`, ['i32.add', ['local.get', `$${o}`], ['i32.const', 1]]]]],
1186
- ['local.set', `$${i}`, ['i32.add', ['local.get', `$${i}`], ['i32.const', 1]]],
1187
- ['br', `$dloop${id}`]]]]],
1427
+ // entity, so we must not emit it twice. Global walks first (schema-dedup
1428
+ // only); sidecar walks second (schema-dedup AND global-dedup, so a key
1429
+ // present in both — reassigned at runtime after being set at init — is
1430
+ // emitted once, from the authoritative global copy).
1431
+ ['if', ['i32.ne', ['local.get', `$${poffG}`], ['i32.const', 0]],
1432
+ ['then', ['local.set', `$${ordG}`, ['call', '$__coll_order', ['local.get', `$${poffG}`], ['local.get', `$${pcapG}`], ['i32.const', 24]]]]],
1433
+ ['if', ['i32.ne', ['local.get', `$${poffS}`], ['i32.const', 0]],
1434
+ ['then', ['local.set', `$${ordS}`, ['call', '$__coll_order', ['local.get', `$${poffS}`], ['local.get', `$${pcapS}`], ['i32.const', 24]]]]],
1435
+ walkDyn('oeg', dnG, ordG, null),
1436
+ walkDyn('oes', dnS, ordS, { dn: dnG, ord: ordG }),
1188
1437
  ['i32.store', ['i32.sub', ['local.get', `$${out}`], ['i32.const', 8]], ['local.get', `$${o}`]],
1438
+ // Fill the enum cache (keyed by sidecar — see roHit above). Objects without
1439
+ // a sidecar are either tier-1 (returned above) or global-only (rare; a 0 key
1440
+ // would collide across objects, so leave them uncached).
1441
+ ...(ro ? [['if', ['i32.ne', ['local.get', `$${poffS}`], ['i32.const', 0]],
1442
+ ['then',
1443
+ ['global.set', '$__enumc_off', ['local.get', `$${poffS}`]],
1444
+ ['global.set', '$__enumc_len', ['local.get', `$${dnS}`]],
1445
+ ['global.set', '$__enumc_arr', mkPtrIR(PTR.ARRAY, 0, ['local.get', `$${out}`])]]]] : []),
1189
1446
  mkPtrIR(PTR.ARRAY, 0, ['local.get', `$${out}`])]
1190
1447
  }
1191
1448
 
1192
1449
  // Object.keys for an OBJECT — copy schema key (i64@src+i*8) then dyn key (i64@slot+8).
1193
- const objectKeysFromTemp = (t) => emitEnumerateObject(t,
1450
+ // ro (for-in): serve the static schema array / enum cache — see emitEnumerateObject.
1451
+ const objectKeysFromTemp = (t, ro) => emitEnumerateObject(t,
1194
1452
  ({ out, o, src, i }) => [
1195
1453
  ['i64.store',
1196
1454
  ['i32.add', ['local.get', `$${out}`], ['i32.shl', ['local.get', `$${o}`], ['i32.const', 3]]],
@@ -1198,7 +1456,7 @@ const objectKeysFromTemp = (t) => emitEnumerateObject(t,
1198
1456
  ({ out, o, slot }) => [
1199
1457
  ['i64.store',
1200
1458
  ['i32.add', ['local.get', `$${out}`], ['i32.shl', ['local.get', `$${o}`], ['i32.const', 3]]],
1201
- ['i64.load', ['i32.add', ['local.get', `$${slot}`], ['i32.const', 8]]]]])
1459
+ ['i64.load', ['i32.add', ['local.get', `$${slot}`], ['i32.const', 8]]]]], ro)
1202
1460
 
1203
1461
  // Object.values for an OBJECT — copy schema value (f64@base+i*8) then dyn value (f64@slot+16).
1204
1462
  const objectValuesFromTemp = (t) => emitEnumerateObject(t,