jz 0.5.1 → 0.6.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 (80) hide show
  1. package/README.md +288 -314
  2. package/bench/README.md +319 -0
  3. package/bench/bench.svg +112 -0
  4. package/cli.js +32 -24
  5. package/index.js +177 -55
  6. package/interop.js +88 -159
  7. package/jz.svg +5 -0
  8. package/jzify/arguments.js +97 -0
  9. package/jzify/bundler.js +382 -0
  10. package/jzify/classes.js +328 -0
  11. package/jzify/hoist-vars.js +177 -0
  12. package/jzify/index.js +51 -0
  13. package/jzify/names.js +37 -0
  14. package/jzify/switch.js +106 -0
  15. package/jzify/transform.js +349 -0
  16. package/layout.js +179 -0
  17. package/module/array.js +322 -153
  18. package/module/collection.js +603 -145
  19. package/module/console.js +55 -43
  20. package/module/core.js +266 -153
  21. package/module/date.js +15 -3
  22. package/module/function.js +73 -6
  23. package/module/index.js +2 -1
  24. package/module/json.js +226 -61
  25. package/module/math.js +414 -186
  26. package/module/number.js +306 -60
  27. package/module/object.js +448 -184
  28. package/module/regex.js +255 -25
  29. package/module/schema.js +24 -6
  30. package/module/simd.js +85 -0
  31. package/module/string.js +586 -220
  32. package/module/symbol.js +1 -1
  33. package/module/timer.js +9 -14
  34. package/module/typedarray.js +45 -48
  35. package/package.json +41 -12
  36. package/src/abi/index.js +39 -23
  37. package/src/abi/string.js +38 -41
  38. package/src/ast.js +460 -0
  39. package/src/autoload.js +26 -24
  40. package/src/bridge.js +111 -0
  41. package/src/compile/analyze-scans.js +661 -0
  42. package/src/compile/analyze.js +1565 -0
  43. package/src/compile/emit-assign.js +408 -0
  44. package/src/compile/emit.js +3201 -0
  45. package/src/compile/flow-types.js +103 -0
  46. package/src/{compile.js → compile/index.js} +497 -125
  47. package/src/{infer.js → compile/infer.js} +27 -98
  48. package/src/{narrow.js → compile/narrow.js} +302 -96
  49. package/src/compile/plan/advise.js +316 -0
  50. package/src/compile/plan/common.js +150 -0
  51. package/src/compile/plan/index.js +118 -0
  52. package/src/compile/plan/inline.js +679 -0
  53. package/src/compile/plan/literals.js +984 -0
  54. package/src/compile/plan/loops.js +472 -0
  55. package/src/compile/plan/scope.js +573 -0
  56. package/src/compile/program-facts.js +404 -0
  57. package/src/ctx.js +176 -58
  58. package/src/ir.js +540 -171
  59. package/src/kind-traits.js +105 -0
  60. package/src/kind.js +462 -0
  61. package/src/op-policy.js +57 -0
  62. package/src/{optimize.js → optimize/index.js} +1106 -446
  63. package/src/optimize/vectorize.js +1874 -0
  64. package/src/param-reps.js +65 -0
  65. package/src/parse.js +44 -0
  66. package/src/{prepare.js → prepare/index.js} +589 -202
  67. package/src/reps.js +115 -0
  68. package/src/resolve.js +12 -3
  69. package/src/static.js +199 -0
  70. package/src/type.js +647 -0
  71. package/src/{assemble.js → wat/assemble.js} +86 -48
  72. package/src/wat/optimize.js +3760 -0
  73. package/transform.js +21 -0
  74. package/wasi.js +47 -5
  75. package/src/analyze.js +0 -3818
  76. package/src/emit.js +0 -3040
  77. package/src/jzify.js +0 -1580
  78. package/src/plan.js +0 -2132
  79. package/src/vectorize.js +0 -1088
  80. /package/src/{codegen.js → wat/codegen.js} +0 -0
