jz 0.5.1 → 0.7.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 (86) hide show
  1. package/README.md +315 -318
  2. package/bench/README.md +369 -0
  3. package/bench/bench.svg +102 -0
  4. package/cli.js +104 -30
  5. package/dist/interop.js +1 -0
  6. package/dist/jz.js +6024 -0
  7. package/index.d.ts +126 -0
  8. package/index.js +362 -84
  9. package/interop.js +159 -186
  10. package/jz.svg +5 -0
  11. package/jzify/arguments.js +97 -0
  12. package/jzify/bundler.js +382 -0
  13. package/jzify/classes.js +328 -0
  14. package/jzify/hoist-vars.js +177 -0
  15. package/jzify/index.js +51 -0
  16. package/jzify/names.js +37 -0
  17. package/jzify/switch.js +106 -0
  18. package/jzify/transform.js +349 -0
  19. package/layout.js +184 -0
  20. package/module/array.js +377 -176
  21. package/module/collection.js +639 -145
  22. package/module/console.js +55 -43
  23. package/module/core.js +264 -153
  24. package/module/date.js +15 -3
  25. package/module/function.js +73 -6
  26. package/module/index.js +2 -1
  27. package/module/json.js +226 -61
  28. package/module/math.js +551 -187
  29. package/module/number.js +327 -60
  30. package/module/object.js +474 -184
  31. package/module/regex.js +255 -25
  32. package/module/schema.js +24 -6
  33. package/module/simd.js +117 -0
  34. package/module/string.js +621 -226
  35. package/module/symbol.js +1 -1
  36. package/module/timer.js +9 -14
  37. package/module/typedarray.js +459 -73
  38. package/package.json +58 -14
  39. package/src/abi/index.js +39 -23
  40. package/src/abi/string.js +38 -41
  41. package/src/ast.js +460 -0
  42. package/src/autoload.js +29 -24
  43. package/src/bridge.js +111 -0
  44. package/src/compile/analyze-scans.js +824 -0
  45. package/src/compile/analyze.js +1600 -0
  46. package/src/compile/emit-assign.js +411 -0
  47. package/src/compile/emit.js +3512 -0
  48. package/src/compile/flow-types.js +103 -0
  49. package/src/{compile.js → compile/index.js} +778 -128
  50. package/src/{infer.js → compile/infer.js} +62 -98
  51. package/src/compile/loop-divmod.js +155 -0
  52. package/src/{narrow.js → compile/narrow.js} +409 -106
  53. package/src/compile/peel-stencil.js +261 -0
  54. package/src/compile/plan/advise.js +370 -0
  55. package/src/compile/plan/common.js +150 -0
  56. package/src/compile/plan/index.js +122 -0
  57. package/src/compile/plan/inline.js +682 -0
  58. package/src/compile/plan/literals.js +1199 -0
  59. package/src/compile/plan/loops.js +472 -0
  60. package/src/compile/plan/scope.js +649 -0
  61. package/src/compile/program-facts.js +423 -0
  62. package/src/ctx.js +220 -64
  63. package/src/ir.js +589 -172
  64. package/src/kind-traits.js +132 -0
  65. package/src/kind.js +524 -0
  66. package/src/op-policy.js +57 -0
  67. package/src/{optimize.js → optimize/index.js} +1432 -473
  68. package/src/optimize/vectorize.js +4660 -0
  69. package/src/param-reps.js +65 -0
  70. package/src/parse.js +44 -0
  71. package/src/{prepare.js → prepare/index.js} +649 -205
  72. package/src/prepare/lift-iife.js +149 -0
  73. package/src/reps.js +116 -0
  74. package/src/resolve.js +12 -3
  75. package/src/static.js +208 -0
  76. package/src/type.js +651 -0
  77. package/src/{assemble.js → wat/assemble.js} +275 -55
  78. package/src/wat/optimize.js +3938 -0
  79. package/transform.js +21 -0
  80. package/wasi.js +47 -5
  81. package/src/analyze.js +0 -3818
  82. package/src/emit.js +0 -3040
  83. package/src/jzify.js +0 -1580
  84. package/src/plan.js +0 -2132
  85. package/src/vectorize.js +0 -1088
  86. /package/src/{codegen.js → wat/codegen.js} +0 -0
