jz 0.7.0 → 0.8.1

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 (55) hide show
  1. package/README.md +37 -33
  2. package/bench/README.md +176 -73
  3. package/bench/bench.svg +58 -71
  4. package/cli.js +12 -5
  5. package/dist/interop.js +1 -1
  6. package/dist/jz.js +1366 -1101
  7. package/index.js +40 -9
  8. package/interop.js +193 -138
  9. package/layout.js +29 -18
  10. package/module/array.js +49 -73
  11. package/module/collection.js +83 -25
  12. package/module/console.js +1 -1
  13. package/module/core.js +161 -15
  14. package/module/json.js +3 -3
  15. package/module/math.js +167 -117
  16. package/module/number.js +247 -13
  17. package/module/object.js +11 -5
  18. package/module/regex.js +8 -7
  19. package/module/string.js +295 -171
  20. package/module/typedarray.js +169 -105
  21. package/package.json +7 -3
  22. package/src/abi/string.js +40 -35
  23. package/src/ast.js +19 -2
  24. package/src/compile/analyze.js +64 -2
  25. package/src/compile/cse-load.js +200 -0
  26. package/src/compile/emit-assign.js +73 -14
  27. package/src/compile/emit.js +324 -34
  28. package/src/compile/index.js +204 -61
  29. package/src/compile/infer.js +8 -1
  30. package/src/compile/loop-divmod.js +12 -58
  31. package/src/compile/loop-model.js +91 -0
  32. package/src/compile/loop-recurrence.js +167 -0
  33. package/src/compile/loop-square.js +102 -0
  34. package/src/compile/narrow.js +180 -34
  35. package/src/compile/peel-stencil.js +18 -64
  36. package/src/compile/plan/common.js +29 -0
  37. package/src/compile/plan/index.js +4 -1
  38. package/src/compile/plan/inline.js +176 -21
  39. package/src/compile/plan/literals.js +93 -19
  40. package/src/ctx.js +51 -12
  41. package/src/helper-counters.js +137 -0
  42. package/src/ir.js +102 -13
  43. package/src/kind-traits.js +7 -3
  44. package/src/kind.js +14 -1
  45. package/src/op-policy.js +5 -2
  46. package/src/ops.js +119 -0
  47. package/src/optimize/index.js +1125 -136
  48. package/src/optimize/recurse.js +182 -0
  49. package/src/optimize/vectorize.js +1302 -144
  50. package/src/prepare/index.js +29 -12
  51. package/src/reps.js +4 -1
  52. package/src/type.js +53 -45
  53. package/src/wat/assemble.js +92 -9
  54. package/src/widen.js +21 -0
  55. package/src/wat/optimize.js +0 -3938
@@ -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
  }
@@ -196,6 +196,19 @@ const stripBoolNot = c => {
196
196
  while (Array.isArray(c) && c[0] === '!' && Array.isArray(c[1]) && c[1][0] === '!') c = c[1][1]
197
197
  return c
198
198
  }
199
+ // In a statement (value-discarded) position, postfix `x++`/`x--` is lowered to `(++x) − 1` /
200
+ // `(--x) + 1` to recover the old value — but nobody reads it, so drop the ∓1 and keep the bare
201
+ // increment. (`obj.p++` lowers via `obj.p = obj.p + 1`, also wrapped.) Cleaner AST for the loop/
202
+ // recurrence passes; codegen already discarded the ∓1, so this is purely canonicalization.
203
+ const isOne = n => Array.isArray(n) && n[0] == null && n[1] === 1
204
+ const dropDeadPostfix = s => {
205
+ if (Array.isArray(s) && s.length === 3 && isOne(s[2]) && Array.isArray(s[1])) {
206
+ const inner = s[1][0]
207
+ if ((s[0] === '-' && (inner === '++' || inner === '=')) ||
208
+ (s[0] === '+' && (inner === '--' || inner === '='))) return s[1]
209
+ }
210
+ return s
211
+ }
199
212
  const stringValue = node => Array.isArray(node) && node[0] == null && typeof node[1] === 'string' ? node[1] : null
