jz 0.6.0 → 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 (49) hide show
  1. package/README.md +66 -43
  2. package/bench/README.md +128 -78
  3. package/bench/bench.svg +32 -42
  4. package/cli.js +73 -7
  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 +190 -34
  9. package/interop.js +71 -27
  10. package/layout.js +5 -0
  11. package/module/array.js +71 -39
  12. package/module/collection.js +41 -5
  13. package/module/core.js +2 -4
  14. package/module/math.js +138 -2
  15. package/module/number.js +21 -0
  16. package/module/object.js +26 -0
  17. package/module/simd.js +37 -5
  18. package/module/string.js +41 -12
  19. package/module/typedarray.js +415 -26
  20. package/package.json +21 -6
  21. package/src/autoload.js +3 -0
  22. package/src/compile/analyze-scans.js +174 -11
  23. package/src/compile/analyze.js +38 -3
  24. package/src/compile/emit-assign.js +9 -6
  25. package/src/compile/emit.js +347 -36
  26. package/src/compile/index.js +307 -29
  27. package/src/compile/infer.js +36 -1
  28. package/src/compile/loop-divmod.js +155 -0
  29. package/src/compile/narrow.js +108 -11
  30. package/src/compile/peel-stencil.js +261 -0
  31. package/src/compile/plan/advise.js +55 -1
  32. package/src/compile/plan/index.js +4 -0
  33. package/src/compile/plan/inline.js +5 -2
  34. package/src/compile/plan/literals.js +220 -5
  35. package/src/compile/plan/scope.js +115 -39
  36. package/src/compile/program-facts.js +21 -2
  37. package/src/ctx.js +45 -7
  38. package/src/ir.js +55 -7
  39. package/src/kind-traits.js +27 -0
  40. package/src/kind.js +65 -3
  41. package/src/optimize/index.js +344 -45
  42. package/src/optimize/vectorize.js +2968 -182
  43. package/src/prepare/index.js +64 -7
  44. package/src/prepare/lift-iife.js +149 -0
  45. package/src/reps.js +3 -2
  46. package/src/static.js +9 -0
  47. package/src/type.js +10 -6
  48. package/src/wat/assemble.js +195 -13
  49. package/src/wat/optimize.js +363 -185
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.7.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,10 @@
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:readme": "node scripts/bench-readme.mjs",
51
59
  "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"
60
+ "build:examples": "node examples/build.mjs all",
61
+ "prepare": "npm run build"
54
62
  },
55
63
  "repository": {
56
64
  "type": "git",
@@ -81,10 +89,17 @@
81
89
  "audio",
82
90
  "creative-coding",
83
91
  "assemblyscript",
84
- "porffor"
92
+ "porffor",
93
+ "numeric",
94
+ "signal-processing",
95
+ "math",
96
+ "audio-worklet",
97
+ "js-to-wasm",
98
+ "performance"
85
99
  ],
86
100
  "devDependencies": {
87
101
  "esbuild": "^0.28.0",
102
+ "sprae": "^13.3.8",
88
103
  "tst": "^9.4.0"
89
104
  }
