jz 0.6.0 → 0.8.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 (63) hide show
  1. package/README.md +99 -72
  2. package/bench/README.md +253 -100
  3. package/bench/bench.svg +58 -81
  4. package/cli.js +85 -12
  5. package/dist/interop.js +1 -0
  6. package/dist/jz.js +6196 -0
  7. package/index.d.ts +126 -0
  8. package/index.js +222 -35
  9. package/interop.js +240 -141
  10. package/layout.js +34 -18
  11. package/module/array.js +99 -111
  12. package/module/collection.js +124 -30
  13. package/module/console.js +1 -1
  14. package/module/core.js +163 -19
  15. package/module/json.js +3 -3
  16. package/module/math.js +162 -3
  17. package/module/number.js +268 -13
  18. package/module/object.js +37 -5
  19. package/module/regex.js +8 -7
  20. package/module/simd.js +37 -5
  21. package/module/string.js +203 -168
  22. package/module/typedarray.js +565 -112
  23. package/package.json +26 -7
  24. package/src/abi/string.js +29 -29
  25. package/src/ast.js +19 -2
  26. package/src/autoload.js +3 -0
  27. package/src/compile/analyze-scans.js +174 -11
  28. package/src/compile/analyze.js +101 -4
  29. package/src/compile/cse-load.js +200 -0
  30. package/src/compile/emit-assign.js +82 -20
  31. package/src/compile/emit.js +592 -51
  32. package/src/compile/index.js +504 -89
  33. package/src/compile/infer.js +36 -1
  34. package/src/compile/loop-divmod.js +109 -0
  35. package/src/compile/loop-model.js +91 -0
  36. package/src/compile/loop-square.js +102 -0
  37. package/src/compile/narrow.js +275 -41
  38. package/src/compile/peel-stencil.js +215 -0
  39. package/src/compile/plan/advise.js +55 -1
  40. package/src/compile/plan/common.js +29 -0
  41. package/src/compile/plan/index.js +8 -1
  42. package/src/compile/plan/inline.js +180 -22
  43. package/src/compile/plan/literals.js +313 -24
  44. package/src/compile/plan/scope.js +115 -39
  45. package/src/compile/program-facts.js +21 -2
  46. package/src/ctx.js +96 -19
  47. package/src/helper-counters.js +137 -0
  48. package/src/ir.js +157 -20
  49. package/src/kind-traits.js +34 -3
  50. package/src/kind.js +79 -4
  51. package/src/op-policy.js +5 -2
  52. package/src/ops.js +119 -0
  53. package/src/optimize/index.js +1274 -151
  54. package/src/optimize/recurse.js +182 -0
  55. package/src/optimize/vectorize.js +4187 -253
  56. package/src/prepare/index.js +75 -14
  57. package/src/prepare/lift-iife.js +149 -0
  58. package/src/reps.js +6 -2
  59. package/src/static.js +9 -0
  60. package/src/type.js +63 -51
  61. package/src/wat/assemble.js +286 -21
  62. package/src/widen.js +21 -0
  63. package/src/wat/optimize.js +0 -3760
package/package.json CHANGED
@@ -1,17 +1,22 @@
1
1
  {
2
2
  "name": "jz",
3
- "version": "0.6.0",
4
- "description": "Functional JS subset compiling to WASM",
3
+ "version": "0.8.0",
4
+ "description": "Ahead-of-time compiler for the numeric core of JavaScript (DSP, audio, math, parsers) to lean GC-free WASM — valid jz is valid JS, no type annotations, no runtime.",
5
5
  "main": "index.js",
6
+ "types": "index.d.ts",
6
7
  "type": "module",
7
8
  "exports": {
8
- ".": "./index.js",
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "default": "./index.js"
12
+ },
9
13
  "./wasi": "./wasi.js",
10
14
  "./interop": "./interop.js",
11
15
  "./transform": "./transform.js"
12
16
  },
