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/src/ctx.js CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { makeAbi } from './abi/index.js'
11
- export { HEAP, LAYOUT, PTR, ATOM, nanPrefixHex, atomNanHex, ssoBitI64Hex, sliceBitI64Hex, ptrNanHex, ptrBoxPrefixBigInt, encodePtrHi, decodePtrType, decodePtrAux, ATOM_HI, oobNanLiteral, oobNanIR, followForwardingWat } from '../layout.js'
11
+ export { HEAP, LAYOUT, PTR, ATOM, FORWARDING_MASK, nanPrefixHex, atomNanHex, ssoBitI64Hex, sliceBitI64Hex, ptrNanHex, ptrBoxPrefixBigInt, encodePtrHi, decodePtrType, decodePtrAux, ATOM_HI, oobNanLiteral, oobNanIR, followForwardingWat } from '../layout.js'
12
12
 
13
13
  // === Carrier layout ===
14
14
  // Canonical bit layout lives in layout.js (compiler-free). Re-exported above for
@@ -144,18 +144,45 @@ export const getter = (fn) => (fn.getter = true, fn)
144
144
  * Each module co-locates its own deps with its stdlib registrations at init time. */
145
145
  export function resolveIncludes() {
146
146
  const graph = ctx.core.stdlibDeps
147
+ const stdlib = ctx.core.stdlib
148
+ // Auto-derived deps: a stdlib template that calls `$__foo` (a registered stdlib
149
+ // func) depends on it, whether or not the hand-maintained `deps()` list says so.
150
+ // Scanning the *realized* template keeps the graph honest, so a missing manual
151
+ // entry can't silently drop a transitively-needed helper (the bug class the old
152
+ // blanket `inc('__mkptr','__alloc')` masked). Factory templates are realized
153
+ // (called) so feature-gated branches — `${hasExt ? '(call $__ext_prop …)' : ''}`
154
+ // — resolve before scanning; reading raw source would over-pull the dead branch.
155
+ // jz's templates are pure string builders, so realizing here (and again at
156
+ // emission) is side-effect-free. A `$__foo` naming a global (not a stdlib func)
157
+ // is skipped. Realization can fail if called before its inputs are ready — then
158
+ // we return nothing *without caching*, so a later pass retries. Memoized per compile.
159
+ const autoCache = ctx.core._autoDeps ??= new Map()
160
+ const autoDepsOf = (name) => {
161
+ let found = autoCache.get(name)
162
+ if (found !== undefined) return found
163
+ const v = stdlib[name]
164
+ let text
165
+ if (typeof v === 'string') text = v
166
+ else if (typeof v === 'function') { try { text = v() } catch { return [] } }
167
+ if (typeof text !== 'string') return (autoCache.set(name, []), [])
168
+ found = []
169
+ const seen = new Set()
170
+ for (const m of text.matchAll(/\$(__[A-Za-z0-9_]+)/g)) {
171
+ const d = m[1]
172
+ if (d !== name && stdlib[d] && !seen.has(d)) { seen.add(d); found.push(d) }
173
+ }
174
+ autoCache.set(name, found)
175
+ return found
176
+ }
147
177
  let changed = true
148
178
  while (changed) {
149
179
  changed = false
150
180
  for (const name of [...ctx.core.includes]) {
151
181
  const entry = graph[name]
152
182
  const deps = typeof entry === 'function' ? entry() : entry
153
- if (deps) for (const dep of deps) {
154
- if (!ctx.core.includes.has(dep)) {
155
- ctx.core.includes.add(dep)
156
- changed = true
157
- }
158
- }
183
+ const add = (dep) => { if (!ctx.core.includes.has(dep)) { ctx.core.includes.add(dep); changed = true } }
184
+ if (deps) for (const dep of deps) add(dep)
185
+ for (const dep of autoDepsOf(name)) add(dep)
159
186
  }
160
187
  }
161
188
  }
