jz 0.4.0 → 0.5.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.
package/src/plan.js CHANGED
@@ -8,7 +8,8 @@
8
8
  * narrowed signatures, finalized global reps, and per-call decisions.
9
9
  *
10
10
  * # Pipeline (top-level `plan(ast)`)
11
- * 1. scanGlobalValueFacts / unboxConstTypedGlobals — finalize global storage.
11
+ * 1. unboxConstTypedGlobals — finalize global storage. (Global value facts
12
+ * themselves are seeded by prepare via `infer.recordGlobalRep`.)
12
13
  * 2. collectProgramFacts — sweep arrow bodies for typed-elem usage, key sets,
13
14
  * loop depth, control-transfer shapes; rerun if hot inlining changes the AST.
14
15
  * 3. materializeAutoBoxSchemas / resolveClosureWidth — settle layout decisions.
@@ -24,9 +25,10 @@
24
25
  */
25
26
 
26
27
  import { ctx } from './ctx.js'
27
- import { T, VAL, ASSIGN_OPS, analyzeBody, invalidateLocalsCache, staticObjectProps, staticPropertyKey, valTypeOf, typedElemCtor, typedElemAux, updateGlobalRep, collectProgramFacts, extractParams } from './analyze.js'
28
- import { MAX_CLOSURE_ARITY } from './ir.js'
29
- import narrowSignatures, { specializeBimorphicTyped, refineDynKeys } from './narrow.js'
28
+ import { T, VAL, ASSIGN_OPS, analyzeBody, invalidateLocalsCache, staticObjectProps, staticPropertyKey, typedElemCtor, typedElemAux, updateGlobalRep, collectProgramFacts, analyzeFuncNamespaces, extractParams, intLiteralValue } from './analyze.js'
29
+ import { includeModule } from './autoload.js'
30
+ import { MAX_CLOSURE_ARITY, UNDEF_WAT } from './ir.js'
31
+ import narrowSignatures, { specializeBimorphicTyped, refineDynKeys, applyJsstringBoundaryCarrierStandalone, narrowBoolResults } from './narrow.js'
30
32
 
31
33
  const CONTROL_TRANSFER = new Set(['return', 'throw', 'break', 'continue'])
32
34
  const LOOP_OPS = new Set(['for', 'while', 'do', 'do-while'])
