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/src/abi/string.js CHANGED
@@ -59,9 +59,14 @@ const ssoI64 = (sF64) => ['i64.reinterpret_f64', sF64]
59
59
  import { isReassigned, isLeaf } from '../ast.js'
60
60
  import { LAYOUT, oobNanIR, ssoBitI64Hex } from '../../layout.js'
61
61
 
62
- /** Pre-shifted SSO discriminator — layout.js is cycle-free; memoized at first use. */
63
- let _ssoBitI64 = null
64
- const ssoBitI64 = () => _ssoBitI64 ??= ssoBitI64Hex()
62
+ /** Pre-shifted SSO discriminator — layout.js is cycle-free; the thunk exists for
63
+ * load-order laziness ONLY. Deliberately NOT memoized: a module-level memo of a
64
+ * runtime-BUILT string dangles across the self-host kernel's `_clear()` arena
65
+ * rewind (warm compile #2 interpolated the stale pointer's garbage bytes into
66
+ * `(i64.const …)` → watr "Bad int") — the same dangling-cache class as DOLLAR /
67
+ * stdlibParseCache (see scripts/self.js setupSelf). Recomputing is a few ops at
68
+ * emit time; correctness over a micro-memo. */
69
+ const ssoBitI64 = () => ssoBitI64Hex()
65
70
 
66
71
  /** Allocate a fresh i64 local in the current function. Replicated here (not
67
72
  * imported from `src/ir.js`) to keep this module loadable during ctx.js
@@ -127,8 +132,12 @@ function emitDecompCharRead(dec, iI32, ctx, oobNan, inBounds = false) {
127
132
  * reaching here via type error) gets `len=0` so every bounds check trips and
128
133
  * every `charCodeAt` returns 0 — same shape as the legacy `__char_at`. */
129
134
  export function emitCharDecompPrologue(dec) {
130
- const param = dec.param
131
- const ptr = ['i64.reinterpret_f64', ['local.get', `$${param}`]]
135
+ // Receiver expression: a param's local slot (shape-1 classic) or a stable
136
+ // module global (dec.recvGlobal the parser-state shape: `cur.charCodeAt(idx)`
137
+ // against a global assigned only outside the scanning function). Built fresh
138
+ // (IR nodes must not be structurally shared; also the self-host kernel
139
+ // compiles this file — stick to constructs jz itself supports).
140
+ const ptr = ['i64.reinterpret_f64', dec.recvGlobal ? ['global.get', dec.recvGlobal] : ['local.get', `$${dec.param}`]]
132
141
  const ssoTest = ['i64.ne',
133
142
  ['i64.and', ptr, ['i64.const', ssoBitI64()]],
134
143
  ['i64.const', 0]]
@@ -162,6 +171,24 @@ export function emitCharDecompPrologue(dec) {
162
171
  ]
163
172
  }
164
173
 
174
+ // True iff every call expression in `body` is a `.charCodeAt` member call and
175
+ // no suspension point (yield/await) or `new` appears — the stability proof for
176
+ // shape-1b global decomposition: nothing that runs during this function can
177
+ // reassign a module global. Escaped arrows are safe to ignore beyond their
178
+ // visible call nodes: they only run when called, and every call here is
179
+ // charCodeAt (single-threaded).
180
+ export function bodyOnlyCharCodeAtCalls(body) {
181
+ if (!Array.isArray(body)) return true
182
+ const op = body[0]
183
+ if (op === 'yield' || op === 'await' || op === 'new') return false
184
+ if (op === '()' || op === '?.()') {
185
+ const callee = body[1]
186
+ if (!(Array.isArray(callee) && (callee[0] === '.' || callee[0] === '?.') && callee[2] === 'charCodeAt')) return false
187
+ }
188
+ for (let i = 1; i < body.length; i++) if (!bodyOnlyCharCodeAtCalls(body[i])) return false
189
+ return true
190
+ }
191
+
165
192
  export const sso = {
166
193
  // Wasm slot type a string value occupies under this carrier: the f64
167
194
  // NaN-boxed slot (PTR.STRING tag in the high bits, SSO inline data or
@@ -272,6 +299,45 @@ export const sso = {
272
299
  }
273
300
  }
274
301
 
302
+ // Shape 1b: receiver is a `global.get` of a module global that is STABLE
303
+ // within this function — the layered-parser hot shape (`cur.charCodeAt(idx)`
304
+ // in subscript's space/peek/next loops, where `cur` is module state written
305
+ // only by parse() entry, never by the scanning function). Same entry
306
+ // decomposition as the param path. Soundness gates:
307
+ // - the global is never assigned in this function's body, AND
308
+ // - the body's only call expressions are `.charCodeAt` member calls (so
309
+ // no user call can transitively reassign the global mid-function), AND
310
+ // - no yield/await (a suspension point lets foreign code write it).
311
+ // Gated on charDecompGlobals — only emitFunc's named-function path drains
312
+ // the prologue (closure bodies have no collectParamInits; an undrained
313
+ // decomposition would read len=0 and misreport every char as OOB).
314
+ if (Array.isArray(sF64) && sF64[0] === 'global.get' && ctx.func.charDecompGlobals) {
315
+ const raw = typeof sF64[1] === 'string' ? sF64[1] : ''
316
+ const name = raw.startsWith('$') ? raw.slice(1) : raw
317
+ if (name && ctx.func.body
318
+ && !isReassigned(ctx.func.body, name)
319
+ && bodyOnlyCharCodeAtCalls(ctx.func.body)) {
320
+ if (!ctx.func.charDecomp) ctx.func.charDecomp = new Map()
321
+ const key = `#g:${name}`
322
+ let dec = ctx.func.charDecomp.get(key)
323
+ if (!dec) {
324
+ const base = `${name}$ccbase`
325
+ const len = `${name}$cclen`
326
+ const sso = `${name}$ccsso`
327
+ const loadbase = `${name}$ccldb`
328
+ const ptr64 = `${name}$ccp64`
329
+ ctx.func.locals.set(base, 'i32')
330
+ ctx.func.locals.set(len, 'i32')
331
+ ctx.func.locals.set(sso, 'i32')
332
+ ctx.func.locals.set(loadbase, 'i32')
333
+ ctx.func.locals.set(ptr64, 'i64')
334
+ dec = { base, len, sso, loadbase, ptr64, param: name, recvGlobal: `$${name}`, global: true }
335
+ ctx.func.charDecomp.set(key, dec)
336
+ }
337
+ return emitDecompCharRead(dec, iI32, ctx, oobNan, inBounds)
338
+ }
339
+ }
340
+
275
341
  // Shape 2: generic inline form for non-param receivers.
