jz 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +56 -9
  2. package/bench/README.md +121 -50
  3. package/bench/bench.svg +27 -27
  4. package/cli.js +3 -1
  5. package/dist/interop.js +1 -1
  6. package/dist/jz.js +7541 -6271
  7. package/index.js +165 -74
  8. package/interop.js +189 -17
  9. package/jzify/arguments.js +32 -1
  10. package/jzify/async.js +409 -0
  11. package/jzify/classes.js +136 -18
  12. package/jzify/generators.js +639 -0
  13. package/jzify/hoist-vars.js +73 -1
  14. package/jzify/index.js +123 -4
  15. package/jzify/names.js +1 -0
  16. package/jzify/transform.js +239 -1
  17. package/layout.js +48 -3
  18. package/module/array.js +299 -44
  19. package/module/atomics.js +144 -0
  20. package/module/collection.js +1429 -155
  21. package/module/core.js +431 -40
  22. package/module/date.js +142 -120
  23. package/module/fs.js +144 -0
  24. package/module/function.js +6 -3
  25. package/module/index.js +4 -1
  26. package/module/json.js +270 -49
  27. package/module/math.js +892 -32
  28. package/module/number.js +532 -163
  29. package/module/object.js +353 -95
  30. package/module/regex.js +157 -7
  31. package/module/schema.js +100 -2
  32. package/module/string.js +301 -84
  33. package/module/typedarray.js +264 -72
  34. package/module/web.js +36 -0
  35. package/package.json +5 -5
  36. package/src/abi/string.js +71 -5
  37. package/src/ast.js +11 -5
  38. package/src/autoload.js +28 -1
  39. package/src/bridge.js +7 -2
  40. package/src/compile/analyze-scans.js +22 -7
  41. package/src/compile/analyze.js +136 -30
  42. package/src/compile/dyn-closure-tables.js +277 -0
  43. package/src/compile/emit-assign.js +281 -15
  44. package/src/compile/emit.js +979 -54
  45. package/src/compile/index.js +271 -29
  46. package/src/compile/infer.js +34 -3
  47. package/src/compile/inplace-store.js +329 -0
  48. package/src/compile/narrow.js +543 -15
  49. package/src/compile/peel-stencil.js +5 -0
  50. package/src/compile/plan/index.js +45 -3
  51. package/src/compile/plan/inline.js +8 -1
  52. package/src/compile/plan/literals.js +39 -0
  53. package/src/compile/plan/scope.js +430 -23
  54. package/src/compile/program-facts.js +732 -38
  55. package/src/ctx.js +64 -9
  56. package/src/helper-counters.js +7 -1
  57. package/src/ir.js +113 -5
  58. package/src/kind-traits.js +66 -3
  59. package/src/kind.js +84 -12
  60. package/src/op-policy.js +7 -4
  61. package/src/optimize/index.js +1060 -750
  62. package/src/optimize/recurse.js +2 -2
  63. package/src/optimize/vectorize.js +972 -67
  64. package/src/prepare/index.js +792 -63
  65. package/src/prepare/math-kernel.js +331 -0
  66. package/src/prepare/pre-eval.js +714 -0
  67. package/src/snapshot.js +194 -0
  68. package/src/type.js +1170 -56
  69. package/src/wat/assemble.js +403 -65
  70. package/src/wat/codegen.js +74 -14
  71. package/transform.js +113 -4
  72. package/wasi.js +3 -0
@@ -8,8 +8,10 @@ import { VAL, lookupValType, repOf } from '../reps.js'
8
8
  import { valTypeOf } from '../kind.js'
9
9
  import { extractParams, classifyParam } from '../ast.js'
10
10
  import { staticObjectProps } from '../static.js'
11
- import { intExprChecker } from '../type.js'
11
+ import { intLevelChecker, typedElemCtor } from '../type.js'
12
+ import { ctorFromElemAux } from '../../layout.js'
12
13
  import { analyzeBody } from './analyze.js'
14
+ import { safeReads } from './analyze-scans.js'
13
15
 
14
16
  // Assignment-shaped ops whose first arg, when a `.`/`?.` member node, is a
15
17
  // PROPERTY WRITE — feeds `writtenProps` (any prop name ever written through
@@ -17,12 +19,94 @@ import { analyzeBody } from './analyze.js'
17
19
  const PROP_WRITE_OPS = new Set(['=', '+=', '-=', '*=', '/=', '%=', '**=',
18
20
  '&=', '|=', '^=', '<<=', '>>=', '>>>=', '&&=', '||=', '??=', '++', '--'])
19
21
 
22
+ // Array methods that can change length or relocate the payload (grow copies to a
23
+ // new arena block and forwards the header). sort/reverse/fill/copyWithin mutate
24
+ // elements IN PLACE — base and len stay put — so they are deliberately absent.
25
+ const ARR_RESIZE_METHODS = new Set(['push', 'pop', 'shift', 'unshift', 'splice'])
26
+
27
+ // Per-op arg slots where a bare string is a NAME BINDING or receiver — not a value
28
+ // read. Everything else marks nameEscapes (see below). `true` = skip all slots.
29
+ // Missing a binding-shaped op here only over-marks (a lost fold), never unsound.
30
+ const ESCAPE_SKIP = {
31
+ '.': true, '?.': true, // receiver never escapes via the read itself; slot2 is a prop NAME
32
+ 'str': true, // payload
33
+ '[]': new Set([0]), // receiver safe; a bare INDEX name still marks (keys coerce so it's over-marking, but harmless)
34
+ '=>': new Set([0]), // params are bindings; a bare-name BODY is a returned value → marks
35
+ 'let': true, 'const': true, 'var': true, // decl heads; initializers are '='-nodes pre-registered below
36
+ 'import': true, 'export': true, // module wiring: exported arrays are host/importer-reachable — see explicit mark below
37
+ }
38
+
20
39
  export function observeNodeFacts(node, f) {
21
40
  if (!Array.isArray(node)) return
22
41
  const [op, ...args] = node
42
+ // ---- const-array stability lattice (module/array.js static base/len fold) ----
43
+ // arrResized: names whose array may change length or relocate — any indexed write
44
+ // (an out-of-range write grows), `.length =`, or a resizing method call.
45
+ // nameEscapes: bare names read in a VALUE position — the reference may alias, so
46
+ // mutations through the alias are invisible to per-name facts. Sound direction:
47
+ // over-marking loses a fold; the SAFE (unmarked) positions are only the receiver
48
+ // slots of '[]'/'.'/'?.' and binding slots.
49
+ if (op === '()' && Array.isArray(args[0]) && (args[0][0] === '.' || args[0][0] === '?.') &&
50
+ typeof args[0][1] === 'string' && ARR_RESIZE_METHODS.has(args[0][2]))
51
+ f.arrResized.add(args[0][1])
52
+ if (op === 'let' || op === 'const' || op === 'var') {
53
+ // Pre-register decl '=' children: their slot-0 is a BINDING, not a reassignment,
54
+ // so the '=' marking below must not flag the declared name as escaped.
55
+ for (const d of args) if (Array.isArray(d) && d[0] === '=') (f._declEq ??= new WeakSet()).add(d)
56
+ }
57
+ if (op === 'export') {
58
+ // An exported binding is reachable by importers and the host — writes through
59
+ // that path are outside this walk, so exported names count as escaped.
60
+ for (const d of args) {
61
+ if (typeof d === 'string') f.nameEscapes.add(d)
62
+ else if (Array.isArray(d) && d[0] === '=' && typeof d[1] === 'string') f.nameEscapes.add(d[1])
63
+ else if (Array.isArray(d) && (d[0] === 'let' || d[0] === 'const' || d[0] === 'var'))
64
+ for (const dd of d.slice(1)) { if (Array.isArray(dd) && dd[0] === '=' && typeof dd[1] === 'string') f.nameEscapes.add(dd[1]) }
65
+ }
66
+ }
67
+ {
68
+ const skip = ESCAPE_SKIP[op]
69
+ if (skip !== true && op != null) {
70
+ const declEq = op === '=' && f._declEq?.has(node)
71
+ for (let i = 0; i < args.length; i++) {
72
+ if (typeof args[i] !== 'string') continue
73
+ if (skip instanceof Set && skip.has(i)) continue
74
+ if (declEq && i === 0) continue
75
+ f.nameEscapes.add(args[i])
76
+ }
77
+ }
78
+ }
79
+ if (PROP_WRITE_OPS.has(op) && Array.isArray(args[0])) {
80
+ if (args[0][0] === '[]') {
81
+ let root = args[0][1]
82
+ while (Array.isArray(root) && root[0] === '[]') root = root[1]
83
+ if (typeof root === 'string') f.arrResized.add(root)
84
+ } else if ((args[0][0] === '.' || args[0][0] === '?.') && args[0][2] === 'length' && typeof args[0][1] === 'string')
85
+ f.arrResized.add(args[0][1])
86
+ }
23
87
  if (PROP_WRITE_OPS.has(op) && Array.isArray(args[0]) &&
24
- (args[0][0] === '.' || args[0][0] === '?.') && typeof args[0][2] === 'string')
88
+ (args[0][0] === '.' || args[0][0] === '?.') && typeof args[0][2] === 'string') {
25
89
  f.writtenProps.add(args[0][2])
90
+ // Per-receiver literal-key writes: a key OUTSIDE the receiver's schema lands
91
+ // in the dyn-props sidecar (locals get no propMap/autoBox schema merge), so
92
+ // static enumeration (for-in pool/unroll, Object.keys/values/entries schema
93
+ // fold) must deopt to the runtime merge for such receivers or the added key
94
+ // is invisible. Bare-var receivers only — expression receivers already take
95
+ // the runtime path (aliasing keeps dynWriteVars' per-name precision).
96
+ if (typeof args[0][1] === 'string') {
97
+ let s = f.literalWriteKeys.get(args[0][1])
98
+ if (!s) f.literalWriteKeys.set(args[0][1], s = new Set())
99
+ s.add(args[0][2])
100
+ }
101
+ }
102
+ // Bracket form of the same: `o['zz'] = v` (isLiteralStr keeps it out of
103
+ // dynWriteVars, so it must be recorded here or it's invisible to every gate).
104
+ if (PROP_WRITE_OPS.has(op) && Array.isArray(args[0]) && args[0][0] === '[]' &&
105
+ isLiteralStr(args[0][2]) && typeof args[0][1] === 'string') {
106
+ let s = f.literalWriteKeys.get(args[0][1])
107
+ if (!s) f.literalWriteKeys.set(args[0][1], s = new Set())
108
+ s.add(args[0][2][1])
109
+ }
26
110
  // Computed-key WRITES (`o[k]=v`, `o[k]+=v`, `o[k]++`) are the ONLY operations