@@ -241,6 +268,7 @@ export function reset(proto, globals, bridge) {
241
268
  ctx.types = {
242
269
  typedElem: null,
243
270
  dynKeyVars: null,
271
+ dynWriteVars: null,
244
272
  anyDynKey: false,
245
273
  }
246
274
 
@@ -316,6 +344,7 @@ export function reset(proto, globals, bridge) {
316
344
  ctx.memory = {
317
345
  shared: false,
318
346
  pages: 0,
347
+ max: 0, // 0 = unbounded; >0 emits a maximum on the memory type (cap growth)
319
348
  }
320
349
 
321
350
  ctx.error = {
@@ -440,6 +469,15 @@ export function warn(code, message, meta = {}, loc = null) {
440
469
  ctx.warnings.sink.entries.push(entry)
441
470
  }
442
471
 
472
+ /** Advise that an emit site fell back to generic runtime dispatch (the slow,
473
+ * un-inferred path). Called from the actual emission point so it fires only when
474
+ * inference/optimization truly couldn't fold it — never a false positive on a
475
+ * case that vectorized/unrolled/slot-folded. `ctx.error.loc` is the current AST
476
+ * node's byte offset (kept up to date by the emit walk), giving line/column. */
477
+ export function warnDeopt(code, message) {
478
+ warn(code, message, { fn: ctx.func.current?.name }, ctx.error.loc)
479
+ }
480
+
443
481
  /** Throw with source location context. */
444
482
  export function err(msg, cause) {
445
483
  let detail = msg
package/src/ir.js CHANGED
@@ -55,6 +55,15 @@ function boxPtrIR(i32node, ptrType, aux = 0) {
55
55
  * `(x >>> 0)` uint32 idiom converts to a positive f64 in [0, 2^32) instead of sign-flipping. */
56
56
  export const asF64 = n => {
57
57
  if (n == null) err(`compiler internal: expected emitted IR value in ${ctx.func.current?.name || '<module>'}, got empty value`)
58
+ // A v128 (SIMD) value can't be NaN-boxed into the uniform f64 closure ABI — there is no
59
+ // lossless f64 carrier for 128 bits. This is reached only at a closure boundary: a SIMD
60
+ // value captured by, passed to, returned from, or flowing through a `(…)=>…` used as a
61
+ // VALUE — most commonly an IIFE `(() => f32x4.…)()`, which jz lowers via the closure path.
62
+ // Without this guard the coercion emits `f64.convert_i32_s(<v128>)` and dies in the wasm
63
+ // validator with an opaque type error. Keep SIMD inside a NAMED top-level function called
64
+ // directly (`let sdf = (x) => f32x4.…; sdf(v)` lowers to a typed v128 `call`, no boxing),
65
+ // and extract scalars with `f64x2.lane` / `f32x4.lane` before crossing a closure boundary.
66
+ if (n.type === 'v128') err(`SIMD (v128) values can't cross a closure/IIFE boundary — closures use the uniform f64 ABI. Move the SIMD into a named top-level function called directly, or extract a lane (f64x2.lane / f32x4.lane) to an f64 first.`)
58
67
  if (n.ptrKind != null) return boxPtrIR(n, valKindToPtr(n.ptrKind), n.ptrAux || 0)
59
68
  if (n.type === 'f64') return n
60
69
  if (n.type === 'i64') {
@@ -97,6 +106,22 @@ export const asPtrOffset = (n, ptrKind) => {
97
106
  /** Coerce emitted IR to a target WASM param type ('i32' | 'i64' | 'f64'). */
98
107
  export const asParamType = (n, t) => t === 'i32' ? asI32(n) : t === 'i64' ? asI64(n) : t === 'v128' ? n : asF64(n)
99
108
 
109
+ // Sound upper bound on the value of a masking expr (`&` / `>>>`), so a product
110
+ // against it can be proven < 2^53 and narrow to i32.mul instead of the guarded f64
111
+ // path. `& m` with a non-negative mask m clamps the result to [0, m] (regardless of
112
+ // the other operand's sign); `>>> k` is logical, so it's ≤ 2^(32−k). Anything else
113
+ // (signed shift, plain locals, negative mask) stays the full i32 range.
114
+ export const maskBound = (x) => {
115
+ if (!Array.isArray(x)) return 2 ** 31
116
+ if (x[0] === 'i32.const') return x[1] >= 0 ? x[1] : 2 ** 31
117
+ if (x[0] === 'i32.and') return Math.min(maskBound(x[1]), maskBound(x[2]))
118
+ if (x[0] === 'i32.shr_u') {
119
+ const k = Array.isArray(x[2]) && x[2][0] === 'i32.const' ? (x[2][1] & 31) : 0
120
+ return k > 0 ? 2 ** (32 - k) : 2 ** 31
121
+ }
122
+ return 2 ** 31
123
+ }
124
+
100
125
  /**
101
126
  * Narrow an f64 arithmetic tree under ToInt32 — the general int-accumulator path.
102
127
  *
@@ -459,7 +484,7 @@ const PURE_F64_OPS = new Set([
459
484
  * is correctly NOT numeric, while `cond ? n*2 : n*3` is. Conservative: any shape
460
485
  * not provably numeric (property gets, user calls, local.get, f64.const nan:…)
461
486
  * returns false, so the caller keeps the __to_num coercion. */
462
- const isNumericIR = (r) => {
487
+ export const isNumericIR = (r) => {
463
488
  if (!Array.isArray(r)) return false
464
489
  const op = r[0]
465
490
  if (PURE_F64_OPS.has(op)) return true
@@ -550,19 +575,21 @@ export function buildRefcount(fn) {
550
575
  * existing ids in a single walk. Replaces the per-pass
551
576
  * `while (fn.some(... === $__prefixK)) k++` (O(K·N)) with one O(N) scan. */
552
577
  export function nextLocalId(fn, prefix) {
553
- const seen = new Set()
578
+ // HIGH-WATER mark (max existing + 1), NOT the first free id. Callers allocate sequentially
579
+ // (id++), so a first-gap start would walk straight into an existing higher local once watr's
580
+ // coalesce has left non-contiguous numbering (e.g. $__pe0,$__pe1,$__pe5 → start at 2, then
581
+ // mint 3,4,5 and collide on $__pe5 = "Duplicate local"). High-water is always collision-free.
554
582
  const needle = `$__${prefix}`
583
+ let id = 0
555
584
  const walk = (n) => {
556
585
  if (!Array.isArray(n)) return
557
586
  if (n[0] === 'local' && typeof n[1] === 'string' && n[1].startsWith(needle)) {
558
587
  const tail = n[1].slice(needle.length)
559
- if (/^\d+$/.test(tail)) seen.add(+tail)
588
+ if (/^\d+$/.test(tail)) { const k = +tail; if (k >= id) id = k + 1 }
560
589
  }
561
590
  for (let i = 0; i < n.length; i++) walk(n[i])
562
591
  }
563
592
  walk(fn)
564
- let id = 0
565
- while (seen.has(id)) id++
566
593
  return id
567
594
  }
568
595
 
@@ -731,10 +758,15 @@ export function toNumF64(node, v) {
731
758
  if (ctx.schema.slotIntCertainAt?.(node[1], node[2]) === true) return asF64(v)
732
759
  }
733
760
  // IR-level shapes that produce real f64 numbers (never NaN-boxed pointers):
734
- // i32→f64 conversions, stdlib clock helper. Skip the __to_num call wrapper.
761
+ // i32→f64 conversions, stdlib clock helper, length/ptr helpers.
762
+ // Skip the __to_num call wrapper for these — they always return plain f64.
735
763
  if (Array.isArray(v)) {
736
764
  if (v[0] === 'f64.convert_i32_s' || v[0] === 'f64.convert_i32_u') return v
737
765
  if (v[0] === 'call' && v[1] === '$__time_ms') return v
766
+ // __length, __str_len return f64.convert_i32_s of an i32 — never a boxed pointer.
767
+ if (v[0] === 'call' && (v[1] === '$__length' || v[1] === '$__len' || v[1] === '$__str_len')) return v
768
+ // __ptr_type returns i32 tag, __ptr_offset returns i32 offset — both numeric.
769
+ if (v[0] === 'call' && (v[1] === '$__ptr_type' || v[1] === '$__ptr_offset')) return v
738
770
  }
739
771
  // f64 arithmetic ops and math intrinsics never produce NaN-boxed pointers — the
740
772
  // result is always a plain f64 number. Skip __to_num for these, eliminating the
@@ -785,6 +817,14 @@ export function toStrI64(node, v) {
785
817
  return typed(['call', '$__to_str', prim], 'i64')
786
818
  }
787
819
  }
820
+ // Provably-integer operand → render with the i32-only formatter, bypassing __to_str's
821
+ // float machinery (__ftoa/__toExp/__pow10, ~2 KB). A raw i32 value (`n|0`, a bitwise
822
+ // result, a loop counter) carries no NaN-box, so its ToString is just digits + sign.
823
+ // ptrKind != null means it's an unboxed pointer (i32 offset), NOT a number — exclude.
824
+ if (v.type === 'i32' && v.ptrKind == null) {
825
+ inc('__i32_to_str')
826
+ return typed(['i64.reinterpret_f64', ['call', '$__i32_to_str', v]], 'i64')
827
+ }
788
828
  inc('__to_str')
789
829
  return typed(['call', '$__to_str', asI64(v)], 'i64')
790
830
  }
@@ -809,8 +849,16 @@ const numericTruthy = e => {
809
849
 
810
850
  // i32 ops whose result is already a 0/1 boolean (comparisons + eqz) — safe to use
811
851
  // directly as a truthiness without a redundant `!= 0`.
852
+ // Ops whose result is already a canonical i32 boolean (0 or 1) — a condition built
853
+ // from one needs no `i32.ne(_, 0)` normalization. Every wasm comparison returns 0/1,
854
+ // so the f64/f32/i64 relations belong here too (they were missing — a `a > b ? …`
855
+ // f64 compare was wrapped in a dead `i32.ne(f64.gt …, 0)` in every branch/select).
812
856
  const I32_BOOL_OPS = new Set(['i32.eq', 'i32.ne', 'i32.lt_s', 'i32.lt_u', 'i32.gt_s', 'i32.gt_u',
813
- 'i32.le_s', 'i32.le_u', 'i32.ge_s', 'i32.ge_u', 'i32.eqz'])
857
+ 'i32.le_s', 'i32.le_u', 'i32.ge_s', 'i32.ge_u', 'i32.eqz',
858
+ 'f64.eq', 'f64.ne', 'f64.lt', 'f64.gt', 'f64.le', 'f64.ge',
859
+ 'f32.eq', 'f32.ne', 'f32.lt', 'f32.gt', 'f32.le', 'f32.ge',
860
+ 'i64.eq', 'i64.ne', 'i64.lt_s', 'i64.lt_u', 'i64.gt_s', 'i64.gt_u',
861
+ 'i64.le_s', 'i64.le_u', 'i64.ge_s', 'i64.ge_u', 'i64.eqz'])
814
862
 
815
863
  export function truthyIR(e) {
816
864
  // An i32 *constant* is a concrete number, not a known 0/1 boolean — fold it to its
@@ -94,9 +94,36 @@ export function methodValType(method, obj, objType, ctx) {
94
94
  if (objType === VAL.STRING || objType === VAL.ARRAY || objType === VAL.TYPED) return objType
95
95
  return null
96
96
  }
97
+ // .subarray / .toReversed / .toSorted / .with all return a typed array of the same kind.
98
+ if (method === 'subarray' || method === 'toReversed' || method === 'toSorted' || method === 'with')
99
+ return objType === VAL.TYPED ? VAL.TYPED : null
97
100
  return null
98
101
  }
99
102
 
103
+ // Built-in PROPERTY val-types — the property-read mirror of methodValType.
104
+ // These are language invariants: `.length` is always a number on the sized
105
+ // value kinds, `.size` on Set/Map, `.byteLength`/`.byteOffset` on typed arrays.
106
+ // Without this, `arr.length + x` sees `.length` as untyped and routes `+`
107
+ // through the __is_str_key string-concat dispatch — even though `.length` can
108
+ // never be a string on a known sized kind. (Object schema slots override this
109
+ // earlier in VT['.'], so `{length:'x'}.length` keeps its true slot type.)
110
+ //
111
+ // Gate on a known objType: an untyped receiver could be an object with a
112
+ // string-valued shadow of the same name, so leave it null there (conservative).
113
+ // null-proto: user code reads `.valueOf`, `.toString` etc. — a plain `{}` would
114
+ // expose inherited Object.prototype members as bogus "numeric props".
115
+ const NUMERIC_PROPS = Object.assign(Object.create(null), {
116
+ length: new Set([VAL.STRING, VAL.ARRAY, VAL.TYPED]),
117
+ byteLength: new Set([VAL.TYPED, VAL.BUFFER]),
118
+ byteOffset: new Set([VAL.TYPED]),
119
+ size: new Set([VAL.SET, VAL.MAP]),
120
+ })
121
+ export function propValType(prop, objType) {
122
+ if (objType == null) return null
123
+ const kinds = NUMERIC_PROPS[prop]
124
+ return kinds && kinds.has(objType) ? VAL.NUMBER : null
125
+ }
126
+
100
127
  export function typedCtorElemValType(ctor) {
101
128
  if (!ctor) return null
102
129
  const isView = ctor.endsWith('.view')
package/src/kind.js CHANGED
@@ -8,10 +8,10 @@
8
8
 
9
9
  import { ctx } from './ctx.js'
10
10
  import { VAL, lookupValType } from './reps.js'
11
- import { intLiteralValue } from './static.js'
11
+ import { intLiteralValue, staticIndexKey } from './static.js'
12
12
  import {
13
13
  BOOL_OPS, NUMERIC_BINARY_OPS, NUMERIC_UNARY_OPS, COMPOUND_NUMERIC_OPS,
14
- calleeValType, methodValType, typedCtorElemValType,
14
+ calleeValType, methodValType, propValType, typedCtorElemValType,
15
15
  } from './kind-traits.js'
16
16
 
17
17
  export { typedCtorElemValType } from './kind-traits.js'
@@ -145,7 +145,11 @@ VT['?:'] = (args) => {
145
145
  // (a condition/guard) and the other has a known non-boolean type,
146
146
  // return the non-boolean type — common in `condition && numericValue`
147
147
  // guard patterns where the falsey boolean is coerced to 0 in numeric context.
148
- VT['&&'] = VT['||'] = (args) => {
148
+ // `a && b` / `a || b` / `a ?? b` all yield one of the two operands, so the result
149
+ // type is their common type (else unknown). Giving `??` a type — not just ||/&& —
150
+ // lets `numA ?? numB` read NaN-safe (value-typed NUMBER → f64.eq) instead of routing
151
+ // through the bit-comparing __is_truthy, which mis-reads a non-canonical NaN.
152
+ VT['&&'] = VT['||'] = VT['??'] = (args) => {
149
153
  const ta = valTypeOf(args[0]), tb = valTypeOf(args[1])
150
154
  if (ta && ta === tb) return ta
151
155
  if (ta === VAL.BOOL && tb && tb !== VAL.BOOL) return tb
@@ -158,6 +162,20 @@ VT['&&'] = VT['||'] = (args) => {
158
162
  // Index access: `arr[i]` → ['[]', arr, i].
159
163
  VT['[]'] = (args) => {
160
164
  if (args.length < 2) return VAL.ARRAY
165
+ // SRoA flat-array slot read: `a[k]` (static index) where `a` dissolved into
166
+ // scalar `a#i` locals (scanFlatObjects). A write-once slot's value-type is its
167
+ // element literal's — same numeric-binding as the `VT['.']` object case, so
168
+ // `a[0] * 2` stays a plain f64 op instead of the polymorphic ToNumber battery.
169
+ if (typeof args[0] === 'string') {
170
+ const flat = ctx.func.flatObjects?.get(args[0])
171
+ if (flat) {
172
+ const k = staticIndexKey(args[1])
173
+ if (k != null && !flat.written?.has(k)) {
174
+ const i = flat.names.indexOf(k)
175
+ if (i >= 0 && flat.values[i] !== undefined) return valTypeOf(flat.values[i])
176
+ }
177
+ }
178
+ }
161
179
  // Indexed read on a known typed-array receiver yields Number except for
162
180
  // BigInt64Array/BigUint64Array, whose i64 carriers must stay BigInt-typed.
163
181
  if (typeof args[0] === 'string' && lookupValType(args[0]) === VAL.TYPED)
@@ -170,6 +188,25 @@ VT['[]'] = (args) => {
170
188
  if (typeof args[0] === 'string') {
171
189
  const elemVt = ctx.func.localReps?.get(args[0])?.arrayElemValType
172
190
  if (elemVt) return elemVt
191
+ // Module-level const array (a numeric/uniform table): its element val-type was
192
+ // recorded on the global rep at decl time. Trust it only when no function element-
193
+ // writes the array — dynWriteVars holds every var written via a non-named-property
194
+ // index, so a `X[i]=str` anywhere disables this and falls back to the untyped read.
195
+ if (!ctx.func.localReps?.has(args[0])) {
196
+ const gElem = ctx.scope.globalReps?.get(args[0])?.arrayElemValType
197
+ if (gElem && !ctx.types?.dynWriteVars?.has(args[0])) return gElem
198
+ }
199
+ }
200
+ // Direct double-index on a module-level nested numeric table — `C[i][j]` where
201
+ // `C = [[…number…], …]`. The receiver is itself a single-index read of a global
202
+ // array whose nested element kind was recorded at decl time. Same dynWriteVars
203
+ // guard (now root-aware, so a `C[i][j]=…` write anywhere disables it).
204
+ if (Array.isArray(args[0]) && args[0][0] === '[]' && args[0].length === 3 && typeof args[0][1] === 'string') {
205
+ const base = args[0][1]
206
+ if (!ctx.func.localReps?.has(base)) {
207
+ const gNested = ctx.scope.globalReps?.get(base)?.arrayElemElemValType
208
+ if (gNested && !ctx.types?.dynWriteVars?.has(base)) return gNested
209
+ }
173
210
  }
174
211
  // Indexed read on an inline all-numeric array literal — `[2,4,2,9][i]` (floatbeat
175
212
  // chord/pattern tables; literal op is `[`, elements inline). Every element is a
@@ -183,6 +220,21 @@ VT['[]'] = (args) => {
183
220
 
184
221
  VT['.'] = (args) => {
185
222
  if (typeof args[1] !== 'string') return null
223
+ // SRoA flat-object slot read: `p.x` where `p` dissolved into scalar `p#i`
224
+ // locals (scanFlatObjects). A write-once slot's value-type IS its literal
225
+ // initializer's, so bind by it — exactly as a plain `let slot = value` local
226
+ // would. Without this `p.x * 2` looks like "could be anything" and pulls the
227
+ // ToNumber + string-format battery, though it can only be numeric. Computed
228
+ // on-demand (not cached at analyze time) because param val-types — `{x:n}`'s
229
+ // `n` is numeric-by-divergence — are only seeded at emit. A reassigned slot
230
+ // (`p.x = …`) stays untyped: its runtime value may differ from the literal.
231
+ if (typeof args[0] === 'string') {
232
+ const flat = ctx.func.flatObjects?.get(args[0])
233
+ if (flat && !flat.written?.has(args[1])) {
234
+ const i = flat.names.indexOf(args[1])
235
+ if (i >= 0 && flat.values[i] !== undefined) return valTypeOf(flat.values[i])
236
+ }
237
+ }
186
238
  // Schema slot read: when `varName` has a bound schemaId and `.prop` resolves
187
239
  // to a slot whose VAL kind is monomorphic across program-wide observations,
188
240
  // return that kind. Lets `+`, `===`, method dispatch skip runtime str-key
@@ -200,6 +252,14 @@ VT['.'] = (args) => {
200
252
  const child = sh.props[args[1]]
201
253
  if (child) return child.val
202
254
  }
255
+ // Built-in property on a known sized kind — `.length` on STRING/ARRAY/TYPED,
256
+ // `.size` on SET/MAP, `.byteLength`/`.byteOffset` on TYPED/BUFFER. These are
257
+ // language invariants (the property is always a number on that kind), so typing
258
+ // them NUMBER lets `+` skip the string-concat dispatch. Object schema slots
259
+ // resolved above override this, keeping user-defined same-name slots sound.
260
+ const objType = typeof args[0] === 'string' ? lookupValType(args[0]) : valTypeOf(args[0])
261
+ const pvt = propValType(args[1], objType)
262
+ if (pvt) return pvt
203
263
  return null
204
264
  }
205
265
 
@@ -248,6 +308,8 @@ VT['()'] = (args) => {
248
308
  const t = valTypeOf(args[1])
249
309
  return t === VAL.SET || t === VAL.MAP ? VAL.ARRAY : t
250
310
  }
311
+ // for-in's read-only key list (src/prepare) — always an Array of key strings.
312
+ if (callee === '__keys_ro') return VAL.ARRAY
251
313
  // Ternary is parsed as call to '?' operator: ['()', ['?', cond, a, b]]
252
314
  if (Array.isArray(callee) && callee[0] === '?') {
253
315
  const truthy = literalTruthiness(callee[1])