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
@@ -44,7 +44,7 @@ import { recordGlobalRep } from '../compile/infer.js'
44
44
  import { isFuncRef } from '../ir.js'
45
45
  import {
46
46
  CTORS, COLLECTION_CTORS, TIMER_NAMES,
47
- hasModule, includeModule,
47
+ hasModule, includeModule, includeMods,
48
48
  includeForArrayAccess, includeForArrayLiteral, includeForArrayPattern, includeForCallableValue,
49
49
  includeForGenericMethod, includeForKnownKeyIteration, includeForNamedCall, includeForNumericCoercion,
50
50
  includeForObjectLiteral, includeForObjectPattern, includeForOp, includeForProperty, includeForRuntimeCtor,
@@ -169,7 +169,7 @@ const addHostImport = (mod, name, alias, spec) => {
169
169
  // ABI — record it so references fold to an f64 literal (see prep's identifier resolution) instead
170
170
  // of emitting a 0-arg func import that can't be read as a value ("'PI' is not in scope").
171
171
  if (typeof spec === 'number') {
172
- if (!ctx.scope.hostConsts) ctx.scope.hostConsts = {}
172
+ if (!ctx.scope.hostConsts) ctx.scope.hostConsts = Object.create(null) // name-keyed: prototype-less (see derive)
173
173
  ctx.scope.hostConsts[alias] = spec
174
174
  return
175
175
  }
@@ -321,6 +321,30 @@ function lookupStaticStringArray(name) {
321
321
  return ctx.scope.shapeStrArrays?.get(resolved) ?? null
322
322
  }
323
323
 
324
+ /** Evaluate a constant numeric expression (number literals + basic arithmetic) for
325
+ * compile-time string/template folding. Returns null when it isn't a pure-number
326
+ * constant — string `+` and dynamic parts fall through to the caller's runtime path. */
327
+ function constNum(node) {
328
+ if (Array.isArray(node) && node[0] == null && typeof node[1] === 'number') return node[1]
329
+ if (!Array.isArray(node)) return null
330
+ const [op, a, b] = node
331
+ if ((op === 'u-' || op === '-' || op === '+') && b === undefined) {
332
+ const x = constNum(a)
333
+ return x == null ? null : op === 'u-' || op === '-' ? -x : +x
334
+ }
335
+ const x = constNum(a), y = constNum(b)
336
+ if (x == null || y == null) return null
337
+ switch (op) {
338
+ case '+': return x + y
339
+ case '-': return x - y
340
+ case '*': return x * y
341
+ case '/': return y === 0 ? null : x / y
342
+ case '%': return y === 0 ? null : x % y
343
+ case '**': return x ** y
344
+ }
345
+ return null
346
+ }
347
+
324
348
  function staticStringExpr(node) {
325
349
  const lit = stringValue(node)
326
350
  if (lit != null) return lit
@@ -341,7 +365,11 @@ function staticStringExpr(node) {
341
365
  if (op === '`') {
342
366
  let out = ''
343
367
  for (const part of args) {
344
- const s = staticStringExpr(part)
368
+ let s = staticStringExpr(part)
369
+ // A numeric interpolation (`${123}`, `${1+2}`) is a constant in string context —
370
+ // ToString it so a fully-static template folds to one literal instead of a runtime
371
+ // concat. (Only the template case stringifies numbers; `+` stays polymorphic.)
372
+ if (s == null) { const n = constNum(part); if (n != null) s = String(n) }
345
373
  if (s == null) return null
346
374
  out += s
347
375
  }
@@ -383,7 +411,7 @@ function resolveImportMeta(spec) {
383
411
 
384
412
  function recordModuleInitFacts(root) {
385
413
  const facts = ctx.module.initFacts ||= {
386
- dynVars: new Set(), anyDyn: false, hasSchemaLiterals: false,
414
+ dynVars: new Set(), dynWriteVars: new Set(), anyDyn: false, hasSchemaLiterals: false,
387
415
  hasFuncValue: false, timerNames: new Set(),
388
416
  maxDef: 0, maxCall: 0, hasRest: false, hasSpread: false,
389
417
  writtenProps: new Set(),
@@ -458,6 +486,11 @@ export default function prepare(node) {
458
486
  // import would cycle (autoload imports every module via module/index.js).
459
487
  ctx.module.include = includeModule
460
488
  includeModule('core')
489
+ // Empty or whitespace-only source parses to a bare '' — an empty program, not an
490
+ // identifier reference. Normalize to an empty statement so it compiles to a bare
491
+ // `(module)` instead of a `(local.get $)` against a zero-length name. (A non-empty
492
+ // bare identifier like `foo` parses to `'foo'` and stays a real reference.)
493
+ if (node === '') node = [';']
461
494
  validateCoalesceMixing(node) // ES2020: reject unparenthesized `??` mixed with `||`/`&&`
462
495
  normalizeIdents(node)
463
496
  fuseSparseMapReads(node) // AST-level fusion; needs pre-resolution shape — defined at end of file
@@ -557,9 +590,13 @@ export default function prepare(node) {
557
590
 
558
591
  // Named constants → numeric literals. The JZ_NULL/JZ_UNDEF atom sentinels live
559
592
  // in ast.js — shared with emit without crossing the prepare↔compile boundary.
560
- const CONSTANTS = { 'true': true, 'false': false, 'null': JZ_NULL, 'undefined': JZ_UNDEF }
593
+ // Prototype-less (Object.create(null)): a plain `{}` inherits Object.prototype in V8, so
594
+ // `'valueOf' in CONSTANTS` / `CONSTANTS['toString']` would hit an inherited method and
595
+ // mis-resolve a user identifier named like an Object method (jz.js-only — kernel objects
596
+ // are already prototype-less). Same reason on F64_CONSTANTS / GLOBALS / REJECT_IDENTS.
597
+ const CONSTANTS = Object.assign(Object.create(null), { 'true': true, 'false': false, 'null': JZ_NULL, 'undefined': JZ_UNDEF })
561
598
  // NaN/Infinity stay as special f64 values in emit()
562
- const F64_CONSTANTS = { 'NaN': NaN, 'Infinity': Infinity }
599
+ const F64_CONSTANTS = Object.assign(Object.create(null), { 'NaN': NaN, 'Infinity': Infinity })
563
600
 
564
601
  /** Resolve variable name through block scope chain (innermost rename wins). */
565
602
  function resolveScope(name) {
@@ -815,7 +852,7 @@ function prep(node) {
815
852
  // Not actually "implicit imports" — these are ambient globals that exist in every jz/JS
816
853
  // program (they do not live in any module). jzify auto-injecting imports would still
817
854
  // need a list of these names to know what to emit, so the table lives here either way.
818
- export const GLOBALS = {
855
+ export const GLOBALS = Object.assign(Object.create(null), {
819
856
  Math: 'math',
820
857
  Number: 'Number',
821
858
  Array: 'Array',
@@ -845,7 +882,7 @@ export const GLOBALS = {
845
882
  BigInt: 'BigInt',
846
883
  TextEncoder: 'TextEncoder',
847
884
  TextDecoder: 'TextDecoder',
848
- }
885
+ })
849
886
 
850
887
  const patternItems = (node) => node?.[0] === ',' ? node.slice(1) : [node]
851
888
  const isDestructPattern = (node) => Array.isArray(node) && (node[0] === '[]' || node[0] === '{}')
@@ -1246,7 +1283,7 @@ function foldNamespaceIntrospection(callee, args) {
1246
1283
  // Compiler-internal synthetic callees: emit-handled intrinsics, never user
1247
1284
  // function values — so a bare reference must not pull in the callable-value
1248
1285
  // (function table / closure) machinery.
1249
- const INTRINSIC_CALLEES = new Set(['__iter_arr'])
1286
+ const INTRINSIC_CALLEES = new Set(['__iter_arr', '__keys_ro'])
1250
1287
 
1251
1288
  function resolveCallee(callee, args) {
1252
1289
  if (typeof callee === 'string') {
@@ -1427,6 +1464,10 @@ const handlers = {
1427
1464
 
1428
1465
  // Template literal: [``, part, ...] → fused single-allocation string concat.
1429
1466
  '`'(...parts) {
1467
+ // Fully-static template (`a${123}b`, `hello ${1+2} world`) folds to a single string
1468
+ // literal — a static data segment / SSO box, no runtime concat and no heap machinery.
1469
+ const folded = staticStringExpr(['`', ...parts])
1470
+ if (folded != null) return staticString(folded)
1430
1471
  includeForStringValue()
1431
1472
  const nodes = parts.map(p =>
1432
1473
  Array.isArray(p) && p[0] == null && typeof p[1] === 'string' ? ['str', p[1]] : prep(p))
@@ -1546,7 +1587,7 @@ const handlers = {
1546
1587
  if (Array.isArray(specifiers) && specifiers[0] === 'as' && specifiers[1] === '*') {
1547
1588
  const alias = specifiers[2]
1548
1589
  // Store namespace mapping so '.' handler can resolve X.prop → mangled name
1549
- if (!ctx.module.namespaces) ctx.module.namespaces = {}
1590
+ if (!ctx.module.namespaces) ctx.module.namespaces = Object.create(null) // name-keyed: prototype-less (see derive)
1550
1591
  ctx.module.namespaces[alias] = resolved.exports
1551
1592
  return null
1552
1593
  }
@@ -1628,6 +1669,15 @@ const handlers = {
1628
1669
  for (const i of decl.slice(1))
1629
1670
  if (Array.isArray(i) && i[0] === '=' && typeof i[1] === 'string')
1630
1671
  ctx.func.exports[i[1]] = true
1672
+ // export name → bare-identifier re-export (shorthand for `export { name }`).
1673
+ // Register the binding and emit nothing; without this the name falls through
1674
+ // to `prep(decl)` below and compiles as a dead `global.get; drop` statement
1675
+ // while the export itself is silently lost.
1676
+ if (typeof decl === 'string') {
1677
+ const resolved = ctx.scope.chain[decl]
1678
+ ctx.func.exports[decl] = (resolved && resolved !== decl) ? resolved : decl
1679
+ return null
1680
+ }
1631
1681
  // export { name, name as alias } from './mod' or export * from './mod'
1632
1682
  if (Array.isArray(decl) && decl[0] === 'from') {
1633
1683
  const mod = decl[2]?.[1]
@@ -1821,7 +1871,11 @@ const handlers = {
1821
1871
  // alone wrongly folds `-5n`, and negating the bigint here yields garbage (-2^63+5).
1822
1872
  // `typeof !== 'bigint'` excludes it in both engines (real JS: 'bigint'; jz: matches
1823
1873
  // 'bigint'). Bigint negation then flows to emit's i64.sub(0,·) path correctly.
1824
- if (b === undefined) { const na = prep(a); return isLit(na) && typeof na[1] === 'number' && typeof na[1] !== 'bigint' ? [, -na[1]] : ['u-', na] }
1874
+ // `-0` is NOT folded: the self-host kernel evaluates the constant `-na[1]` with
1875
+ // i32 negation (i32 has no signed zero), collapsing -0→+0 — observable via sort's
1876
+ // -0<+0 tiebreak, Object.is, and 1/x. Leaving it as a runtime `u-` emits f64.neg,
1877
+ // which preserves the sign in both engines; V8 re-folds it, so no native cost.
1878
+ if (b === undefined) { const na = prep(a); return isLit(na) && typeof na[1] === 'number' && typeof na[1] !== 'bigint' && na[1] !== 0 ? [, -na[1]] : ['u-', na] }
1825
1879
  return ['-', prep(a), prep(b)]
1826
1880
  },
1827
1881
 
@@ -2123,10 +2177,17 @@ const handlers = {
2123
2177
  // `[null, null]`, `undefined` is `[null]` (empty value slot). A numeric/string literal
2124
2178
  // `[null, v]` has a non-nullish value slot, so `src[1] == null` discriminates them.
2125
2179
  const nullish = Array.isArray(src) && src[0] == null && src[1] == null
2180
+ // `__keys_ro` is for-in's read-only key list: identical to Object.keys, but
2181
+ // when the receiver has a complete static schema the keys are a compile-time
2182
+ // constant, so it pools ONE static-data array instead of allocating a fresh
2183
+ // one each evaluation — the per-iteration heap-growth cliff (jz#deopt-forin).
2184
+ // Sound only because for-in reads ks[i]/ks.length and never mutates (unlike
2185
+ // user Object.keys, which permits in-place `.sort()`/`.reverse()`).
2186
+ includeMods('core', 'object', 'string')
2126
2187
  const keysExpr = nullish ? ['[]', null]
2127
2188
  : typeof src === 'string'
2128
- ? ['?', ['==', src, [null, null]], ['[]', null], ['()', ['.', 'Object', 'keys'], src]]
2129
- : ['()', ['.', 'Object', 'keys'], src]
2189
+ ? ['?', ['==', src, [null, null]], ['[]', null], ['()', '__keys_ro', src]]
2190
+ : ['()', '__keys_ro', src]
2130
2191
  const ks = `${T}fik${ctx.func.uniq++}`, ix = `${T}fii${ctx.func.uniq++}`, lenV = `${T}fil${ctx.func.uniq++}`
2131
2192
  const decls = ['let',
2132
2193
  ['=', ks, keysExpr],
@@ -2409,7 +2470,7 @@ function prepareModule(specifier, source) {
2409
2470
  const savedFuncCount = ctx.func.list.length // track new funcs from this module
2410
2471
  const savedModulePrefix = ctx.module.currentPrefix
2411
2472
  ctx.scope.chain = derive(savedScope) // inherit parent scope
2412
- ctx.func.exports = {}
2473
+ ctx.func.exports = Object.create(null) // name-keyed: prototype-less (see derive)
2413
2474
  ctx.module.currentPrefix = prefix
2414
2475
 
2415
2476
  try {
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Lambda-lift immediately-invoked arrow literals (IIFEs).
3
+ *
4
+ * `(params => body)(args)` is lowered by jz's default path as a CLOSURE value invoked
5
+ * via `call_indirect` through the uniform-f64 closure ABI. That ABI can't carry a
6
+ * `v128`, so a SIMD IIFE — `(() => f32x4.…)()` — emits invalid wasm; it also pays a
7
+ * closure alloc + indirect call for what is really a direct, single call.
8
+ *
9
+ * This pass rewrites each such IIFE into a TOP-LEVEL function whose free variables are
10
+ * appended as parameters, and replaces the call with a direct call passing those vars:
11
+ *
12
+ * let r = (() => { let t = f32x4.mul(a, a); return f32x4.add(t, …) })() // captures a
13
+ * ⇓
14
+ * let ⟨lift⟩ = (a) => { let t = f32x4.mul(a, a); return f32x4.add(t, …) } // hoisted to top
15
+ * let r = ⟨lift⟩(a) // direct call
16
+ *
17
+ * The lifted function flows through jz's monomorphic typed-function path (like any
18
+ * `let f = (x) => …; f(v)`), so a captured/returned `v128` is typed `v128` from the
19
+ * single call site — and `inlineOnce` then folds the single-caller body back in, so
20
+ * there's no residual call. Capture is BY VALUE, which is exact for a synchronous
21
+ * immediate invocation (the value at the call instant == what the closure would read);
22
+ * the one divergence — a capture MUTATED inside the body, which a closure writes back
23
+ * through its cell — is the bail below.
24
+ *
25
+ * Bails (leaving the closure path untouched) on: non-plain params (rest / default /
26
+ * destructured), or a body that assigns any captured variable. Everything else folds.
27
+ *
28
+ * @module prepare/lift-iife
29
+ */
30
+ import { T, extractParams, classifyParam } from '../ast.js'
31
+ import { findFreeVars, findMutations } from '../compile/analyze-scans.js'
32
+
33
+ // Build a comma-list operand node (the parser's shape) from an array of nodes.
34
+ const commaList = (items) =>
35
+ items.length === 0 ? null : items.length === 1 ? items[0] : [',', ...items]
36
+
37
+ // Unwrap parenthesis nodes (`['()', x]`, length 2) to reach the inner expression.
38
+ const unwrapParens = (n) => {
39
+ while (Array.isArray(n) && n[0] === '()' && n.length === 2) n = n[1]
40
+ return n
41
+ }
42
+
43
+ // `['()', callee, args]` (length 3) whose callee unwraps to an arrow literal → that arrow.
44
+ const iifeArrow = (n) => {
45
+ if (!Array.isArray(n) || n[0] !== '()' || n.length !== 3) return null
46
+ const callee = unwrapParens(n[1])
47
+ return Array.isArray(callee) && callee[0] === '=>' ? callee : null
48
+ }
49
+
50
+ // Plain-name params only; anything else (rest/default/destructure) → null (bail).
51
+ const plainParamNames = (arrow) => {
52
+ const out = []
53
+ for (const p of extractParams(arrow[1])) {
54
+ const c = classifyParam(p)
55
+ if (c.kind !== 'plain' || typeof c.name !== 'string') return null
56
+ out.push(c.name)
57
+ }
58
+ return out
59
+ }
60
+
61
+ // Names a function binds: its plain params + every let/const name in its body, NOT
62
+ // descending into nested arrows (their locals are their own). Over-approximating
63
+ // within a frame is safe — a free ident only resolves to one of these if it's
64
+ // actually in scope at the reference. Used to scope captures to enclosing LOCALS
65
+ // (module-level names stay global refs in the lifted body, never captured).
66
+ const collectDeclNames = (decl, out) => {
67
+ if (!Array.isArray(decl)) return
68
+ if (decl[0] === '=' && typeof decl[1] === 'string') out.add(decl[1])
69
+ else if (decl[0] === '=' && Array.isArray(decl[1])) collectPatternNames(decl[1], out)
70
+ else if (typeof decl[1] === 'string') out.add(decl[1])
71
+ }
72
+ const collectPatternNames = (pat, out) => {
73
+ if (typeof pat === 'string') { out.add(pat); return }
74
+ if (!Array.isArray(pat)) return
75
+ for (let i = 1; i < pat.length; i++) collectPatternNames(pat[i], out)
76
+ }
77
+ function functionLocals(paramNodes, body) {
78
+ const names = new Set()
79
+ for (const p of paramNodes) { const c = classifyParam(p); if (typeof c.name === 'string') names.add(c.name); else if (c.pattern) collectPatternNames(c.pattern, names) }
80
+ const scan = (n) => {
81
+ if (!Array.isArray(n)) return
82
+ if (n[0] === '=>') return
83
+ if (n[0] === 'let' || n[0] === 'const') for (const d of n.slice(1)) collectDeclNames(d, names)
84
+ for (let i = 1; i < n.length; i++) scan(n[i])
85
+ }
86
+ scan(body)
87
+ return names
88
+ }
89
+
90
+ export function liftIIFEs(ast) {
91
+ if (!Array.isArray(ast)) return ast
92
+ const lifted = [] // hoisted `['let', ['=', name, arrow]]` decls
93
+ let uid = 0
94
+
95
+ // Copy non-index node metadata (parser `.loc`, etc.) onto a rebuilt node so error
96
+ // source-locations survive the transform.
97
+ const copyMeta = (to, from) => { for (const k of Object.keys(from)) if (isNaN(+k)) to[k] = from[k]; return to }
98
+
99
+ // `locals` = union of every enclosing FUNCTION frame's bound names. The transform
100
+ // is post-order (inner IIFEs fold first) so an outer lift sees already-direct inner
101
+ // calls; scope tracking is top-down so each IIFE sees the right enclosing locals.
102
+ // Identity-preserving: a node with no lifted descendant is returned unchanged.
103
+ const visit = (node, locals) => {
104
+ if (!Array.isArray(node)) return node
105
+
106
+ // Descend into an arrow body with the frame extended by this arrow's locals.
107
+ if (node[0] === '=>') {
108
+ const inner = new Set(locals)
109
+ for (const n of functionLocals(extractParams(node[1]), node[2])) inner.add(n)
110
+ let changed = false
111
+ const out = node.map((c, i) => { if (i === 0) return c; const v = visit(c, inner); if (v !== c) changed = true; return v })
112
+ return changed ? copyMeta(out, node) : node
113
+ }
114
+
115
+ // Transform children first (post-order).
116
+ let changed = false
117
+ const mapped = node.map((c, i) => { if (i === 0) return c; const v = visit(c, locals); if (v !== c) changed = true; return v })
118
+ const base = changed ? copyMeta(mapped, node) : node
119
+
120
+ const arrow = iifeArrow(base)
121
+ if (!arrow) return base
122
+
123
+ const paramNames = plainParamNames(arrow)
124
+ if (paramNames === null) return base // bail: non-plain params
125
+
126
+ const callArgs = base[2] == null ? [] : (Array.isArray(base[2]) && base[2][0] === ',') ? base[2].slice(1) : [base[2]]
127
+ if (callArgs.some(a => Array.isArray(a) && a[0] === '...')) return base // bail: spread into a fixed-arity direct call
128
+
129
+ const body = arrow[2]
130
+ const captures = []
131
+ findFreeVars(body, new Set(paramNames), captures, locals)
132
+
133
+ const mutated = new Set()
134
+ findMutations(body, new Set(captures), mutated)
135
+ if (mutated.size) return base // bail: a captured var is written
136
+
137
+ // Lift: `(…params, …captures) => body`, hoisted to module top; call passes captures.
138
+ const name = `${T}lift${uid++}`
139
+ lifted.push(['let', ['=', name, ['=>', ['()', commaList([...paramNames, ...captures])], body]]])
140
+ return copyMeta(['()', name, commaList([...callArgs, ...captures])], base)
141
+ }
142
+
143
+ const result = visit(ast, new Set())
144
+ if (!lifted.length) return result
145
+
146
+ // Prepend the lifted decls to the module body (`[';', …stmts]`).
147
+ if (Array.isArray(result) && result[0] === ';') return [';', ...lifted, ...result.slice(1)]
148
+ return [';', ...lifted, result]
149
+ }
package/src/reps.js CHANGED
@@ -56,6 +56,10 @@ export const VAL = {
56
56
  * @property {boolean} [notString] proven not a string (skips string-path guards).
57
57
  * @property {number} [arrayElemSchema] element object-schema id for arrays.
58
58
  * @property {string} [arrayElemValType] element VAL.* kind for arrays.
59
+ * @property {string} [arrayElemElemValType] nested element VAL.* kind (`X[i][j]`) for arrays of arrays.
60
+ * @property {string} [arrayElemTypedCtor] element TypedArray ctor (`new.Float32Array`) for an
61
+ * array whose elements are all typed arrays of one ctor (`Array.from(n,()=>new Float32Array())`),
62
+ * so `arr[i]` is a known typed array and `arr[i][j]` inlines instead of runtime aux-dispatch.
59
63
  * @property {string} [carrier] abi carrier id override (e.g. 'jsstring').
60
64
  * @property {boolean} [unsigned] i32 carries an unsigned value (`>>>` result).
61
65
  * @property {*} [jsonShape] inferred shape for the JSON.stringify fast path.
@@ -67,8 +71,8 @@ export const VAL = {
67
71
  */
68
72
  export const REP_FIELDS = new Set([
69
73
  'val', 'ptrKind', 'ptrAux', 'schemaId', 'intConst', 'intCertain', 'notString',
70
- 'arrayElemSchema', 'arrayElemValType', 'carrier', 'unsigned', 'jsonShape',
71
- 'typedCtor', 'wasm', 'nullable',
74
+ 'arrayElemSchema', 'arrayElemValType', 'arrayElemElemValType', 'arrayElemTypedCtor', 'carrier', 'unsigned', 'jsonShape',
75
+ 'typedCtor', 'wasm', 'nullable', 'neverGrown',
72
76
  ])
73
77
 
74
78
  const DBG_REPS = typeof process !== 'undefined' && process.env?.JZ_DEBUG_INVARIANTS === '1'
package/src/static.js CHANGED
@@ -19,6 +19,15 @@ export function intLiteralValue(expr) {
19
19
  /** Non-negative integer literal — used for string/typed-array index bounds. */
20
20
  export const nonNegIntLiteral = (node) => { const n = intLiteralValue(node); return n != null && n >= 0 ? n : null }
21
21
 
22
+ /** Flat-array slot key for a *bare* non-negative integer index literal `[null, k]`
23
+ * — returns the stringified index ("0","1",…) so an array `a[k]` resolves through
24
+ * the same SRoA `name#i` machinery as an object `o.key`. Only a literal index
25
+ * qualifies (not a const-folded identifier): the key must be unambiguous at scan
26
+ * time, before any rep is known. Null for dynamic / non-integer / huge indices. */
27
+ export const staticIndexKey = (node) =>
28
+ Array.isArray(node) && node[0] == null && Number.isInteger(node[1]) && node[1] >= 0 && node[1] < 0x100000000
29
+ ? String(node[1]) : null
30
+
22
31
  /** Fold compile-time integer expressions (literals, const bindings, + - * <<). */
23
32
  export function constIntExpr(node) {
24
33
  let lit = intLiteralValue(node)
package/src/type.js CHANGED
@@ -12,8 +12,10 @@
12
12
  */
13
13
  import { isI32, isReassigned } from './ast.js'
14
14
  import { ctx } from './ctx.js'
15
+ import { FITS_I32_MAX } from './widen.js'
15
16
  import { VAL, lookupValType } from './reps.js'
16
17
  import { valTypeOf } from './kind.js'
18
+ import { propValType, CMP_OPS } from './kind-traits.js'
17
19
  import { NO_VALUE, staticValue, intLiteralValue } from './static.js'
18
20
  import { typedElemAux } from '../layout.js'
19
21
 
@@ -362,9 +364,24 @@ export function isTerminator(body) {
362
364
  return false
363
365
  }
364
366
 
365
- const isUnsignedI32Expr = (e) => Array.isArray(e) && (
367
+ // Resolve a name's typed-array element ctor: in-progress local overlay (analyzeBody)
368
+ // per-func map (post-analyze) → module-global registry. The global fallback matters during
369
+ // analyzeBody/narrow when the per-func map is null, so a read of a *global* typed array
370
+ // (`DX[i]` with `let DX = new Int32Array(...)` at module scope) resolves its element type
371
+ // instead of defaulting to f64. Guard against local shadows / dynamic rewrites (cf. kind.js).
372
+ const typedElemCtorOf = (name, locals) =>
373
+ ctx.func.localTypedElemsOverlay?.get(name) ?? ctx.types.typedElem?.get(name)
374
+ ?? (!locals?.has?.(name) && !ctx.types?.dynWriteVars?.has?.(name)
375
+ ? ctx.scope?.globalTypedElem?.get(name) : undefined)
376
+
377
+ // An expression whose i32 value carries the unsigned [0, 2^32) magnitude (not a signed i32):
378
+ // `>>>`, an unsigned-result call, or a Uint32Array read (aux 5 — the only typed array whose
379
+ // element can exceed signed-i32 range). The +/-/*/% rules widen these to f64 so `U[i] + 1`
380
+ // near 2^32 doesn't wrap; bitwise/store consumers are ToInt32-exact and keep the i32 bits.
381
+ const isUnsignedI32Expr = (e, locals) => Array.isArray(e) && (
366
382
  e[0] === '>>>' ||
367
- (e[0] === '()' && typeof e[1] === 'string' && ctx.func.map?.get(e[1])?.sig?.unsignedResult === true)
383
+ (e[0] === '()' && typeof e[1] === 'string' && ctx.func.map?.get(e[1])?.sig?.unsignedResult === true) ||
384
+ (e[0] === '[]' && typeof e[1] === 'string' && typedElemAux(typedElemCtorOf(e[1], locals)) === 5)
368
385
  )
369
386
 
370
387
  /**
@@ -380,12 +397,16 @@ export function exprType(expr, locals) {
380
397
  if (locals?.has?.(expr)) return locals.get(expr)
381
398
  const paramType = ctx.func.current?.params?.find(p => p.name === expr)?.type
382
399
  if (paramType) return paramType
383
- // Module-level numeric consts (top-level `const N = 128`) are emitted as
384
- // wasm globals with a known wasm type. Without this lookup, references to
385
- // them inside functions fall back to f64, widening counters bounded by the
386
- // const (`for (let r = 0; r < N_ROUNDS; r++)`) to f64 via the comparison
387
- // pass. Only propagate primitive numeric kinds i64 globals are reserved
388
- // for the NaN-box carrier ABI and shouldn't influence local typing.
400
+ // A module-level INTEGER const (`const N = 16384`) is an integer compile-time
401
+ // constant type it i32 when it fits, regardless of the global's f64 (NaN-box)
402
+ // storage. Otherwise a counter bounded by it (`for (i=0; i<N; i++)`) widens to
403
+ // f64 and `x % N` / `x & N` / `x / N` take the f64 round-trip instead of the
404
+ // native integer path (i32.rem_s / i32.and / i32.shr). Mirrors a literal int.
405
+ const ci = ctx.scope?.constInts?.get?.(expr)
406
+ if (ci != null && isI32(ci)) return 'i32'
407
+ // Module-level numeric consts emitted as wasm globals with a known wasm type.
408
+ // Only propagate primitive numeric kinds — i64 globals are reserved for the
409
+ // NaN-box carrier ABI and shouldn't influence local typing.
389
410
  const gt = ctx.scope?.globalTypes?.get?.(expr)
390
411
  if (gt === 'i32' || gt === 'f64') return gt
391
412
  return 'f64'
@@ -401,44 +422,41 @@ export function exprType(expr, locals) {
401
422
 
402
423
  // Always f64
403
424
  if (op === '/' || op === '**' || op === '[' || op === '{}' || op === 'str') return 'f64'
404
- // arr[i] — typed integer arrays return i32. Only Int8/Uint8/Int16/Uint16/Int32
405
- // (every value fits in signed i32). Skip Uint32: 0..2^32-1 overflows signed.
406
- // During analyzeBody the in-progress typedElems is in localTypedElemsOverlay;
407
- // post-analyze passes read from ctx.types.typedElem.
425
+ // arr[i] — integer typed arrays (Int8/Uint8/Int16/Uint16/Int32/Uint32, aux 0..5) read as i32:
426
+ // the element IS a 32-bit machine integer, so a binding used in integer/bitwise ops stays i32
427
+ // instead of round-tripping i32.load → f64 → trunc back (the deopt that made packed-pixel fade
428
+ // loops like lorenz slow). Uint32 reads carry the full 0..2^32-1 range as the i32 bit-pattern;
429
+ // ToInt32-coercing uses (& | ^ << >> >>>, i32.store) are bit-exact, and value uses that need the
430
+ // unsigned magnitude (compare, f64 convert) go through the elem-aux's unsigned path. Floats
431
+ // (Float32/Float64, aux 6/7) genuinely yield f64. typedElems: in-progress reads come from
432
+ // localTypedElemsOverlay during analyzeBody; post-analyze passes read ctx.types.typedElem.
408
433
  if (op === '[]') {
409
434
  if (typeof args[0] === 'string') {
410
- const ctor = ctx.func.localTypedElemsOverlay?.get(args[0]) ?? ctx.types.typedElem?.get(args[0])
435
+ // Resolve the element ctor across local overlay → per-func map → module-global registry
436
+ // (the global fallback is why `DX[i]` on a module-scope Int32Array types as i32 instead of
437
+ // f64-round-tripping integer accumulation like `ax = ax + DX[i]`). See typedElemCtorOf.
438
+ const ctor = typedElemCtorOf(args[0], locals)
411
439
  if (ctor) {
412
440
  const aux = typedElemAux(ctor)
413
- if (aux != null && (aux & 7) <= 4) return 'i32'
441
+ if (aux != null && (aux & 7) <= 5) return 'i32'
414
442
  }
415
443
  }
416
444
  return 'f64'
417
445
  }
418
- // `.length` on a known sized receiver returns i32 directly (__len/__str_byteLen
419
- // both return i32). Letting it stay i32 lets analyzeBody keep the counter
420
- // local i32 too, eliminating the per-iteration `f64.convert_i32_s` widen and
421
- // the matching `i32.trunc_sat_f64_s` truncs at every `arr[i]` / `i*k` site.
422
- // Only safe when receiver type is statically known to expose an integer length.
446
+ // A sized built-in property on a statically-known receiver (`.length` on
447
+ // STRING/ARRAY/TYPED, `.size` on SET/MAP, `.byteLength`/`.byteOffset` on
448
+ // TYPED/BUFFER) returns i32 directly (`__len`/`__str_byteLen` return i32).
449
+ // Keeping it i32 lets analyzeBody keep the counter local i32, eliminating the
450
+ // per-iteration `f64.convert_i32_s` widen and matching `arr[i]`/`i*k` truncs.
451
+ // The membership lives in one place — `propValType` (src/kind-traits.js).
423
452
  if (op === '.') {
424
- if (args[1] === 'length' && typeof args[0] === 'string') {
425
- const vt = lookupValType(args[0])
426
- if (vt === VAL.TYPED || vt === VAL.ARRAY || vt === VAL.STRING || vt === VAL.BUFFER) return 'i32'
427
- }
428
- if (args[1] === 'size' && typeof args[0] === 'string') {
429
- const vt = lookupValType(args[0])
430
- if (vt === VAL.SET || vt === VAL.MAP) return 'i32'
431
- }
432
- if (args[1] === 'byteLength' && typeof args[0] === 'string') {
433
- const vt = lookupValType(args[0])
434
- if (vt === VAL.BUFFER || vt === VAL.TYPED) return 'i32'
435
- }
453
+ if (typeof args[0] === 'string' && propValType(args[1], lookupValType(args[0])) === VAL.NUMBER) return 'i32'
436
454
  return 'f64'
437
455
  }
438
456
  // Comparisons, logical-not, and unsigned shift always yield an i32 — a boolean,
439
457
  // or a ToUint32 result. True even on BigInt operands (`>>>` throws on bigint, so
440
458
  // it never reaches here with one).
441
- if (['>', '<', '>=', '<=', '==', '!=', '!', '>>>'].includes(op)) return 'i32'
459
+ if (CMP_OPS.has(op) || op === '>>>') return 'i32'
442
460
  // Bitwise & signed-shift: i32 on numbers, but f64 when operands are BigInt — the
443
461
  // result is a bigint carried in the i64-bits-as-f64 ABI, not a 32-bit int.
444
462
  if (['&', '|', '^', '~', '<<', '>>'].includes(op))
@@ -451,7 +469,7 @@ export function exprType(expr, locals) {
451
469
  // A uint32 operand ([0, 2^32)) makes the result exceed signed i32 range, so
452
470
  // emit widens to f64 (see emit.js `+`/`-`). exprType must agree — else
453
471
  // narrowing the result back to i32 would trunc_sat-saturate the f64 to INT32_MAX.
454
- if (isUnsignedI32Expr(args[0]) || (args[1] != null && isUnsignedI32Expr(args[1]))) return 'f64'
472
+ if (isUnsignedI32Expr(args[0], locals) || (args[1] != null && isUnsignedI32Expr(args[1], locals))) return 'f64'
455
473
  return 'i32'
456
474
  }
457
475
  // `%` is i32 only when emit takes the i32.rem_s path: both operands i32, neither
@@ -461,7 +479,7 @@ export function exprType(expr, locals) {
461
479
  if (op === '%') {
462
480
  const ta = exprType(args[0], locals), tb = exprType(args[1], locals)
463
481
  if (ta !== 'i32' || tb !== 'i32') return 'f64'
464
- if (isUnsignedI32Expr(args[0]) || isUnsignedI32Expr(args[1])) return 'f64'
482
+ if (isUnsignedI32Expr(args[0], locals) || isUnsignedI32Expr(args[1], locals)) return 'f64'
465
483
  const dv = staticValue(args[1])
466
484
  return (dv !== NO_VALUE && typeof dv === 'number' && dv !== 0 && Number.isInteger(dv)) ? 'i32' : 'f64'
467
485
  }
@@ -474,11 +492,15 @@ export function exprType(expr, locals) {
474
492
  const ta = exprType(args[0], locals), tb = exprType(args[1], locals)
475
493
  if (ta !== 'i32' || tb !== 'i32') return 'f64'
476
494
  // uint32 operand: product can exceed i32; emit widens to f64 (see emit.js `*`).
477
- if (isUnsignedI32Expr(args[0]) || isUnsignedI32Expr(args[1])) return 'f64'
495
+ if (isUnsignedI32Expr(args[0], locals) || isUnsignedI32Expr(args[1], locals)) return 'f64'
478
496
  if (sv !== NO_VALUE && typeof sv === 'number') return isI32(sv) ? 'i32' : 'f64'
497
+ // Shared FITS_I32_MAX threshold (widen.js) keeps this in lock-step with emit's
498
+ // `mulFitsI32`. exprType only proves the static-literal case — a strict SUBSET of
499
+ // emit's i32 verdict (emit also admits masked-bound operands), which is the safe
500
+ // direction: never claim i32 where emit might widen to f64.
479
501
  const small = e => {
480
502
  const v = staticValue(e)
481
- return v !== NO_VALUE && typeof v === 'number' && Math.abs(v) <= 0x400000
503
+ return v !== NO_VALUE && typeof v === 'number' && Math.abs(v) <= FITS_I32_MAX
482
504
  }
483
505
  return small(args[0]) || small(args[1]) ? 'i32' : 'f64'
484
506
  }
@@ -501,7 +523,7 @@ export function exprType(expr, locals) {
501
523
  // hand a scalar back (i32x4.lane / v128.anyTrue / v128.allTrue → i32;
502
524
  // f32x4.lane → f64). See module/simd.js.
503
525
  if (typeof args[0] === 'string' && (args[0].startsWith('f32x4.') || args[0].startsWith('i32x4.') || args[0].startsWith('f64x2.') || args[0].startsWith('v128.'))) {
504
- if (args[0] === 'f32x4.lane') return 'f64'
526
+ if (args[0] === 'f32x4.lane' || args[0] === 'f64x2.lane') return 'f64'
505
527
  if (args[0] === 'i32x4.lane' || args[0] === 'v128.anyTrue' || args[0] === 'v128.allTrue') return 'i32'
506
528
  return 'v128'
507
529
  }
@@ -527,7 +549,6 @@ export function exprType(expr, locals) {
527
549
  // === Integer-certainty fixpoint (shared by analyzeIntCertain + program-facts) ===
528
550
 
529
551
  const INT_BIT_OPS = new Set(['|', '&', '^', '~', '<<', '>>', '>>>'])
530
- const INT_CMP_OPS = new Set(['<', '>', '<=', '>=', '==', '!=', '===', '!==', '!'])
531
552
  const INT_CLOSED_OPS = new Set(['+', '-', '*']) // `%` handled separately — int only for nonzero divisor
532
553
  const INT_MATH_FNS = new Set(['imul', 'clz32', 'floor', 'ceil', 'round', 'trunc'])
533
554
 
@@ -548,7 +569,7 @@ function collectIntDefs(body) {
548
569
  } else if (op === '=' && typeof args[0] === 'string') {
549
570
  pushDef(args[0], args[1])
550
571
  } else if (typeof op === 'string' && op.length > 1 && op.endsWith('=') &&
551
- !INT_CMP_OPS.has(op) && op !== '=>' && typeof args[0] === 'string') {
572
+ !CMP_OPS.has(op) && op !== '=>' && typeof args[0] === 'string') {
552
573
  pushDef(args[0], [op.slice(0, -1), args[0], args[1]])
553
574
  } else if ((op === '++' || op === '--') && typeof args[0] === 'string') {
554
575
  pushDef(args[0], [op === '++' ? '+' : '-', args[0], [null, 1]])
@@ -574,18 +595,9 @@ function makeIsIntExpr(intCertain) {
574
595
  if (typeof v === 'boolean') return true
575
596
  return false
576
597
  }
577
- if (INT_BIT_OPS.has(op) || INT_CMP_OPS.has(op)) return true
578
- if (op === '.') {
579
- if ((args[1] === 'length' || args[1] === 'byteLength') && typeof args[0] === 'string') {
580
- const vt = lookupValType(args[0])
581
- return vt === VAL.TYPED || vt === VAL.ARRAY || vt === VAL.STRING || vt === VAL.BUFFER
582
- }
583
- if (args[1] === 'size' && typeof args[0] === 'string') {
584
- const vt = lookupValType(args[0])
585
- return vt === VAL.SET || vt === VAL.MAP
586
- }
587
- return false
588
- }
598
+ if (INT_BIT_OPS.has(op) || CMP_OPS.has(op)) return true
599
+ if (op === '.')
600
+ return typeof args[0] === 'string' && propValType(args[1], lookupValType(args[0])) === VAL.NUMBER
589
601
  if (INT_CLOSED_OPS.has(op)) {
590
602
  const a = isIntExpr(args[0])
591
603
  const b = args[1] != null ? isIntExpr(args[1]) : a