13
17
  "files": [
14
18
  "index.js",
19
+ "index.d.ts",
15
20
  "cli.js",
16
21
  "wasi.js",
17
22
  "interop.js",
@@ -20,6 +25,8 @@
20
25
  "jzify",
21
26
  "src",
22
27
  "module",
28
+ "dist/jz.js",
29
+ "dist/interop.js",
23
30
  "README.md",
24
31
  "jz.svg",
25
32
  "alternatives.svg",
@@ -48,9 +55,14 @@
48
55
  "bench:size": "node scripts/bench-size.mjs",
49
56
  "bench:compile": "node scripts/bench-compile.mjs",
50
57
  "bench:startup": "node scripts/bench-startup.mjs",
58
+ "bench:self": "node scripts/bench-selfhost.mjs",
59
+ "bench:readme": "node scripts/bench-readme.mjs",
60
+ "audit:as": "node scripts/audit-assemblyscript.mjs",
51
61
  "build": "node scripts/build-dist.mjs",
52
- "build:examples": "for dir in examples/*/; do [ -f \"$dir/build.mjs\" ] && node \"$dir/build.mjs\"; done",
53
- "prepublishOnly": "npm test && npm run test:self"
62
+ "build:examples": "node examples/build.mjs all",
63
+ "prepublishOnly": "npm test && npm run build && npm run test:self",
64
+ "prepare": "npm run build",
65
+ "audit:fixpoint": "node scripts/audit-fixpoint.mjs"
54
66
  },
55
67
  "repository": {
56
68
  "type": "git",
@@ -64,7 +76,7 @@
64
76
  "license": "MIT",
65
77
  "dependencies": {
66
78
  "subscript": "^10.4.17",
67
- "watr": "^4.6.10"
79
+ "watr": "^4.7.1"
68
80
  },
69
81
  "keywords": [
70
82
  "javascript",
@@ -81,10 +93,17 @@
81
93
  "audio",
82
94
  "creative-coding",
83
95
  "assemblyscript",
84
- "porffor"
96
+ "porffor",
97
+ "numeric",
98
+ "signal-processing",
99
+ "math",
100
+ "audio-worklet",
101
+ "js-to-wasm",
102
+ "performance"
85
103
  ],
86
104
  "devDependencies": {
87
105
  "esbuild": "^0.28.0",
106
+ "sprae": "^13.3.8",
88
107
  "tst": "^9.4.0"
89
108
  }
90
109
  }
package/src/abi/string.js CHANGED
@@ -56,7 +56,7 @@
56
56
  // take. Kept as a one-liner so each op reads as a single `call`.
57
57
  const ssoI64 = (sF64) => ['i64.reinterpret_f64', sF64]
58
58
 
59
- import { isReassigned } from '../ast.js'
59
+ import { isReassigned, isLeaf } from '../ast.js'
60
60
  import { LAYOUT, oobNanIR, ssoBitI64Hex } from '../../layout.js'
61
61
 
62
62
  /** Pre-shifted SSO discriminator — layout.js is cycle-free; memoized at first use. */
@@ -79,10 +79,6 @@ const allocLocalI32 = (ctx, tag) => {
79
79
  return name
80
80
  }
81
81
 
82
- const _isLeaf = (n) => Array.isArray(n) &&
83
- (n[0] === 'local.get' || n[0] === 'global.get' ||
84
- n[0] === 'i32.const' || n[0] === 'i64.const' ||
85
- n[0] === 'f64.const' || n[0] === 'f32.const')
86
82
 
87
83
  /** Per-use cheap form of `charCodeAt` against a pre-decomposed param receiver
88
84
  * (shape 1): a bounds check plus a 2-arm SSO/heap byte select reading the four
@@ -90,22 +86,29 @@ const _isLeaf = (n) => Array.isArray(n) &&
90
86
  * side-effecting `iI32` is spilled to a scratch local first — leaves are safe
91
87
  * to duplicate. `oobNan` picks the OOB contract / result type exactly as in
92
88
  * the generic path. */