276
342
  //
277
343
  // Both the receiver `sF64` and the index `iI32` are duplicated across the
@@ -373,15 +439,20 @@ export const sso = {
373
439
  * dispatcher's string/array runtime guess (emit.js) would hijack it into a
374
440
  * bogus array concat. A non-builtin name routes through dynamic property
375
441
  * dispatch (load the closure slot, call it) correctly. */
376
- cat: (aF64, bF64, ctx) => {
377
- ctx.core.includes.add('__str_concat')
378
- return ['call', '$__str_concat', ssoI64(aF64), ssoI64(bF64)]
442
+ // `ext` (default false) opts into the bump-EXTEND fast path — sound only when emit
443
+ // proves `a` is dead-after (a self-accumulation `x = x + …`). Otherwise the _fresh twin
444
+ // alloc+copies, never mutating the live `a` operand. (See __str_concat in module/string.js.)
445
+ cat: (aF64, bF64, ctx, ext = false) => {
446
+ const fn = ext ? '__str_concat' : '__str_concat_fresh'
447
+ ctx.core.includes.add(fn)
448
+ return ['call', '$' + fn, ssoI64(aF64), ssoI64(bF64)]
379
449
  },
380
450
 
381
451
  /** Concat assuming both sides are already strings (skip ToString). */
382
- concatRaw: (aF64, bF64, ctx) => {
383
- ctx.core.includes.add('__str_concat_raw')
384
- return ['call', '$__str_concat_raw', ssoI64(aF64), ssoI64(bF64)]
452
+ concatRaw: (aF64, bF64, ctx, ext = false) => {
453
+ const fn = ext ? '__str_concat_raw' : '__str_concat_raw_fresh'
454
+ ctx.core.includes.add(fn)
455
+ return ['call', '$' + fn, ssoI64(aF64), ssoI64(bF64)]
385
456
  },
386
457
  },
387
458
  }
package/src/ast.js CHANGED
@@ -39,7 +39,7 @@ export const isI32 = (v) => Number.isInteger(v) && v >= I32_MIN && v <= I32_MAX
39
39
 
40
40
  /** Statement operators — distinguish block bodies from object literals. */
41
41
  export const STMT_OPS = new Set([';', 'let', 'const', 'return', 'if', 'for', 'for-in', 'while', 'break', 'continue', 'switch',
42
- '=', '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '>>=', '<<=', '>>>=', '||=', '&&=', '??=',
42
+ '=', '+=', '-=', '*=', '/=', '%=', '**=', '&=', '|=', '^=', '>>=', '<<=', '>>>=', '||=', '&&=', '??=',
43
43
  'throw', 'try', 'catch', 'finally', '++', '--', '()'])
44
44
 
45
45
  /** jzify superset: pre-lowered JS shapes before prepare strips them. */
@@ -74,7 +74,7 @@ export const isLeaf = n => Array.isArray(n) && (n[0] === 'local.get' || n[0] ===
74
74
  // === Assignment / reassignment ===
75
75
 
76
76
  /** Assignment operators — shared across analyze, plan, emit, abi. */
77
- export const ASSIGN_OPS = new Set(['=', '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '>>=', '<<=', '>>>=', '||=', '&&=', '??='])
77
+ export const ASSIGN_OPS = new Set(['=', '+=', '-=', '*=', '/=', '%=', '**=', '&=', '|=', '^=', '>>=', '<<=', '>>>=', '||=', '&&=', '??='])
78
78
 
79
79
  /** Detect whether `name` is written to (=, +=, ++, --, etc.) anywhere within `body`. */
80
80
  export function isReassigned(body, name) {
@@ -397,14 +397,20 @@ export function cloneNode(node) {
397
397
  // identically and compare equal — an unsound merge (SLP packs `[Inf,-Inf]` as one
398
398
  // splat lane; CSE/LICM dedups distinct invariants). Tag each so it round-trips with
399
399
  // Object.is semantics.
400
- export const stableKey = (_k, v) => {
400
+ // Recursive keyer, NOT JSON.stringify with a replacer: the kernel's stringify
401
+ // silently dropped the replacer, so in-kernel nodeEqual collapsed the very
402
+ // constants the tagging exists to distinguish (a latent unsound SLP merge,
403
+ // host≠kernel). A plain walk behaves identically on both sides.
404
+ export function stableNodeKey(v) {
405
+ if (Array.isArray(v)) { let s = '['; for (let i = 0; i < v.length; i++) s += (i ? ',' : '') + stableNodeKey(v[i]); return s + ']' }
401
406
  if (typeof v === 'bigint') return `${v}n`
402
407
  if (typeof v === 'number' && (v !== v || v === Infinity || v === -Infinity || Object.is(v, -0)))
403
408
  return v !== v ? '#NaN' : v === Infinity ? '#Inf' : v === -Infinity ? '#-Inf' : '#-0'
404
- return v
409
+ if (typeof v === 'string') return JSON.stringify(v)
410
+ return String(v) // numbers, booleans, null, undefined — all distinct spellings
405
411
  }
406
412
  export function nodeEqual(a, b) {
407
- return JSON.stringify(a, stableKey) === JSON.stringify(b, stableKey)
413
+ return stableNodeKey(a) === stableNodeKey(b)
408
414
  }
409
415
 
410
416
  /** Property entries of an object-literal AST node (`['{}', …]`). */
package/src/autoload.js CHANGED
@@ -5,7 +5,7 @@ import * as mods from '../module/index.js'
5
5
 
6
6
  const dict = obj => Object.assign(Object.create(null), obj)
7
7
 
8
- export const MOD_ALIAS = { Number: 'number', Array: 'array', Object: 'object', Symbol: 'symbol', JSON: 'json', Date: 'date', BigInt: 'number', Error: 'core', TextEncoder: 'string', TextDecoder: 'string',
8
+ export const MOD_ALIAS = { Number: 'number', Array: 'array', Object: 'object', Symbol: 'symbol', JSON: 'json', Date: 'date', BigInt: 'number', Error: 'core', TextEncoder: 'string', TextDecoder: 'string', Atomics: 'atomics',
9
9
  // SIMD intrinsic namespaces (f32x4/i32x4/f64x2/v128) all live in the `simd` module.
10
10
  f32x4: 'simd', i32x4: 'simd', f64x2: 'simd', v128: 'simd' }
11
11
 
@@ -17,6 +17,7 @@ export const PROP_MODULES = Object.assign(Object.create(null), {
17
17
  findLast: ['core', 'array'], findLastIndex: ['core', 'array'],
18
18
  every: ['core', 'array'], some: ['core', 'array'], flat: ['core', 'array'], flatMap: ['core', 'array'],
19
19
  join: ['core', 'array'], copyWithin: ['core', 'array'], at: ['core', 'string', 'array'],
20
+ toSorted: ['core', 'array'], toReversed: ['core', 'array'], with: ['core', 'array'],
20
21
  charAt: ['core', 'string'], charCodeAt: ['core', 'string'], codePointAt: ['core', 'string'],
21
22
  toUpperCase: ['core', 'string'], toLowerCase: ['core', 'string'], toLocaleLowerCase: ['core', 'string'], trim: ['core', 'string'],
22
23
  trimStart: ['core', 'string'], trimEnd: ['core', 'string'],
@@ -26,6 +27,10 @@ export const PROP_MODULES = Object.assign(Object.create(null), {
26
27
  matchAll: ['core', 'string'], match: ['core', 'string'],
27
28
  substring: ['core', 'string'], substr: ['core', 'string'],
28
29
  add: ['core', 'collection'], clear: ['core', 'collection'],
30
+ union: ['core', 'collection'], intersection: ['core', 'collection'],
31
+ difference: ['core', 'collection'], symmetricDifference: ['core', 'collection'],
32
+ isSubsetOf: ['core', 'collection'], isSupersetOf: ['core', 'collection'],
33
+ isDisjointFrom: ['core', 'collection'],
29
34
  slice: ['core', 'string', 'array'], concat: ['core', 'string', 'array'],
30
35
  indexOf: ['core', 'string', 'array'], lastIndexOf: ['core', 'string', 'array'],
31
36
  includes: ['core', 'string', 'array'],
@@ -48,6 +53,7 @@ export const OP_MODULES = {
48
53
  'delete': ['core', 'collection', 'string'],
49
54
  '//': ['core', 'string', 'regex'],
50
55
  '**': ['math'],
56
+ '**=': ['math'], // desugars to `name = name ** val` at emit — needs the same module
51
57
  }
52
58
 
53
59
  export const TYPED_CTORS = ['Float64Array','Float32Array','Int32Array','Uint32Array','Int16Array','Uint16Array','Int8Array','Uint8Array','BigInt64Array','BigUint64Array','ArrayBuffer','DataView']
@@ -77,6 +83,22 @@ export const CALL_MODULES = dict({
77
83
  'Object.values': ['core', 'object', 'string'],
78
84
  'Object.entries': ['core', 'object', 'string'],
79
85
  'Object.hasOwn': ['core', 'object', 'string', 'collection'],
86
+ 'Object.groupBy': ['core', 'collection', 'object', 'string', 'array', 'fn'],
87
+ 'Map.groupBy': ['core', 'collection', 'array', 'fn'],
88
+ 'RegExp.escape': ['core', 'string', 'regex'],
89
+ structuredClone: ['core', 'collection', 'array'],
90
+ 'Atomics.load': ['core', 'typedarray', 'atomics'],
91
+ 'Atomics.store': ['core', 'typedarray', 'atomics'],
92
+ 'Atomics.add': ['core', 'typedarray', 'atomics'],
93
+ 'Atomics.sub': ['core', 'typedarray', 'atomics'],
94
+ 'Atomics.and': ['core', 'typedarray', 'atomics'],
95
+ 'Atomics.or': ['core', 'typedarray', 'atomics'],
96
+ 'Atomics.xor': ['core', 'typedarray', 'atomics'],
97
+ 'Atomics.exchange': ['core', 'typedarray', 'atomics'],
98
+ 'Atomics.compareExchange': ['core', 'typedarray', 'atomics'],
99
+ 'Atomics.notify': ['core', 'typedarray', 'atomics'],
100
+ 'Atomics.isLockFree': ['core', 'typedarray', 'atomics'],
101
+ 'Atomics.wait': ['core', 'typedarray', 'atomics', 'number', 'string'],
80
102
  'Object.freeze': ['core', 'object'],
81
103
  'Object.assign': ['core', 'object'],
82
104
  'Object.create': ['core', 'object'],
@@ -87,11 +109,15 @@ export const CALL_MODULES = dict({
87
109
  'Date.now': ['core', 'console'],
88
110
  'performance.now': ['core', 'console'],
89
111
  'readStdin': ['core', 'console'],
112
+ 'fs.read': ['core', 'string', 'fs'],
113
+ 'fetch': ['core', 'web'],
114
+ 'fs.write': ['core', 'string', 'fs'],
90
115
  'String.fromCharCode': ['core', 'string'],
91
116
  'String.fromCodePoint': ['core', 'string'],
92
117
  'BigInt.asIntN': ['number'],
93
118
  'BigInt.asUintN': ['number'],
94
119
  ...Object.fromEntries(TYPED_CTORS.filter(n => n.endsWith('Array')).map(n => [`${n}.from`, ['core', 'typedarray', 'array']])),
120
+ 'Array.of': ['core', 'array'],
95
121
  'ArrayBuffer.isView': ['core', 'typedarray'],
96
122
  // instanceof Map / Set / TypedArray predicates (synthesized by jzify).
97
123
  '__is_map': ['core', 'collection'],
@@ -118,6 +144,7 @@ export const TIMER_NAMES = new Set(['setTimeout', 'clearTimeout', 'setInterval',
118
144
 
119
145
  const MOD_DEPS = {
120
146
  number: ['core', 'string'],
147
+ atomics: ['core', 'typedarray'],
121
148
  string: ['core', 'number'],
122
149
  array: ['core'],
123
150
  object: ['core'],
package/src/bridge.js CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { ctx, emitter } from './ctx.js'
12
- import { typed, asF64, asI32, asI64 } from './ir.js'
12
+ import { typed, asF64, asI32, asI64, carrierF64 } from './ir.js'
13
13
 
14
14
  export { emitter } from './ctx.js'
15
15
 
@@ -83,8 +83,13 @@ export const dual = (wrap, core, fast) => {
83
83
 
84
84
  const cast = { I: asI64, F: asF64, i: asI32 }
85
85
 
86
+ // 'I' is the boxed-value slot (receivers, collection keys/values) — a boolean
87
+ // crosses it as its TRUE/FALSE atom so identity survives the container round-trip
88
+ // (typeof / String / strict-eq); 'F'/'i' are numeric positions and stay raw.
86
89
  const coerce = (sig, nodes) =>
87
- sig.split('').map((c, i) => cast[c](emit(nodes[i])))
90
+ sig.split('').map((c, i) => c === 'I'
91
+ ? asI64(carrierF64(nodes[i], emit(nodes[i])))
92
+ : cast[c](emit(nodes[i])))
88
93
 
89
94
  const wrap = (fmt, call) => {
90
95
  if (fmt === 'i64') return typed(['f64.reinterpret_i64', call], 'f64')
@@ -149,15 +149,27 @@ export const USE = {
149
149
  DELETE_MEMBER: 11, // `delete name.member`
150
150
  BARE: 12, // any other value position — the conservative catch-all
151
151
  }
152
- const _bindingUsesCache = new WeakMap()
152
+ let _bindingUsesCache = new WeakMap()
153
+ // Self-host-only: see resetProgramFactsCache (program-facts.js) — swap in a fresh
154
+ // WeakMap so a warm-instance compile-clear-compile loop never reads a dangling
155
+ // arena pointer out of the old backing storage.
156
+ export function resetBindingUsesCache() { _bindingUsesCache = new WeakMap() }
153
157
  const _CMP_OPS = new Set(['==', '!=', '===', '!==', '<', '>', '<=', '>='])
154
158
  const _isNullishLit = (e) =>
155
159
  e === 'null' || e === 'undefined' ||
156
160
  (Array.isArray(e) && e[0] == null && (e[1] === null || e[1] === undefined))
157
161
 
158
- export function scanBindingUses(body) {
159
- const hit = _bindingUsesCache.get(body)
160
- if (hit) return hit
162
+ // `trackNames` (optional): also report uses of these names even though they're
163
+ // never `let`/`const`-declared IN THIS body — the program-wide dyn-fn-table scan
164
+ // (compile/dyn-closure-tables.js) uses this to see a GLOBAL's uses inside every
165
+ // function body, not just its one module-scope declaration site. Bypasses the
166
+ // cache (a different `trackNames` on the same body would otherwise read a stale
167
+ // entry keyed only by `body`) — fine, this path is a one-shot pre-pass, not hot.
168
+ export function scanBindingUses(body, trackNames) {
169
+ if (!trackNames) {
170
+ const hit = _bindingUsesCache.get(body)
171
+ if (hit) return hit
172
+ }
161
173
 
162
174
  const summary = new Map() // name → { decls, initRhs, uses }
163
175
  const slot = (name) => {
@@ -323,8 +335,11 @@ export function scanBindingUses(body) {
323
335
 
324
336
  walk(body, false)
325
337
 
326
- for (const [name, s] of summary) if (s.decls === 0) summary.delete(name)
327
- _bindingUsesCache.set(body, summary)
338
+ for (const [name, s] of summary) if (s.decls === 0 && !trackNames?.has(name)) summary.delete(name)
339
+ // `body` can be null (a module whose every top-level statement got lifted
340
+ // into ctx.func.list, e.g. a single `export const f = () => …` leaves
341
+ // nothing at module scope) — WeakMap keys must be objects.
342
+ if (!trackNames && body != null && typeof body === 'object') _bindingUsesCache.set(body, summary)
328
343
  return summary
329
344
  }
330
345
 
@@ -481,7 +496,7 @@ export function scanSliceViews(body) {
481
496
  * `a[0].push(x)`, never relocates `a` itself, so `a` stays eligible — see safeReads.)
482
497
  */
483
498
  const grownOrEscapes = (op) => ASSIGN_OPS.has(op) || op === '++' || op === '--' || op === 'delete'
484
- function safeReads(node, name) {
499
+ export function safeReads(node, name) {
485
500
  if (typeof node === 'string') return node !== name // bare value use → escape
486
501
  if (!Array.isArray(node)) return true
487
502
  const op = node[0]
@@ -23,7 +23,7 @@ import { ctx, err } from '../ctx.js'
23
23
  import { VAL, repOf, repOfGlobal, updateRep, updateGlobalRep, lookupValType, lookupNotString } from '../reps.js'
24
24
  import { valTypeOf, jsonConstString, shapeOf, shapeOfObjectLiteralAst } from '../kind.js'
25
25
  import { intLiteralValue, nonNegIntLiteral, constIntExpr, NO_VALUE, staticPropertyKey, staticValue, staticObjectProps, staticArrayElems, objLiteralSchemaId, exprSchemaId, inlineArraySid } from '../static.js'
26
- import { typedElemCtor, MIXED_CTORS, isCondExpr, ternaryCtorOfRhs, scanBoundedLoops, inBoundsCharCodeAt, exprType, intCertainMap } from '../type.js'
26
+ import { typedElemCtor, typedStaticLen, MIXED_CTORS, isCondExpr, ternaryCtorOfRhs, scanBoundedLoops, inBoundsCharCodeAt, exprType, intCertainMap } from '../type.js'
27
27
  import { TYPED_ELEM_CODE, TYPED_ELEM_VIEW_FLAG, TYPED_ELEM_BIGINT_FLAG, encodeTypedElemAux, typedElemAux, TYPED_ELEM_NAMES, ctorFromElemAux } from '../../layout.js'
28
28
 
29
29
  // ValueRep field docs + ParamReps lattice helpers — storage lives in src/reps.js.
@@ -81,7 +81,13 @@ import {
81
81
 
82
82
  export { findFreeVars, findMutations, boxedCaptures } from './analyze-scans.js'
83
83
 
84
- const _bodyFactsCache = new WeakMap()
84
+ let _bodyFactsCache = new WeakMap()
85
+ // Self-host-only: see resetProgramFactsCache (program-facts.js) — the WeakMap's own
86
+ // backing storage is an arena allocation that a warm-instance `_clear` rewinds, so
87
+ // a compile-clear-compile loop must swap in a fresh instance, not just rely on
88
+ // per-body identity misses (which natively is enough since AST nodes are fresh
89
+ // each compile and the old WeakMap contents just become GC-unreachable).
90
+ export function resetBodyFactsCache() { _bodyFactsCache = new WeakMap() }
85
91
 
86
92
  // Per-name monotone fact trackers over a pluggable {get,set,delete} store. First
87
93
  // observation wins; a conflicting later one poisons the name (and clears the store
@@ -104,9 +110,9 @@ const makeValTracker = (get, set, del) => {
104
110
  set(name, vt)
105
111
  }
106
112
  }
107
- const makeTypedTracker = (get, set, del) => {
113
+ const makeTypedTracker = (get, set, del, getLen, setLen, delLen) => {
108
114
  const poison = new Set()
109
- const invalidate = (name) => { poison.add(name); del(name) }
115
+ const invalidate = (name) => { poison.add(name); del(name); if (delLen) delLen(name) }
110
116
  // Resolve a variable-name ternary branch to its known typed-array ctor: a
111
117
  // local typed binding (`get`), or a module global promoted typed by plan
112
118
  // (`inferModuleLetTypes` populates `globalTypedElem`, copied into
@@ -125,7 +131,21 @@ const makeTypedTracker = (get, set, del) => {
125
131
  if (typeof c === 'string' && c.endsWith('.view')) ctx.features.typedView = true
126
132
  const prev = get(name)
127
133
  if (prev && prev !== c) invalidate(name)
128
- else set(name, c)
134
+ else {
135
+ set(name, c)
136
+ // Static length rides the ctor's stability (fixed-length arrays): a redef
137
+ // with an unknown or conflicting length drops the entry — typedStaticLen is
138
+ // null for subarray/copy/ternary/computed rhs, so those invalidate for free.
139
+ // Same live-closure style as get/set/del (call-time ctx deref, per the
140
+ // makeValTracker comment above — a captured Map would orphan on the
141
+ // per-function ctx.types reset).
142
+ if (setLen) {
143
+ const len = typedStaticLen(rhs)
144
+ const prevLen = getLen(name)
145
+ if (len == null || (prevLen !== undefined && prevLen !== len)) delLen(name)
146
+ else setLen(name, len)
147
+ }
148
+ }
129
149
  }
130
150
  const ctor = typedElemCtor(rhs)
131
151
  if (ctor) return setOrInvalidate(ctor)
@@ -148,6 +168,15 @@ const makeTypedTracker = (get, set, del) => {
148
168
  }
149
169
  return
150
170
  }
171
+ // Field provenance: `const tw = plan.twRe` where the receiver's schema slot
172
+ // holds one typed-array kind program-wide and the prop is never written
173
+ // (gate inside slotTypedCtorAt) — the binding keeps the concrete kind, so
174
+ // hot-loop reads stay on the typed path (bench: provenance, fftplan).
175
+ if (Array.isArray(rhs) && (rhs[0] === '.' || rhs[0] === '?.') &&
176
+ typeof rhs[1] === 'string' && typeof rhs[2] === 'string' && ctx.schema?.slotTypedCtorAt) {
177
+ const fc = ctx.schema.slotTypedCtorAt(rhs[1], rhs[2])
178
+ if (fc) return setOrInvalidate(fc)
179
+ }
151
180
  // Heterogeneous ternary (`n===16 ? new Uint8Array(16) : new Uint16Array(8)`):
152
181
  // ctors that don't unify must invalidate so a sibling-scope decl can't lock in
153
182
  // the wrong store width.
@@ -194,6 +223,7 @@ export function analyzeBody(body) {
194
223
  // resolve as that typed array so `arr[i][j]` / `let o = arr[i]; o[j]` inline.
195
224
  const arrElemTypedCtors = new Map()
196
225
  const typedElems = new Map()
226
+ const typedLens = new Map()
197
227
  const escapes = new Map() // name → bool: local holds allocation, true if it escapes
198
228
 
199
229
  const doSchemas = !!ctx.schema?.register
@@ -274,7 +304,8 @@ export function analyzeBody(body) {
274
304
 
275
305
  // Local-Map slices: bind the Map's get/set/delete as the tracker's three ops.
276
306
  const trackVal = makeValTracker(n => valTypes.get(n), (n, vt) => valTypes.set(n, vt), n => valTypes.delete(n))
277
- const trackTyped = makeTypedTracker(n => typedElems.get(n), (n, c) => typedElems.set(n, c), n => typedElems.delete(n))
307
+ const trackTyped = makeTypedTracker(n => typedElems.get(n), (n, c) => typedElems.set(n, c), n => typedElems.delete(n),
308
+ n => typedLens.get(n), (n, l) => typedLens.set(n, l), n => typedLens.delete(n))
278
309
 
279
310
  // === Per-decl observation (called for each `let`/`const` `name = rhs`) ===
280
311
  const processDecl = (name, rhs) => {
@@ -630,7 +661,7 @@ export function analyzeBody(body) {
630
661
  // Never-relocated array bindings — reads may skip the realloc-forwarding follow.
631
662
  const neverGrown = doSchemas ? scanNeverGrown(body) : new Set()
632
663
 
633
- const result = { locals, valTypes, arrElemSchemas, arrElemValTypes, arrElemTypedCtors, typedElems, escapes, flatObjects, sliceViews, unsignedLocals, neverGrown, numericFill }
664
+ const result = { locals, valTypes, arrElemSchemas, arrElemValTypes, arrElemTypedCtors, typedElems, typedLens, escapes, flatObjects, sliceViews, unsignedLocals, neverGrown, numericFill }
634
665
  _bodyFactsCache.set(body, result)
635
666
  return result
636
667
  }
@@ -659,7 +690,21 @@ export function analyzeBody(body) {
659
690
  */
660
691
  function widenLocalTypes(body, locals) {
661
692
  const i32SafeIdx = collectI32SafeIndexVars(body, locals)
662
- const intCounters = intCertainMap(body)
693
+ // Names this scope's own locals map might be reassigned FROM INSIDE A NESTED
694
+ // ARROW — a captured, mutated variable. analyzeBody runs before boxedCaptures
695
+ // populates ctx.func.boxed, so recompute the same "some arrow writes this
696
+ // name" fact locally via findMutations (which already doesn't skip `=>`).
697
+ // Threaded into intCertainMap and both widening walks below: none of them
698
+ // used to look past a `=>` boundary, so `let env = 0; let set = () => { env
699
+ // = 1.5 }` never saw the closure-body float write — `env` stayed provably-int
700
+ // (keepI32 exempted it from Pass A, Pass B never re-checked its only visible
701
+ // def, the never-a-def-of-1.5 one), and the ENCLOSING FUNCTION's own result
702
+ // then narrowed to i32 (narrowI32Results trusts this same `locals` map),
703
+ // silently truncating the return. Gated on nestedNames.size so the common
704
+ // case (no nested reassignment anywhere) keeps the original, cheaper walk.
705
+ const nestedNames = new Set()
706
+ findMutations(body, new Set(locals.keys()), nestedNames)
707
+ const intCounters = intCertainMap(body, nestedNames)
663
708
  const f64IdxVars = collectF64StridedIndexVars(body, locals) // counters that trunc anyway — don't keep i32
664
709
  const keepI32 = (name) => i32SafeIdx.has(name) || (intCounters.get(name) === true && !f64IdxVars.has(name))
665
710
  const CMP_OPS = new Set(['<', '>', '<=', '>=', '==', '!='])
@@ -672,7 +717,8 @@ function widenLocalTypes(body, locals) {
672
717
  if (ta === 'i32' && tb === 'f64' && typeof a === 'string' && locals.has(a) && !keepI32(a)) locals.set(a, 'f64')
673
718
  if (tb === 'i32' && ta === 'f64' && typeof b === 'string' && locals.has(b) && !keepI32(b)) locals.set(b, 'f64')
674
719
  }
675
- if (op !== '=>') for (const a of args) widenPass(a)
720
+ if (op === '=>') { if (nestedNames.size) widenPass(args[1]) }
721
+ else for (const a of args) widenPass(a)
676
722
  }
677
723
  widenPass(body)
678
724
 
@@ -682,7 +728,7 @@ function widenLocalTypes(body, locals) {
682
728
  const recheck = (node) => {
683
729
  if (!Array.isArray(node)) return
684
730
  const op = node[0]
685
- if (op === '=>') return
731
+ if (op === '=>') { if (nestedNames.size) recheck(node[2]); return }
686
732
  if (op === 'let' || op === 'const') {
687
733
  for (let i = 1; i < node.length; i++) {
688
734
  const a = node[i]
@@ -724,18 +770,46 @@ export function invalidateLocalsCache(body) {
724
770
  if (body && typeof body === 'object') _bodyFactsCache.delete(body)
725
771
  }
726
772
 
727
- // Can this RHS expression produce null/undefined? A direct nullish literal
728
- // (`[null, null]` covers both null and undefined), or a `?:`/`&&`/`||` with a
729
- // nullish branch. Drives the `nullable` rep flag so `x === null` on a binding
730
- // that was ever assigned null isn't constant-folded to false (emit.js
731
- // strictSentinel). Conservative opaque sources (calls, member reads) aren't
732
- // flagged; the fold they'd suppress is rare and a runtime nullish check is cheap.
733
- function mayBeNullish(n) {
734
- if (!Array.isArray(n)) return false
735
- if (n.length === 2 && n[0] == null && n[1] == null) return true
736
- if (n[0] === '?' || n[0] === '?:') return mayBeNullish(n[2]) || mayBeNullish(n[3])
737
- if (n[0] === '&&' || n[0] === '||') return mayBeNullish(n[1]) || mayBeNullish(n[2])
738
- return false
773
+ // Can this RHS expression produce null/undefined? FAIL-CLOSED: anything not
774
+ // STRUCTURALLY provable non-nullish counts nullable. The flag's only effect
775
+ // is suppressing emit.js's strictSentinel constant fold (the comparison pays
776
+ // a cheap runtime nullish check instead) plus capture propagation — while a
777
+ // wrong non-nullable verdict FOLDS AWAY a real miss guard. The old shape
778
+ // list (nullish literals + ternary arms only) was sound while opaque sources
779
+ // carried no value kind (no kind ⇒ no fold); the Map/element value-kind
780
+ // inference broke that assumption: the self-host kernel's own
781
+ // `autoCache.get(name) !== undefined` cache probe folded to TRUE (the get's
782
+ // rep carried the map's value kind, non-nullable) and every autoDepsOf call
783
+ // returned the miss sentinel unconditionally the byte-parity root.
784
+ const NEVER_NULLISH_OPS = new Set([
785
+ 'str', '//', '{}', '[', '=>', 'new', 'bool',
786
+ '+', '-', '*', '/', '%', '**', '|', '&', '^', '~', '<<', '>>', '>>>',
787
+ '==', '!=', '===', '!==', '<', '>', '<=', '>=', '!', 'u-', 'u+',
788
+ 'typeof', 'in', 'instanceof', '++', '--',
789
+ ])
790
+ // `nameNullable` resolves a bare-name read; the default reads the CURRENT
791
+ // function's rep (emit-time callers). narrow.js passes its own resolver — at
792
+ // plan time no caller's ctx.func is installed, so it re-derives nullability
793
+ // from the caller body's writes instead. Exported for exactly that consumer.
794
+ export function mayBeNullish(n, nameNullable = (name) => !!repOf(name)?.nullable) {
795
+ if (typeof n === 'number' || typeof n === 'boolean') return false
796
+ // name read: inherit the source binding's settled flag (best-effort — an
797
+ // unsettled rep reads false, matching the old behavior for plain aliases)
798
+ if (typeof n === 'string') return nameNullable(n)
799
+ if (!Array.isArray(n)) return true
800
+ const op = n[0]
801
+ if (op == null) return n[1] == null // [null, v] literal value
802
+ if (op === '?' || op === '?:') return mayBeNullish(n[2], nameNullable) || mayBeNullish(n[3], nameNullable)
803
+ // `a && b` yields a (when falsy — possibly nullish) or b; `a || b` / `a ?? b`
804
+ // yield a only when truthy/non-nullish, so only b's nullability matters.
805
+ if (op === '&&') return mayBeNullish(n[1], nameNullable) || mayBeNullish(n[2], nameNullable)
806
+ if (op === '||' || op === '??') return mayBeNullish(n[2], nameNullable)
807
+ if (op === '=') return mayBeNullish(n[2], nameNullable) // assignment expression yields its rhs
808
+ if (op === ',') return mayBeNullish(n[n.length - 1], nameNullable)
809
+ if (typeof op === 'string' && (NEVER_NULLISH_OPS.has(op) || op.startsWith('new.'))) return false
810
+ // calls (incl. `.get()` misses), member/element reads, optional chains,
811
+ // and anything unrecognized: missable — fail closed.
812
+ return true
739
813
  }
740
814
 
741
815
  /**
@@ -800,6 +874,9 @@ export function analyzeValTypes(body) {
800
874
  (n) => ctx.types.typedElem?.get(n),
801
875
  (n, c) => (ctx.types.typedElem ??= new Map()).set(n, c),
802
876
  (n) => ctx.types.typedElem?.delete(n),
877
+ (n) => ctx.types.typedLen?.get(n),
878
+ (n, l) => (ctx.types.typedLen ??= new Map()).set(n, l),
879
+ (n) => ctx.types.typedLen?.delete(n),
803
880
  )
804
881
  // Total write count for `name` across the whole body, recursing into nested
805
882
  // closures so a closure that reassigns the var is also counted. Capped at 2 —
@@ -955,9 +1032,21 @@ export function analyzeValTypes(body) {
955
1032
  }
956
1033
  }
957
1034
 
958
- /** Forward-propagate `intCertain` on local bindings. Fixpoint lives in type.js. */
1035
+ /** Forward-propagate `intCertain` on local bindings. Fixpoint lives in type.js.
1036
+ * Threads the settled slot census as the `.prop`-read resolver — without it a
1037
+ * binding built from an int-certain slot (`const x = hitX ? p.x : nx`) stayed
1038
+ * uncertain and every consumer re-paid the ToNumber guard. */
959
1039
  export function analyzeIntCertain(body) {
960
- for (const [name, intC] of intCertainMap(body)) {
1040
+ const slotIntOf = ctx.schema?.slotIntCertainAt
1041
+ ? (obj, prop) => {
1042
+ const id = ctx.schema.idOf?.(obj)
1043
+ if (id == null) return null
1044
+ const idx = ctx.schema.list[id]?.indexOf(prop)
1045
+ if (idx == null || idx < 0) return null
1046
+ return ctx.schema.slotIntCertainAt(obj, prop)
1047
+ }
1048
+ : undefined
1049
+ for (const [name, intC] of intCertainMap(body, undefined, slotIntOf)) {
961
1050
  if (intC) updateRep(name, { intCertain: true })
962
1051
  }
963
1052
  }
@@ -1550,6 +1639,16 @@ export function analyzeStructInline(funcFacts, programFacts) {
1550
1639
  * (direct calls, no storage at all).
1551
1640
  * - otherwise → a mutable f64 module global (`global.get` / `global.set`).
1552
1641
  *
1642
+ * Escape-as-CALLEE vs escape-as-TABLE: `f`/`f.PROP` appearing as a call's
1643
+ * callee (`f(...)`, `f?.(...)`, `f.prop(...)`, `f.prop?.(...)`) or as the
1644
+ * exported value (`export`) hands out only the invoked/callable function
1645
+ * VALUE — never a handle onto f's property table — so neither disqualifies.
1646
+ * Every other bare mention of `f` (stored into a data structure, aliased
1647
+ * `let g = f`, compared, an argument to anything but treated as a plain call
1648
+ * position, `f[computed]`/`f?.[computed]`) COULD leak a reference the table
1649
+ * is reachable through, so it still disqualifies (`start strict`: this is
1650
+ * deliberately not trying to prove higher-order callback safety yet).
1651
+ *
1553
1652
  * Returns `Map<funcName, { disq, props:Set, valRead:Set,
1554
1653
  * writes:Map<prop,[{rhs,atInit}]> }>` — `valRead` is the subset of props read
1555
1654
  * as a value (not merely called). `flattenFuncNamespaces` (plan.js) turns it
@@ -1620,22 +1719,29 @@ export function analyzeFuncNamespaces(ast) {
1620
1719
  return
1621
1720
  }
1622
1721
 
1623
- if (op === '()') {
1722
+ // `f(...)` / `f?.(...)` and `f.PROP(...)` / `f.PROP?.(...)` — escape-as-
1723
+ // CALLEE, not escape-as-table. Calling f (or a prop of f) hands the host/
1724
+ // callee only the invoked function VALUE, never f's property table, so
1725
+ // neither shape disqualifies. `prep()` keeps `?.()` a distinct op from
1726
+ // `()` (same flattened-args shape — see prepare/index.js's `'?.()'`
1727
+ // handler) — mirror both here, exactly as `boundSafeCalls` does.
1728
+ if (op === '()' || op === '?.()') {
1624
1729
  const m = memberOf(node[1])
1625
1730
  if (m) rec(m[1]).props.add(m[2])
1626
- else if (!isFuncRef(node[1], funcNames)) visit(node[1], false) // bare f(...) ok
1731
+ else if (!isFuncRef(node[1], funcNames)) visit(node[1], false) // bare f(...)/f?.(...) ok
1627
1732
  for (let i = 2; i < node.length; i++) visit(node[i], false)
1628
1733
  return
1629
1734
  }
1630
1735
 
1631
1736
  // `f.PROP` / `f?.PROP` as a plain value (read) — not the callee of a call
1632
- // (those are handled by the `()` branch above). A value-read means the
1633
- // property's stored value must stay retrievable; devirt cannot drop it.
1737
+ // (those are handled by the `()`/`?.()` branch above). A value-read means
1738
+ // the property's stored value must stay retrievable; devirt cannot drop it.
1634
1739
  const m = memberOf(node)
1635
1740
  if (m) { const r = rec(m[1]); r.props.add(m[2]); r.valRead.add(m[2]); return }
1636
1741
 
1637
- // Computed `f[k]` — the key set is no longer static.
1638
- if (op === '[]' && isFuncRef(node[1], funcNames)) {
1742
+ // Computed `f[k]` / `f?.[k]` — the key set is no longer static: a genuine
1743
+ // table escape (unlike computed CALL args, this reaches arbitrary props).
1744
+ if ((op === '[]' || op === '?.[]') && isFuncRef(node[1], funcNames)) {
1639
1745
  rec(node[1]).disq = true
1640
1746
  for (let i = 2; i < node.length; i++) visit(node[i], false)
1641
1747
  return