@@ -199,17 +201,27 @@ const fixedScalarTypedArray = (expr) => {
199
201
 
200
202
  const ASSIGN_TARGET_OPS = new Set(['=', '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '>>=', '<<=', '>>>=', '||=', '&&=', '??='])
201
203
 
202
- const safeScalarArrayUse = (node, name, parentOp = null) => {
204
+ const safeScalarArrayUse = (node, name, len, parentOp = null) => {
203
205
  if (typeof node === 'string') return node !== name
204
206
  if (!Array.isArray(node)) return true
205
207
  const op = node[0]
206
208
  if (ASSIGN_TARGET_OPS.has(op) && node[1] === name) return false
207
209
  if ((op === 'let' || op === 'const') && node.slice(1).some(d => d === name || (Array.isArray(d) && d[1] === name))) return false
208
210
  if ((op === '.' || op === '?.') && node[1] === name) return node[2] === 'length'
211
+ // Element write `name[idx] (op)= v` / `name[idx]++`: an out-of-bounds index
212
+ // grows the array (sparse-array semantics), which the fixed scalar slot set
213
+ // can't model — reject unless idx is a literal within the literal's bounds.
214
+ if ((ASSIGN_TARGET_OPS.has(op) || op === '++' || op === '--')
215
+ && Array.isArray(node[1]) && node[1][0] === '[]' && node[1][1] === name) {
216
+ const idx = intLit(node[1][2])
217
+ if (idx == null || idx < 0 || idx >= len) return false
218
+ for (let i = 2; i < node.length; i++) if (!safeScalarArrayUse(node[i], name, len, op)) return false
219
+ return true
220
+ }
209
221
  if (op === '[]' && node[1] === name) return intLit(node[2]) != null
210
222
  if (op === '...' && node[1] === name) return parentOp === '['
211
223
  for (let i = 1; i < node.length; i++) {
212
- if (!safeScalarArrayUse(node[i], name, op)) return false
224
+ if (!safeScalarArrayUse(node[i], name, len, op)) return false
213
225
  }
214
226
  return true
215
227
  }
@@ -674,7 +686,7 @@ const scalarizeArrayLiteralSeq = (seq) => {
674
686
  let ok = true
675
687
  for (let j = 0; j < stmts.length && ok; j++) {
676
688
  if (j === i) continue
677
- ok = safeScalarArrayUse(stmts[j], decl[1])
689
+ ok = safeScalarArrayUse(stmts[j], decl[1], elems.length)
678
690
  }
679
691
  if (!ok) continue
680
692
  candidates.set(decl[1], { index: i, op: stmt[0], elems })
@@ -804,6 +816,316 @@ const scalarizeFunctionArrayLiterals = () => {
804
816
  return changed
805
817
  }
806
818
 
819
+ // === Int-array → Int32Array auto-promotion =================================
820
+ //
821
+ // A `let xs = [intLit, intLit, …]` binding whose every use is TYPED-compatible
822
+ // is rewritten to `let xs = new Int32Array([intLit, …])`. Downstream analysis
823
+ // then takes over: valTypeOf → VAL.TYPED, methods dispatch through `.typed:`,
824
+ // loops get auto-vectorized via i32x4 (SIMD pass), and the carrier shrinks
825
+ // from 8-byte f64 slots to packed i32. The promotion runs after literal
826
+ // scalarization (so arrays fully scalarized away are already gone) and before
827
+ // typed-array param scalarization (so a freshly-promoted array can still
828
+ // participate in subsequent loop unrolling).
829
+ //
830
+ // Safety: the binding must never appear in a pattern that TYPED can't honor.
831
+ // Disqualifiers (each fires per binding name):
832
+ // 1. reassignment `xs = …` / compound `xs +=` / `++xs` / `--xs` — TYPED has
833
+ // no value-replacement op; `xs = new TypedArray(…)` would also drop the
834
+ // promoted view's identity.
835
+ // 2. element write `xs[k] = v` — TYPED's i32-trunc store would lose
836
+ // fractional/NaN bits that VAL.ARRAY would have preserved.
837
+ // 3. method calls outside the read-safe whitelist (push, pop, shift, …) —
838
+ // TYPED arrays are fixed-length; mutators don't exist on the carrier.
839
+ // 4. `Array.isArray(xs)` — semantics flip true→false on promotion.
840
+ // 5. `…xs` spread / `xs` as call arg / `xs` as return / bare reference in
841
+ // any other position — escape; callee may rely on ARRAY layout.
842
+ // 6. captured by a closure / shadowed by an inner decl — same escape
843
+ // reasoning, plus the inner decl could rebind to a non-array.
844
+ //
845
+ // Elements at init must all be i32-range integer literals. A negative literal
846
+ // arrives as `[null, -n]` after prepare's constant folding; intLiteralValue
847
+ // recognizes that form. Float literals (`[1, 2.5]`) and out-of-range ints
848
+ // (`0x80000000` on the +ve side, etc.) disqualify the array as a whole.
849
+
850
+ // Methods we promote across. The bar: every entry must have a real `.typed:*`
851
+ // emitter in module/typedarray.js. Receiver-flow into chained methods is also
852
+ // typed-aware now — `.typed:map`/`.typed:filter`/`.typed:slice` all return
853
+ // TYPED carriers, and downstream `.filter`/`.slice`/`.map`/etc. on those
854
+ // re-dispatch via emit.js:2211's `.typed:<m>` lookup (VAL.TYPED ⇒ typed
855
+ // emitter). Methods missing here (.join, .sort, .reverse, .subarray, .fill,
856
+ // .toString, .copyWithin, …) lack a typed emitter — disqualify the candidate.
857
+ const _TYPED_SAFE_METHODS = new Set([
858
+ 'set',
859
+ 'map', 'filter', 'slice',
860
+ 'forEach', 'reduce',
861
+ 'indexOf', 'includes', 'find', 'findIndex', 'some', 'every',
862
+ ])
863
+
864
+ // `.length` is TYPED-aware via core.js:__len (shifts the byte header by
865
+ // __typed_shift on TAG=3). `.byteLength`/`.byteOffset`/`.buffer` are
866
+ // TYPED-only — a user reading those already expects a typed array, so a
867
+ // promotion candidate that hits them is a coincidence we'd rather not
868
+ // rely on; disqualify and let them write the TypedArray construction
869
+ // themselves.
870
+ const _TYPED_SAFE_PROPS = new Set(['length'])
871
+
872
+ // Returns the i32-range integer payload of an array-literal element, or null
873
+ // if the element isn't a literal integer that fits in i32. Mirrors the shape
874
+ // check used by `intLiteralValue` but without the rep-lookup (we're pre-
875
+ // analysis here, and want a pure syntactic gate).
876
+ const _intArrayLitElems = (expr) => {
877
+ if (!Array.isArray(expr) || expr[0] !== '[') return null
878
+ if (expr.length < 2) return null // empty literal — low value, skip
879
+ const out = []
880
+ for (let i = 1; i < expr.length; i++) {
881
+ const v = intLiteralValue(expr[i])
882
+ if (v == null) return null
883
+ out.push(v)
884
+ }
885
+ return out
886
+ }
887
+
888
+ // True iff `name` appears anywhere within `node` as a bare identifier or
889
+ // inside any expression position. Used to detect escape across a closure
890
+ // boundary (where we can't trace the use sites locally).
891
+ const _refsName = (node, name) => {
892
+ if (typeof node === 'string') return node === name
893
+ if (!Array.isArray(node)) return false
894
+ for (let i = 1; i < node.length; i++) if (_refsName(node[i], name)) return true
895
+ return false
896
+ }
897
+
898
+ // Walks `node` and disqualifies every candidate name that appears in an
899
+ // unsafe context. `initSet` holds the candidate's own init-decl AST nodes
900
+ // (their LHS reference is the binding being defined, not an escape).
901
+ const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
902
+ if (initSet.has(node)) {
903
+ // The init decl itself: only walk the RHS (skip the LHS `name`).
904
+ return _disqualifyPromotion(node[2], candidates, disqualified, initSet)
905
+ }
906
+ if (typeof node === 'string') {
907
+ // Bare identifier outside any handled parent context — escape.
908
+ if (candidates.has(node)) disqualified.add(node)
909
+ return
910
+ }
911
+ if (!Array.isArray(node)) return
912
+ const op = node[0]
913
+
914
+ // Closure body — any candidate referenced inside is captured. Bail without
915
+ // recursing further (we'd otherwise hit the bare-name leaf and disqualify
916
+ // anyway, but this is explicit and avoids walking the inner closure).
917
+ if (op === '=>') {
918
+ for (const n of candidates.keys()) {
919
+ if (!disqualified.has(n) && _refsName(node, n)) disqualified.add(n)
920
+ }
921
+ return
922
+ }
923
+
924
+ // Property access `name.prop` / `name?.prop`. Bare property reads only —
925
+ // method calls reach here via the `()` handler below (which intercepts
926
+ // before recursing into its callee).
927
+ if ((op === '.' || op === '?.') && typeof node[1] === 'string' && candidates.has(node[1])) {
928
+ if (!_TYPED_SAFE_PROPS.has(node[2])) disqualified.add(node[1])
929
+ return // node[2] is the property name (string), not an expression — done
930
+ }
931
+
932
+ // Method or function call. Two shapes carry the candidate:
933
+ // `name.method(args)` — receiver at node[1][1]; method whitelist gates.
934
+ // `f(…, name, …)` / `Array.isArray(name)` — name appears as a plain arg.
935
+ if (op === '()') {
936
+ const callee = node[1]
937
+ // Method call on a candidate receiver.
938
+ if (Array.isArray(callee) && (callee[0] === '.' || callee[0] === '?.') &&
939
+ typeof callee[1] === 'string' && candidates.has(callee[1])) {
940
+ if (!_TYPED_SAFE_METHODS.has(callee[2])) disqualified.add(callee[1])
941
+ // Walk method args (skip the receiver — already validated above).
942
+ for (let i = 2; i < node.length; i++) _disqualifyPromotion(node[i], candidates, disqualified, initSet)
943
+ return
944
+ }
945
+ // Array.isArray flips true→false under promotion.
946
+ if (callee === 'Array.isArray') {
947
+ const raw = node[2]
948
+ const list = raw == null ? [] : (Array.isArray(raw) && raw[0] === ',') ? raw.slice(1) : [raw]
949
+ for (const a of list) {
950
+ if (typeof a === 'string' && candidates.has(a)) disqualified.add(a)
951
+ else _disqualifyPromotion(a, candidates, disqualified, initSet)
952
+ }
953
+ return
954
+ }
955
+ // Fall through to generic recursion — `name` as a plain arg will hit the
956
+ // bare-name leaf above and disqualify.
957
+ }
958
+
959
+ // Index read `name[k]` — read access is TYPED-safe. Walk the key in case
960
+ // it contains references to other candidate names.
961
+ if (op === '[]' && typeof node[1] === 'string' && candidates.has(node[1])) {
962
+ _disqualifyPromotion(node[2], candidates, disqualified, initSet)
963
+ return
964
+ }
965
+
966
+ // Element write: `['=', ['[]', name, k], v]` (and compound forms).
967
+ // V1 stays read-only after init — element writes would silently truncate.
968
+ if (ASSIGN_OPS.has(op) && Array.isArray(node[1]) && node[1][0] === '[]' &&
969
+ typeof node[1][1] === 'string' && candidates.has(node[1][1])) {
970
+ disqualified.add(node[1][1])
971
+ _disqualifyPromotion(node[1][2], candidates, disqualified, initSet)
972
+ _disqualifyPromotion(node[2], candidates, disqualified, initSet)
973
+ return
974
+ }
975
+
976
+ // Whole-binding reassign: `name = …` / `name += …` / etc.
977
+ if (ASSIGN_OPS.has(op) && typeof node[1] === 'string' && candidates.has(node[1])) {
978
+ disqualified.add(node[1])
979
+ _disqualifyPromotion(node[2], candidates, disqualified, initSet)
980
+ return
981
+ }
982
+
983
+ // Pre/post-increment on var or element.
984
+ if (op === '++' || op === '--') {
985
+ const t = node[1]
986
+ if (typeof t === 'string' && candidates.has(t)) { disqualified.add(t); return }
987
+ if (Array.isArray(t) && t[0] === '[]' && typeof t[1] === 'string' && candidates.has(t[1])) {
988
+ disqualified.add(t[1])
989
+ _disqualifyPromotion(t[2], candidates, disqualified, initSet)
990
+ return
991
+ }
992
+ }
993
+
994
+ // `let`/`const` declaration — the candidate's own init lands here via the
995
+ // initSet branch at the top. Any *other* decl that names a candidate is a
996
+ // shadow (impossible after jz hoists, but defensive) and disqualifies.
997
+ if (op === 'let' || op === 'const') {
998
+ for (let i = 1; i < node.length; i++) {
999
+ const d = node[i]
1000
+ if (typeof d === 'string' && candidates.has(d)) disqualified.add(d)
1001
+ else if (Array.isArray(d) && d[0] === '=' && typeof d[1] === 'string' &&
1002
+ candidates.has(d[1]) && !initSet.has(d)) {
1003
+ disqualified.add(d[1])
1004
+ _disqualifyPromotion(d[2], candidates, disqualified, initSet)
1005
+ } else {
1006
+ _disqualifyPromotion(d, candidates, disqualified, initSet)
1007
+ }
1008
+ }
1009
+ return
1010
+ }
1011
+
1012
+ // Spread `…name` — could be in a call, a `[`, or a destructure target.
1013
+ if (op === '...' && typeof node[1] === 'string' && candidates.has(node[1])) {
1014
+ disqualified.add(node[1])
1015
+ return
1016
+ }
1017
+
1018
+ // for-of / for-in iteration: receiver position is `node[2]` (a bare name
1019
+ // there would otherwise trigger escape). TYPED supports iteration, so
1020
+ // allow the receiver but walk the body for other refs.
1021
+ if (op === 'for-of' || op === 'for-in') {
1022
+ // Walk decl (node[1]), iter (node[2]), body (node[3]); receiver as bare
1023
+ // name is fine — only the body matters for further refs to the same name
1024
+ // (but body refs would shadow or escape, which other rules catch).
1025
+ for (let i = 1; i < node.length; i++) {
1026
+ const child = node[i]
1027
+ if (i === 2 && typeof child === 'string' && candidates.has(child)) continue
1028
+ _disqualifyPromotion(child, candidates, disqualified, initSet)
1029
+ }
1030
+ return
1031
+ }
1032
+
1033
+ // Generic — recurse into children. Bare-name refs at unhandled positions
1034
+ // hit the string-leaf branch above and disqualify on contact.
1035
+ for (let i = 1; i < node.length; i++) _disqualifyPromotion(node[i], candidates, disqualified, initSet)
1036
+ }
1037
+
1038
+ // Walk `body` to collect every `let X = [intLit, …]` candidate. Each entry
1039
+ // carries the exact init-decl AST node so the disqualifier can skip the
1040
+ // binding's own LHS reference (which would otherwise look like a reassign).
1041
+ const _collectIntArrayCandidates = (node, candidates) => {
1042
+ if (!Array.isArray(node)) return
1043
+ const op = node[0]
1044
+ if (op === '=>') return
1045
+ if (op === 'let' || op === 'const') {
1046
+ for (let i = 1; i < node.length; i++) {
1047
+ const d = node[i]
1048
+ if (!Array.isArray(d) || d[0] !== '=' || typeof d[1] !== 'string') continue
1049
+ const elems = _intArrayLitElems(d[2])
1050
+ if (elems == null) continue
1051
+ // jz hoists `let` to function scope — duplicate candidate-name collisions
1052
+ // shouldn't happen, but if they do the second wins (disqualifyPromotion
1053
+ // will mark both via the shadow rule).
1054
+ candidates.set(d[1], { initDecl: d, elems })
1055
+ }
1056
+ }
1057
+ for (let i = 1; i < node.length; i++) _collectIntArrayCandidates(node[i], candidates)
1058
+ }
1059
+
1060
+ // Rewrite `let name = [...]` → `let name = new Int32Array([...])` for every
1061
+ // validated candidate. Preserves the original element AST nodes so the
1062
+ // downstream `Int32Array.from` lowering picks up its existing static-data
1063
+ // segment / per-element-store fast paths.
1064
+ const _rewritePromoted = (node, validated, initSet) => {
1065
+ if (!Array.isArray(node)) return { node, changed: false }
1066
+ const op = node[0]
1067
+ if (op === '=>') {
1068
+ // Closures don't reach validated bindings (we disqualified on capture).
1069
+ // Still recurse — a nested closure may itself hold a promotable.
1070
+ let changed = false
1071
+ const out = [op]
1072
+ for (let i = 1; i < node.length; i++) {
1073
+ const r = _rewritePromoted(node[i], validated, initSet)
1074
+ if (r.changed) changed = true
1075
+ out.push(r.node)
1076
+ }
1077
+ return changed ? { node: out, changed: true } : { node, changed: false }
1078
+ }
1079
+ let changed = false
1080
+ const out = [op]
1081
+ for (let i = 1; i < node.length; i++) {
1082
+ const child = node[i]
1083
+ if ((op === 'let' || op === 'const') && Array.isArray(child) && child[0] === '=' &&
1084
+ typeof child[1] === 'string' && validated.has(child[1]) && initSet.has(child)) {
1085
+ const newRhs = ['()', 'new.Int32Array', child[2]]
1086
+ out.push(['=', child[1], newRhs])
1087
+ changed = true
1088
+ continue
1089
+ }
1090
+ const r = _rewritePromoted(child, validated, initSet)
1091
+ if (r.changed) changed = true
1092
+ out.push(r.node)
1093
+ }
1094
+ return changed ? { node: out, changed: true } : { node, changed: false }
1095
+ }
1096
+
1097
+ const promoteIntArrayLiteralsInBody = (body) => {
1098
+ const candidates = new Map()
1099
+ _collectIntArrayCandidates(body, candidates)
1100
+ if (!candidates.size) return { node: body, changed: false }
1101
+ const initSet = new Set()
1102
+ for (const { initDecl } of candidates.values()) initSet.add(initDecl)
1103
+ const disqualified = new Set()
1104
+ _disqualifyPromotion(body, candidates, disqualified, initSet)
1105
+ const validated = new Set()
1106
+ for (const name of candidates.keys()) if (!disqualified.has(name)) validated.add(name)
1107
+ if (!validated.size) return { node: body, changed: false }
1108
+ return _rewritePromoted(body, validated, initSet)
1109
+ }
1110
+
1111
+ // On the first successful promotion, pull in the typedarray module so the
1112
+ // emitted `new.Int32Array` callee has a registered emitter. Calling
1113
+ // `includeModule('typedarray')` on a no-op (already loaded) is cheap.
1114
+ const promoteIntArrayLiterals = () => {
1115
+ let changed = false
1116
+ for (const func of ctx.func.list) {
1117
+ if (!func.body || func.raw) continue
1118
+ const r = promoteIntArrayLiteralsInBody(func.body)
1119
+ if (r.changed) {
1120
+ func.body = r.node
1121
+ invalidateLocalsCache(func.body)
1122
+ if (!changed) includeModule('typedarray')
1123
+ changed = true
1124
+ }
1125
+ }
1126
+ return changed
1127
+ }
1128
+
807
1129
  const scalarizeFunctionObjectLiterals = () => {
808
1130
  let changed = false
809
1131
  for (const func of ctx.func.list) {
@@ -1018,13 +1340,20 @@ const inlineHotInternalCalls = (programFacts, ast) => {
1018
1340
  }
1019
1341
 
1020
1342
  const candidates = new Map()
1343
+ // Forwarders — a candidate whose body calls one of its own parameters.
1344
+ // Inlining one replaces that parameter with the call-site argument; when the
1345
+ // argument is a known function name the resulting indirect call collapses to
1346
+ // a direct `call` (devirtualization).
1347
+ const forwarders = new Set()
1021
1348
  for (const func of ctx.func.list) {
1022
1349
  if (func.exported || func.raw || !func.body || func.rest || programFacts.valueUsed.has(func.name)) continue
1023
1350
  if (func.defaults && Object.keys(func.defaults).length) continue
1024
1351
  const sites = sitesByCallee.get(func.name)
1025
1352
  const fixedTypedArraySite = hasFixedTypedArraySites(func, sites)
1026
1353
  const fullyFixedTypedArraySite = hasFullyFixedTypedArraySites(func, sites)
1027
- if (!sites || sites.length < 1 || (!fixedTypedArraySite && sites.length > 2) || sites.length > 8) continue
1354
+ const hasLoop = scanBody(func.body, n => LOOP_OPS.has(n[0]))
1355
+ const isTinyLeaf = !hasLoop && nodeSize(func.body) <= 15
1356
+ if (!sites || sites.length < 1 || (!isTinyLeaf && !fixedTypedArraySite && sites.length > 2) || sites.length > 8) continue
1028
1357
  const stmts = blockStmts(func.body)
1029
1358
  // Expression-bodied arrow funcs (`(c) => expr`) have no block — body IS the
1030
1359
  // return value. Treat as a "tiny leaf" branch handled below; force hasLoop=false.
@@ -1043,9 +1372,8 @@ const inlineHotInternalCalls = (programFacts, ast) => {
1043
1372
  // The leaf branch catches helpers like `isAlpha(c) => (c>=65 && c<=90) || …`
1044
1373
  // that get hammered from a hot caller's loop — replacing the call with its
1045
1374
  // body saves the per-iteration call+reinterpret overhead (tokenizer hot path).
1046
- const hasLoop = scanBody(func.body, n => LOOP_OPS.has(n[0]))
1047
1375
  if (!hasLoop) {
1048
- if (scanBody(func.body, n => n[0] === '()')) continue
1376
+ if (scanBody(func.body, n => n[0] === '()' && typeof n[1] === 'string' && ctx.func.names.has(n[1]))) continue
1049
1377
  if (nodeSize(func.body) > 30) continue
1050
1378
  }
1051
1379
  if (scanBody(func.body, n => n[0] === '()' && n[1] === func.name)) continue
@@ -1061,6 +1389,9 @@ const inlineHotInternalCalls = (programFacts, ast) => {
1061
1389
  // stays at generic f64 ABI with __typed_idx dispatch instead of i32 + f64.load.
1062
1390
  // Keeping the factory as a callable function preserves the call-site type fact.
1063
1391
  if (scanBody(func.body, n => n[0] === '()' && typeof n[1] === 'string' && n[1].startsWith('new.'))) continue
1392
+ const paramNames = new Set((func.sig?.params || []).map(p => p.name))
1393
+ if (paramNames.size && scanBody(func.body, n => n[0] === '()' && typeof n[1] === 'string' && paramNames.has(n[1])))
1394
+ forwarders.add(func.name)
1064
1395
  candidates.set(func.name, func)
1065
1396
  }
1066
1397
  if (!candidates.size) return false
@@ -1077,10 +1408,12 @@ const inlineHotInternalCalls = (programFacts, ast) => {
1077
1408
  const exportedCandidates = new Map()
1078
1409
  for (const [name, func] of candidates) {
1079
1410
  const sites = sitesByCallee.get(name)
1080
- if (hasFixedTypedArraySites(func, sites) &&
1081
- !sites.some(site => site.callerFunc?.exported && site.callerFunc.body && containsNode(site.callerFunc.body, site.node))) {
1082
- exportedCandidates.set(name, func)
1083
- }
1411
+ const fixedSiteExported = hasFixedTypedArraySites(func, sites) &&
1412
+ !sites.some(site => site.callerFunc?.exported && site.callerFunc.body && containsNode(site.callerFunc.body, site.node))
1413
+ // Forwarders cross into an exported caller too: the tier-up rationale that
1414
+ // keeps candidates out of exports concerns relocated loop kernels, not
1415
+ // these tiny leaves — and inlining one devirtualizes a closure dispatch.
1416
+ if (fixedSiteExported || forwarders.has(name)) exportedCandidates.set(name, func)
1084
1417
  }
1085
1418
  for (const func of ctx.func.list) {
1086
1419
  if (!func.body || func.raw) continue
@@ -1093,7 +1426,16 @@ const inlineHotInternalCalls = (programFacts, ast) => {
1093
1426
  // caller's heap arrays.
1094
1427
  const activeCandidates = func.exported ? exportedCandidates : candidates
1095
1428
  if (func.exported && !activeCandidates.size) continue
1096
- const r = inlineInStmt(func.body, activeCandidates)
1429
+ // Expression-bodied arrows (`() => expr`) have func.body as the return
1430
+ // value itself — never a `{}` block. inlineInStmt treats its argument as a
1431
+ // statement (discards the return value of any top-level candidate call),
1432
+ // which would turn `() => x()` into an empty block and lose the result.
1433
+ // Route those through inlineInExpr so the call is replaced by the inlined
1434
+ // value expression instead.
1435
+ const isExprBody = !Array.isArray(func.body) || func.body[0] !== '{}'
1436
+ const r = isExprBody
1437
+ ? inlineInExpr(func.body, activeCandidates)
1438
+ : inlineInStmt(func.body, activeCandidates)
1097
1439
  let body = r.changed ? r.node : func.body
1098
1440
  let bodyChanged = r.changed
1099
1441
  if (!func.exported && exprOnlyCandidates.size) {
@@ -1341,28 +1683,86 @@ const specializeFixedRestCalls = (programFacts) => {
1341
1683
  return changed
1342
1684
  }
1343
1685
 
1344
- const scanGlobalValueFacts = (root) => {
1345
- if (!root) return
1346
- const stmts = Array.isArray(root) && root[0] === ';' ? root.slice(1) : [root]
1347
- for (const stmt of stmts) {
1348
- if (!Array.isArray(stmt) || (stmt[0] !== 'const' && stmt[0] !== 'let')) continue
1349
- for (const decl of stmt.slice(1)) {
1350
- if (!Array.isArray(decl) || decl[0] !== '=' || typeof decl[1] !== 'string') continue
1351
- const vt = valTypeOf(decl[2])
1352
- if (vt) {
1353
- if (!ctx.scope.globalValTypes) ctx.scope.globalValTypes = new Map()
1354
- ctx.scope.globalValTypes.set(decl[1], vt)
1355
- if (vt === VAL.REGEX && ctx.runtime.regex) ctx.runtime.regex.vars.set(decl[1], decl[2])
1356
- }
1357
- const ctor = typedElemCtor(decl[2])
1358
- if (ctor) {
1359
- if (!ctx.scope.globalTypedElem) ctx.scope.globalTypedElem = new Map()
1360
- ctx.scope.globalTypedElem.set(decl[1], ctor)
1686
+ // `scanGlobalValueFacts` was deleted prepare's depth-0 catch (calling
1687
+ // `recordGlobalRep` from src/infer.js) is the authoritative pass and a
1688
+ // strict superset of what this top-level walker observed.
1689
+
1690
+ // Flow-insensitive type inference for module-level `let` bindings whose
1691
+ // initial RHS doesn't pin a type (most often `let mem;` followed later by
1692
+ // `mem = new TypedArray(...)` inside an init function). Without this the
1693
+ // read site has to runtime-check the NaN-box tag on every access — game-of-life's
1694
+ // inner step does that 9× per cell, blowing up the hot loop. We union RHS types
1695
+ // across every assignment (initial decl + every `name = …` in any function);
1696
+ // if every observed RHS is either a typed-array ctor of the same kind, a known
1697
+ // VAL.TYPED binding of the same ctor, or null/undefined, the binding is
1698
+ // monomorphically VAL.TYPED. Anything else (literal number, non-typed call,
1699
+ // mixed ctors) clears the candidacy, keeping the read site polymorphic.
1700
+ const inferModuleLetTypes = (ast) => {
1701
+ if (!ctx.scope.userGlobals) return
1702
+ // candidates: name → { ctor: string|null, valid: true } | { valid: false }
1703
+ // valid=true with ctor=null means "still no positive evidence"; we promote
1704
+ // only when ctor is non-null at the end. Assignments to nullish (undef/null)
1705
+ // don't change ctor — they're consistent with any typed-array value.
1706
+ const seen = new Map()
1707
+ for (const name of ctx.scope.userGlobals) seen.set(name, { ctor: null, valid: true })
1708
+
1709
+ const isNullishLit = (e) => e == null || e === 'undefined' || e === 'null'
1710
+ || (Array.isArray(e) && e[0] == null && (e[1] === undefined || e[1] === null))
1711
+
1712
+ const observe = (name, rhs) => {
1713
+ const c = seen.get(name)
1714
+ if (!c || !c.valid) return
1715
+ if (isNullishLit(rhs)) return
1716
+ // Resolve typed-array ctor from `new TypedArrayCtor(...)`, ternary of typed,
1717
+ // or a reference to a name we already know is typed.
1718
+ let ctor = typedElemCtor(rhs) ?? ternaryCtorOfRhs(rhs)
1719
+ if (ctor === MIXED_CTORS) { c.valid = false; return }
1720
+ if (!ctor && typeof rhs === 'string') {
1721
+ if (ctx.scope.globalValTypes?.get(rhs) === VAL.TYPED)
1722
+ ctor = ctx.scope.globalTypedElem?.get(rhs) ?? null
1723
+ }
1724
+ if (!ctor) { c.valid = false; return }
1725
+ if (c.ctor && c.ctor !== ctor) { c.valid = false; return }
1726
+ c.ctor = ctor
1727
+ }
1728
+
1729
+ const walk = (node) => {
1730
+ if (!Array.isArray(node)) return
1731
+ const op = node[0]
1732
+ if (op === '=' && typeof node[1] === 'string' && seen.has(node[1])) observe(node[1], node[2])
1733
+ if ((op === 'let' || op === 'const') && node.length > 1) {
1734
+ for (let i = 1; i < node.length; i++) {
1735
+ const d = node[i]
1736
+ if (Array.isArray(d) && d[0] === '=' && typeof d[1] === 'string' && seen.has(d[1]))
1737
+ observe(d[1], d[2])
1361
1738
  }
1362
1739
  }
1740
+ // Compound-assigns (`+=`, etc.) to a typed-array binding can't preserve
1741
+ // the typed-array kind — invalidate.
1742
+ if (ASSIGN_OPS.has(op) && op !== '=' && typeof node[1] === 'string' && seen.has(node[1])) {
1743
+ const c = seen.get(node[1])
1744
+ if (c) c.valid = false
1745
+ }
1746
+ for (let i = 1; i < node.length; i++) walk(node[i])
1747
+ }
1748
+ walk(ast)
1749
+ for (const f of ctx.func.list) if (f.body && !f.raw) walk(f.body)
1750
+
1751
+ for (const [name, c] of seen) {
1752
+ if (!c.valid || !c.ctor) continue
1753
+ if (ctx.scope.globalValTypes?.get(name) === VAL.TYPED) continue
1754
+ ;(ctx.scope.globalValTypes ||= new Map()).set(name, VAL.TYPED)
1755
+ ;(ctx.scope.globalTypedElem ||= new Map()).set(name, c.ctor)
1363
1756
  }
1364
1757
  }
1365
1758
 
1759
+ // `MIXED_CTORS` and `ternaryCtorOfRhs` are not exported from analyze — re-derive
1760
+ // the sentinel locally and look up the ctor through `typedElemCtor` alone.
1761
+ // `ternaryCtorOfRhs` is purely for ternary RHSs (e.g. `cond ? new Int32Array(N) : new Int32Array(M)`),
1762
+ // which game-of-life-style sources don't use; falling back to typedElemCtor is fine.
1763
+ const MIXED_CTORS = Symbol('MIXED_CTORS')
1764
+ const ternaryCtorOfRhs = () => null
1765
+
1366
1766
  const unboxConstTypedGlobals = () => {
1367
1767
  if (!ctx.scope.globalTypedElem || !ctx.scope.consts) return
1368
1768
  for (const [name, ctor] of ctx.scope.globalTypedElem) {
@@ -1378,6 +1778,254 @@ const unboxConstTypedGlobals = () => {
1378
1778
  }
1379
1779
  }
1380
1780
 
1781
+ /**
1782
+ * Function-namespace scalar replacement + devirtualization.
1783
+ *
1784
+ * A property of a user function compiles, by default, as a dynamic object: each
1785
+ * `f.prop` write is a `__dyn_set` into a closure-keyed hash side-table, each
1786
+ * read a `__dyn_get`. But a function's property table can never be observed by
1787
+ * the host (the host receives only the callable; the table lives in jz linear
1788
+ * memory), so jz sees every `f.prop` site — the slot is a closed, fully-known
1789
+ * cell. Per property of a non-escaping namespace:
1790
+ *
1791
+ * - reassigned (`multiProp`) slot → dissolve into a plain f64 module global:
1792
+ * `__dyn_get/__dyn_set` → `global.get/global.set`. The indirect call stays
1793
+ * (a genuinely reassigned function pointer needs `call_indirect`). Pure
1794
+ * storage relocation: the global inits to `UNDEF_WAT`, exactly mirroring
1795
+ * "key never set → __dyn_get yields undefined".
1796
+ * - written once to its lifted `$f$prop` function and only ever *called*
1797
+ * (never read as a value) → the `__dyn_set` is dead: emit already lowers
1798
+ * `f.prop()` to a direct `call $f$prop`. Drop the write entirely.
1799
+ *
1800
+ * Disqualified namespaces (`f` escapes as a bare value / is computed-indexed —
1801
+ * an alias could reach the table) keep the dynamic path. Together these can
1802
+ * eliminate the `__dyn_*` machinery from a namespace-only program outright.
1803
+ */
1804
+ const flattenFuncNamespaces = (ast) => {
1805
+ const names = ctx.func.names
1806
+ if (!names?.size) return false
1807
+ // Cheap structural gate: a flattenable namespace exists only if some lifted
1808
+ // `f$prop` name's `f` is itself a function (prepare lifts every `f.prop =
1809
+ // arrow` — multiProp slots included). The base `f` may itself carry a module
1810
+ // prefix (`mod$f`), so scan every `$` boundary, not just the first; a
1811
+ // populated `multiProp` registry is itself a direct namespace witness.
1812
+ let hasNs = ctx.func.multiProp.size > 0
1813
+ if (!hasNs) outer: for (const n of names) {
1814
+ for (let i = n.indexOf('$'); i > 0; i = n.indexOf('$', i + 1))
1815
+ if (names.has(n.slice(0, i))) { hasNs = true; break outer }
1816
+ }
1817
+ if (!hasNs) return false
1818
+ const ns = analyzeFuncNamespaces(ast)
1819
+ if (!ns.size) return false
1820
+ // f → Map<prop, decision>; decision is { global } (SROA) or { drop } (dead
1821
+ // write to an only-called single-write slot).
1822
+ const flat = new Map()
1823
+ for (const [f, info] of ns) {
1824
+ if (info.disq) continue
1825
+ let decide
1826
+ const plan = (prop, d) => { if (!decide) flat.set(f, decide = new Map()); decide.set(prop, d) }
1827
+ for (const prop of info.props) {
1828
+ if (ctx.func.multiProp.has(`${f}.${prop}`)) { plan(prop, { global: `${f}${T}${prop}` }); continue }
1829
+ const w = info.writes.get(prop)
1830
+ // Single write of the lifted `$f$prop`, never read as a value → drop it.
1831
+ if (w && w.length === 1 && w[0].atInit && w[0].rhs === `${f}$${prop}` && !info.valRead.has(prop))
1832
+ plan(prop, { drop: true })
1833
+ }
1834
+ }
1835
+ if (!flat.size) return false
1836
+ for (const decide of flat.values())
1837
+ for (const d of decide.values())
1838
+ if (d.global && !ctx.scope.globals.has(d.global)) {
1839
+ ctx.scope.globals.set(d.global, `(global $${d.global} (mut f64) ${UNDEF_WAT})`)
1840
+ ctx.scope.globalTypes.set(d.global, 'f64')
1841
+ }
1842
+ const decisionFor = (obj, prop) =>
1843
+ typeof obj === 'string' && typeof prop === 'string' && flat.has(obj)
1844
+ ? flat.get(obj).get(prop) : undefined
1845
+ const isEmptySeq = (n) => Array.isArray(n) && n.length === 1 && n[0] === ';'
1846
+ const rewrite = (node) => {
1847
+ if (!Array.isArray(node)) return node
1848
+ const op = node[0]
1849
+ if (op === '.' || op === '?.') {
1850
+ const d = decisionFor(node[1], node[2])
1851
+ if (d?.global) return d.global // drop-decisions leave reads/calls alone
1852
+ }
1853
+ if (op === '=' && Array.isArray(node[1]) && (node[1][0] === '.' || node[1][0] === '?.')) {
1854
+ const d = decisionFor(node[1][1], node[1][2])
1855
+ if (d?.global) return ['=', d.global, rewrite(node[2])]
1856
+ if (d?.drop) return [';'] // dead write — emit nothing
1857
+ }
1858
+ const out = [op]
1859
+ // Filter dropped writes out of statement sequences (an empty `[';']` left in
1860
+ // a body would lower to an unrenderable node).
1861
+ for (let i = 1; i < node.length; i++) {
1862
+ const c = rewrite(node[i])
1863
+ if (op === ';' && isEmptySeq(c)) continue
1864
+ out.push(c)
1865
+ }
1866
+ return out
1867
+ }
1868
+ const newAst = rewrite(ast)
1869
+ ast.length = 0
1870
+ for (let i = 0; i < newAst.length; i++) ast.push(newAst[i])
1871
+ for (const fn of ctx.func.list)
1872
+ if (fn.body && !fn.raw) fn.body = rewrite(fn.body)
1873
+ // The defining `f.prop = …` writes live in moduleInits for bundled programs —
1874
+ // rewrite them too, or reads would resolve to an unwritten global.
1875
+ if (ctx.module.moduleInits)
1876
+ for (let i = 0; i < ctx.module.moduleInits.length; i++)
1877
+ ctx.module.moduleInits[i] = rewrite(ctx.module.moduleInits[i])
1878
+ return true
1879
+ }
1880
+
1881
+ /**
1882
+ * Closure devirtualization.
1883
+ *
1884
+ * `flattenFuncNamespaces` dissolves a reassigned `f.prop` function slot into a
1885
+ * module global, but the call through it stays a `call_indirect` on a
1886
+ * `global.get`, dispatched via an ABI-adapting trampoline. When that global is
1887
+ * written *only* by unconditional module-init assignments it holds, for the
1888
+ * entire post-init program, one statically-known function — so every call
1889
+ * through it collapses to a direct `call`: no table lookup, no trampoline, no
1890
+ * 8-wide padding ABI, no closure type guard.
1891
+ *
1892
+ * A global G qualifies iff:
1893
+ * 1. every assignment to G is an unconditional module-init statement — none in
1894
+ * a function body, none nested inside init control flow;
1895
+ * 2. G's final init value resolves (through global aliases) to a top-level
1896
+ * function F;
1897
+ * 3. G is never *called* by module-init code, nor by any function reachable
1898
+ * from it — so every call site runs strictly post-init, where G ≡ F.
1899
+ * Devirt then only swaps an indirect call for a direct call to the very same
1900
+ * callee: it cannot change behavior, only drop dispatch overhead. The result is
1901
+ * recorded in `ctx.func.globalDevirt` (`Map<global, fn>`) and consumed by emit.
1902
+ */
1903
+ const devirtGlobalCalls = (ast) => {
1904
+ const fnNames = ctx.func.names
1905
+ if (!fnNames?.size || !ctx.scope.globals?.size) return
1906
+
1907
+ // Module-init statement stream, in execution order: moduleInits run first in
1908
+ // `$__start`, then the main module's top-level.
1909
+ const initStmts = []
1910
+ const flatten = (n) => {
1911
+ if (Array.isArray(n) && n[0] === ';') for (let i = 1; i < n.length; i++) flatten(n[i])
1912
+ else if (n != null) initStmts.push(n)
1913
+ }
1914
+ for (const mi of ctx.module.moduleInits || []) flatten(mi)
1915
+ flatten(ast)
1916
+
1917
+ const isGlobal = (s) => typeof s === 'string' && ctx.scope.globals.has(s)
1918
+ // `[target, rhs]` pairs for a `=` / `let` / `const` node assigning a global.
1919
+ const writesOf = (node) => {
1920
+ if (!Array.isArray(node)) return []
1921
+ if (node[0] === '=' && isGlobal(node[1])) return [[node[1], node[2]]]
1922
+ if (node[0] === 'let' || node[0] === 'const') {
1923
+ const out = []
1924
+ for (let i = 1; i < node.length; i++) {
1925
+ const d = node[i]
1926
+ if (Array.isArray(d) && d[0] === '=' && isGlobal(d[1])) out.push([d[1], d[2]])
1927
+ }
1928
+ return out
1929
+ }
1930
+ return []
1931
+ }
1932
+
1933
+ // Poison a global assigned anywhere but an unconditional init statement — in a
1934
+ // function body, or nested in init control flow. Its value is then not a
1935
+ // fixed post-init constant.
1936
+ const poison = new Set()
1937
+ const scanWrites = (node, topInit) => {
1938
+ if (!Array.isArray(node)) return
1939
+ const op = node[0]
1940
+ if (op === 'let' || op === 'const') {
1941
+ // A declarator `=` is part of the declaration, not a nested assignment —
1942
+ // poison only when the declaration itself is non-top-level.
1943
+ for (let i = 1; i < node.length; i++) {
1944
+ const d = node[i]
1945
+ if (Array.isArray(d) && d[0] === '=') {
1946
+ if (!topInit && isGlobal(d[1])) poison.add(d[1])
1947
+ scanWrites(d[2], false)
1948
+ } else scanWrites(d, false)
1949
+ }
1950
+ return
1951
+ }
1952
+ if (op === '=') {
1953
+ if (!topInit && isGlobal(node[1])) poison.add(node[1])
1954
+ scanWrites(node[1], false)
1955
+ scanWrites(node[2], false)
1956
+ return
1957
+ }
1958
+ for (let i = 1; i < node.length; i++) scanWrites(node[i], false)
1959
+ }
1960
+ for (const stmt of initStmts) scanWrites(stmt, true)
1961
+ for (const fn of ctx.func.map.values())
1962
+ if (fn.body && !fn.raw) scanWrites(fn.body, false)
1963
+
1964
+ // Resolve each global's value by a linear pass over init in execution order.
1965
+ const env = new Map()
1966
+ const evalFn = (rhs) =>
1967
+ typeof rhs !== 'string' ? null
1968
+ : fnNames.has(rhs) ? rhs
1969
+ : env.has(rhs) ? env.get(rhs)
1970
+ : null
1971
+ for (const stmt of initStmts)
1972
+ for (const [g, rhs] of writesOf(stmt)) env.set(g, evalFn(rhs))
1973
+
1974
+ const devirt = new Map()
1975
+ for (const [g, fn] of env)
1976
+ if (fn && fnNames.has(fn) && !poison.has(g)) devirt.set(g, fn)
1977
+ if (!devirt.size) return
1978
+
1979
+ // Condition 3: a call through G that runs *during* init would see an
1980
+ // intermediate value. Drop any candidate G called by init code, or by a
1981
+ // function reachable from it.
1982
+ //
1983
+ // `walkStraightLine` follows only straight-line execution: a nested `=>`
1984
+ // literal is a closure *constructed* here, not run here, so its body is
1985
+ // skipped — an IIFE callee `(=> …)()` is the one exception, its body does
1986
+ // run. This is what keeps operator-registration init (`binary('+', 11)`
1987
+ // builds, but does not invoke, a parselet closure) from dragging the parser
1988
+ // into the init-reachable set, and keeps a wrapper body's `space()` call —
1989
+ // which fires at parse time — from counting as an init call. (Soundness
1990
+ // rests on a closure constructed during init not also being invoked during
1991
+ // init: true of function-slot wrappers, which are registered then called at
1992
+ // use time.)
1993
+ const walkStraightLine = (node, onCall) => {
1994
+ if (!Array.isArray(node)) return
1995
+ const op = node[0]
1996
+ if (op === '()') {
1997
+ onCall(node[1])
1998
+ if (Array.isArray(node[1]) && node[1][0] === '=>') walkStraightLine(node[1][2], onCall)
1999
+ for (let i = 2; i < node.length; i++) walkStraightLine(node[i], onCall)
2000
+ return
2001
+ }
2002
+ if (op === '=>' || op === 'function') return
2003
+ for (let i = 1; i < node.length; i++) walkStraightLine(node[i], onCall)
2004
+ }
2005
+ const reachable = new Set()
2006
+ const queue = []
2007
+ const seedCalls = (node) => walkStraightLine(node, (c) => {
2008
+ if (typeof c === 'string' && fnNames.has(c)) queue.push(c)
2009
+ })
2010
+ for (const s of initStmts) seedCalls(s)
2011
+ while (queue.length) {
2012
+ const f = queue.pop()
2013
+ if (reachable.has(f)) continue
2014
+ reachable.add(f)
2015
+ const fn = ctx.func.map.get(f)
2016
+ if (fn?.body && !fn.raw) seedCalls(fn.body)
2017
+ }
2018
+ const calledInInit = new Set()
2019
+ const collectCalled = (node) => walkStraightLine(node, (c) => {
2020
+ if (devirt.has(c)) calledInInit.add(c)
2021
+ })
2022
+ for (const s of initStmts) collectCalled(s)
2023
+ for (const f of reachable) { const fn = ctx.func.map.get(f); if (fn?.body) collectCalled(fn.body) }
2024
+ for (const g of calledInInit) devirt.delete(g)
2025
+
2026
+ if (devirt.size) ctx.func.globalDevirt = devirt
2027
+ }
2028
+
1381
2029
  const materializeAutoBoxSchemas = (programFacts) => {
1382
2030
  if (!ctx.schema.register) return
1383
2031
  for (const [name, props] of programFacts.propMap) {
@@ -1434,10 +2082,17 @@ const canSkipWholeProgramNarrowing = (programFacts) =>
1434
2082
  !ctx.closure.make
1435
2083
 
1436
2084
  export default function plan(ast) {
1437
- scanGlobalValueFacts(ast)
2085
+ inferModuleLetTypes(ast)
1438
2086
  unboxConstTypedGlobals()
1439
2087
 
1440
2088
  let programFacts = collectProgramFacts(ast)
2089
+ // Function-namespace SROA — dissolve reassigned `f.prop` slots into module
2090
+ // globals before inlining/narrowing, so all downstream passes see plain
2091
+ // globals instead of the dynamic property machinery.
2092
+ if (flattenFuncNamespaces(ast)) programFacts = collectProgramFacts(ast)
2093
+ // Devirtualize calls through init-constant function globals (closure
2094
+ // devirtualization) — must follow the SROA above, which creates the globals.
2095
+ devirtGlobalCalls(ast)
1441
2096
  // The call-inlining family (`inlineHotInternalCalls` self-gates on `sourceInline`)
1442
2097
  // is a pure speed optimization — the un-inlined calls emit correctly. Scalar
1443
2098
  // replacement (`scalarize*`) is *not* gated on `sourceInline`: callers turn it on
@@ -1447,13 +2102,27 @@ export default function plan(ast) {
1447
2102
  if (specializeFixedRestCalls(programFacts)) programFacts = collectProgramFacts(ast)
1448
2103
  if (scalarizeFunctionArrayLiterals()) programFacts = collectProgramFacts(ast)
1449
2104
  if (scalarizeFunctionObjectLiterals()) programFacts = collectProgramFacts(ast)
2105
+ // Promotion runs AFTER literal scalarization (those that fully reduce to scalars
2106
+ // are gone) and BEFORE typed-array scalarization (so a freshly-promoted array's
2107
+ // fixed-length-typed-of-known-size variant could still participate in loop
2108
+ // unrolling — currently it can't, since promotion produces the `[...]`-arg
2109
+ // form rather than `new Int32Array(N)`, but the ordering keeps the door open).
2110
+ if (promoteIntArrayLiterals()) programFacts = collectProgramFacts(ast)
1450
2111
  if (scalarizeFunctionTypedArrays(programFacts)) programFacts = collectProgramFacts(ast)
1451
2112
  ctx.types.dynKeyVars = programFacts.dynVars
1452
2113
  ctx.types.anyDynKey = programFacts.anyDyn
1453
2114
 
1454
2115
  materializeAutoBoxSchemas(programFacts)
1455
2116
  resolveClosureWidth(programFacts)
1456
- if (canSkipWholeProgramNarrowing(programFacts)) return programFacts
2117
+ if (canSkipWholeProgramNarrowing(programFacts)) {
2118
+ // Phase J (jsstring boundary opt-in) is body-local and call-site-independent;
2119
+ // run it even when the rest of narrowing is skipped so simple `export let
2120
+ // f = (s) => s.length` still flips to externref. Likewise the boolean-result
2121
+ // fact, so `export let f = (a) => a > 2` boxes its boundary atom.
2122
+ applyJsstringBoundaryCarrierStandalone(programFacts)
2123
+ narrowBoolResults()
2124
+ return programFacts
2125
+ }
1457
2126
 
1458
2127
  narrowSignatures(programFacts, ast)
1459
2128
  specializeBimorphicTyped(programFacts)