@@ -0,0 +1,661 @@
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 } 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] : null
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
+ export function scanFlatObjects(body) {
354
+ const cand = new Map() // name → {names, values}
355
+
356
+ // A binding referenced as a value inside `node` (skips `:`/`.` property-name
357
+ // slots). Used only to reject a self-referential initializer — a literal
358
+ // whose own field values mention the binding is not a self-contained object.
359
+ for (const [name, s] of scanBindingUses(body)) {
360
+ if (s.decls !== 1 || !Array.isArray(s.initRhs) || s.initRhs[0] !== '{}') continue
361
+ const props = staticObjectProps(s.initRhs.slice(1))
362
+ if (!props || new Set(props.names).size !== props.names.length) continue
363
+ if (props.values.some(v => refsName(v, name, REFS_IN_EXPR))) continue
364
+
365
+ // Schema = literal keys ∪ plain literal-key member writes. Such a write
366
+ // monotonically extends the static field universe (the new field reads
367
+ // `undefined` until the write runs, exactly as JS does); the schema stays
368
+ // closed because any computed/off-schema access disqualifies below.
369
+ const schema = new Set(props.names)
370
+ for (const u of s.uses)
371
+ if (u.kind === USE.MEMBER_W && !u.compound && !u.computed && u.key != null)
372
+ schema.add(u.key)
373
+
374
+ // Flat iff every mention is an in-schema literal-key `.`/`[]` READ, or an
375
+ // in-schema literal-key plain `.`/`[]` WRITE. Any other use kind — `?.`,
376
+ // computed/off-schema key, reassignment, compound or `delete` member write,
377
+ // `++`/`--`, call arg, closure capture, bare ref — leaves the object live.
378
+ const flat = s.uses.every(u =>
379
+ (u.kind === USE.MEMBER_R && !u.optional && !u.computed && schema.has(u.key)) ||
380
+ (u.kind === USE.MEMBER_W && !u.compound && !u.computed && schema.has(u.key)))
381
+ if (!flat) continue
382
+
383
+ // Materialize the parallel {names, values}: literal props first, then each
384
+ // extension field (value `undefined`), in first-write order.
385
+ const names = props.names.slice(), values = props.values.slice()
386
+ for (const k of schema)
387
+ if (!names.includes(k)) { names.push(k); values.push(undefined) }
388
+ cand.set(name, { names, values })
389
+ }
390
+ return cand
391
+ }
392
+
393
+ /**
394
+ * No-copy slice scan — which `let/const t = s.slice(...)` bindings can be a
395
+ * VIEW (a SLICE_BIT pointer straight into `s`'s buffer) instead of a fresh
396
+ * byte copy.
397
+ *
398
+ * jz rewinds the bump arena only at function exit, so every string the
399
+ * function can observe stays alive until it returns. A view is therefore sound
400
+ * exactly when its binding does NOT escape the function: `t` must never be
401
+ * returned, passed as a call argument, stored into a heap object/array,
402
+ * captured by a closure, aliased to another binding, reassigned, or
403
+ * compound-assigned. The permitted uses — receiver of a `.`/`[]`, operand of a
404
+ * comparison or `+`, a boolean test — read `t` synchronously and never persist
405
+ * it past the function.
406
+ *
407
+ * Declared exactly once as `let/const`. The result is purely structural —
408
+ * whether the receiver is actually a string (so `.slice` lowers to the string
409
+ * view) is settled later, at emit time, when param types are known; emitDecl
410
+ * keeps the ordinary copying slice for any non-string receiver. Conservative:
411
+ * any unrecognised position disqualifies the binding.
412
+ *
413
+ * Returns `Set<name>` of view-eligible binding names.
414
+ */
415
+ // Permitted use-kinds for a slice view — the value is read synchronously and
416
+ // never persisted past the function. `MEMBER_R`/`MEMBER_W` cover any `.`/`[]`
417
+ // receiver; `COMPARE` any comparison; `CONCAT`/`BOOL_TEST` the copy / test
418
+ // positions. Any other kind (reassign, call arg, return, capture, bare alias)
419
+ // escapes and disqualifies the binding.
420
+ const _SLICE_VIEW_OK = new Set([USE.MEMBER_R, USE.MEMBER_W, USE.COMPARE, USE.CONCAT, USE.BOOL_TEST])
421
+
422
+ export function scanSliceViews(body) {
423
+ const isSliceCall = (n) =>
424
+ Array.isArray(n) && n[0] === '()' && Array.isArray(n[1])
425
+ && n[1][0] === '.' && n[1][2] === 'slice'
426
+
427
+ const views = new Set()
428
+ for (const [name, s] of scanBindingUses(body)) {
429
+ if (s.decls !== 1 || !isSliceCall(s.initRhs)) continue
430
+ if (s.uses.every(u => _SLICE_VIEW_OK.has(u.kind))) views.add(name)
431
+ }
432
+ return views
433
+ }
434
+
435
+ /**
436
+ * Narrow uint32 accumulator locals to unsigned i32. A local qualifies when its
437
+ * initializer is a non-negative integer literal in [0, 2^32), every
438
+ * reassignment is `name = (…) >>> k` (so it always holds a canonical uint32),
439
+ * and every read sits inside a `>>>` (ToUint32) sink reached only through
440
+ * bit-faithful operators (`^ & | ~ << >> + - *`). Under those constraints the
441
+ * raw i32 bit pattern reproduces JS semantics exactly — every observable use is
442
+ * funnelled through ToUint32 — so the f64 round-trip on the hot path is pure
443
+ * overhead. Names that escape (closures, bare `return`, signed-sensitive
444
+ * operands) keep their wider type. Returns the qualifying set; callers retype
445
+ * `locals` to 'i32' and tag `readVar` reads `.unsigned` for convert_i32_u.
446
+ */
447
+ export function narrowUint32(body, locals) {
448
+ const TRANSPARENT = new Set(['^', '&', '|', '~', '<<', '>>', '+', '-', '*'])
449
+ const initLit = new Set() // names with a valid u32-literal initializer
450
+ const disq = new Set() // names disqualified by an unsafe occurrence
451
+ const seen = new Set()
452
+ const isU32Lit = e => {
453
+ const v = typeof e === 'number' ? e
454
+ : Array.isArray(e) && e[0] == null && typeof e[1] === 'number' ? e[1] : NaN
455
+ return Number.isInteger(v) && v >= 0 && v < 4294967296
456
+ }
457
+ const banNames = n => {
458
+ if (typeof n === 'string') disq.add(n)
459
+ else if (Array.isArray(n)) for (let i = 1; i < n.length; i++) banNames(n[i])
460
+ }
461
+ const walk = (node, underShr, inClosure) => {
462
+ if (typeof node === 'string') { if (inClosure) disq.add(node); return }
463
+ if (!Array.isArray(node)) return
464
+ const op = node[0]
465
+ if (typeof op !== 'string') {
466
+ for (let i = 1; i < node.length; i++) walk(node[i], false, inClosure)
467
+ return
468
+ }
469
+ if (op === '=>') { for (let i = 1; i < node.length; i++) walk(node[i], false, true); return }
470
+ if (op === 'let' || op === 'const') {
471
+ for (let i = 1; i < node.length; i++) {
472
+ const d = node[i]
473
+ if (Array.isArray(d) && d[0] === '=' && typeof d[1] === 'string') {
474
+ const nm = d[1]
475
+ if (seen.has(nm) || inClosure || !isU32Lit(d[2])) disq.add(nm)
476
+ else initLit.add(nm)
477
+ seen.add(nm)
478
+ walk(d[2], false, inClosure)
479
+ } else if (typeof d === 'string') { disq.add(d); seen.add(d) }
480
+ else if (Array.isArray(d) && d[0] === '=') { banNames(d[1]); walk(d[2], false, inClosure) }
481
+ }
482
+ return
483
+ }
484
+ if ((op === '++' || op === '--') && typeof node[1] === 'string') { disq.add(node[1]); return }
485
+ if (ASSIGN_OPS.has(op)) {
486
+ const lhs = node[1]
487
+ if (typeof lhs === 'string') {
488
+ if (op !== '=' || inClosure || !(Array.isArray(node[2]) && node[2][0] === '>>>')) disq.add(lhs)
489
+ } else banNames(lhs)
490
+ walk(node[2], false, inClosure)
491
+ return
492
+ }
493
+ const childShr = op === '>>>' ? true : TRANSPARENT.has(op) ? underShr : false
494
+ for (let i = 1; i < node.length; i++) {
495
+ const c = node[i]
496
+ if (typeof c === 'string') { if (inClosure || !childShr) disq.add(c) }
497
+ else walk(c, childShr, inClosure)
498
+ }
499
+ }
500
+ walk(body, false, false)
501
+ const result = new Set()
502
+ for (const nm of initLit) {
503
+ if (disq.has(nm)) continue
504
+ const t = locals.get(nm)
505
+ if (t !== 'i32' && t !== 'f64') continue
506
+ locals.set(nm, 'i32')
507
+ result.add(nm)
508
+ }
509
+ return result
510
+ }
511
+
512
+ // Operators under which a counter remains a *monotone, bounded* function of the
513
+ // index root: an affine index `base + i*stride` (and `i << k`) whose computed
514
+ // offset must fit i32-addressable wasm32 memory therefore bounds the counter to
515
+ // i32 range. `/ % & | ^ >> >>>` are excluded — they decouple the index magnitude
516
+ // from the counter (`arr[i & 7]` stays small however large `i` grows), so they
517
+ // prove nothing about the counter's range.
518
+ const AFFINE_INDEX_OPS = new Set(['+', '-', '*', '<<', 'u-'])
519
+
520
+ /**
521
+ * Locals proven to stay within i32 range, so they need not widen to f64 when
522
+ * compared against an f64 loop bound. Keeping them i32 yields direct i32 indexing
523
+ * (no per-access `trunc_sat_f64_s`) and lets the relational compare coerce the
524
+ * counter instead — the compiler-inferred form of the manual `let n = N | 0` hoist.
525
+ *
526
+ * Two sound sources of an i32-range proof:
527
+ * 1. Direct: a local appears as an *affine* component of an array index. A valid
528
+ * wasm32 access requires the byte offset to fit i32, and an affine index is
529
+ * monotone in the local, so the local is i32-bounded for every non-trapping run.
530
+ * 2. Transitive (back-propagation): a local that flows — via affine
531
+ * assignment/step (`let i0 = ix`, `i0 += id`) — into an already-bounded index
532
+ * var is itself bounded by that var's range. This captures the common
533
+ * nested-loop shape where the outer bound seeds an inner index (FFT butterflies:
534
+ * `while (ix < N) { let i0 = ix; while (i0 < N) … x[i0] … i0 += id }`).
535
+ *
536
+ * Fractional locals are unaffected: this set only suppresses *comparison*-driven
537
+ * widening; the assignment fixpoint that follows still widens any local with an
538
+ * f64-typed RHS (`i = i / 3`), overriding membership here.
539
+ */
540
+ // An integer literal that fits signed i32 — the only constant a promoted i32
541
+ // local may hold. A larger integer (`0xFFFFFFFF`, a NaN-box mask) is emitted as
542
+ // an f64.const, so treating it as an i32 leaf would store f64 into an i32 local.
543
+ const isI32Lit = (v) => typeof v === 'number' && Number.isInteger(v) && v >= -2147483648 && v <= 2147483647
544
+
545
+ export function collectI32SafeIndexVars(body, locals) {
546
+ const safe = new Set()
547
+ // Collect names reachable from `node` through affine ops only, into `sink`.
548
+ const addAffine = (node, sink) => {
549
+ if (typeof node === 'string') { sink.add(node); return }
550
+ if (!Array.isArray(node)) return
551
+ if (AFFINE_INDEX_OPS.has(node[0])) for (let i = 1; i < node.length; i++) addAffine(node[i], sink)
552
+ }
553
+ // Pass 1: record assignment edges (back-prop) + a name→definitions map (for the
554
+ // integer-shape test). `+= …` reconstructs to `name + …` so its shape includes
555
+ // the prior value.
556
+ const edges = []
557
+ const defs = new Map()
558
+ const addDef = (name, rhs) => { (defs.get(name) ?? defs.set(name, []).get(name)).push(rhs) }
559
+ const collect = (node) => {
560
+ if (!Array.isArray(node)) return
561
+ const op = node[0]
562
+ if (op === 'let' || op === 'const') {
563
+ for (let i = 1; i < node.length; i++) {
564
+ const d = node[i]
565
+ if (Array.isArray(d) && d[0] === '=' && typeof d[1] === 'string') { edges.push({ target: d[1], rhs: d[2] }); addDef(d[1], d[2]) }
566
+ }
567
+ } else if (op === '=' && typeof node[1] === 'string') { edges.push({ target: node[1], rhs: node[2] }); addDef(node[1], node[2]) }
568
+ 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]]) }
569
+ if (op === '=>') return
570
+ for (let i = 1; i < node.length; i++) collect(node[i])
571
+ }
572
+ collect(body)
573
+
574
+ // Integer-shaped AND i32-representable: provably an integer through `+ - * << u-`
575
+ // (AFFINE_INDEX_OPS — excludes `/`/`**`/fractional ops) over leaves that are
576
+ // i32-typed, i32-range integer literals, or other integer-shaped locals. Lets a
577
+ // hoisted offset `let o = y*w` (f64-typed product, integer-valued) qualify as an
578
+ // index leaf before narrowing. A fractional leaf, an out-of-i32-range literal, or
579
+ // a param of unknown type disqualifies — so no truncation and no f64.const→i32.
580
+ const isIntShaped = (node, seen) => {
581
+ if (typeof node === 'number') return isI32Lit(node)
582
+ if (typeof node === 'string') {
583
+ if (exprType(node, locals) === 'i32') return true
584
+ if (seen.has(node)) return true // recursion through a self-step — other defs still gate
585
+ const ds = defs.get(node)
586
+ if (!ds || !ds.length) return false // param / unknown source — not provably integer
587
+ seen.add(node)
588
+ const r = ds.every(d => isIntShaped(d, seen))
589
+ seen.delete(node)
590
+ return r
591
+ }
592
+ if (!Array.isArray(node)) return false
593
+ const op = node[0]
594
+ if (op == null) return isI32Lit(node[1]) // [null, value] literal
595
+ if (!AFFINE_INDEX_OPS.has(op)) return false
596
+ for (let i = 1; i < node.length; i++) if (node[i] != null && !isIntShaped(node[i], new Set(seen))) return false
597
+ return true
598
+ }
599
+
600
+ // Pass 2: seed from array indices already i32 OR integer-shaped (the latter
601
+ // rescues hoisted integer offsets the type pass left at f64). A fractional index
602
+ // (`mem[y*w+x]` with fractional `w`) is not integer-shaped → still truncs per
603
+ // access and is left to widen, preserving the prior guard.
604
+ const seed = (node) => {
605
+ if (!Array.isArray(node)) return
606
+ const op = node[0]
607
+ if (op === '[]' && !isLiteralStr(node[2]) && (exprType(node[2], locals) === 'i32' || isIntShaped(node[2], new Set()))) addAffine(node[2], safe)
608
+ if (op === '=>') return
609
+ for (let i = 1; i < node.length; i++) seed(node[i])
610
+ }
611
+ seed(body)
612
+
613
+ // Back-propagate to a fixpoint: feeders of a bounded index var are bounded.
614
+ let changed = true
615
+ while (changed) {
616
+ changed = false
617
+ for (const { target, rhs } of edges) {
618
+ if (!safe.has(target)) continue
619
+ const src = new Set()
620
+ addAffine(rhs, src)
621
+ for (const s of src) if (!safe.has(s)) { safe.add(s); changed = true }
622
+ }
623
+ }
624
+ // Promote integer-shaped index feeders the type pass left at f64 (a hoisted
625
+ // `o = y*w`). The byte offset must fit i32-addressable memory, so the i32-wrap
626
+ // residue reproduces the true in-bounds value — same contract as inline `a[y*w+x]`.
627
+ // Skip boxed (closure-captured) cells — those live as f64 in memory.
628
+ for (const n of safe) if (locals.get(n) === 'f64' && !ctx.func.boxed?.has(n) && isIntShaped(n, new Set())) locals.set(n, 'i32')
629
+ return safe
630
+ }
631
+
632
+ /**
633
+ * Locals that affinely feed an *f64-typed* array index (e.g. `mem[i*w + x]` with
634
+ * an f64 stride/global `w`). The access truncs the byte offset regardless, so
635
+ * keeping such a counter i32 buys no trunc savings and ADDS a per-iteration
636
+ * compare-convert — a net loss (the game-of-life regression). These are excluded
637
+ * from the integer-counter i32-keep in analyzeBody's widenPass, so they widen to
638
+ * f64 as before. (A counter used only in arithmetic — no f64 index — is NOT here,
639
+ * so it stays i32, where the i32 body + increment is the real win.)
640
+ */
641
+ export function collectF64StridedIndexVars(body, locals) {
642
+ const set = new Set()
643
+ const addAffine = (node) => {
644
+ if (typeof node === 'string') { set.add(node); return }
645
+ if (Array.isArray(node) && AFFINE_INDEX_OPS.has(node[0])) for (let i = 1; i < node.length; i++) addAffine(node[i])
646
+ }
647
+ const walk = (node) => {
648
+ if (!Array.isArray(node)) return
649
+ if (node[0] === '[]' && !isLiteralStr(node[2]) && exprType(node[2], locals) === 'f64') addAffine(node[2])
650
+ if (node[0] === '=>') return
651
+ for (let i = 1; i < node.length; i++) walk(node[i])
652
+ }
653
+ walk(body)
654
+ return set
655
+ }
656
+
657
+ /**
658
+ * Returns the cached facts object directly — DO NOT MUTATE the returned maps.
659
+ * Callers that need to extend (e.g. add params to locals) must clone explicitly
660
+ * before mutating. Slice reads via `analyzeBody(body).<slice>`.
661
+ */