27
111
  // that add ENUMERABLE keys beyond the static schema — computed reads and dot-adds
28
112
  // (`o.b=2`) do not enumerate in jz. Tracked separately from `dynVars` (which also
@@ -66,13 +150,23 @@ export function observeNodeFacts(node, f) {
66
150
  }
67
151
  }
68
152
 
69
- const _programFactsCache = new WeakMap()
70
- const _moduleInitSlotCache = new WeakMap()
71
- const _bodyIntCertainCache = new WeakMap()
153
+ let _programFactsCache = new WeakMap()
154
+ let _moduleInitSlotCache = new WeakMap()
155
+ let _bodyIntCertainCache = new WeakMap()
72
156
  let _programFactsGen = 0
73
157
 
74
- /** Drop all cached program-fact walks (called at compile entry). */
75
- export function resetProgramFactsCache() { _programFactsGen++ }
158
+ /** Drop all cached program-fact walks (called at compile entry).
159
+ * Natively the gen bump alone is enough (stale entries just go unreachable on a
160
+ * real GC heap). In the self-host kernel these WeakMaps' own backing storage is
161
+ * itself an arena allocation that `_clear` rewinds between compiles in a warm-
162
+ * instance loop — a post-`_clear` alloc can overwrite the WeakMap's internal
163
+ * bytes, so we also swap in fresh WeakMap instances (cheap: O(1), no traversal). */
164
+ export function resetProgramFactsCache() {
165
+ _programFactsGen++
166
+ _programFactsCache = new WeakMap()
167
+ _moduleInitSlotCache = new WeakMap()
168
+ _bodyIntCertainCache = new WeakMap()
169
+ }
76
170
 
77
171
  /** Drop cached walks for specific AST roots (in-place module rewrites). */
78
172
  export function invalidateProgramFactsCache(...roots) {
@@ -89,7 +183,8 @@ function emptyWalkFacts() {
89
183
  dynVars: new Set(), dynWriteVars: new Set(), anyDyn: false, hasSchemaLiterals: false,
90
184
  maxDef: 0, maxCall: 0, hasRest: false, hasSpread: false,
91
185
  propMap: new Map(), valueUsed: new Set(), callSites: [],
92
- writtenProps: new Set(),
186
+ writtenProps: new Set(), literalWriteKeys: new Map(),
187
+ arrResized: new Set(), nameEscapes: new Set(),
93
188
  }
94
189
  }
95
190
 
@@ -103,6 +198,12 @@ function mergeWalkFacts(into, from) {
103
198
  if (from.hasRest) into.hasRest = true
104
199
  if (from.hasSpread) into.hasSpread = true
105
200
  for (const p of from.writtenProps) into.writtenProps.add(p)
201
+ for (const v of from.arrResized) into.arrResized.add(v)
202
+ for (const v of from.nameEscapes) into.nameEscapes.add(v)
203
+ for (const [obj, keys] of from.literalWriteKeys) {
204
+ if (!into.literalWriteKeys.has(obj)) into.literalWriteKeys.set(obj, new Set())
205
+ for (const k of keys) into.literalWriteKeys.get(obj).add(k)
206
+ }
106
207
  for (const [obj, props] of from.propMap) {
107
208
  if (!into.propMap.has(obj)) into.propMap.set(obj, new Set())
108
209
  for (const p of props) into.propMap.get(obj).add(p)
@@ -179,7 +280,12 @@ function walkFactsRoot(root, full, callerFunc, doSchema, cache = true) {
179
280
  const isFuncLit = Array.isArray(decl[2]) && decl[2][0] === '=>'
180
281
  if (isFuncLit || caller?.name !== name) acc.valueUsed.add(name)
181
282
  }
182
- walkFacts(decl[2], true, inArrow, caller)
283
+ // A bare func-ref RHS (`let c = taylor` — the fn-attached-memo idiom)
284
+ // is a VALUE use: resolveClosureWidth must size the uniform ABI to the
285
+ // referenced function's full arity, or its boundary trampoline forwards
286
+ // $__a{k} slots it never declared. Mirrors the '=' handler below.
287
+ if (isFuncRef(decl[2], ctx.func.names)) acc.valueUsed.add(decl[2])
288
+ else walkFacts(decl[2], true, inArrow, caller)
183
289
  } else walkFacts(decl, true, inArrow, caller)
184
290
  }
185
291
  return
@@ -216,6 +322,30 @@ export function collectProgramFacts(ast) {
216
322
  if (func.body && !func.raw) mergeWalkFacts(f, walkFactsRoot(func.body, true, func, doSchema, true))
217
323
  }
218
324
  const { propMap, valueUsed, callSites } = f