200
213
  const MUTATING_ARRAY_METHODS = new Set(['copyWithin', 'fill', 'pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift'])
201
214
 
@@ -590,9 +603,13 @@ export default function prepare(node) {
590
603
 
591
604
  // Named constants → numeric literals. The JZ_NULL/JZ_UNDEF atom sentinels live
592
605
  // in ast.js — shared with emit without crossing the prepare↔compile boundary.
593
- const CONSTANTS = { 'true': true, 'false': false, 'null': JZ_NULL, 'undefined': JZ_UNDEF }
606
+ // Prototype-less (Object.create(null)): a plain `{}` inherits Object.prototype in V8, so
607
+ // `'valueOf' in CONSTANTS` / `CONSTANTS['toString']` would hit an inherited method and
608
+ // mis-resolve a user identifier named like an Object method (jz.js-only — kernel objects
609
+ // are already prototype-less). Same reason on F64_CONSTANTS / GLOBALS / REJECT_IDENTS.
610
+ const CONSTANTS = Object.assign(Object.create(null), { 'true': true, 'false': false, 'null': JZ_NULL, 'undefined': JZ_UNDEF })
594
611
  // NaN/Infinity stay as special f64 values in emit()
595
- const F64_CONSTANTS = { 'NaN': NaN, 'Infinity': Infinity }
612
+ const F64_CONSTANTS = Object.assign(Object.create(null), { 'NaN': NaN, 'Infinity': Infinity })
596
613
 
597
614
  /** Resolve variable name through block scope chain (innermost rename wins). */
598
615
  function resolveScope(name) {
@@ -848,7 +865,7 @@ function prep(node) {
848
865
  // Not actually "implicit imports" — these are ambient globals that exist in every jz/JS
849
866
  // program (they do not live in any module). jzify auto-injecting imports would still
850
867
  // need a list of these names to know what to emit, so the table lives here either way.
851
- export const GLOBALS = {
868
+ export const GLOBALS = Object.assign(Object.create(null), {
852
869
  Math: 'math',
853
870
  Number: 'Number',
854
871
  Array: 'Array',
@@ -878,7 +895,7 @@ export const GLOBALS = {
878
895
  BigInt: 'BigInt',
879
896
  TextEncoder: 'TextEncoder',
880
897
  TextDecoder: 'TextDecoder',
881
- }
898
+ })
882
899
 
883
900
  const patternItems = (node) => node?.[0] === ',' ? node.slice(1) : [node]
884
901
  const isDestructPattern = (node) => Array.isArray(node) && (node[0] === '[]' || node[0] === '{}')
@@ -1583,7 +1600,7 @@ const handlers = {
1583
1600
  if (Array.isArray(specifiers) && specifiers[0] === 'as' && specifiers[1] === '*') {
1584
1601
  const alias = specifiers[2]
1585
1602
  // Store namespace mapping so '.' handler can resolve X.prop → mangled name
1586
- if (!ctx.module.namespaces) ctx.module.namespaces = {}
1603
+ if (!ctx.module.namespaces) ctx.module.namespaces = Object.create(null) // name-keyed: prototype-less (see derive)
1587
1604
  ctx.module.namespaces[alias] = resolved.exports
1588
1605
  return null
1589
1606
  }
@@ -1632,20 +1649,20 @@ const handlers = {
1632
1649
  '!=='(a, b) { return prepStrictEq('!==', a, b) },
1633
1650
 
1634
1651
  // Statements
1635
- ';': (...stmts) => [';', ...stmts.map(prep).filter(x => x != null)],
1652
+ ';': (...stmts) => [';', ...stmts.map(prep).filter(x => x != null).map(dropDeadPostfix)],
1636
1653
  'let': (...inits) => prepDecl('let', ...inits),
1637
1654
  'const': (...inits) => prepDecl('const', ...inits),
1638
1655
 
1639
1656
  // Block-scoped control flow: push scope for bodies so inner let/const shadows correctly
1640
1657
  'if': (cond, then, els) => {
1641
1658
  const c = prep(stripBoolNot(cond))
1642
- pushScope(); const t = prep(then); popScope()
1643
- if (els != null) { pushScope(); const e = prep(els); popScope(); return ['if', c, t, e] }
1659
+ pushScope(); const t = dropDeadPostfix(prep(then)); popScope()
1660
+ if (els != null) { pushScope(); const e = dropDeadPostfix(prep(els)); popScope(); return ['if', c, t, e] }
1644
1661
  return ['if', c, t]
1645
1662
  },
1646
1663
  'while': (cond, body) => {
1647
1664
  const c = prep(stripBoolNot(cond))
1648
- pushScope(); const b = prep(body); popScope()
1665
+ pushScope(); const b = dropDeadPostfix(prep(body)); popScope()
1649
1666
  return ['while', c, b]
1650
1667
  },
1651
1668
  // do { body } while (cond) → flag-guarded while: `flag=true; while (flag||cond) { flag=false; body }`.
@@ -2123,7 +2140,7 @@ const handlers = {
2123
2140
  }
2124
2141
  }
2125
2142
  }
2126
- r = ['for', init ? prep(init) : null, cond ? prep(cond) : null, step ? prep(step) : null, prep(body)]
2143
+ r = ['for', init ? prep(init) : null, cond ? prep(cond) : null, step ? dropDeadPostfix(prep(step)) : null, dropDeadPostfix(prep(body))]
2127
2144
  } else if (Array.isArray(head) && head[0] === 'of') {
2128
2145
  // for (let x of arr) → hoist arr (if non-trivial) and arr.length once, iterate by index.
2129
2146
  // Divergence from JS: mutating arr during iteration won't extend/shorten the loop.
@@ -2466,7 +2483,7 @@ function prepareModule(specifier, source) {
2466
2483
  const savedFuncCount = ctx.func.list.length // track new funcs from this module
2467
2484
  const savedModulePrefix = ctx.module.currentPrefix
2468
2485
  ctx.scope.chain = derive(savedScope) // inherit parent scope
2469
- ctx.func.exports = {}
2486
+ ctx.func.exports = Object.create(null) // name-keyed: prototype-less (see derive)
2470
2487
  ctx.module.currentPrefix = prefix
2471
2488
 
2472
2489
  try {
package/src/reps.js CHANGED
@@ -57,6 +57,9 @@ export const VAL = {
57
57
  * @property {number} [arrayElemSchema] element object-schema id for arrays.
58
58
  * @property {string} [arrayElemValType] element VAL.* kind for arrays.
59
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.
60
63
  * @property {string} [carrier] abi carrier id override (e.g. 'jsstring').
61
64
  * @property {boolean} [unsigned] i32 carries an unsigned value (`>>>` result).
62
65
  * @property {*} [jsonShape] inferred shape for the JSON.stringify fast path.
@@ -68,7 +71,7 @@ export const VAL = {
68
71
  */
69
72
  export const REP_FIELDS = new Set([
70
73
  'val', 'ptrKind', 'ptrAux', 'schemaId', 'intConst', 'intCertain', 'notString',
71
- 'arrayElemSchema', 'arrayElemValType', 'arrayElemElemValType', 'carrier', 'unsigned', 'jsonShape',
74
+ 'arrayElemSchema', 'arrayElemValType', 'arrayElemElemValType', 'arrayElemTypedCtor', 'carrier', 'unsigned', 'jsonShape',
72
75
  'typedCtor', 'wasm', 'nullable', 'neverGrown',
73
76
  ])
74
77
 
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'
@@ -411,7 +432,10 @@ export function exprType(expr, locals) {
411
432
  // localTypedElemsOverlay during analyzeBody; post-analyze passes read ctx.types.typedElem.
412
433
  if (op === '[]') {
413
434
  if (typeof args[0] === 'string') {
414
- 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)
415
439
  if (ctor) {
416
440
  const aux = typedElemAux(ctor)
417
441
  if (aux != null && (aux & 7) <= 5) return 'i32'
@@ -419,30 +443,20 @@ export function exprType(expr, locals) {
419
443
  }
420
444
  return 'f64'
421
445
  }
422
- // `.length` on a known sized receiver returns i32 directly (__len/__str_byteLen
423
- // both return i32). Letting it stay i32 lets analyzeBody keep the counter
424
- // local i32 too, eliminating the per-iteration `f64.convert_i32_s` widen and
425
- // the matching `i32.trunc_sat_f64_s` truncs at every `arr[i]` / `i*k` site.
426
- // 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).
427
452
  if (op === '.') {
428
- if (args[1] === 'length' && typeof args[0] === 'string') {
429
- const vt = lookupValType(args[0])
430
- if (vt === VAL.TYPED || vt === VAL.ARRAY || vt === VAL.STRING || vt === VAL.BUFFER) return 'i32'
431
- }
432
- if (args[1] === 'size' && typeof args[0] === 'string') {
433
- const vt = lookupValType(args[0])
434
- if (vt === VAL.SET || vt === VAL.MAP) return 'i32'
435
- }
436
- if (args[1] === 'byteLength' && typeof args[0] === 'string') {
437
- const vt = lookupValType(args[0])
438
- if (vt === VAL.BUFFER || vt === VAL.TYPED) return 'i32'
439
- }
453
+ if (typeof args[0] === 'string' && propValType(args[1], lookupValType(args[0])) === VAL.NUMBER) return 'i32'
440
454
  return 'f64'
441
455
  }
442
456
  // Comparisons, logical-not, and unsigned shift always yield an i32 — a boolean,
443
457
  // or a ToUint32 result. True even on BigInt operands (`>>>` throws on bigint, so
444
458
  // it never reaches here with one).
445
- if (['>', '<', '>=', '<=', '==', '!=', '!', '>>>'].includes(op)) return 'i32'
459
+ if (CMP_OPS.has(op) || op === '>>>') return 'i32'
446
460
  // Bitwise & signed-shift: i32 on numbers, but f64 when operands are BigInt — the
447
461
  // result is a bigint carried in the i64-bits-as-f64 ABI, not a 32-bit int.
448
462
  if (['&', '|', '^', '~', '<<', '>>'].includes(op))
@@ -455,7 +469,7 @@ export function exprType(expr, locals) {
455
469
  // A uint32 operand ([0, 2^32)) makes the result exceed signed i32 range, so
456
470
  // emit widens to f64 (see emit.js `+`/`-`). exprType must agree — else
457
471
  // narrowing the result back to i32 would trunc_sat-saturate the f64 to INT32_MAX.
458
- 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'
459
473
  return 'i32'
460
474
  }
461
475
  // `%` is i32 only when emit takes the i32.rem_s path: both operands i32, neither
@@ -465,7 +479,7 @@ export function exprType(expr, locals) {
465
479
  if (op === '%') {
466
480
  const ta = exprType(args[0], locals), tb = exprType(args[1], locals)
467
481
  if (ta !== 'i32' || tb !== 'i32') return 'f64'
468
- if (isUnsignedI32Expr(args[0]) || isUnsignedI32Expr(args[1])) return 'f64'
482
+ if (isUnsignedI32Expr(args[0], locals) || isUnsignedI32Expr(args[1], locals)) return 'f64'
469
483
  const dv = staticValue(args[1])
470
484
  return (dv !== NO_VALUE && typeof dv === 'number' && dv !== 0 && Number.isInteger(dv)) ? 'i32' : 'f64'
471
485
  }
@@ -478,11 +492,15 @@ export function exprType(expr, locals) {
478
492
  const ta = exprType(args[0], locals), tb = exprType(args[1], locals)
479
493
  if (ta !== 'i32' || tb !== 'i32') return 'f64'
480
494
  // uint32 operand: product can exceed i32; emit widens to f64 (see emit.js `*`).
481
- if (isUnsignedI32Expr(args[0]) || isUnsignedI32Expr(args[1])) return 'f64'
495
+ if (isUnsignedI32Expr(args[0], locals) || isUnsignedI32Expr(args[1], locals)) return 'f64'
482
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.
483
501
  const small = e => {
484
502
  const v = staticValue(e)
485
- 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
486
504
  }
487
505
  return small(args[0]) || small(args[1]) ? 'i32' : 'f64'
488
506
  }
@@ -531,7 +549,6 @@ export function exprType(expr, locals) {
531
549
  // === Integer-certainty fixpoint (shared by analyzeIntCertain + program-facts) ===
532
550
 
533
551
  const INT_BIT_OPS = new Set(['|', '&', '^', '~', '<<', '>>', '>>>'])
534
- const INT_CMP_OPS = new Set(['<', '>', '<=', '>=', '==', '!=', '===', '!==', '!'])
535
552
  const INT_CLOSED_OPS = new Set(['+', '-', '*']) // `%` handled separately — int only for nonzero divisor
536
553
  const INT_MATH_FNS = new Set(['imul', 'clz32', 'floor', 'ceil', 'round', 'trunc'])
537
554
 
@@ -552,7 +569,7 @@ function collectIntDefs(body) {
552
569
  } else if (op === '=' && typeof args[0] === 'string') {
553
570
  pushDef(args[0], args[1])
554
571
  } else if (typeof op === 'string' && op.length > 1 && op.endsWith('=') &&
555
- !INT_CMP_OPS.has(op) && op !== '=>' && typeof args[0] === 'string') {
572
+ !CMP_OPS.has(op) && op !== '=>' && typeof args[0] === 'string') {
556
573
  pushDef(args[0], [op.slice(0, -1), args[0], args[1]])
557
574
  } else if ((op === '++' || op === '--') && typeof args[0] === 'string') {
558
575
  pushDef(args[0], [op === '++' ? '+' : '-', args[0], [null, 1]])
@@ -578,18 +595,9 @@ function makeIsIntExpr(intCertain) {
578
595
  if (typeof v === 'boolean') return true
579
596
  return false
580
597
  }
581
- if (INT_BIT_OPS.has(op) || INT_CMP_OPS.has(op)) return true
582
- if (op === '.') {
583
- if ((args[1] === 'length' || args[1] === 'byteLength') && typeof args[0] === 'string') {
584
- const vt = lookupValType(args[0])
585
- return vt === VAL.TYPED || vt === VAL.ARRAY || vt === VAL.STRING || vt === VAL.BUFFER
586
- }
587
- if (args[1] === 'size' && typeof args[0] === 'string') {
588
- const vt = lookupValType(args[0])
589
- return vt === VAL.SET || vt === VAL.MAP
590
- }
591
- return false
592
- }
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
593
601
  if (INT_CLOSED_OPS.has(op)) {
594
602
  const a = isIntExpr(args[0])
595
603
  const b = args[1] != null ? isIntExpr(args[1]) : a
@@ -39,6 +39,7 @@ import { VAL } from '../reps.js'
39
39
  import { optimizeFunc, collectVolatileGlobals, collectReachableGlobalWrites, hoistGlobalPtrOffset, stablePtrGlobalNames, hoistConstantPool, specializeMkptr, specializePtrBase, sortStrPoolByFreq, arenaRewindModule } from '../optimize/index.js'
40
40
  import { emit, emitVoid } from '../compile/emit.js'
41
41
  import { mkPtrIR, MAX_CLOSURE_ARITY, MEM_OPS, findBodyStart } from '../ir.js'
42
+ import { installHelperCounters, instrumentHelperCounter } from '../helper-counters.js'
42
43
 
43
44
  // NaN-prefix top-13-bits as BigInt — used by the static-prefix-strip pass
44
45
  const NAN_PREFIX = BigInt(LAYOUT.NAN_PREFIX)
@@ -221,6 +222,7 @@ export function buildStartFn(ast, sec, closureFuncs, compilePendingClosures) {
221
222
  // table holding only empty schemas is pure dead weight there. __json_obj has
222
223
  // no such guard — it must read the table whenever stringify is in play.
223
224
  const tblConsumed = hasStringify ||
225
+ ctx.core.includes.has('__obj_clone') ||
224
226
  ctx.core.includes.has('__dyn_get') ||
225
227
  ctx.core.includes.has('__dyn_get_t') ||
226
228
  ctx.core.includes.has('__dyn_get_t_h') ||
@@ -230,7 +232,13 @@ export function buildStartFn(ast, sec, closureFuncs, compilePendingClosures) {
230
232
  ctx.core.includes.has('__dyn_get_any_t_h') ||
231
233
  ctx.core.includes.has('__dyn_get_expr') ||
232
234
  ctx.core.includes.has('__dyn_get_expr_t') ||
233
- ctx.core.includes.has('__dyn_get_or')
235
+ ctx.core.includes.has('__dyn_get_or') ||
236
+ // A string runtime-key WRITE `o[k]=v` whose `k` matches a schema field must
237
+ // mirror the value into the fixed schema slot (buildObjectSchemaSetArm), or a
238
+ // later static `o.x` read returns the stale slot. That mirror is gated on
239
+ // `$__schema_tbl != 0`, so a write-only module (no `__dyn_get*`) must still
240
+ // build the table. (needsSchemaTbl below skips it when every schema is empty.)
241
+ ctx.core.includes.has('__dyn_set')
234
242
  const needsSchemaTbl = (ctx.schema.list.length && tblConsumed &&
235
243
  (hasStringify || ctx.schema.list.some(s => s.length > 0))) ||
236
244
  hasJpObj
@@ -552,6 +560,7 @@ export function appendLateStdlib(moduleArr) {
552
560
  * Phase: pull stdlib + memory.
553
561
  */
554
562
  export function pullStdlib(sec) {
563
+ installHelperCounters()
555
564
  resolveIncludes()
556
565
 
557
566
  // Reachability, not inclusion, decides what the output needs. `ctx.core.includes`
@@ -600,7 +609,26 @@ export function pullStdlib(sec) {
600
609
  // are pulled in on demand, never eagerly, so they're already minimal and never pruned
601
610
  // here (guarding against any reachability blind spot in a dotted-name template).
602
611
  for (const n of [...ctx.core.includes]) if (n.startsWith('__') && !reachable.has(n)) ctx.core.includes.delete(n)
603
- if (!needsAlloc) ctx.scope.globals.delete('__heap')
612
+ // Lazy Eisel-Lemire table injection: only when __dec_to_f64 (correctly-rounded
613
+ // decimal→f64, module/number.js) survived pruning — append its trimmed 131-entry
614
+ // (~2KB) power-of-10 table and declare $__el_tbl = that offset. Must run HERE so
615
+ // dataPages (below) accounts for the addition; keeps it out of programs that never
616
+ // parse decimals at runtime.
617
+ if (ctx.core.includes.has('__dec_to_f64') && ctx.runtime.elTable) {
618
+ const elBefore = ctx.runtime.data.length
619
+ while (ctx.runtime.data.length % 8 !== 0) ctx.runtime.data += '\0'
620
+ const elTblOff = ctx.runtime.data.length
621
+ ctx.runtime.data += ctx.runtime.elTable
622
+ declGlobal('__el_tbl', 'i32', elTblOff, { mut: false })
623
+ ctx.runtime.elTable = null // prevent double-injection on re-entry (null-sentinel; jz forbids delete)
624
+ // Reachability here OVER-counts __dec_to_f64: a dead inlined helper's `arr[i] | 0`
625
+ // on an untyped param pulls __to_num → __dec_to_f64, landing the table even when no
626
+ // LIVE code parses decimals. Record the appended span (padding + table, always the
627
+ // data tail — it's the last append) so stripDeadElTable can drop it post-lowering,
628
+ // once reachability is exact. See stripDeadElTable.
629
+ ctx.runtime.elTableLen = ctx.runtime.data.length - elBefore
630
+ }
631
+ if (!needsAlloc) { ctx.scope.globals.delete('__heap'); ctx.scope.globals.delete('__heap_reset') }
604
632
  if (needsMemory && ctx.module.modules.core) {
605
633
  if (needsAlloc) {
606
634
  for (const fn of ['__alloc', '__alloc_hdr', '__clear']) ctx.core.includes.add(fn)
@@ -608,6 +636,20 @@ export function pullStdlib(sec) {
608
636
  // etc.) that the initial resolveIncludes did not yet see; re-resolve.
609
637
  // No-op when the alloc trio was already present.
610
638
  resolveIncludes()
639
+ // Record the post-init heap top into `__heap_reset` so `__clear` rewinds to
640
+ // just above this module's init-time heap state (e.g. the self-host compiler's
641
+ // GLOBALS/atom tables), not into it. Done here — where `__heap` is known to
642
+ // survive — as the last `__start` action before any non-returning timer loop.
643
+ // No `__start` ⇒ no init allocations ⇒ `__heap_reset`'s data-end seed is right.
644
+ if (!ctx.memory.shared && ctx.scope.globals.has('__heap_reset')) {
645
+ const startFn = sec.start.find(n => Array.isArray(n) && n[0] === 'func' && n[1] === '$__start')
646
+ if (startFn) {
647
+ const capture = ['global.set', '$__heap_reset', ['global.get', '$__heap']]
648
+ const tail = startFn[startFn.length - 1]
649
+ if (Array.isArray(tail) && tail[0] === 'call' && tail[1] === '$__timer_loop') startFn.splice(startFn.length - 1, 0, capture)
650
+ else startFn.push(capture)
651
+ }
652
+ }
611
653
  }
612
654
  // Initial pages must cover the static data segment (it loads at instantiation), not
613
655
  // just the default 1 — otherwise a module whose constants exceed 64 KiB emits a data
@@ -637,7 +679,7 @@ export function pullStdlib(sec) {
637
679
  }
638
680
  }
639
681
  for (const n of ctx.core.includes) if (!ctx.core.stdlib[n]) err(`internal: stdlib '${n}' was requested but never registered (this is a jz bug — feature pulled in something it can't deliver)`)
640
- sec.stdlib.push(...[...ctx.core.includes].map(n => parseTemplate(stdlibStr(n))))
682
+ sec.stdlib.push(...[...ctx.core.includes].map(n => instrumentHelperCounter(n, parseTemplate(stdlibStr(n)))))
641
683
  }
642
684
 
643
685
  export function syncImports(sec) {
@@ -706,17 +748,58 @@ export function optimizeModule(sec, profiler) {
706
748
  if (dataLen > 1024 && !ctx.memory.shared) {
707
749
  const heapBase = (dataLen + 7) & ~7
708
750
  // Non-shared memory always carries a $__heap global — start it past the
709
- // static data so the bump allocator never overwrites a literal.
751
+ // static data so the bump allocator never overwrites a literal. `__heap_reset`
752
+ // seeds to the same data end (its runtime value is overwritten by `__start`'s
753
+ // tail capture for modules that init-allocate; this seed serves modules with no
754
+ // `__start`, where the data end IS the correct rewind point). `__clear` reads
755
+ // `$__heap_reset` directly, so no per-function constant patch is needed.
710
756
  declGlobal('__heap', 'i32', heapBase, { export: '__heap' })
757
+ if (ctx.scope.globals.has('__heap_reset')) declGlobal('__heap_reset', 'i32', heapBase)
711
758
  if (ctx.scope.globals.has('__heap_start')) declGlobal('__heap_start', 'i32', heapBase)
712
- for (const s of sec.stdlib)
713
- if (s[0] === 'func' && s[1] === '$__clear')
714
- for (let i = 2; i < s.length; i++)
715
- if (Array.isArray(s[i]) && s[i][0] === 'global.set' && Array.isArray(s[i][2]) && s[i][2][0] === 'i32.const')
716
- s[i][2][1] = `${heapBase}`
717
759
  }
718
760
  }
719
761
 
762
+ /**
763
+ * Phase: strip the Eisel-Lemire table when it is dead.
764
+ *
765
+ * pullStdlib injects the ~2 KB power-of-10 table whenever `__dec_to_f64` is *reachable*,
766
+ * but that over-counts: a dead inlined helper's `arr[i] | 0` on an untyped param pulls
767
+ * `__to_num` → `__dec_to_f64`, so the table lands even in a module no live code parses
768
+ * decimals in. watr later treeshakes the dead function + its `$__el_tbl` global, but it
769
+ * does NOT treeshake the data segment — so the orphaned table bloated every module ~2 KB.
770
+ *
771
+ * This runs LAST (after every lowering has emitted its call/ref.func — doing it earlier is
772
+ * unsound: refs like `util.clone` are emitted *after* pullStdlib), so a mark-sweep from the
773
+ * real roots (inline-exported funcs, __start, the closure table, globals/tags/table) gives
774
+ * EXACT liveness. If `__dec_to_f64` is dead, truncate the table from the data tail (it is
775
+ * the last append — see pullStdlib). DATA only: the dead function + global are left for
776
+ * watr, which already removes them. Keeps correctly-rounded decimal parsing wherever it is
777
+ * genuinely live (parseFloat, the self-host compiler's `Number()` on source literals).
778
+ */
779
+ export function stripDeadElTable(sec) {
780
+ if (!ctx.runtime.elTableLen) return
781
+ const byName = new Map()
782
+ for (const arr of [sec.funcs, sec.stdlib, sec.start])
783
+ for (const f of arr || []) if (Array.isArray(f) && f[0] === 'func' && typeof f[1] === 'string') byName.set(f[1], f)
784
+ const live = new Set(), work = []
785
+ const mark = (ref) => { if (typeof ref === 'string' && byName.has(ref) && !live.has(ref)) { live.add(ref); work.push(ref) } }
786
+ const scan = (n) => {
787
+ if (!Array.isArray(n)) return
788
+ if ((n[0] === 'call' || n[0] === 'return_call' || n[0] === 'ref.func') && typeof n[1] === 'string') mark(n[1])
789
+ for (const c of n) scan(c)
790
+ }
791
+ for (const f of sec.funcs) if (f.some(el => Array.isArray(el) && el[0] === 'export')) mark(f[1])
792
+ for (const f of sec.start) scan(f)
793
+ for (const part of [sec.elem, sec.globals, sec.tags, sec.table]) for (const n of part || []) {
794
+ if (!Array.isArray(n)) continue
795
+ for (const c of n) { if (typeof c === 'string' && c[0] === '$') mark(c); else scan(c) }
796
+ }
797
+ while (work.length) scan(byName.get(work.pop()))
798
+ if (live.has('$__dec_to_f64')) return // genuinely parses decimals at runtime — keep it
799
+ ctx.runtime.data = ctx.runtime.data.slice(0, ctx.runtime.data.length - ctx.runtime.elTableLen)
800
+ ctx.runtime.elTableLen = 0
801
+ }
802
+
720
803
  /**
721
804
  * Phase: strip static-data prefix.
722
805
  */
package/src/widen.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Numeric widening thresholds — the SINGLE SOURCE for "when does an i32 arithmetic
3
+ * op stay i32 vs widen to f64", shared by the two phases that must agree:
4
+ * • emit.js DECIDES — emits `i32.mul`/`i32.add` or widens to `f64.mul`/`f64.add`
5
+ * • type.js MIRRORS — exprType predicts the same i32/f64 so locals are typed right
6
+ *
7
+ * SOUNDNESS INVARIANT (one-way, unforgiving): exprType's i32 verdict must be a SUBSET
8
+ * of emit's — exprType may answer i32 only where emit DEFINITELY produces i32. If type
9
+ * says i32 but emit yields f64, the result is `trunc_sat`-narrowed back to i32 → silent
10
+ * miscompile. The two predicates can't share a function (emit reads IR values via
11
+ * isLit/maskBound, type reads AST via staticValue), but they MUST share this threshold,
12
+ * or a future edit to one silently drifts the other out of the safe subset.
13
+ *
14
+ * @module widen
15
+ */
16
+
17
+ // JS `*` is an f64 multiply; `i32.mul` agrees only while the exact product stays
18
+ // f64-exact (|product| ≤ 2^53). Against the full i32 range (2^31) of one operand, the
19
+ // other must be bounded |v| ≤ 2^22 for the product to hold within 2^53 — so a literal
20
+ // or provably-masked operand of that magnitude keeps the multiply on `i32.mul`.
21
+ export const FITS_I32_MAX = 0x400000 // 2^22