90
105
  }
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
@@ -76,7 +76,7 @@ import { TYPED_ELEM_CODE, TYPED_ELEM_VIEW_FLAG, TYPED_ELEM_BIGINT_FLAG, encodeTy
76
76
  import {
77
77
  findFreeVars, findMutations, boxedCaptures,
78
78
  collectI32SafeIndexVars, collectF64StridedIndexVars, narrowUint32, scanBindingUses,
79
- scanFlatObjects, scanSliceViews, USE,
79
+ scanFlatObjects, scanSliceViews, scanNeverGrown, scanNumericFill, USE,
80
80
  } from './analyze-scans.js'
81
81
 
82
82
  export { findFreeVars, findMutations, boxedCaptures } from './analyze-scans.js'
@@ -124,6 +124,15 @@ const makeTypedTracker = (get, set, del) => {
124
124
  }
125
125
  const ctor = typedElemCtor(rhs)
126
126
  if (ctor) return setOrInvalidate(ctor)
127
+ // `recv.subarray(...)` is a zero-copy VIEW aliasing the receiver's buffer — its elem
128
+ // ctor is the receiver's type with the `.view` flag, so the binding unboxes to a typed
129
+ // pointer and element writes take the descriptor-indirected path (not desc-as-data).
130
+ if (Array.isArray(rhs) && rhs[0] === '()' && Array.isArray(rhs[1]) && rhs[1][0] === '.'
131
+ && rhs[1][2] === 'subarray' && typeof rhs[1][1] === 'string') {
132
+ const recvCtor = resolveName(rhs[1][1])
133
+ if (recvCtor) return setOrInvalidate((recvCtor.endsWith('.view') ? recvCtor.slice(0, -5) : recvCtor) + '.view')
134
+ return
135
+ }
127
136
  // TYPED-narrowed call result carries its elem aux on f.sig.ptrAux — reverse-map
128
137
  // to a canonical ctor so the unboxed local's rep restores the same aux.
129
138
  if (Array.isArray(rhs) && rhs[0] === '()' && typeof rhs[1] === 'string') {
@@ -319,8 +328,12 @@ export function analyzeBody(body) {
319
328
  }
320
329
  // `x = arr[i]` where `arr` is a known array-of-arrays → `x`'s elements take
321
330
  // `arr`'s nested element kind (the missing index-step in observeArrValType).
331
+ // `arr` may be a function-local (arrElemElemValTypes) or a module-level const
332
+ // table (global rep, recorded by recordGlobalRep) — the latter dynWrite-guarded.
322
333
  if (Array.isArray(rhs) && rhs[0] === '[]' && rhs.length === 3 && typeof rhs[1] === 'string') {
323
334
  const nested = arrElemElemValTypes.get(rhs[1])
335
+ ?? (!ctx.func.localReps?.has(rhs[1]) && !ctx.types?.dynWriteVars?.has(rhs[1])
336
+ ? ctx.scope.globalReps?.get(rhs[1])?.arrayElemElemValType : null)
324
337
  if (nested) observeArrValType(name, nested)
325
338
  }
326
339
  }
@@ -517,7 +530,7 @@ export function analyzeBody(body) {
517
530
  const prevTypedOverlay = ctx.func.localTypedElemsOverlay
518
531
  ctx.func.localValTypesOverlay = valTypes
519
532
  ctx.func.localTypedElemsOverlay = typedElems
520
- let unsignedLocals
533
+ let unsignedLocals, numericFill
521
534
  try {
522
535
  walk(body)
523
536
  widenLocalTypes(body, locals)
@@ -526,6 +539,18 @@ export function analyzeBody(body) {
526
539
  // reconsidered with final types — and stays f64, since a relational compare
527
540
  // is a non-transparent read that disqualifies narrowing anyway.
528
541
  unsignedLocals = narrowUint32(body, locals)
542
+ // Numeric-fill arrays — fresh `Array(n)`/`[]` whose every element write stores a
543
+ // Number, so `a[i]` reads can skip __to_num (the win `[1,2,3]` already gets, for the
544
+ // construct-then-fill kernel shape). Runs HERE, inside the val-type overlay, so a
545
+ // write of a bare numeric local (`a[i] = out`) resolves via the just-built `valTypes`.
546
+ // A bare read of the array's OWN elements (`a[i] = a[j]`, heapsort) is Numeric by
547
+ // induction; any genuinely non-numeric write still fails the test and disqualifies.
548
+ const numericFillRhs = (rhs, selfName) => {
549
+ if (Array.isArray(rhs) && rhs[0] === '[]' && rhs[1] === selfName) return true
550
+ if (typeof rhs === 'string') return valTypes.get(rhs) === VAL.NUMBER || exprElemSourceVal(rhs) === VAL.NUMBER
551
+ return valTypeOf(rhs) === VAL.NUMBER
552
+ }
553
+ numericFill = scanNumericFill(body, numericFillRhs)
529
554
  } finally {
530
555
  ctx.func.localValTypesOverlay = prevOverlay
531
556
  ctx.func.localTypedElemsOverlay = prevTypedOverlay
@@ -545,7 +570,10 @@ export function analyzeBody(body) {
545
570
  // Consumed by emitDecl, which lowers the initializer to a SLICE_BIT view.
546
571
  const sliceViews = doSchemas ? scanSliceViews(body) : new Set()
547
572
 
548
- const result = { locals, valTypes, arrElemSchemas, arrElemValTypes, typedElems, escapes, flatObjects, sliceViews, unsignedLocals }
573
+ // Never-relocated array bindings reads may skip the realloc-forwarding follow.
574
+ const neverGrown = doSchemas ? scanNeverGrown(body) : new Set()
575
+
576
+ const result = { locals, valTypes, arrElemSchemas, arrElemValTypes, typedElems, escapes, flatObjects, sliceViews, unsignedLocals, neverGrown, numericFill }
549
577
  _bodyFactsCache.set(body, result)
550
578
  return result
551
579
  }
@@ -677,6 +705,13 @@ export function analyzeValTypes(body) {
677
705
  for (const [name, vt] of facts.arrElemValTypes) {
678
706
  if (vt != null) updateRep(name, { arrayElemValType: vt })
679
707
  }
708
+ // Construct-then-fill numeric arrays (`let a = Array(n); a[i] = expr`) carry no
709
+ // element evidence at their decl, so the walk above leaves them untyped. scanNumericFill
710
+ // proved every write Numeric and every other use a pure read — record NUMBER so `arr[i]`
711
+ // reads skip __to_num, unless an observation already poisoned the slot to a conflict.
712
+ for (const name of facts.numericFill || []) {
713
+ if (facts.arrElemValTypes.get(name) !== null) updateRep(name, { arrayElemValType: VAL.NUMBER })
714
+ }
680
715
  // Propagate body-observed array-elem schemas to localReps so unboxablePtrs's
681
716
  // `let p = arr[i]` rule (which only consults rep) sees the schema and can unbox `p`
682
717
  // to an i32 offset. Without this, `arr.push({x,y,z})` followed by `arr[i].x` reads
@@ -10,9 +10,9 @@
10
10
  * @module compile/emit-assign
11
11
  */
12
12
 
13
- import { ctx, err, inc, PTR } from '../ctx.js'
13
+ import { ctx, err, inc, warnDeopt, PTR } from '../ctx.js'
14
14
  import { T } from '../ast.js'
15
- import { staticPropertyKey } from '../static.js'
15
+ import { staticPropertyKey, staticIndexKey } from '../static.js'
16
16
  import { valTypeOf, shapeOf } from '../kind.js'
17
17
  import { VAL, lookupValType, repOf } from '../reps.js'
18
18
  import {
@@ -74,8 +74,9 @@ function storeArrayPayload(arrExpr, idxNode, valueExpr, persist) {
74
74
  /** Strict-mode guard for dynamic property writes — emitted in branches that
75
75
  * fall through to `__dyn_set` or its key-kind dispatch. */
76
76
  function ensureDynSetAllowed(arr) {
77
- if (!ctx.transform.strict) return
78
77
  const arrLabel = typeof arr === 'string' ? arr : '<expr>'
78
+ warnDeopt('deopt-dyn-write', `dynamic property write \`${arrLabel}[…] = …\` couldn't resolve a static type — it falls back to a runtime hash store (~2× slower than a typed/slot write, far worse in a hot loop). Use a literal key, a numeric typed-array index, or a Map for genuinely dynamic keys.`)
79
+ if (!ctx.transform.strict) return
79
80
  err(`strict mode: dynamic property assignment \`${arrLabel}[<expr>] = ...\` falls back to __dyn_set. Use a literal key or known array/typed-array numeric index, or pass { strict: false }.`)
80
81
  }
81
82
 
@@ -159,10 +160,12 @@ export function emitElementAssign(arr, idx, val) {
159
160
  : typeof arr === 'string' && lookupValType(arr) === VAL.OBJECT ? staticPropertyKey(idx)
160
161
  : null
161
162
 
162
- // 1. SRoA flat object: `o['k'] = x` → `local.set $o#i` (no heap store).
163
- if (litKey != null && typeof arr === 'string' && ctx.func.flatObjects?.has(arr)) {
163
+ // 1. SRoA flat object/array: `o['k'] = x` / `a[2] = x` → `local.set $o#i` (no heap
164
+ // store). A bare integer index resolves its slot key here (not via `litKey`).
165
+ if (typeof arr === 'string' && ctx.func.flatObjects?.has(arr)) {
164
166
  const fo = ctx.func.flatObjects.get(arr)
165
- const fi = fo.names.indexOf(litKey)
167
+ const flatKey = litKey != null ? litKey : staticIndexKey(idx)
168
+ const fi = flatKey != null ? fo.names.indexOf(flatKey) : -1
166
169
  if (fi >= 0) return withTemp(valueExpr, t => [
167
170
  ['local.set', `$${arr}#${fi}`, ['local.get', `$${t}`]],
168
171
  ['local.get', `$${t}`]])