325
+ // Bundled sub-module inits live OUTSIDE `ast` (ctx.module.moduleInits — the
326
+ // main walk never sees them) and prepare's recordModuleInitFacts collects a
327
+ // REDUCED set with no call sites. But init code is a first-class CALLER:
328
+ // const tables of arrows (watr's FOLD/FOLD2) call helpers with args visible
329
+ // only here, and without these sites the param lattice settles callees
330
+ // blind — _i64Arith.r never proved BIGINT, _i64Hex16's radix-toString
331
+ // misformatted raw i64 bits (the speed-tier lab throw), and
332
+ // filterLiveCallSites culled both as dead code. Collect ONLY '()' sites
333
+ // (callerFunc = null — module scope; narrow's callerCtx already carries a
334
+ // null entry and filterLiveCallSites keeps null-caller sites and marks
335
+ // their callees live). Everything else stays on the reduced initFacts
336
+ // path: a full walkFactsRoot here would re-register schemas and promote
337
+ // init-stored func REFS into valueUsed — a program-wide dispatch behavior
338
+ // change this census repair must not smuggle in.
339
+ const initCallSites = (node) => {
340
+ if (!Array.isArray(node)) return
341
+ if (node[0] === '()' && isFuncRef(node[1], ctx.func.names)) {
342
+ const a = node[2]
343
+ const argList = a == null ? [] : (Array.isArray(a) && a[0] === ',') ? a.slice(1) : [a]
344
+ f.callSites.push({ callee: node[1], argList, callerFunc: null, node })
345
+ }
346
+ for (let i = 1; i < node.length; i++) initCallSites(node[i])
347
+ }
348
+ if (ctx.module.moduleInits) for (const init of ctx.module.moduleInits) initCallSites(init)
219
349
  const initFacts = ctx.module.initFacts