93
- function emitDecompCharRead(dec, iI32, ctx, oobNan) {
89
+ function emitDecompCharRead(dec, iI32, ctx, oobNan, inBounds = false) {
94
90
  const rt = oobNan ? 'f64' : 'i32'
95
91
  let idx = iI32, spill = null
96
- if (!_isLeaf(iI32)) { spill = allocLocalI32(ctx, 'ci'); idx = ['local.get', `$${spill}`] }
97
- const ssoByteExpr = ['i32.and',
98
- ['i32.shr_u', ['local.get', `$${dec.base}`], ['i32.shl', idx, ['i32.const', 3]]],
99
- ['i32.const', 0xFF]]
92
+ if (!isLeaf(iI32)) { spill = allocLocalI32(ctx, 'ci'); idx = ['local.get', `$${spill}`] }
93
+ const ssoByteExpr = ['i32.wrap_i64', ['i64.and',
94
+ ['i64.shr_u', ['local.get', `$${dec.ptr64}`], ['i64.mul', ['i64.extend_i32_u', idx], ['i64.const', 7]]],
95
+ ['i64.const', '0x7f']]]
100
96
  const heapByteExpr = ['i32.load8_u', ['i32.add', ['local.get', `$${dec.loadbase}`], idx]]
101
97
  const ccByte = ['if', ['result', 'i32'],
102
98
  ['local.get', `$${dec.sso}`],
103
99
  ['then', ssoByteExpr],
104
100
  ['else', heapByteExpr]]
105
- const use = ['if', ['result', rt],
106
- ['i32.ge_u', idx, ['local.get', `$${dec.len}`]],
107
- ['then', oobNan ? oobNanIR() : ['i32.const', 0]],
108
- ['else', oobNan ? ['f64.convert_i32_u', ccByte] : ccByte]]
101
+ // `inBounds`: the index is proven in [0, len) by an enclosing canonical scan
102
+ // (analyze.js inBoundsCharCodeAt / splitCharScanLoops' in-bounds main loop), so
103
+ // the OOB arm is dead — drop the per-char `i >= len` compare. This is what turns
104
+ // a split char-scan loop into a bare load (the `if(sso)` arm is loop-invariant and
105
+ // V8-folds), matching AS/native on tokenizer-shape scans. Otherwise keep the guard.
106
+ const use = inBounds
107
+ ? (oobNan ? ['f64.convert_i32_u', ccByte] : ccByte)
108
+ : ['if', ['result', rt],
109
+ ['i32.ge_u', idx, ['local.get', `$${dec.len}`]],
110
+ ['then', oobNan ? oobNanIR() : ['i32.const', 0]],
111
+ ['else', oobNan ? ['f64.convert_i32_u', ccByte] : ccByte]]
109
112
  return spill
110
113
  ? ['block', ['result', rt], ['local.set', `$${spill}`, iI32], use]
111
114
  : use
@@ -131,9 +134,8 @@ export function emitCharDecompPrologue(dec) {
131
134
  ['i64.const', 0]]
132
135
  const offMask = `0x${LAYOUT.OFFSET_MASK.toString(16).toUpperCase()}`
133
136
  const off = ['i32.wrap_i64', ['i64.and', ptr, ['i64.const', offMask]]]
134
- const ssoLen = ['i32.and',
135
- ['i32.wrap_i64', ['i64.shr_u', ptr, ['i64.const', LAYOUT.AUX_SHIFT]]],
136
- ['i32.const', LAYOUT.SSO_BIT - 1]]
137
+ // SSO length lives at payload bits 42-44 (7-bit-codec layout; see module/string.js).
138
+ const ssoLen = ['i32.wrap_i64', ['i64.and', ['i64.shr_u', ptr, ['i64.const', 42]], ['i64.const', 7]]]
137
139
  // Heap length is `i32.load(off - 4)`; guard against off<4 (corrupt/non-string
138
140
  // payload) so the load doesn't trap on a wrapped-negative address.
139
141
  const heapLen = ['if', ['result', 'i32'],
@@ -146,6 +148,7 @@ export function emitCharDecompPrologue(dec) {
146
148
  ['then',
147
149
  ['local.set', `$${dec.sso}`, ['i32.const', 1]],
148
150
  ['local.set', `$${dec.base}`, off],
151
+ ['local.set', `$${dec.ptr64}`, ptr], // full payload: SSO chars are 7-bit, span into aux
149
152
  ['local.set', `$${dec.len}`, ssoLen],
150
153
  // SSO: route every per-iter load to address 0 (always valid, byte
151
154
  // discarded by the outer select).
@@ -212,7 +215,7 @@ export const sso = {
212
215
  * `src/compile.js#emitFunc` — it drains `ctx.func.charDecomp` after the
213
216
  * body emit completes and splices an init block between the boxed-param
214
217
  * inits and the user statements. */
215
- charCodeAt: (sF64, iI32, ctx, oobNan = false) => {
218
+ charCodeAt: (sF64, iI32, ctx, oobNan = false, inBounds = false) => {
216
219
  // `oobNan` selects the out-of-bounds semantics and result type:
217
220
  // - false (default): OOB → `i32.const 0`, result i32 — the raw-byte
218
221
  // primitive used by the `buf += s[i]` append-byte fast path.
@@ -256,14 +259,16 @@ export const sso = {
256
259
  // discards it). Pre-computing it in the prologue removes a
257
260
  // per-iter `select` and lets V8 fold the add into the load.
258
261
  const loadbase = `${name}$ccldb`
262
+ const ptr64 = `${name}$ccp64`
259
263
  ctx.func.locals.set(base, 'i32')
260
264
  ctx.func.locals.set(len, 'i32')
261
265
  ctx.func.locals.set(sso, 'i32')
262
266
  ctx.func.locals.set(loadbase, 'i32')
263
- dec = { base, len, sso, loadbase, param: name }
267
+ ctx.func.locals.set(ptr64, 'i64') // full SSO payload for 7-bit char extraction
268
+ dec = { base, len, sso, loadbase, ptr64, param: name }
264
269
  ctx.func.charDecomp.set(name, dec)
265
270
  }
266
- return emitDecompCharRead(dec, iI32, ctx, oobNan)
271
+ return emitDecompCharRead(dec, iI32, ctx, oobNan, inBounds)
267
272
  }
268
273
  }
269
274
 
@@ -277,9 +282,6 @@ export const sso = {
277
282
  // so we MUST spill anything that isn't side-effect-free to a local
278
283
  // before referencing it more than once. Leaves (`local.get`, `*.const`,
279
284
  // `global.get`) are safe to duplicate; everything else is spilled.
280
- const isLeaf = (n) => Array.isArray(n) &&
281
- (n[0] === 'local.get' || n[0] === 'global.get' ||
282
- n[0] === 'i32.const' || n[0] === 'i64.const' || n[0] === 'f64.const' || n[0] === 'f32.const')
283
285
  const sLeaf = isLeaf(sF64)
284
286
  const iLeaf = isLeaf(iI32)
285
287
  const ptrI64Expr = ssoI64(sF64)
@@ -301,12 +303,10 @@ export const sso = {
301
303
  }
302
304
  const offMask = `0x${LAYOUT.OFFSET_MASK.toString(16).toUpperCase()}`
303
305
  const offExpr = () => ['i32.wrap_i64', ['i64.and', getPtr(), ['i64.const', offMask]]]
304
- const ssoLen = ['i32.and',
305
- ['i32.wrap_i64', ['i64.shr_u', getPtr(), ['i64.const', LAYOUT.AUX_SHIFT]]],
306
- ['i32.const', LAYOUT.SSO_BIT - 1]]
307
- const ssoByte = ['i32.and',
308
- ['i32.shr_u', offExpr(), ['i32.mul', getIdx(), ['i32.const', 8]]],
309
- ['i32.const', 0xFF]]
306
+ const ssoLen = ['i32.wrap_i64', ['i64.and', ['i64.shr_u', getPtr(), ['i64.const', 42]], ['i64.const', 7]]]
307
+ const ssoByte = ['i32.wrap_i64', ['i64.and',
308
+ ['i64.shr_u', getPtr(), ['i64.mul', ['i64.extend_i32_u', getIdx()], ['i64.const', 7]]],
309
+ ['i64.const', '0x7f']]]
310
310
  const ssoBranch = ['if', ['result', rt],
311
311
  ['i32.ge_u', getIdx(), ssoLen],
312
312
  ['then', mkOob()],
package/src/ast.js CHANGED
@@ -66,6 +66,11 @@ export const isBlockBody = (body) =>
66
66
  export const isLiteralStr = idx => Array.isArray(idx) && idx[0] === 'str' && typeof idx[1] === 'string'
67
67
  export const isFuncRef = (node, funcNames) => typeof node === 'string' && funcNames.has(node)
68
68
 
69
+ /** A value-leaf IR instruction — `local.get`/`global.get`/any `*.const`. Cheap and
70
+ * side-effect-free, so safe to duplicate without spilling to a temp. The one
71
+ * source of truth for "is this trivially duplicatable" across ir/abi/optimize. */
72
+ export const isLeaf = n => Array.isArray(n) && (n[0] === 'local.get' || n[0] === 'global.get' || n[0].endsWith('.const'))
73
+
69
74
  // === Assignment / reassignment ===
70
75
 
71
76
  /** Assignment operators — shared across analyze, plan, emit, abi. */
@@ -385,9 +390,21 @@ export function cloneNode(node) {
385
390
  /** Structural equality via JSON. AST nodes are JSON-serializable except i64.const
386
391
  * BigInt payloads (NaN-box prefixes — dcbb433 routes pointer offsets through boxed
387
392
  * forms); the replacer stringifies those as `<n>n` (cf. formatErrorNode in ctx.js). */
388
- export const bigintSafeKey = (_k, v) => typeof v === 'bigint' ? `${v}n` : v
393
+ // Replacer for structural node-equality / dedup keys. JSON.stringify is the fast
394
+ // path, but it silently collapses values it can't round-trip: bigint throws, and —
395
+ // the subtle one — Infinity / -Infinity / NaN ALL stringify to `null` while -0
396
+ // stringifies to `0`. Two nodes differing ONLY in such a constant then serialize
397
+ // identically and compare equal — an unsound merge (SLP packs `[Inf,-Inf]` as one
398
+ // splat lane; CSE/LICM dedups distinct invariants). Tag each so it round-trips with
399
+ // Object.is semantics.
400
+ export const stableKey = (_k, v) => {
401
+ if (typeof v === 'bigint') return `${v}n`
402
+ if (typeof v === 'number' && (v !== v || v === Infinity || v === -Infinity || Object.is(v, -0)))
403
+ return v !== v ? '#NaN' : v === Infinity ? '#Inf' : v === -Infinity ? '#-Inf' : '#-0'
404
+ return v
405
+ }
389
406
  export function nodeEqual(a, b) {
390
- return JSON.stringify(a, bigintSafeKey) === JSON.stringify(b, bigintSafeKey)
407
+ return JSON.stringify(a, stableKey) === JSON.stringify(b, stableKey)
391
408
  }
392
409
 
393
410
  /** Property entries of an object-literal AST node (`['{}', …]`). */
package/src/autoload.js CHANGED
@@ -127,6 +127,9 @@ const MOD_DEPS = {
127
127
  date: ['core', 'number', 'string'],
128
128
  console: ['core', 'string', 'number'],
129
129
  regex: ['core', 'string', 'array'],
130
+ // f64x2.sin/cos lower to math's $math.sin2/$math.cos2 WAT helpers; pull math in so
131
+ // they're registered. Unused math helpers are pruned by reachability — no output cost.
132
+ simd: ['math'],
130
133
  }
131
134
 
132
135
  export function includeModule(name) {
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { ASSIGN_OPS, collectParamNames, extractParams, REFS_IN_EXPR, refsName, T, isLiteralStr } from '../ast.js'
7
7
  import { ctx } from '../ctx.js'
8
- import { staticObjectProps } from '../static.js'
8
+ import { staticObjectProps, staticArrayElems, staticIndexKey, staticValue, NO_VALUE } from '../static.js'
9
9
  import { exprType } from '../type.js'
10
10
 
11
11
  export function findFreeVars(node, bound, free, scope) {
@@ -168,7 +168,7 @@ export function scanBindingUses(body) {
168
168
  const use = (name, kind, extra) => slot(name).uses.push(extra ? { kind, ...extra } : { kind })
169
169
 
170
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
171
+ const litKey = (k) => (Array.isArray(k) && k[0] === 'str' && typeof k[1] === 'string') ? k[1] : staticIndexKey(k)
172
172
 
173
173
  // A child sitting in a value position. A bare string there is a real use —
174
174
  // `walk` alone silently drops non-array children, so every value-position
@@ -350,6 +350,10 @@ export function scanBindingUses(body) {
350
350
  * Field `i` of binding `o` lives in WASM local `o#${i}` (`#` cannot occur in a
351
351
  * jz identifier, so the name is collision-free).
352
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
+
353
357
  export function scanFlatObjects(body) {
354
358
  const cand = new Map() // name → {names, values}
355
359
 
@@ -357,19 +361,48 @@ export function scanFlatObjects(body) {
357
361
  // slots). Used only to reject a self-referential initializer — a literal
358
362
  // whose own field values mention the binding is not a self-contained object.
359
363
  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))
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] !== '{}'
362
388
  if (!props || new Set(props.names).size !== props.names.length) continue
363
389
  if (props.values.some(v => refsName(v, name, REFS_IN_EXPR))) continue
364
390
 
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.
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.
369
399
  const schema = new Set(props.names)
400
+ const written = new Set()
370
401
  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)
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
+ }
373
406
 
374
407
  // Flat iff every mention is an in-schema literal-key `.`/`[]` READ, or an
375
408
  // in-schema literal-key plain `.`/`[]` WRITE. Any other use kind — `?.`,
@@ -385,7 +418,7 @@ export function scanFlatObjects(body) {
385
418
  const names = props.names.slice(), values = props.values.slice()
386
419
  for (const k of schema)
387
420
  if (!names.includes(k)) { names.push(k); values.push(undefined) }
388
- cand.set(name, { names, values })
421
+ cand.set(name, { names, values, written })
389
422
  }
390
423
  return cand
391
424
  }
@@ -432,6 +465,136 @@ export function scanSliceViews(body) {
432
465
  return views
433
466
  }
434
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
+
435
598
  /**
436
599
  * Narrow uint32 accumulator locals to unsigned i32. A local qualifies when its
437
600
  * initializer is a non-negative integer literal in [0, 2^32), every