@@ -0,0 +1,824 @@
1
+ /**
2
+ * Body scan passes — free vars, mutations, binding-use taxonomy, SRoA/slice eligibility.
3
+ * @module analyze-scans
4
+ */
5
+
6
+ import { ASSIGN_OPS, collectParamNames, extractParams, REFS_IN_EXPR, refsName, T, isLiteralStr } from '../ast.js'
7
+ import { ctx } from '../ctx.js'
8
+ import { staticObjectProps, staticArrayElems, staticIndexKey, staticValue, NO_VALUE } from '../static.js'
9
+ import { exprType } from '../type.js'
10
+
11
+ export function findFreeVars(node, bound, free, scope) {
12
+ if (node == null) return
13
+ if (typeof node === 'string') {
14
+ if (bound.has(node) || free.includes(node)) return
15
+ const inScope = scope
16
+ ? scope.has(node)
17
+ : (ctx.func.locals?.has(node) || ctx.func.current?.params.some(p => p.name === node))
18
+ if (inScope) free.push(node)
19
+ return
20
+ }
21
+ if (!Array.isArray(node)) return
22
+ const [op, ...args] = node
23
+ if (op === '=>') {
24
+ const innerBound = collectParamNames(extractParams(args[0]), new Set(bound))
25
+ findFreeVars(args[1], innerBound, free, scope)
26
+ return
27
+ }
28
+ if (op === 'catch') {
29
+ findFreeVars(args[0], bound, free, scope)
30
+ const errName = args[1]
31
+ const handlerBound = typeof errName === 'string' && errName
32
+ ? new Set(bound).add(errName) : bound
33
+ findFreeVars(args[2], handlerBound, free, scope)
34
+ return
35
+ }
36
+ if (op === 'let' || op === 'const') {
37
+ collectParamNames(args, bound)
38
+ if (scope) collectParamNames(args, scope)
39
+ }
40
+ if (op === 'for' && Array.isArray(args[0]) && (args[0][0] === 'let' || args[0][0] === 'const')) {
41
+ collectParamNames(args[0].slice(1), bound)
42
+ if (scope) collectParamNames(args[0].slice(1), scope)
43
+ }
44
+ for (const a of args) findFreeVars(a, bound, free, scope)
45
+ }
46
+
47
+ /** Check if any of the given variable names are assigned anywhere in the AST. */
48
+ export function findMutations(node, names, mutated) {
49
+ if (node == null || typeof node !== 'object' || !Array.isArray(node)) return
50
+ const [op, ...args] = node
51
+ if (op === 'let' || op === 'const') {
52
+ for (const decl of args)
53
+ if (Array.isArray(decl) && decl[0] === '=') findMutations(decl[2], names, mutated)
54
+ return
55
+ }
56
+ if (ASSIGN_OPS.has(op) && typeof args[0] === 'string' && names.has(args[0]))
57
+ mutated.add(args[0])
58
+ if ((op === '++' || op === '--') && typeof args[0] === 'string' && names.has(args[0]))
59
+ mutated.add(args[0])
60
+ for (const a of args) findMutations(a, names, mutated)
61
+ }
62
+
63
+ /**
64
+ * Pre-scan function body for captured variables that are mutated.
65
+ * Marks mutably-captured vars in ctx.func.boxed for cell-based capture.
66
+ */
67
+ export function boxedCaptures(body) {
68
+ const outerScope = new Set()
69
+ ;(function collectDecls(node) {
70
+ if (!Array.isArray(node)) return
71
+ const [op, ...args] = node
72
+ if (op === '=>') return
73
+ if (op === 'let' || op === 'const')
74
+ collectParamNames(args, outerScope)
75
+ for (const a of args) collectDecls(a)
76
+ })(body)
77
+ if (ctx.func.current?.params) for (const p of ctx.func.current.params) outerScope.add(p.name)
78
+ if (ctx.func.locals) for (const k of ctx.func.locals.keys()) outerScope.add(k)
79
+
80
+ const markArrowCaptures = (node, assignTarget, seen) => {
81
+ const pnode = node[1]
82
+ let p = pnode
83
+ if (Array.isArray(p) && p[0] === '()') p = p[1]
84
+ const raw = p == null ? [] : Array.isArray(p) ? (p[0] === ',' ? p.slice(1) : [p]) : [p]
85
+ const paramSet = new Set(raw.map(r => Array.isArray(r) && r[0] === '...' ? r[1] : r))
86
+ const captures = []
87
+ findFreeVars(node[2], paramSet, captures, outerScope)
88
+ if (captures.length === 0) return
89
+ const captureSet = new Set(captures)
90
+ const boxed = new Set()
91
+ findMutations(body, captureSet, boxed)
92
+ for (const v of captures) if (!seen.has(v)) boxed.add(v)
93
+ if (assignTarget && captureSet.has(assignTarget)) boxed.add(assignTarget)
94
+ for (const v of boxed) if (!ctx.func.boxed.has(v)) ctx.func.boxed.set(v, `${T}cell_${v}`)
95
+ }
96
+
97
+ ;(function walk(node, assignTarget, seen = new Set(ctx.func.current?.params?.map(p => p.name) || [])) {
98
+ if (!Array.isArray(node)) return
99
+ const [op, ...args] = node
100
+ if (op === '=>') {
101
+ markArrowCaptures(node, assignTarget, seen)
102
+ return
103
+ }
104
+
105
+ if (op === ';' || op === '{}') {
106
+ const blockSeen = new Set(seen)
107
+ for (const a of args) walk(a, null, blockSeen)
108
+ return
109
+ }
110
+
111
+ if (op === 'let' || op === 'const') {
112
+ for (const decl of args) {
113
+ if (Array.isArray(decl) && decl[0] === '=') walk(decl[2], typeof decl[1] === 'string' ? decl[1] : null, seen)
114
+ else walk(decl, null, seen)
115
+ collectParamNames([decl], seen)
116
+ }
117
+ return
118
+ }
119
+
120
+ if (op === '=' && typeof args[0] === 'string' && Array.isArray(args[1]) && args[1][0] === '=>')
121
+ return walk(args[1], args[0], seen)
122
+ for (const a of args) walk(a, null, seen)
123
+ })(body)
124
+ }
125
+
126
+ /**
127
+ * Narrow return arr-elem-{schema|valType}: for each non-exported, non-value-used
128
+ * user func with `valResult === VAL.ARRAY` and `func[field] == null`, walk return
129
+ * exprs (and trailing-fallthrough literal), resolve each via body-local elem map
130
+ * + caller-param facts + transitive user-fn results, and if all agree set `func[field]`.
131
+ * Lets callers' `const rows = initRows()` gain the elem fact, propagating to
132
+ * runKernel params via paramReps. `field` selects which fact ('arrayElemSchema'
133
+ * | 'arrayElemValType') — slice key is derived.
134
+ */
135
+
136
+ // === body walks / program facts ===
137
+
138
+ export const USE = {
139
+ MEMBER_R: 1, // receiver of a `.`/`?.`/`[]` READ — {key, optional, computed}
140
+ MEMBER_W: 2, // base of a `.`/`[]` WRITE — {key, computed, compound}
141
+ REASSIGN: 3, // `=`(non-init) / `++` / `--` / compound-assign of the name
142
+ CALL_ARG: 4, // passed as a call argument — {callee, argIndex}
143
+ CALL_CALLEE: 5, // invoked: `name(...)`
144
+ RETURN: 6, // `return name`
145
+ CAPTURE: 7, // mentioned inside a nested `=>`
146
+ COMPARE: 8, // operand of a comparison — {nullCmp}
147
+ CONCAT: 9, // operand of `+`
148
+ BOOL_TEST: 10, // operand of `!`/`typeof`/`void`, or an `if`/`while`/`?:` test
149
+ DELETE_MEMBER: 11, // `delete name.member`
150
+ BARE: 12, // any other value position — the conservative catch-all
151
+ }
152
+ const _bindingUsesCache = new WeakMap()
153
+ const _CMP_OPS = new Set(['==', '!=', '===', '!==', '<', '>', '<=', '>='])
154
+ const _isNullishLit = (e) =>
155
+ e === 'null' || e === 'undefined' ||
156
+ (Array.isArray(e) && e[0] == null && (e[1] === null || e[1] === undefined))
157
+
158
+ export function scanBindingUses(body) {
159
+ const hit = _bindingUsesCache.get(body)
160
+ if (hit) return hit
161
+
162
+ const summary = new Map() // name → { decls, initRhs, uses }
163
+ const slot = (name) => {
164
+ let s = summary.get(name)
165
+ if (!s) { s = { decls: 0, initRhs: undefined, uses: [] }; summary.set(name, s) }
166
+ return s
167
+ }
168
+ const use = (name, kind, extra) => slot(name).uses.push(extra ? { kind, ...extra } : { kind })
169
+
170
+ // Static string key of a `[]` index node, else null (computed).
171
+ const litKey = (k) => (Array.isArray(k) && k[0] === 'str' && typeof k[1] === 'string') ? k[1] : staticIndexKey(k)
172
+
173
+ // A child sitting in a value position. A bare string there is a real use —
174
+ // `walk` alone silently drops non-array children, so every value-position
175
+ // child (let-rhs, assign-rhs, call/index args, closure body, …) must route
176
+ // through here or its use goes unrecorded (a latent miscompile: the binding
177
+ // looks unused and an optimization fires unsoundly).
178
+ const val = (child, inClosure) => {
179
+ if (typeof child === 'string') use(child, inClosure ? USE.CAPTURE : USE.BARE)
180
+ else walk(child, inClosure)
181
+ }
182
+
183
+ // Classify the target of an assignment-like node (`=`, compound, `++`, `--`).
184
+ const assignTarget = (t, compound) => {
185
+ if (typeof t === 'string') { use(t, USE.REASSIGN); return }
186
+ if (!Array.isArray(t)) return
187
+ const o = t[0]
188
+ if ((o === '.' || o === '?.') && typeof t[1] === 'string') {
189
+ use(t[1], USE.MEMBER_W, { key: typeof t[2] === 'string' ? t[2] : null, computed: false, compound })
190
+ return
191
+ }
192
+ if (o === '[]' && typeof t[1] === 'string') {
193
+ const k = litKey(t[2])
194
+ use(t[1], USE.MEMBER_W, { key: k, computed: k == null, compound })
195
+ if (t[2] != null) val(t[2])
196
+ return
197
+ }
198
+ walk(t) // some other LHS shape — generic
199
+ }
200
+
201
+ function walk(node, inClosure) {
202
+ if (!Array.isArray(node)) return
203
+ const op = node[0]
204
+ if (typeof op !== 'string') return // literal node `[null, value]`
205
+ if (op === 'str') return // string literal
206
+ if (op === '=>') { for (let i = 1; i < node.length; i++) val(node[i], true); return }
207
+
208
+ if (op === 'let' || op === 'const') {
209
+ for (let i = 1; i < node.length; i++) {
210
+ const d = node[i]
211
+ if (typeof d === 'string') { if (!inClosure) slot(d).decls++; continue }
212
+ if (Array.isArray(d) && d[0] === '=') {
213
+ const lhs = d[1], rhs = d[2]
214
+ if (typeof lhs === 'string') {
215
+ if (!inClosure) { const s = slot(lhs); s.decls++; if (s.initRhs === undefined) s.initRhs = rhs }
216
+ } else {
217
+ walk(lhs, inClosure) // pattern — computed keys/defaults are real uses
218
+ }
219
+ val(rhs, inClosure)
220
+ } else walk(d, inClosure)
221
+ }
222
+ return
223
+ }
224
+
225
+ if (inClosure) { // every mention here is a CAPTURE
226
+ for (let i = 1; i < node.length; i++) {
227
+ const c = node[i]
228
+ if (typeof c === 'string') use(c, USE.CAPTURE)
229
+ else walk(c, true)
230
+ }
231
+ return
232
+ }
233
+
234
+ // === precise classification (outside any closure) ===
235
+ if (ASSIGN_OPS.has(op)) { assignTarget(node[1], op !== '='); val(node[2]); return }
236
+ if (op === '++' || op === '--') { assignTarget(node[1], true); return }
237
+ if (op === 'delete') {
238
+ const t = node[1]
239
+ if (Array.isArray(t) && (t[0] === '.' || t[0] === '?.' || t[0] === '[]') && typeof t[1] === 'string') {
240
+ use(t[1], USE.DELETE_MEMBER)
241
+ if (t[0] === '[]' && t[2] != null) val(t[2])
242
+ } else val(t)
243
+ return
244
+ }
245
+ if (op === '.' || op === '?.') {
246
+ const recv = node[1]
247
+ if (typeof recv === 'string')
248
+ use(recv, USE.MEMBER_R, { key: typeof node[2] === 'string' ? node[2] : null, optional: op === '?.', computed: false })
249
+ else walk(recv)
250
+ return // node[2] is the property name
251
+ }
252
+ if (op === '[]') {
253
+ const recv = node[1], k = litKey(node[2])
254
+ if (typeof recv === 'string') use(recv, USE.MEMBER_R, { key: k, optional: false, computed: k == null })
255
+ else walk(recv)
256
+ if (node[2] != null) val(node[2])
257
+ return
258
+ }
259
+ if (op === ':') { // object property `{k:v}` / labeled statement
260
+ if (Array.isArray(node[1])) walk(node[1]) // computed key `{[expr]:v}` — a real use
261
+ val(node[2]) // property value (or the labeled statement)
262
+ return // string node[1] = plain key / label — not a use
263
+ }
264
+ if (op === 'return') {
265
+ const e = node[1]
266
+ if (typeof e === 'string') use(e, USE.RETURN)
267
+ else walk(e)
268
+ return
269
+ }
270
+ if (op === '()') {
271
+ const callee = node[1]
272
+ if (typeof callee === 'string') use(callee, USE.CALL_CALLEE)
273
+ else walk(callee)
274
+ const argNode = node[2]
275
+ if (argNode != null) {
276
+ const args = (Array.isArray(argNode) && argNode[0] === ',') ? argNode.slice(1) : [argNode]
277
+ for (let ai = 0; ai < args.length; ai++) {
278
+ const a = args[ai]
279
+ if (Array.isArray(a) && a[0] === '...') { val(a[1]); continue }
280
+ if (typeof a === 'string') use(a, USE.CALL_ARG, { callee: typeof callee === 'string' ? callee : null, argIndex: ai })
281
+ else walk(a)
282
+ }
283
+ }
284
+ return
285
+ }
286
+ if (_CMP_OPS.has(op) && node.length === 3) {
287
+ for (let i = 1; i <= 2; i++) {
288
+ const side = node[i]
289
+ if (typeof side === 'string') use(side, USE.COMPARE, { nullCmp: _isNullishLit(node[3 - i]) })
290
+ else walk(side)
291
+ }
292
+ return
293
+ }
294
+ if (op === '+') {
295
+ for (let i = 1; i < node.length; i++) {
296
+ const c = node[i]
297
+ if (typeof c === 'string') use(c, USE.CONCAT)
298
+ else walk(c)
299
+ }
300
+ return
301
+ }
302
+ if (op === '!' || op === 'typeof' || op === 'void') {
303
+ const c = node[1]
304
+ if (typeof c === 'string') use(c, USE.BOOL_TEST)
305
+ else walk(c)
306
+ return
307
+ }
308
+ if (op === 'if' || op === 'while' || op === '?:') { // `prepare` normalizes `?` → `?:`
309
+ const c = node[1]
310
+ if (typeof c === 'string') use(c, USE.BOOL_TEST)
311
+ else walk(c)
312
+ for (let i = 2; i < node.length; i++) val(node[i])
313
+ return
314
+ }
315
+
316
+ // generic — every string child is a BARE value use
317
+ for (let i = 1; i < node.length; i++) {
318
+ const c = node[i]
319
+ if (typeof c === 'string') use(c, USE.BARE)
320
+ else walk(c)
321
+ }
322
+ }
323
+
324
+ walk(body, false)
325
+
326
+ for (const [name, s] of summary) if (s.decls === 0) summary.delete(name)
327
+ _bindingUsesCache.set(body, summary)
328
+ return summary
329
+ }
330
+
331
+ /**
332
+ * SRoA eligibility scan — which `let/const o = {staticLiteral}` bindings can
333
+ * have their fields dissolved into plain WASM locals (`flat` carrier): no heap
334
+ * alloc, no field load/store, `o.prop` becomes `local.get`.
335
+ *
336
+ * A binding is flat-eligible iff `o` appears ONLY as a literal-key `.`/`[]`
337
+ * READ of an in-schema prop, or the member LHS of a literal-key `.`/`[]` WRITE
338
+ * of an in-schema prop. Any other mention — bare ref, dynamic/numeric key,
339
+ * off-schema prop, `?.`, reassignment, compound assign, `++`/`--`, `delete`,
340
+ * closure capture, self-referential initializer, duplicate keys, or a second
341
+ * declaration — disqualifies it. A non-escaping object is never observed by
342
+ * any object walk (keys/values/entries/assign/spread/JSON/for-in/dyn), so the
343
+ * transform is additive and sound. Conservative: any doubt → not flat.
344
+ *
345
+ * A policy over `scanBindingUses`: the shared traversal classifies every
346
+ * mention; this scan keeps a binding only if its initializer is a self-
347
+ * contained static literal and every use is an in-schema literal-key access.
348
+ *
349
+ * Returns `Map<name, {names, values}>` — the literal's parallel prop arrays.
350
+ * Field `i` of binding `o` lives in WASM local `o#${i}` (`#` cannot occur in a
351
+ * jz identifier, so the name is collision-free).
352
+ */
353
+ // Largest array literal that dissolves into scalar slots. Beyond this a single
354
+ // constant data segment is cheaper than N locals (+ the per-slot init prologue).
355
+ const FLAT_ARRAY_MAX = 8
356
+
357
+ export function scanFlatObjects(body) {
358
+ const cand = new Map() // name → {names, values}
359
+
360
+ // A binding referenced as a value inside `node` (skips `:`/`.` property-name
361
+ // slots). Used only to reject a self-referential initializer — a literal
362
+ // whose own field values mention the binding is not a self-contained object.
363
+ for (const [name, s] of scanBindingUses(body)) {
364
+ if (s.decls !== 1 || !Array.isArray(s.initRhs)) continue
365
+ // Candidate aggregate: an object literal `{…}` (string keys) or a small array
366
+ // literal `[…]` (index keys "0","1",…). An array dissolves into `name#i` scalar
367
+ // locals exactly like an object — same `.`/`[]` flat hooks, no heap alloc — when
368
+ // every use is a static-index read/write. Capped at FLAT_ARRAY_MAX: a larger
369
+ // literal belongs in one constant data-segment region, not N spilled locals.
370
+ let props
371
+ if (s.initRhs[0] === '{}') {
372
+ props = staticObjectProps(s.initRhs.slice(1))
373
+ } else if (s.initRhs[0] === '[' || s.initRhs[0] === '[]') {
374
+ const elems = staticArrayElems(s.initRhs)
375
+ if (!elems || !elems.length || elems.length > FLAT_ARRAY_MAX) continue
376
+ // Holes (`[1,,3]`) and spreads (`[...x]`) aren't a fixed positional schema.
377
+ if (elems.some(e => e == null || (Array.isArray(e) && e[0] === '...'))) continue
378
+ // Only compile-time-constant *value* elements dissolve — number/string/bool/null
379
+ // ("arrays hold JSON values"). A non-literal element (identifier, call, closure,
380
+ // arithmetic on a runtime var) can carry a function/closure whose call-indirect
381
+ // table index binds to the array, not a scalar local — dissolving the slot
382
+ // desyncs the `elem` section. Conservative: any non-constant element keeps the
383
+ // array heap-backed.
384
+ if (!elems.every(e => staticValue(e) !== NO_VALUE)) continue
385
+ props = { names: elems.map((_, i) => String(i)), values: elems }
386
+ } else continue
387
+ const isArr = s.initRhs[0] !== '{}'
388
+ if (!props || new Set(props.names).size !== props.names.length) continue
389
+ if (props.values.some(v => refsName(v, name, REFS_IN_EXPR))) continue
390
+
391
+ // Schema = literal keys ∪ plain literal-key member writes. For an OBJECT such a
392
+ // write monotonically extends the static field universe (the new field reads
393
+ // `undefined` until the write runs, exactly as JS does). An ARRAY has a *fixed*
394
+ // positional schema: `a.length = …` / `a[n] = …` (off the literal indices) resize
395
+ // or grow it — not a field add — so arrays never extend, and any off-schema write
396
+ // (including `.length`, which isn't a slot) disqualifies below.
397
+ // `written` = the keys a MEMBER_W reassigns — a slot is write-once (its
398
+ // value-type is exactly its literal initializer's) iff its key is absent here.
399
+ const schema = new Set(props.names)
400
+ const written = new Set()
401
+ for (const u of s.uses)
402
+ if (u.kind === USE.MEMBER_W && !u.compound && !u.computed && u.key != null) {
403
+ if (!isArr) schema.add(u.key)
404
+ written.add(u.key)
405
+ }
406
+
407
+ // Flat iff every mention is an in-schema literal-key `.`/`[]` READ, or an
408
+ // in-schema literal-key plain `.`/`[]` WRITE. Any other use kind — `?.`,
409
+ // computed/off-schema key, reassignment, compound or `delete` member write,
410
+ // `++`/`--`, call arg, closure capture, bare ref — leaves the object live.
411
+ const flat = s.uses.every(u =>
412
+ (u.kind === USE.MEMBER_R && !u.optional && !u.computed && schema.has(u.key)) ||
413
+ (u.kind === USE.MEMBER_W && !u.compound && !u.computed && schema.has(u.key)))
414
+ if (!flat) continue
415
+
416
+ // Materialize the parallel {names, values}: literal props first, then each
417
+ // extension field (value `undefined`), in first-write order.
418
+ const names = props.names.slice(), values = props.values.slice()
419
+ for (const k of schema)
420
+ if (!names.includes(k)) { names.push(k); values.push(undefined) }
421
+ cand.set(name, { names, values, written })
422
+ }
423
+ return cand
424
+ }
425
+
426
+ /**
427
+ * No-copy slice scan — which `let/const t = s.slice(...)` bindings can be a
428
+ * VIEW (a SLICE_BIT pointer straight into `s`'s buffer) instead of a fresh
429
+ * byte copy.
430
+ *
431
+ * jz rewinds the bump arena only at function exit, so every string the
432
+ * function can observe stays alive until it returns. A view is therefore sound
433
+ * exactly when its binding does NOT escape the function: `t` must never be
434
+ * returned, passed as a call argument, stored into a heap object/array,
435
+ * captured by a closure, aliased to another binding, reassigned, or
436
+ * compound-assigned. The permitted uses — receiver of a `.`/`[]`, operand of a
437
+ * comparison or `+`, a boolean test — read `t` synchronously and never persist
438
+ * it past the function.
439
+ *
440
+ * Declared exactly once as `let/const`. The result is purely structural —
441
+ * whether the receiver is actually a string (so `.slice` lowers to the string
442
+ * view) is settled later, at emit time, when param types are known; emitDecl
443
+ * keeps the ordinary copying slice for any non-string receiver. Conservative:
444
+ * any unrecognised position disqualifies the binding.
445
+ *
446
+ * Returns `Set<name>` of view-eligible binding names.
447
+ */
448
+ // Permitted use-kinds for a slice view — the value is read synchronously and
449
+ // never persisted past the function. `MEMBER_R`/`MEMBER_W` cover any `.`/`[]`
450
+ // receiver; `COMPARE` any comparison; `CONCAT`/`BOOL_TEST` the copy / test
451
+ // positions. Any other kind (reassign, call arg, return, capture, bare alias)
452
+ // escapes and disqualifies the binding.
453
+ const _SLICE_VIEW_OK = new Set([USE.MEMBER_R, USE.MEMBER_W, USE.COMPARE, USE.CONCAT, USE.BOOL_TEST])
454
+
455
+ export function scanSliceViews(body) {
456
+ const isSliceCall = (n) =>
457
+ Array.isArray(n) && n[0] === '()' && Array.isArray(n[1])
458
+ && n[1][0] === '.' && n[1][2] === 'slice'
459
+
460
+ const views = new Set()
461
+ for (const [name, s] of scanBindingUses(body)) {
462
+ if (s.decls !== 1 || !isSliceCall(s.initRhs)) continue
463
+ if (s.uses.every(u => _SLICE_VIEW_OK.has(u.kind))) views.add(name)
464
+ }
465
+ return views
466
+ }
467
+
468
+ /**
469
+ * Never-relocated array bindings — reads through them may skip the realloc-forwarding
470
+ * follow (`__ptr_offset`). A fresh array-literal binding is never relocated iff EVERY
471
+ * occurrence of it is a pure READ — `a[i]` (any index) or `a.length`. Anything else
472
+ * grows or escapes it: a grow method (push/unshift/shift/splice), a `.length`/element
473
+ * write (incl. compound `a.length += 1`), a bare value use (alias `let b=a`, store
474
+ * `w.x=a`, return, call argument, spread), a reassignment, or a dynamic call
475
+ * `a[i]()`/`a.m()`.
476
+ *
477
+ * MEMORY-SAFETY CRITICAL and so DEFAULT-DENY + self-contained: it does NOT trust the
478
+ * `escapes` map, which misses member-write RHS (`w.data = a`) and compound assigns. If
479
+ * the analysis is wrong and the array IS relocated, a read through the stale base
480
+ * corrupts memory — so any unrecognized use disqualifies. (Growing an INNER array,
481
+ * `a[0].push(x)`, never relocates `a` itself, so `a` stays eligible — see safeReads.)
482
+ */
483
+ const grownOrEscapes = (op) => ASSIGN_OPS.has(op) || op === '++' || op === '--' || op === 'delete'
484
+ function safeReads(node, name) {
485
+ if (typeof node === 'string') return node !== name // bare value use → escape
486
+ if (!Array.isArray(node)) return true
487
+ const op = node[0]
488
+ // `a(…)` / `a.m(…)` / `a[i](…)` — calling `a` or a method/element of it may grow/escape it.
489
+ if (op === '()') {
490
+ const c = node[1]
491
+ if (c === name) return false
492
+ if (Array.isArray(c) && (c[0] === '.' || c[0] === '?.' || c[0] === '[]' || c[0] === '?.[]') && c[1] === name) return false
493
+ }
494
+ // write / update / delete on `a`, `a[..]`, or `a.x` (incl. `a.length = …` and compounds)
495
+ if (grownOrEscapes(op)) {
496
+ const t = node[1]
497
+ if (t === name) return false
498
+ if (Array.isArray(t) && (t[0] === '[]' || t[0] === '.' || t[0] === '?.') && t[1] === name) return false
499
+ }
500
+ // declaration: check each initializer RHS (so `let b = a` aliasing disqualifies);
501
+ // the bound names themselves are definitions, not uses (skips `a`'s own decl).
502
+ if (op === 'let' || op === 'const' || op === 'var') {
503
+ for (let i = 1; i < node.length; i++) {
504
+ const d = node[i]
505
+ if (Array.isArray(d) && d[0] === '=' && !safeReads(d[2], name)) return false
506
+ }
507
+ return true
508
+ }
509
+ // the only safe forms: `a.length` read, and `a[i]` index read (recurse the index expr).
510
+ if ((op === '.' || op === '?.') && node[1] === name) return node[2] === 'length'
511
+ if (op === '[]' && node[1] === name) return safeReads(node[2], name)
512
+ if (op === '...' && node[1] === name) return false // spread → escape
513
+ for (let i = 1; i < node.length; i++) if (!safeReads(node[i], name)) return false
514
+ return true
515
+ }
516
+
517
+ export function scanNeverGrown(body) {
518
+ const out = new Set()
519
+ for (const [name, s] of scanBindingUses(body)) {
520
+ // Candidate: a single-declaration binding initialized from a fresh array literal.
521
+ if (s.decls !== 1 || !Array.isArray(s.initRhs)) continue
522
+ if (s.initRhs[0] !== '[' && !(s.initRhs[0] === '[]' && s.initRhs.length <= 2)) continue
523
+ if (safeReads(body, name)) out.add(name)
524
+ }
525
+ return out
526
+ }
527
+
528
+ /**
529
+ * Numeric-fill arrays — the construct-then-fill counterpart of an all-number array
530
+ * literal. A fresh `Array(n)` / `new Array(n)` / `[]` binding whose EVERY element write
531
+ * stores a provably-NUMBER value, and which never escapes, aliases, is reassigned, grows
532
+ * by method, or takes a non-numeric / compound element write, holds only Numbers (unwritten
533
+ * holes read as 0 in jz, also a Number). So its `a[i]` reads can skip the polymorphic
534
+ * `__to_num` coercion — exactly the win `[1,2,3]` already gets, extended to the dominant
535
+ * numeric-kernel shape `let a = Array(n); for (..) a[i] = expr`.
536
+ *
537
+ * Default-deny and self-contained, like scanNeverGrown (the same memory-safety discipline):
538
+ * any occurrence that isn't a pure index/length READ or a NUMBER-valued `a[i] = …` write
539
+ * disqualifies — so `w.x = a`, `f(a)`, `let b = a`, `a.push(x)`, `a[i] += x` all bail.
540
+ * `isNumericRhs` injects the value-type judgement (valTypeOf === VAL.NUMBER) the syntactic
541
+ * scan can't make itself.
542
+ */
543
+ // Both `Array(n)` and `new Array(n)` normalize to a `new.Array` call by prepare; an
544
+ // empty literal stays `['[]', null]`. (Typed ctors become `new.Float64Array` etc. — the
545
+ // exact-match on `new.Array` keeps them out.)
546
+ const isFreshArrayCtor = (rhs) =>
547
+ Array.isArray(rhs) && (
548
+ (rhs[0] === '[]' && rhs.length <= 2) || // empty `[]`
549
+ (rhs[0] === '()' && rhs[1] === 'new.Array') // `Array(n)` / `new Array(n)` / `Array()`
550
+ )
551
+
552
+ function numFillSafe(node, name, isNumericRhs) {
553
+ if (typeof node === 'string') return node !== name // bare value use → escape
554
+ if (!Array.isArray(node)) return true
555
+ const op = node[0]
556
+ // `a[i] = rhs` — the fill write. Allowed iff rhs is provably NUMBER; recurse the index
557
+ // and rhs so a stray `a` inside either still disqualifies. (Compound `a[i] += …` is NOT
558
+ // matched here, so it falls through to the deny below — conservative for v1.)
559
+ if (op === '=' && Array.isArray(node[1]) && node[1][0] === '[]' && node[1][1] === name)
560
+ return isNumericRhs(node[2], name) &&
561
+ numFillSafe(node[1][2], name, isNumericRhs) && numFillSafe(node[2], name, isNumericRhs)
562
+ // calling `a`, `a.m(…)`, `a[i](…)` may grow/escape it
563
+ if (op === '()') {
564
+ const c = node[1]
565
+ if (c === name) return false
566
+ if (Array.isArray(c) && (c[0] === '.' || c[0] === '?.' || c[0] === '[]' || c[0] === '?.[]') && c[1] === name) return false
567
+ }
568
+ // any other write/update/delete on `a`, `a[..]`, `a.x` (incl. `a.length = …`, compounds)
569
+ if (grownOrEscapes(op)) {
570
+ const t = node[1]
571
+ if (t === name) return false
572
+ if (Array.isArray(t) && (t[0] === '[]' || t[0] === '.' || t[0] === '?.') && t[1] === name) return false
573
+ }
574
+ if (op === 'let' || op === 'const' || op === 'var') {
575
+ for (let i = 1; i < node.length; i++) {
576
+ const d = node[i]
577
+ if (Array.isArray(d) && d[0] === '=' && !numFillSafe(d[2], name, isNumericRhs)) return false
578
+ }
579
+ return true
580
+ }
581
+ // the only safe forms: `a.length` read, and `a[i]` index read (recurse the index expr).
582
+ if ((op === '.' || op === '?.') && node[1] === name) return node[2] === 'length'
583
+ if (op === '[]' && node[1] === name) return numFillSafe(node[2], name, isNumericRhs)
584
+ if (op === '...' && node[1] === name) return false // spread → escape
585
+ for (let i = 1; i < node.length; i++) if (!numFillSafe(node[i], name, isNumericRhs)) return false
586
+ return true
587
+ }
588
+
589
+ export function scanNumericFill(body, isNumericRhs) {
590
+ const out = new Set()
591
+ for (const [name, s] of scanBindingUses(body)) {
592
+ if (s.decls !== 1 || !isFreshArrayCtor(s.initRhs)) continue
593
+ if (numFillSafe(body, name, isNumericRhs)) out.add(name)
594
+ }
595
+ return out
596
+ }
597
+
598
+ /**
599
+ * Narrow uint32 accumulator locals to unsigned i32. A local qualifies when its
600
+ * initializer is a non-negative integer literal in [0, 2^32), every
601
+ * reassignment is `name = (…) >>> k` (so it always holds a canonical uint32),
602
+ * and every read sits inside a `>>>` (ToUint32) sink reached only through
603
+ * bit-faithful operators (`^ & | ~ << >> + - *`). Under those constraints the
604
+ * raw i32 bit pattern reproduces JS semantics exactly — every observable use is
605
+ * funnelled through ToUint32 — so the f64 round-trip on the hot path is pure
606
+ * overhead. Names that escape (closures, bare `return`, signed-sensitive
607
+ * operands) keep their wider type. Returns the qualifying set; callers retype
608
+ * `locals` to 'i32' and tag `readVar` reads `.unsigned` for convert_i32_u.
609
+ */
610
+ export function narrowUint32(body, locals) {
611
+ const TRANSPARENT = new Set(['^', '&', '|', '~', '<<', '>>', '+', '-', '*'])
612
+ const initLit = new Set() // names with a valid u32-literal initializer
613
+ const disq = new Set() // names disqualified by an unsafe occurrence
614
+ const seen = new Set()
615
+ const isU32Lit = e => {
616
+ const v = typeof e === 'number' ? e
617
+ : Array.isArray(e) && e[0] == null && typeof e[1] === 'number' ? e[1] : NaN
618
+ return Number.isInteger(v) && v >= 0 && v < 4294967296
619
+ }
620
+ const banNames = n => {
621
+ if (typeof n === 'string') disq.add(n)
622
+ else if (Array.isArray(n)) for (let i = 1; i < n.length; i++) banNames(n[i])
623
+ }
624
+ const walk = (node, underShr, inClosure) => {
625
+ if (typeof node === 'string') { if (inClosure) disq.add(node); return }
626
+ if (!Array.isArray(node)) return
627
+ const op = node[0]
628
+ if (typeof op !== 'string') {
629
+ for (let i = 1; i < node.length; i++) walk(node[i], false, inClosure)
630
+ return
631
+ }
632
+ if (op === '=>') { for (let i = 1; i < node.length; i++) walk(node[i], false, true); return }
633
+ if (op === 'let' || op === 'const') {
634
+ for (let i = 1; i < node.length; i++) {
635
+ const d = node[i]
636
+ if (Array.isArray(d) && d[0] === '=' && typeof d[1] === 'string') {
637
+ const nm = d[1]
638
+ if (seen.has(nm) || inClosure || !isU32Lit(d[2])) disq.add(nm)
639
+ else initLit.add(nm)
640
+ seen.add(nm)
641
+ walk(d[2], false, inClosure)
642
+ } else if (typeof d === 'string') { disq.add(d); seen.add(d) }
643
+ else if (Array.isArray(d) && d[0] === '=') { banNames(d[1]); walk(d[2], false, inClosure) }
644
+ }
645
+ return
646
+ }
647
+ if ((op === '++' || op === '--') && typeof node[1] === 'string') { disq.add(node[1]); return }
648
+ if (ASSIGN_OPS.has(op)) {
649
+ const lhs = node[1]
650
+ if (typeof lhs === 'string') {
651
+ if (op !== '=' || inClosure || !(Array.isArray(node[2]) && node[2][0] === '>>>')) disq.add(lhs)
652
+ } else banNames(lhs)
653
+ walk(node[2], false, inClosure)
654
+ return
655
+ }
656
+ const childShr = op === '>>>' ? true : TRANSPARENT.has(op) ? underShr : false
657
+ for (let i = 1; i < node.length; i++) {
658
+ const c = node[i]
659
+ if (typeof c === 'string') { if (inClosure || !childShr) disq.add(c) }
660
+ else walk(c, childShr, inClosure)
661
+ }
662
+ }
663
+ walk(body, false, false)
664
+ const result = new Set()
665
+ for (const nm of initLit) {
666
+ if (disq.has(nm)) continue
667
+ const t = locals.get(nm)
668
+ if (t !== 'i32' && t !== 'f64') continue
669
+ locals.set(nm, 'i32')
670
+ result.add(nm)
671
+ }
672
+ return result
673
+ }
674
+
675
+ // Operators under which a counter remains a *monotone, bounded* function of the
676
+ // index root: an affine index `base + i*stride` (and `i << k`) whose computed
677
+ // offset must fit i32-addressable wasm32 memory therefore bounds the counter to
678
+ // i32 range. `/ % & | ^ >> >>>` are excluded — they decouple the index magnitude
679
+ // from the counter (`arr[i & 7]` stays small however large `i` grows), so they
680
+ // prove nothing about the counter's range.
681
+ const AFFINE_INDEX_OPS = new Set(['+', '-', '*', '<<', 'u-'])
682
+
683
+ /**
684
+ * Locals proven to stay within i32 range, so they need not widen to f64 when
685
+ * compared against an f64 loop bound. Keeping them i32 yields direct i32 indexing
686
+ * (no per-access `trunc_sat_f64_s`) and lets the relational compare coerce the
687
+ * counter instead — the compiler-inferred form of the manual `let n = N | 0` hoist.
688
+ *
689
+ * Two sound sources of an i32-range proof:
690
+ * 1. Direct: a local appears as an *affine* component of an array index. A valid
691
+ * wasm32 access requires the byte offset to fit i32, and an affine index is
692
+ * monotone in the local, so the local is i32-bounded for every non-trapping run.
693
+ * 2. Transitive (back-propagation): a local that flows — via affine
694
+ * assignment/step (`let i0 = ix`, `i0 += id`) — into an already-bounded index
695
+ * var is itself bounded by that var's range. This captures the common
696
+ * nested-loop shape where the outer bound seeds an inner index (FFT butterflies:
697
+ * `while (ix < N) { let i0 = ix; while (i0 < N) … x[i0] … i0 += id }`).
698
+ *
699
+ * Fractional locals are unaffected: this set only suppresses *comparison*-driven
700
+ * widening; the assignment fixpoint that follows still widens any local with an
701
+ * f64-typed RHS (`i = i / 3`), overriding membership here.
702
+ */
703
+ // An integer literal that fits signed i32 — the only constant a promoted i32
704
+ // local may hold. A larger integer (`0xFFFFFFFF`, a NaN-box mask) is emitted as
705
+ // an f64.const, so treating it as an i32 leaf would store f64 into an i32 local.
706
+ const isI32Lit = (v) => typeof v === 'number' && Number.isInteger(v) && v >= -2147483648 && v <= 2147483647
707
+
708
+ export function collectI32SafeIndexVars(body, locals) {
709
+ const safe = new Set()
710
+ // Collect names reachable from `node` through affine ops only, into `sink`.
711
+ const addAffine = (node, sink) => {
712
+ if (typeof node === 'string') { sink.add(node); return }
713
+ if (!Array.isArray(node)) return
714
+ if (AFFINE_INDEX_OPS.has(node[0])) for (let i = 1; i < node.length; i++) addAffine(node[i], sink)
715
+ }
716
+ // Pass 1: record assignment edges (back-prop) + a name→definitions map (for the
717
+ // integer-shape test). `+= …` reconstructs to `name + …` so its shape includes
718
+ // the prior value.
719
+ const edges = []
720
+ const defs = new Map()
721
+ const addDef = (name, rhs) => { (defs.get(name) ?? defs.set(name, []).get(name)).push(rhs) }
722
+ const collect = (node) => {
723
+ if (!Array.isArray(node)) return
724
+ const op = node[0]
725
+ if (op === 'let' || op === 'const') {
726
+ for (let i = 1; i < node.length; i++) {
727
+ const d = node[i]
728
+ if (Array.isArray(d) && d[0] === '=' && typeof d[1] === 'string') { edges.push({ target: d[1], rhs: d[2] }); addDef(d[1], d[2]) }
729
+ }
730
+ } else if (op === '=' && typeof node[1] === 'string') { edges.push({ target: node[1], rhs: node[2] }); addDef(node[1], node[2]) }
731
+ else if ((op === '+=' || op === '-=' || op === '*=') && typeof node[1] === 'string') { edges.push({ target: node[1], rhs: node[2] }); addDef(node[1], [op[0], node[1], node[2]]) }
732
+ if (op === '=>') return
733
+ for (let i = 1; i < node.length; i++) collect(node[i])
734
+ }
735
+ collect(body)
736
+
737
+ // Integer-shaped AND i32-representable: provably an integer through `+ - * << u-`
738
+ // (AFFINE_INDEX_OPS — excludes `/`/`**`/fractional ops) over leaves that are
739
+ // i32-typed, i32-range integer literals, or other integer-shaped locals. Lets a
740
+ // hoisted offset `let o = y*w` (f64-typed product, integer-valued) qualify as an
741
+ // index leaf before narrowing. A fractional leaf, an out-of-i32-range literal, or
742
+ // a param of unknown type disqualifies — so no truncation and no f64.const→i32.
743
+ const isIntShaped = (node, seen) => {
744
+ if (typeof node === 'number') return isI32Lit(node)
745
+ if (typeof node === 'string') {
746
+ if (exprType(node, locals) === 'i32') return true
747
+ if (seen.has(node)) return true // recursion through a self-step — other defs still gate
748
+ const ds = defs.get(node)
749
+ if (!ds || !ds.length) return false // param / unknown source — not provably integer
750
+ seen.add(node)
751
+ const r = ds.every(d => isIntShaped(d, seen))
752
+ seen.delete(node)
753
+ return r
754
+ }
755
+ if (!Array.isArray(node)) return false
756
+ const op = node[0]
757
+ if (op == null) return isI32Lit(node[1]) // [null, value] literal
758
+ if (!AFFINE_INDEX_OPS.has(op)) return false
759
+ for (let i = 1; i < node.length; i++) if (node[i] != null && !isIntShaped(node[i], new Set(seen))) return false
760
+ return true
761
+ }
762
+
763
+ // Pass 2: seed from array indices already i32 OR integer-shaped (the latter
764
+ // rescues hoisted integer offsets the type pass left at f64). A fractional index
765
+ // (`mem[y*w+x]` with fractional `w`) is not integer-shaped → still truncs per
766
+ // access and is left to widen, preserving the prior guard.
767
+ const seed = (node) => {
768
+ if (!Array.isArray(node)) return
769
+ const op = node[0]
770
+ if (op === '[]' && !isLiteralStr(node[2]) && (exprType(node[2], locals) === 'i32' || isIntShaped(node[2], new Set()))) addAffine(node[2], safe)
771
+ if (op === '=>') return
772
+ for (let i = 1; i < node.length; i++) seed(node[i])
773
+ }
774
+ seed(body)
775
+
776
+ // Back-propagate to a fixpoint: feeders of a bounded index var are bounded.
777
+ let changed = true
778
+ while (changed) {
779
+ changed = false
780
+ for (const { target, rhs } of edges) {
781
+ if (!safe.has(target)) continue
782
+ const src = new Set()
783
+ addAffine(rhs, src)
784
+ for (const s of src) if (!safe.has(s)) { safe.add(s); changed = true }
785
+ }
786
+ }
787
+ // Promote integer-shaped index feeders the type pass left at f64 (a hoisted
788
+ // `o = y*w`). The byte offset must fit i32-addressable memory, so the i32-wrap
789
+ // residue reproduces the true in-bounds value — same contract as inline `a[y*w+x]`.
790
+ // Skip boxed (closure-captured) cells — those live as f64 in memory.
791
+ for (const n of safe) if (locals.get(n) === 'f64' && !ctx.func.boxed?.has(n) && isIntShaped(n, new Set())) locals.set(n, 'i32')
792
+ return safe
793
+ }
794
+
795
+ /**
796
+ * Locals that affinely feed an *f64-typed* array index (e.g. `mem[i*w + x]` with
797
+ * an f64 stride/global `w`). The access truncs the byte offset regardless, so
798
+ * keeping such a counter i32 buys no trunc savings and ADDS a per-iteration
799
+ * compare-convert — a net loss (the game-of-life regression). These are excluded
800
+ * from the integer-counter i32-keep in analyzeBody's widenPass, so they widen to
801
+ * f64 as before. (A counter used only in arithmetic — no f64 index — is NOT here,
802
+ * so it stays i32, where the i32 body + increment is the real win.)
803
+ */
804
+ export function collectF64StridedIndexVars(body, locals) {
805
+ const set = new Set()
806
+ const addAffine = (node) => {
807
+ if (typeof node === 'string') { set.add(node); return }
808
+ if (Array.isArray(node) && AFFINE_INDEX_OPS.has(node[0])) for (let i = 1; i < node.length; i++) addAffine(node[i])
809
+ }
810
+ const walk = (node) => {
811
+ if (!Array.isArray(node)) return
812
+ if (node[0] === '[]' && !isLiteralStr(node[2]) && exprType(node[2], locals) === 'f64') addAffine(node[2])
813
+ if (node[0] === '=>') return
814
+ for (let i = 1; i < node.length; i++) walk(node[i])
815
+ }
816
+ walk(body)
817
+ return set
818
+ }
819
+
820
+ /**
821
+ * Returns the cached facts object directly — DO NOT MUTATE the returned maps.
822
+ * Callers that need to extend (e.g. add params to locals) must clone explicitly
823
+ * before mutating. Slice reads via `analyzeBody(body).<slice>`.
824
+ */