220
350
  if (initFacts) {
221
351
  if (initFacts.anyDyn) {
@@ -223,6 +353,12 @@ export function collectProgramFacts(ast) {
223
353
  for (const v of initFacts.dynVars) f.dynVars.add(v)
224
354
  }
225
355
  if (initFacts.writtenProps) for (const p of initFacts.writtenProps) f.writtenProps.add(p)
356
+ if (initFacts.arrResized) for (const v of initFacts.arrResized) f.arrResized.add(v)
357
+ if (initFacts.nameEscapes) for (const v of initFacts.nameEscapes) f.nameEscapes.add(v)
358
+ if (initFacts.literalWriteKeys) for (const [obj, keys] of initFacts.literalWriteKeys) {
359
+ if (!f.literalWriteKeys.has(obj)) f.literalWriteKeys.set(obj, new Set())
360
+ for (const k of keys) f.literalWriteKeys.get(obj).add(k)
361
+ }
226
362
  if (doArity) {
227
363
  if (initFacts.maxDef > f.maxDef) f.maxDef = initFacts.maxDef
228
364
  if (initFacts.maxCall > f.maxCall) f.maxCall = initFacts.maxCall
@@ -256,6 +392,8 @@ export function collectProgramFacts(ast) {
256
392
  dynVars: f.dynVars, dynWriteVars: f.dynWriteVars, anyDyn: f.anyDyn, propMap, valueUsed, callSites,
257
393
  maxDef: f.maxDef, maxCall: f.maxCall, hasRest: f.hasRest, hasSpread: f.hasSpread,
258
394
  paramReps, hasSchemaLiterals: f.hasSchemaLiterals, writtenProps: f.writtenProps,
395
+ literalWriteKeys: f.literalWriteKeys,
396
+ arrResized: f.arrResized, nameEscapes: f.nameEscapes,
259
397
  }
260
398
  }
261
399
 
@@ -273,9 +411,10 @@ export const refreshProgramFacts = (ast, _prev) => collectProgramFacts(ast)
273
411
  * is `const x = userFn(...)` from `undefined` to `NUMBER`/etc.
274
412
  * observeSlot's first-wins-then-clash rule means later precise observations
275
413
  * upgrade undefined slots without re-poisoning already-monomorphic ones. */
276
- export function observeProgramSlots(ast) {
414
+ export function observeProgramSlots(ast, opts) {
277
415
  if (!ctx.schema?.register) return
278
416
  const slotTypes = ctx.schema.slotTypes
417
+ const slotCtors = ctx.schema.slotTypedCtors
279
418
  const observeSlot = (sid, idx, vt) => {
280
419
  if (!vt) return
281
420
  let arr = slotTypes.get(sid)
@@ -285,6 +424,84 @@ export function observeProgramSlots(ast) {
285
424
  if (arr[idx] === undefined) arr[idx] = vt
286
425
  else if (arr[idx] !== vt) arr[idx] = null
287
426
  }
427
+ // Hard kind-poison (observeSlot's `!vt` arm is a SKIP, not a poison): a write
428
+ // whose kind can't be independently proven forces the slot polymorphic.
429
+ const poisonSlot = (sid, idx) => {
430
+ let arr = slotTypes.get(sid)
431
+ if (!arr) { arr = []; slotTypes.set(sid, arr) }
432
+ while (arr.length <= idx) arr.push(undefined)
433
+ arr[idx] = null
434
+ }
435
+ const poisonCtor = (sid, idx) => {
436
+ let arr = slotCtors.get(sid)
437
+ if (!arr) { arr = []; slotCtors.set(sid, arr) }
438
+ while (arr.length <= idx) arr.push(undefined)
439
+ arr[idx] = null
440
+ }
441
+ // Strict write-kind resolver: valTypeOf EXCEPT (a) `.prop` reads answer null —
442
+ // consulting the live slotTypes state mid-census would make observations
443
+ // order-dependent (a source slot poisoned LATER would leave a stale kind
444
+ // standing), and (b) `+`/`+=` never guess — VT['+']'s NUMBER-for-unknowns
445
+ // optimism is fine for expression typing (the emitter still dispatches at
446
+ // runtime) but would durably misclassify a slot a string flows into.
447
+ // Non-plus arithmetic stays trustworthy through plain valTypeOf: ToNumber
448
+ // semantics make `* - / % << >> & | ^` NUMBER whatever the operands.
449
+ const writeVT = (n) => {
450
+ if (Array.isArray(n)) {
451
+ const op = n[0]
452
+ if (op === '.' || op === '?.') return null
453
+ if (op === '+' || op === '+=') {
454
+ const ta = writeVT(n[1]), tb = writeVT(n[2])
455
+ if (ta === VAL.STRING || tb === VAL.STRING) return VAL.STRING
456
+ if (ta == null || tb == null) return null
457
+ if (ta === VAL.BIGINT || tb === VAL.BIGINT) return VAL.BIGINT
458
+ return VAL.NUMBER
459
+ }
460
+ if (op === '?:') { const a = writeVT(n[2]), b = writeVT(n[3]); return a === b ? a : null }
461
+ if (op === '&&' || op === '||' || op === '??') { const a = writeVT(n[1]), b = writeVT(n[2]); return a === b ? a : null }
462
+ }
463
+ return valTypeOf(n)
464
+ }
465
+ // Poison every hazarded slot's kind AND elem-ctor up front (unresolvable
466
+ // receivers, computed-key writes, extern constructors — see
467
+ // collectSlotWriteHazards). Sticky: observeSlot never upgrades null.
468
+ // Kind-safe sids (JSON shaped/const parsers) OBSERVE their sample kinds
469
+ // instead — clash with a same-sid literal still nulls, exactly right; their
470
+ // elem-ctors poison regardless (JSON never carries typed arrays).
471
+ // opts.fresh (plan's post-narrowing refine): REBUILD from scratch — the late
472
+ // hazard recompute resolves receivers the early pass poisoned wholesale
473
+ // (fftplan's `re[j] = tr` on a then-unnarrowed param poisoned the world).
474
+ // Sound to rebuild: every kind consumer left reads at emit, after this.
475
+ if (opts?.fresh) { slotTypes.clear(); slotCtors.clear() }
476
+ const hazards = collectSlotWriteHazards(ast, opts?.fresh ? { paramReps: opts.paramReps } : undefined)
477
+ applySlotWriteHazards(hazards,
478
+ (sid, idx) => { poisonSlot(sid, idx); poisonCtor(sid, idx) },
479
+ { observe: (sid, idx, vt) => { vt ? observeSlot(sid, idx, vt) : poisonSlot(sid, idx); poisonCtor(sid, idx) } })
480
+ // Elem-ctor sibling of observeSlot — same first-wins-then-clash lattice. A
481
+ // slot whose every observed value is one typed-array kind keeps that kind for
482
+ // `plan.tw[i]`-style reads (consumption additionally gates on the prop never
483
+ // being written program-wide — see schema.slotTypedCtorAt).
484
+ const observeCtor = (sid, idx, ctor) => {
485
+ if (!ctor) return
486
+ let arr = slotCtors.get(sid)
487
+ if (!arr) { arr = []; slotCtors.set(sid, arr) }
488
+ while (arr.length <= idx) arr.push(undefined)
489
+ if (arr[idx] === null) return
490
+ if (arr[idx] === undefined) arr[idx] = ctor
491
+ else if (arr[idx] !== ctor) arr[idx] = null
492
+ }
493
+ let teOverlay = null
494
+ const ctorOfValue = (expr) => {
495
+ if (typeof expr === 'string')
496
+ return teOverlay?.get(expr) ?? ctx.scope.globalTypedElem?.get(expr) ?? null
497
+ const c = typedElemCtor(expr)
498
+ if (c) return c
499
+ if (Array.isArray(expr) && expr[0] === '()' && typeof expr[1] === 'string') {
500
+ const f = ctx.func.map?.get(expr[1])
501
+ if (f?.sig?.ptrKind === VAL.TYPED && f.sig.ptrAux != null) return ctorFromElemAux(f.sig.ptrAux)
502
+ }
503
+ return null
504
+ }
288
505
  const visit = (node) => {
289
506
  if (!Array.isArray(node)) return
290
507
  const op = node[0]
@@ -295,28 +512,51 @@ export function observeProgramSlots(ast) {
295
512
  const sid = ctx.schema.register(parsed.names)
296
513
  for (let i = 0; i < parsed.values.length; i++) {
297
514
  observeSlot(sid, i, valTypeOf(parsed.values[i]))
515
+ observeCtor(sid, i, ctorOfValue(parsed.values[i]))
298
516
  }
299
517
  }
518
+ } else if (PROP_WRITE_OPS.has(op) && Array.isArray(node[1]) &&
519
+ (node[1][0] === '.' || node[1][0] === '?.') && typeof node[1][1] === 'string' && typeof node[1][2] === 'string') {
520
+ // Resolvable `.prop` writes: observe the written kind when it's
521
+ // independently provable (writeVT), hard-poison otherwise — a slot's
522
+ // censused kind must reflect EVERY write, not just literal init values
523
+ // (`o.x = 'oops'` on a NUMBER-observed slot was a live miscompile).
524
+ // Unresolvable receivers are hazard-poisoned; elem-ctor consumers are
525
+ // already fail-closed on writtenProps, so no ctor action here.
526
+ const sid = repOf(node[1][1])?.schemaId ?? ctx.schema.vars.get(node[1][1])
527
+ const idx = sid != null ? (ctx.schema.list[sid]?.indexOf(node[1][2]) ?? -1) : -1
528
+ if (idx >= 0) {
529
+ const vt = writeVT(effectiveWriteValue(op, node[1], node[2]))
530
+ if (vt) observeSlot(sid, idx, vt)
531
+ else poisonSlot(sid, idx)
532
+ }
300
533
  }
301
534
  for (let i = 1; i < node.length; i++) visit(node[i])
302
535
  }
303
536
  const prevOverlay = ctx.func.localValTypesOverlay
304
- if (ast) { ctx.func.localValTypesOverlay = null; visit(ast) }
537
+ if (ast) { ctx.func.localValTypesOverlay = null; teOverlay = null; visit(ast) }
305
538
  for (const func of ctx.func.list) {
306
539
  if (!func.body || func.raw) continue
307
- ctx.func.localValTypesOverlay = analyzeBody(func.body).valTypes
540
+ const facts = analyzeBody(func.body)
541
+ ctx.func.localValTypesOverlay = facts.valTypes
542
+ teOverlay = facts.typedElems
308
543
  visit(func.body)
309
544
  }
545
+ teOverlay = null
310
546
  if (ctx.module.initFacts?.hasSchemaLiterals && ctx.module.moduleInits) {
311
547
  ctx.func.localValTypesOverlay = null
312
548
  for (const mi of ctx.module.moduleInits) {
313
549
  const hit = _moduleInitSlotCache.get(mi)
314
550
  if (hit?.gen === _programFactsGen) {
315
- for (const [sid, idx, vt] of hit.obs) observeSlot(sid, idx, vt)
551
+ for (const [sid, idx, vt, ctor] of hit.obs) { observeSlot(sid, idx, vt); observeCtor(sid, idx, ctor) }
316
552
  continue
317
553
  }
318
554
  const obs = []
319
- const record = (sid, idx, vt) => { if (vt) obs.push([sid, idx, vt]); observeSlot(sid, idx, vt) }
555
+ const record = (sid, idx, vt, ctor) => {
556
+ if (vt || ctor) obs.push([sid, idx, vt, ctor])
557
+ observeSlot(sid, idx, vt)
558
+ observeCtor(sid, idx, ctor)
559
+ }
320
560
  const visitInit = (node) => {
321
561
  if (!Array.isArray(node)) return
322
562
  const op = node[0]
@@ -325,11 +565,13 @@ export function observeProgramSlots(ast) {
325
565
  const parsed = staticObjectProps(node.slice(1))
326
566
  if (parsed) {
327
567
  const sid = ctx.schema.register(parsed.names)
328
- for (let i = 0; i < parsed.values.length; i++) record(sid, i, valTypeOf(parsed.values[i]))
568
+ for (let i = 0; i < parsed.values.length; i++)
569
+ record(sid, i, valTypeOf(parsed.values[i]), ctorOfValue(parsed.values[i]))
329
570
  }
330
571
  }
331
572
  for (let i = 1; i < node.length; i++) visitInit(node[i])
332
573
  }
574
+ teOverlay = null
333
575
  visitInit(mi)
334
576
  if (mi != null && typeof mi === 'object') _moduleInitSlotCache.set(mi, { gen: _programFactsGen, obs })
335
577
  }
@@ -337,6 +579,369 @@ export function observeProgramSlots(ast) {
337
579
  ctx.func.localValTypesOverlay = prevOverlay
338
580
  }
339
581
 
582
+ // ————————————————————————————— slot-write hazards —————————————————————————————
583
+ // The slot censuses (slotIntCertain here, slotTypes/slotTypedCtors in
584
+ // observeProgramSlots) observe `{}` literals and resolvable `obj.prop =`
585
+ // writes — every OTHER way a schema slot's value can change is a HAZARD the
586
+ // censuses must poison, or a consumer bakes a stale fact into codegen
587
+ // (Math.floor elision on a 1.5, raw arithmetic on a string box — live
588
+ // miscompiles, each probed):
589
+ // - `.prop` writes through an UNRESOLVABLE receiver (expression receivers,
590
+ // params no caller agreement pins) → by-prop poison across all schemas.
591
+ // - computed-key writes `o[k] = v` / `delete o[k]` — a resolvable OBJECT
592
+ // receiver poisons its whole sid; HASH/ARRAY/TYPED/MAP/SET/STRING
593
+ // receivers never hit schema slots (dict/element/sidecar homes); an
594
+ // unknown receiver with a provably-NUMERIC key can only hit slots with
595
+ // canonical-integer names; anything else poisons everything.
596
+ // - destructuring assignment into member targets (value shapes unknown).
597
+ // - extern slot writers — the JSON const emitter / shaped parser and
598
+ // spread / Object.assign slot copies (ctx.schema.externSlotSids +
599
+ // Object.assign / spread / JSON.parse discovery here).
600
+ // Fail-closed: under-resolution only loses precision, never soundness.
601
+ const _numericName = (s) => /^(0|[1-9][0-9]*)$/.test(String(s))
602
+
603
+ /** Body-local element-alias sids: single-`=` bindings whose init is a whole
604
+ * element read of an array with a known element schema (local decl facts or a
605
+ * narrowed param's arrayElemSchema — the latter exists only post-narrowing).
606
+ * Shared by the late slot-int census and the hazard scan so both resolve
607
+ * receivers equally (a hazard scan weaker than the census would poison the
608
+ * very slots the census just proved). */
609
+ function collectBodyElemSids(func, paramReps) {
610
+ if (!paramReps || !func?.body || func.raw) return null
611
+ const facts = analyzeBody(func.body)
612
+ const reps = paramReps.get(func.name)
613
+ const paramIdx = new Map((func.sig?.params || []).map((p, k) => [p.name, k]))
614
+ const elemSidOf = (arr) => facts.arrElemSchemas?.get(arr)
615
+ ?? (paramIdx.has(arr) ? reps?.get(paramIdx.get(arr))?.arrayElemSchema : null)
616
+ const sids = new Map(), writes = new Map()
617
+ const scan = (n) => {
618
+ if (!Array.isArray(n)) return
619
+ if (n[0] === '=' && typeof n[1] === 'string') {
620
+ writes.set(n[1], (writes.get(n[1]) || 0) + 1)
621
+ const rhs = n[2]
622
+ if (Array.isArray(rhs) && rhs[0] === '[]' && rhs.length === 3 && typeof rhs[1] === 'string') {
623
+ const sid = elemSidOf(rhs[1])
624
+ if (sid != null) sids.set(n[1], sid)
625
+ }
626
+ }
627
+ for (let i = 1; i < n.length; i++) scan(n[i])
628
+ }
629
+ scan(func.body)
630
+ for (const [name, c] of writes) if (c > 1) sids.delete(name)
631
+ return sids.size ? sids : null
632
+ }
633
+
634
+ /** The value a compound assignment / inc-dec effectively stores — synthesized
635
+ * so census value-analyses (isIntExpr, kind checks) see the real shape:
636
+ * `o.n++` → `['+', o.n, 1]` (self-referential, resolved by the censuses' own
637
+ * optimistic fixpoint), `o.f ||= x` → either arm. */
638
+ export function effectiveWriteValue(op, lhs, rhs) {
639
+ if (op === '=') return rhs
640
+ if (op === '++' || op === '--') return [op === '++' ? '+' : '-', lhs, [null, 1]]
641
+ if (op === '&&=' || op === '||=' || op === '??=') return ['?:', lhs, lhs, rhs]
642
+ return [op.slice(0, -1), lhs, rhs]
643
+ }
644
+
645
+ const KEYED_EXEMPT_VALS = new Set([VAL.ARRAY, VAL.TYPED, VAL.HASH, VAL.MAP, VAL.SET, VAL.STRING])
646
+ let _hazardCache = null
647
+
648
+ /** Program-wide slot-write hazard scan → `{ all, sids, props, numeric,
649
+ * kindSafeSids }`, stashed on `ctx.schema.slotWriteHazards` for the census
650
+ * readers' belt checks. Recomputed per program-facts generation, and again
651
+ * post-narrowing (plan's refine step, opts.paramReps) — narrowed param reps
652
+ * resolve receivers the early pass can't (`re[j] = tr` on a TYPED param is an
653
+ * element write, not a world-poison). `kindSafeSids` maps a sid to its
654
+ * guarded-constructor slot KINDS (the JSON shaped/const parsers — any runtime
655
+ * shape divergence falls back to the generic parser's disjoint runtime sids,
656
+ * so the sample's kinds hold for every object carrying the sid): slotTypes
657
+ * OBSERVES those kinds, slotIntCertain still poisons (a JSON number is any
658
+ * double). */
659
+ export function collectSlotWriteHazards(ast, opts) {
660
+ const late = !!opts?.paramReps
661
+ if (_hazardCache && _hazardCache.gen === _programFactsGen && _hazardCache.late === late)
662
+ return (ctx.schema.slotWriteHazards = _hazardCache.hz)
663
+ const hz = { all: false, sids: new Set(), props: new Set(), numeric: false, kindSafeSids: new Map() }
664
+ let curSids = null, curParamVts = null
665
+ const sidOf = (obj) => {
666
+ if (typeof obj !== 'string' || ctx.schema.poisoned?.has(obj)) return null
667
+ return curSids?.get(obj) ?? repOf(obj)?.schemaId ?? ctx.schema.vars.get(obj) ?? null
668
+ }
669
+ const kindOf = (obj) => typeof obj === 'string'
670
+ ? (curParamVts?.get(obj) ?? repOf(obj)?.val ?? valTypeOf(obj))
671
+ : valTypeOf(obj)
672
+ const propWrite = (obj, prop) => {
673
+ // Resolvable string receivers are the censuses' own precise territory.
674
+ if (sidOf(obj) == null) hz.props.add(prop)
675
+ }
676
+ const keyedWrite = (obj, key) => {
677
+ if (isLiteralStr(key)) return propWrite(obj, key[1])
678
+ const sid = sidOf(obj)
679
+ if (sid != null) { hz.sids.add(sid); return }
680
+ const vt = kindOf(obj)
681
+ if (vt != null && vt !== VAL.OBJECT && KEYED_EXEMPT_VALS.has(vt)) return
682
+ if (valTypeOf(key) === VAL.NUMBER || (typeof key === 'string' && repOf(key)?.intCertain === true)) hz.numeric = true
683
+ else hz.all = true
684
+ }
685
+ // Member targets buried in a destructuring pattern — written with values the
686
+ // censuses can't see; hazard them like opaque writes.
687
+ const patternTargets = (pat) => {
688
+ if (!Array.isArray(pat)) return
689
+ const op = pat[0]
690
+ if (op === '.' || op === '?.') {
691
+ if (typeof pat[2] === 'string') {
692
+ const sid = sidOf(pat[1])
693
+ if (sid != null) hz.sids.add(sid)
694
+ else hz.props.add(pat[2])
695
+ }
696
+ return
697
+ }
698
+ if (op === '[]') return keyedWrite(pat[1], pat[2])
699
+ for (let i = 1; i < pat.length; i++) patternTargets(pat[i])
700
+ }
701
+ const visit = (node) => {
702
+ if (!Array.isArray(node)) return
703
+ const op = node[0]
704
+ if (PROP_WRITE_OPS.has(op) && Array.isArray(node[1])) {
705
+ const lhs = node[1]
706
+ if ((lhs[0] === '.' || lhs[0] === '?.') && typeof lhs[2] === 'string') propWrite(lhs[1], lhs[2])
707
+ else if (lhs[0] === '[]') keyedWrite(lhs[1], lhs[2])
708
+ else if (op === '=' && (lhs[0] === '{}' || lhs[0] === '[')) patternTargets(lhs)
709
+ } else if (op === 'delete') {
710
+ // prepare only lets computed-key deletes through (['delete', obj, key]);
711
+ // __dyn_del's schema arm writes UNDEF into a matching slot.
712
+ keyedWrite(node[1], node[2])
713
+ } else if (op === '{}') {
714
+ // Spread literal: the emitter slot-copies source schemas into the merged
715
+ // sid — writes outside the census's view. Resolve the merged name-set the
716
+ // same way (explicit `: names` + spread source schemas); an unresolvable
717
+ // source builds a HASH / __obj_clone result instead (no censused sid).
718
+ // The `{}` emitter's own extern belt covers any resolution divergence.
719
+ const entries = node.length === 2 && Array.isArray(node[1]) && node[1][0] === ','
720
+ ? node[1].slice(1) : node.slice(1)
721
+ if (entries.some(p => Array.isArray(p) && p[0] === '...')) {
722
+ const names = []
723
+ let known = true
724
+ for (const p of entries) {
725
+ if (!Array.isArray(p)) continue
726
+ if (p[0] === '...') {
727
+ const sid = sidOf(p[1])
728
+ const src = sid != null ? ctx.schema.list[sid] : null
729
+ if (src) { for (const n of src) if (!names.includes(n)) names.push(n) }
730
+ else known = false
731
+ } else if (p[0] === ':' && (typeof p[1] === 'string' || typeof p[1] === 'number')) {
732
+ if (!names.includes(String(p[1]))) names.push(String(p[1]))
733
+ }
734
+ }
735
+ if (known && names.length) hz.sids.add(ctx.schema.register(names))
736
+ }
737
+ } else if (op === '()' && node[1] === 'Object.assign') {
738
+ const target = node[2]
739
+ const sid = sidOf(target)
740
+ if (sid != null) hz.sids.add(sid)
741
+ else {
742
+ const vt = kindOf(target)
743
+ if (vt == null || vt === VAL.OBJECT) hz.all = true
744
+ }
745
+ } else if (op === '()' && (node[1] === 'JSON.parse' ||
746
+ (Array.isArray(node[1]) && node[1][0] === '.' && node[1][1] === 'JSON' && node[1][2] === 'parse'))) {
747
+ // Plan-time mirror of the JSON.parse dispatch (module/json.js hook): every
748
+ // key-set the const emitter / shaped parser will register gets its sid
749
+ // KIND-SAFE-marked here with the sample's slot kinds, before any census
750
+ // consumer reads it (a null kind entry poisons that slot's kind too).
751
+ const keysets = ctx.schema.jsonParseKeysets?.(node[2])
752
+ if (keysets) for (const { keys, kinds } of keysets)
753
+ hz.kindSafeSids.set(ctx.schema.register(keys), kinds)
754
+ }
755
+ for (let i = 1; i < node.length; i++) visit(node[i])
756
+ }
757
+ // Per-body valTypes overlays (mirrors observeProgramSlots): receiver/key
758
+ // resolution must see local kinds — `ps[i] = {…}` with ps a local ARRAY and
759
+ // i an int counter is an ELEMENT write, not a slot hazard; without the
760
+ // overlay both fall to unknown and the scan poisons the world.
761
+ const prevOverlay = ctx.func.localValTypesOverlay
762
+ if (ast) { ctx.func.localValTypesOverlay = null; curSids = null; visit(ast) }
763
+ for (const func of ctx.func.list) {
764
+ if (!func.body || func.raw) continue
765
+ ctx.func.localValTypesOverlay = analyzeBody(func.body).valTypes
766
+ curSids = late ? collectBodyElemSids(func, opts.paramReps) : null
767
+ // Late mode: narrowed param reps type this body's params (the early pass
768
+ // can't — `re[j] = tr` on a TYPED param must classify as an element write).
769
+ if (late) {
770
+ const reps = opts.paramReps.get(func.name)
771
+ curParamVts = reps
772
+ ? new Map((func.sig?.params || []).map((p, k) => [p.name, reps.get(k)?.val]).filter(([, v]) => v != null))
773
+ : null
774
+ }
775
+ visit(func.body)
776
+ curSids = curParamVts = null
777
+ }
778
+ ctx.func.localValTypesOverlay = null
779
+ if (ctx.module.moduleInits) for (const mi of ctx.module.moduleInits) visit(mi)
780
+ ctx.func.localValTypesOverlay = prevOverlay
781
+ _hazardCache = { gen: _programFactsGen, late, hz }
782
+ return (ctx.schema.slotWriteHazards = hz)
783
+ }
784
+
785
+ /** Apply hazards (+ the extern-sid belt set) to a census map: `poison(sid, idx)`
786
+ * for every hazarded slot. Idempotent; each census calls it at (re)build entry.
787
+ * `opts.kindSafe` (slotTypes only): kind-safe sids' sample kinds are OBSERVED
788
+ * via the callback instead of poisoned — `observe(sid, idx, vtOrNull)`; the
789
+ * int census omits it, so kind-safe sids fully poison there (JSON numbers are
790
+ * arbitrary doubles). */
791
+ export function applySlotWriteHazards(hz, poison, opts) {
792
+ const list = ctx.schema.list || []
793
+ const externs = ctx.schema.externSlotSids
794
+ for (let sid = 0; sid < list.length; sid++) {
795
+ const names = list[sid]
796
+ if (!names) continue
797
+ const kindSafe = opts?.observe ? hz.kindSafeSids?.get(sid) : undefined
798
+ const whole = hz.all || hz.sids.has(sid) || externs?.has(sid) ||
799
+ (hz.kindSafeSids?.has(sid) && kindSafe == null)
800
+ for (let i = 0; i < names.length; i++) {
801
+ if (whole || hz.props.has(String(names[i])) || (hz.numeric && _numericName(names[i]))) { poison(sid, i); continue }
802
+ if (kindSafe) opts.observe(sid, i, kindSafe[i] ?? null)
803
+ }
804
+ }
805
+ }
806
+
807
+ // ————————————————————————— param neverGrown (cross-function) —————————————————————————
808
+ // scanNeverGrown proves never-relocation for fresh-literal LOCALS only; a
809
+ // read-only array PARAM (the word-frequency kernel's `words`) re-resolves its
810
+ // base through `__ptr_offset` on every element read because the param-holding
811
+ // function can't see its callers. The cross-function proof: during any
812
+ // activation of f, the array a param holds can only relocate if some code
813
+ // RUNNING WITHIN that activation grows an array it can reach — so a param is
814
+ // never-grown iff (a) the body only ever purely READS it (safeReads: index /
815
+ // .length, no aliasing, no passing on), and (b) f's body and every transitive
816
+ // callee are ARRAY-GROWTH-FREE: no resize-method call / .length write /
817
+ // non-literal-key indexed write on a possibly-ARRAY receiver, and no call
818
+ // whose callee we can't resolve (computed callees, closure params, unknown
819
+ // methods — any of which could reach user code that grows an alias).
820
+ // Name-keyed caller facts (arrResized/nameEscapes) can't express this — the
821
+ // builder's `words.push` (its own local) would collide with the kernel's
822
+ // read-only param of the same name; the activation-scoped argument doesn't.
823
+ // MEMORY-SAFETY CRITICAL (same class as scanNeverGrown): default-deny —
824
+ // nested arrows are walked as part of the enclosing body (builtin-invoked
825
+ // callbacks run within the activation), unknown callees poison.
826
+ const _NG_SAFE_CALLEES = new Set([
827
+ 'JSON.parse', 'JSON.stringify', 'String.fromCharCode', 'performance.now',
828
+ 'Object.keys', 'Object.values', 'Object.entries', 'Number.isInteger',
829
+ 'Number.isFinite', 'Number.isNaN', 'parseInt', 'parseFloat', 'isNaN', 'isFinite',
830
+ ])
831
+ // Builtin methods that never RELOCATE their receiver nor call user code
832
+ // (beyond function-valued args, which are handled separately): pure reads,
833
+ // fresh-allocating transforms, and the in-place non-relocating mutators.
834
+ const _NG_SAFE_METHODS = new Set([
835
+ 'length', 'charCodeAt', 'charAt', 'codePointAt', 'indexOf', 'lastIndexOf',
836
+ 'includes', 'slice', 'substring', 'concat', 'join', 'split', 'toString',
837
+ 'toLowerCase', 'toUpperCase', 'trim', 'startsWith', 'endsWith', 'repeat',
838
+ 'padStart', 'padEnd', 'get', 'set', 'has', 'add', 'delete', 'keys', 'values',
839
+ 'entries', 'sort', 'reverse', 'fill', 'copyWithin', 'subarray', 'at',
840
+ 'map', 'filter', 'forEach', 'reduce', 'reduceRight', 'some', 'every',
841
+ 'find', 'findIndex', 'flat', 'flatMap', 'now',
842
+ ])
843
+ /** Compute per-function array-growth-freedom (poison fixpoint over the direct
844
+ * call graph) and stamp `paramReps[f][k].neverGrown` for safe-read params.
845
+ * Consumed at emit via localReps (module/array.js's raw-base fast path). */
846
+ export function analyzeParamNeverGrown(paramReps) {
847
+ if (!ctx.func?.list?.length) return
848
+ const poisoned = new Set(), edges = new Map()
849
+ const prevOverlay = ctx.func.localValTypesOverlay
850
+ for (const func of ctx.func.list) {
851
+ if (!func.body || func.raw) continue
852
+ const facts = analyzeBody(func.body)
853
+ ctx.func.localValTypesOverlay = facts.valTypes
854
+ // Receiver kinds the body facts miss: narrowed param kinds (post-
855
+ // narrowSignatures paramReps) and `{}`-literal decl locals — the
856
+ // dictionary idiom's `const counts = {}` carries no valTypes entry, but
857
+ // ANY object-literal binding is a non-ARRAY receiver.
858
+ const reps = paramReps?.get(func.name)
859
+ const paramIdx = new Map((func.sig?.params || []).map((p, k) => [p.name, k]))
860
+ const objLocals = new Set()
861
+ const collectObjDecls = (n) => {
862
+ if (!Array.isArray(n)) return
863
+ if (n[0] === 'let' || n[0] === 'const' || n[0] === 'var') {
864
+ for (let i = 1; i < n.length; i++) {
865
+ const d = n[i]
866
+ if (Array.isArray(d) && d[0] === '=' && typeof d[1] === 'string' &&
867
+ Array.isArray(d[2]) && d[2][0] === '{}') objLocals.add(d[1])
868
+ }
869
+ }
870
+ for (let i = 1; i < n.length; i++) collectObjDecls(n[i])
871
+ }
872
+ collectObjDecls(func.body)
873
+ const out = new Set()
874
+ let dirty = false
875
+ const kindOf = (x) => typeof x === 'string'
876
+ ? (objLocals.has(x) ? VAL.OBJECT
877
+ : paramIdx.has(x) ? (reps?.get(paramIdx.get(x))?.val ?? null)
878
+ : repOf(x)?.val ?? valTypeOf(x))
879
+ : valTypeOf(x)
880
+ // Only ARRAY receivers relocate on growth; an unknown kind could be one.
881
+ // (OBJECT/HASH keyed writes land in slots / dict tables — arena-bump
882
+ // allocation never moves an existing array.)
883
+ const maybeArray = (x) => { const v = kindOf(x); return v == null || v === VAL.ARRAY }
884
+ const scan = (n) => {
885
+ if (dirty || !Array.isArray(n)) return
886
+ const op = n[0]
887
+ if (op === '()') {
888
+ const c = n[1]
889
+ // function-valued ARGUMENT to any call: a builtin may invoke it with
890
+ // receiver state we can't see; a bare func ref gives no edge to walk.
891
+ // (Arrow LITERAL args are fine — their bodies are scanned right here.)
892
+ const argRoot = n[2]
893
+ const args = Array.isArray(argRoot) && argRoot[0] === ',' ? argRoot.slice(1) : argRoot === undefined ? [] : [argRoot]
894
+ for (const a of args) if (typeof a === 'string' && ctx.func.map?.has(a)) { dirty = true; return }
895
+ if (typeof c === 'string') {
896
+ if (ctx.func.map?.has(c)) out.add(c)
897
+ else if (!(c.startsWith('math.') || c.startsWith('new.') || _NG_SAFE_CALLEES.has(c))) { dirty = true; return }
898
+ } else if (Array.isArray(c) && (c[0] === '.' || c[0] === '?.') && typeof c[2] === 'string') {
899
+ // A method name WRITTEN anywhere program-wide could be a user
900
+ // closure shadowing the builtin (the sidecar method fork) — its
901
+ // body is invisible here, so it poisons like any unknown call.
902
+ if (ctx.types.writtenProps?.has(c[2])) { dirty = true; return }
903
+ if (ARR_RESIZE_METHODS.has(c[2])) {
904
+ if (maybeArray(c[1])) { dirty = true; return }
905
+ } else if (!_NG_SAFE_METHODS.has(c[2])) { dirty = true; return }
906
+ } else { dirty = true; return } // computed callee — could be any user closure
907
+ } else if (PROP_WRITE_OPS.has(op) && Array.isArray(n[1])) {
908
+ const lhs = n[1]
909
+ if ((lhs[0] === '.' || lhs[0] === '?.') && lhs[2] === 'length') { dirty = true; return }
910
+ if (lhs[0] === '[]' && !isLiteralStr(lhs[2]) && maybeArray(lhs[1])) { dirty = true; return }
911
+ }
912
+ for (let i = 1; i < n.length; i++) scan(n[i])
913
+ }
914
+ scan(func.body)
915
+ ctx.func.localValTypesOverlay = null
916
+ if (dirty) poisoned.add(func.name)
917
+ else edges.set(func.name, out)
918
+ }
919
+ ctx.func.localValTypesOverlay = prevOverlay
920
+ // Poison propagation: a caller of a poisoned/unknown callee is poisoned.
921
+ let changed = true
922
+ while (changed) {
923
+ changed = false
924
+ for (const [name, out] of edges) {
925
+ if (poisoned.has(name)) continue
926
+ for (const callee of out)
927
+ if (poisoned.has(callee) || !edges.has(callee)) { poisoned.add(name); changed = true; break }
928
+ }
929
+ }
930
+ for (const func of ctx.func.list) {
931
+ if (!func.body || func.raw || poisoned.has(func.name) || !edges.has(func.name)) continue
932
+ const params = func.sig?.params || []
933
+ for (let k = 0; k < params.length; k++) {
934
+ if (func.rest && k === params.length - 1) continue
935
+ if (!safeReads(func.body, params[k].name)) continue
936
+ let reps = paramReps.get(func.name)
937
+ if (!reps) paramReps.set(func.name, reps = new Map())
938
+ const r = reps.get(k)
939
+ if (r) r.neverGrown = true
940
+ else reps.set(k, { neverGrown: true })
941
+ }
942
+ }
943
+ }
944
+
340
945
  /** Whole-program slot intCertain observation.
341
946
  *
342
947
  * A schema slot `(sid, idx)` is `intCertain` iff every write to it across the
@@ -347,34 +952,89 @@ export function observeProgramSlots(ast) {
347
952
  *
348
953
  * Global poison semantics: any non-int write to a slot — in any body —
349
954
  * permanently flips it false. Slots never observed stay `undefined`.
955
+ * Writes the census can't SEE (unresolvable receivers, computed keys, extern
956
+ * constructors) are poisoned via collectSlotWriteHazards at every (re)build.
350
957
  *
351
958
  * Cross-function flow (slot written from a call's return value) is **not**
352
959
  * tracked — those writes count as non-int and poison the slot. Conservative:
353
960
  * produces only false negatives, never false positives. */
354
- export function analyzeSchemaSlotIntCertain(ast) {
961
+ /** @param opts.paramReps LATE-mode (plan's post-narrowSignatures block): re-derive
962
+ * the census FRESH with BODY-LOCAL receiver resolution — `const p = ps[i]`
963
+ * binds p's sid from the array's element schema (analyzeBody.arrElemSchemas
964
+ * for locals, paramReps.arrayElemSchema for params), which only exists after
965
+ * narrowing. Sound to REBUILD (not merely widen): every census consumer
966
+ * (toNumF64 / floor elision / intIndexIR) reads at EMIT time, after this. */
967
+ export function analyzeSchemaSlotIntCertain(ast, opts) {
355
968
  if (!ctx.schema?.register) return
356
- const slotIntCertain = ctx.schema.slotIntCertain
969
+ // Working state is the LEVEL map (0 | 1 integral | 2 strict-int32 — see
970
+ // type.js's lattice); the boolean projections slotIntCertain (≥1) and
971
+ // slotI32Certain (≥2) are published for consumers after the rounds settle.
972
+ const slotIntLevels = ctx.schema.slotIntLevels
973
+ if (opts?.paramReps) slotIntLevels.clear()
974
+ const hazards = collectSlotWriteHazards(ast, opts)
975
+ let flipped = false
357
976
  const poisonSlot = (sid, idx) => {
358
- let arr = slotIntCertain.get(sid)
359
- if (!arr) { arr = []; slotIntCertain.set(sid, arr) }
977
+ let arr = slotIntLevels.get(sid)
978
+ if (!arr) { arr = []; slotIntLevels.set(sid, arr) }
360
979
  while (arr.length <= idx) arr.push(undefined)
361
- arr[idx] = false
980
+ if (arr[idx] !== 0) flipped = true
981
+ arr[idx] = 0
362
982
  }
363
- const observeSlot = (sid, idx, isInt) => {
364
- let arr = slotIntCertain.get(sid)
365
- if (!arr) { arr = []; slotIntCertain.set(sid, arr) }
983
+ const observeSlot = (sid, idx, level) => {
984
+ let arr = slotIntLevels.get(sid)
985
+ if (!arr) { arr = []; slotIntLevels.set(sid, arr) }
366
986
  while (arr.length <= idx) arr.push(undefined)
367
- if (arr[idx] === false) return
368
- if (!isInt) { arr[idx] = false; return }
369
- if (arr[idx] === undefined) arr[idx] = true
987
+ const cur = arr[idx]
988
+ if (cur === 0) return
989
+ const next = cur === undefined ? level : Math.min(cur, level)
990
+ if (next !== cur) {
991
+ arr[idx] = next
992
+ // Any drop below the optimistic top contradicts reads already resolved
993
+ // through it this round — re-derive (mirrors the old true→false flip).
994
+ if (next < (cur ?? 2)) flipped = true
995
+ }
370
996
  }
371
997
 
372
- const bodyIntCertainOf = (body) => {
998
+ // OPTIMISTIC slot-read resolver the self-referential immutable-update
999
+ // idiom (`ps[i] = { x: hitX ? p.x : nx, … }`) rebuilds a slot FROM a read
1000
+ // of the same slot, so a single pessimistic pass poisons every such field.
1001
+ // Greatest fixpoint instead: a censused slot read counts int until some
1002
+ // write proves otherwise; each round re-derives every observation and any
1003
+ // true→false flip triggers another round (monotone-down, so it terminates
1004
+ // in ≤ slots+1 rounds and re-runs can only widen poisoning, never unpoison
1005
+ // — the documented re-entrancy contract holds). Same precise-path receiver
1006
+ // resolution as the write side; a censused FALSE answers definitively.
1007
+ // Receiver → sid. `curSids` is the CURRENT body's local element-alias map
1008
+ // (late mode only): `const p = ps[i]` resolves p through ps's element
1009
+ // schema. Precise-path rep/vars resolution is the fallback either way.
1010
+ let curSids = null
1011
+ const sidOfName = (obj) => {
1012
+ if (ctx.schema.poisoned?.has(obj)) return undefined
1013
+ return curSids?.get(obj) ?? repOf(obj)?.schemaId ?? ctx.schema.vars.get(obj)
1014
+ }
1015
+ const slotLevelOf = (obj, prop) => {
1016
+ const sid = sidOfName(obj)
1017
+ if (sid == null) return null
1018
+ const idx = ctx.schema.list[sid]?.indexOf(prop)
1019
+ if (idx == null || idx < 0) return null
1020
+ return slotIntLevels.get(sid)?.[idx] ?? 2 // unobserved = optimistic top
1021
+ }
1022
+
1023
+ // LATE mode: body-local element-alias sids (collectBodyElemSids — shared
1024
+ // with the hazard scan so receiver resolution stays in lockstep).
1025
+ const paramReps = opts?.paramReps
1026
+ const bodySidsOf = (func) => collectBodyElemSids(func, paramReps)
1027
+
1028
+ // Round 1 may reuse gen-cached checkers (they close over the LIVE census, so
1029
+ // later poisoning flows through); after any flip the LOCAL binding fixpoints
1030
+ // baked into those checkers may be stale-optimistic, so rebuild fresh.
1031
+ const bodyIntCertainOf = (body, fresh) => {
1032
+ if (fresh) return intLevelChecker(body, slotLevelOf)
373
1033
  if (body != null && typeof body === 'object') {
374
1034
  const hit = _bodyIntCertainCache.get(body)
375
1035
  if (hit?.gen === _programFactsGen) return hit.isInt
376
1036
  }
377
- const isInt = intExprChecker(body)
1037
+ const isInt = intLevelChecker(body, slotLevelOf)
378
1038
  if (body != null && typeof body === 'object')
379
1039
  _bodyIntCertainCache.set(body, { gen: _programFactsGen, isInt })
380
1040
  return isInt
@@ -393,31 +1053,65 @@ export function analyzeSchemaSlotIntCertain(ast) {
393
1053
  const sid = ctx.schema.register(parsed.names)
394
1054
  for (let i = 0; i < parsed.values.length; i++) observeSlot(sid, i, isInt(parsed.values[i]))
395
1055
  }
396
- } else if (op === '=' && Array.isArray(node[1]) && node[1][0] === '.') {
1056
+ } else if (PROP_WRITE_OPS.has(op) && Array.isArray(node[1]) && node[1][0] === '.') {
397
1057
  const [, obj, prop] = node[1]
398
1058
  if (typeof obj === 'string') {
399
1059
  // Same precise-path resolution as ctx.schema.slotVT — no structural
400
1060
  // fallback (slot index could differ across schemas with the same prop).
401
1061
  // Poisoned names carry no schema (shape-disagreeing assignments).
402
- const sid = ctx.schema.poisoned?.has(obj) ? undefined
403
- : repOf(obj)?.schemaId ?? ctx.schema.vars.get(obj)
1062
+ // Late mode adds the current body's element-alias sids (sidOfName).
1063
+ // Compound assigns / inc-dec observe their EFFECTIVE value (`o.n++` →
1064
+ // `o.n + 1` — self-referential, the optimistic fixpoint resolves it).
1065
+ const sid = sidOfName(obj)
404
1066
  if (sid != null) {
405
1067
  const idx = ctx.schema.list[sid]?.indexOf(prop)
406
- if (idx >= 0) observeSlot(sid, idx, isInt(node[2]))
1068
+ if (idx >= 0) observeSlot(sid, idx, isInt(effectiveWriteValue(op, node[1], node[2])))
407
1069
  else if (idx < 0) {/* off-schema write — irrelevant to existing slots */}
408
1070
  }
1071
+ // Unresolvable receivers are hazard-poisoned (collectSlotWriteHazards).
409
1072
  }
410
1073
  }
411
1074
  for (let i = 1; i < node.length; i++) visit(node[i], isInt)
412
1075
  }
413
1076
 
414
- if (ast) visit(ast, bodyIntCertainOf(ast))
415
- for (const func of ctx.func.list) {
416
- if (!func.body || func.raw) continue
417
- visit(func.body, bodyIntCertainOf(func.body))
1077
+ const sweep = (fresh) => {
1078
+ // Hazard poison FIRST: the optimistic slotIntOf resolver must never count a
1079
+ // hazarded slot int mid-fixpoint (it would infect other slots' certainty).
1080
+ applySlotWriteHazards(hazards, poisonSlot)
1081
+ flipped = false
1082
+ curSids = null
1083
+ if (ast) visit(ast, bodyIntCertainOf(ast, fresh))
1084
+ for (const func of ctx.func.list) {
1085
+ if (!func.body || func.raw) continue
1086
+ curSids = bodySidsOf(func)
1087
+ visit(func.body, bodyIntCertainOf(func.body, fresh))
1088
+ curSids = null
1089
+ }
1090
+ if (ctx.module.initFacts?.hasSchemaLiterals && ctx.module.moduleInits) {
1091
+ for (const mi of ctx.module.moduleInits) visit(mi, bodyIntCertainOf(mi, fresh))
1092
+ }
418
1093
  }
419
- if (ctx.module.initFacts?.hasSchemaLiterals && ctx.module.moduleInits) {
420
- for (const mi of ctx.module.moduleInits) visit(mi, bodyIntCertainOf(mi))
1094
+ sweep(!!paramReps)
1095
+ // Any flip invalidates the LOCAL binding fixpoints baked into round-1
1096
+ // checkers (both the rounds below and any same-gen cache reuse later), so
1097
+ // drop the cache and re-derive until the census is stable.
1098
+ let rounds = 0
1099
+ while (flipped && ++rounds <= 64) {
1100
+ _bodyIntCertainCache = new WeakMap()
1101
+ flipped = false
1102
+ sweep(true)
1103
+ }
1104
+ // Cap exhaustion (never expected — each slot descends ≤2 levels): the state
1105
+ // may still carry stale optimism, so fail closed for the whole program.
1106
+ if (flipped) for (const arr of slotIntLevels.values()) arr.fill(0)
1107
+ // Publish the consumer projections: intCertain = integral (≥1) for the
1108
+ // ToNumber-skip / floor-elision family, i32Certain = strict (=2) for raw
1109
+ // i32 slot loads and i32 local typing.
1110
+ const slotIntCertain = ctx.schema.slotIntCertain, slotI32Certain = ctx.schema.slotI32Certain
1111
+ slotIntCertain.clear(); slotI32Certain.clear()
1112
+ for (const [sid, arr] of slotIntLevels) {
1113
+ slotIntCertain.set(sid, arr.map(l => l === undefined ? undefined : l >= 1))
1114
+ slotI32Certain.set(sid, arr.map(l => l === 2))
421
1115
  }
422
1116
  }
423
1117