jz 0.5.1 → 0.6.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 (80) hide show
  1. package/README.md +288 -314
  2. package/bench/README.md +319 -0
  3. package/bench/bench.svg +112 -0
  4. package/cli.js +32 -24
  5. package/index.js +177 -55
  6. package/interop.js +88 -159
  7. package/jz.svg +5 -0
  8. package/jzify/arguments.js +97 -0
  9. package/jzify/bundler.js +382 -0
  10. package/jzify/classes.js +328 -0
  11. package/jzify/hoist-vars.js +177 -0
  12. package/jzify/index.js +51 -0
  13. package/jzify/names.js +37 -0
  14. package/jzify/switch.js +106 -0
  15. package/jzify/transform.js +349 -0
  16. package/layout.js +179 -0
  17. package/module/array.js +322 -153
  18. package/module/collection.js +603 -145
  19. package/module/console.js +55 -43
  20. package/module/core.js +266 -153
  21. package/module/date.js +15 -3
  22. package/module/function.js +73 -6
  23. package/module/index.js +2 -1
  24. package/module/json.js +226 -61
  25. package/module/math.js +414 -186
  26. package/module/number.js +306 -60
  27. package/module/object.js +448 -184
  28. package/module/regex.js +255 -25
  29. package/module/schema.js +24 -6
  30. package/module/simd.js +85 -0
  31. package/module/string.js +586 -220
  32. package/module/symbol.js +1 -1
  33. package/module/timer.js +9 -14
  34. package/module/typedarray.js +45 -48
  35. package/package.json +41 -12
  36. package/src/abi/index.js +39 -23
  37. package/src/abi/string.js +38 -41
  38. package/src/ast.js +460 -0
  39. package/src/autoload.js +26 -24
  40. package/src/bridge.js +111 -0
  41. package/src/compile/analyze-scans.js +661 -0
  42. package/src/compile/analyze.js +1565 -0
  43. package/src/compile/emit-assign.js +408 -0
  44. package/src/compile/emit.js +3201 -0
  45. package/src/compile/flow-types.js +103 -0
  46. package/src/{compile.js → compile/index.js} +497 -125
  47. package/src/{infer.js → compile/infer.js} +27 -98
  48. package/src/{narrow.js → compile/narrow.js} +302 -96
  49. package/src/compile/plan/advise.js +316 -0
  50. package/src/compile/plan/common.js +150 -0
  51. package/src/compile/plan/index.js +118 -0
  52. package/src/compile/plan/inline.js +679 -0
  53. package/src/compile/plan/literals.js +984 -0
  54. package/src/compile/plan/loops.js +472 -0
  55. package/src/compile/plan/scope.js +573 -0
  56. package/src/compile/program-facts.js +404 -0
  57. package/src/ctx.js +176 -58
  58. package/src/ir.js +540 -171
  59. package/src/kind-traits.js +105 -0
  60. package/src/kind.js +462 -0
  61. package/src/op-policy.js +57 -0
  62. package/src/{optimize.js → optimize/index.js} +1106 -446
  63. package/src/optimize/vectorize.js +1874 -0
  64. package/src/param-reps.js +65 -0
  65. package/src/parse.js +44 -0
  66. package/src/{prepare.js → prepare/index.js} +589 -202
  67. package/src/reps.js +115 -0
  68. package/src/resolve.js +12 -3
  69. package/src/static.js +199 -0
  70. package/src/type.js +647 -0
  71. package/src/{assemble.js → wat/assemble.js} +86 -48
  72. package/src/wat/optimize.js +3760 -0
  73. package/transform.js +21 -0
  74. package/wasi.js +47 -5
  75. package/src/analyze.js +0 -3818
  76. package/src/emit.js +0 -3040
  77. package/src/jzify.js +0 -1580
  78. package/src/plan.js +0 -2132
  79. package/src/vectorize.js +0 -1088
  80. /package/src/{codegen.js → wat/codegen.js} +0 -0
@@ -19,33 +19,70 @@
19
19
  * Each handler may touch multiple concerns, but helpers keep each concern self-contained.
20
20
  * Unhandled ops fall through to recursive prep() of their children.
21
21
  *
22
+ * # Forward seeding (the two compile/ imports — deliberate, not a layering leak)
23
+ * Prepare is the only pass that sees module-scope declarations in source order,
24
+ * so it seeds two compile-stage fact stores AS it walks (re-deriving them later
25
+ * would need a second whole-AST pass over information prepare already holds):
26
+ * - `recordGlobalRep` (compile/infer.js) — module-global value reps
27
+ * - `observeNodeFacts` (compile/program-facts.js) — per-node program facts
28
+ * The contract is write-only: prepare never READS compile-stage state, so the
29
+ * stage remains re-runnable and compile owns every read path.
30
+ *
22
31
  * @module prepare
23
32
  */
24
33
 
25
- import { parse } from 'subscript/feature/jessie'
26
- import { ctx, err, derive } from './ctx.js'
27
- import { T, STMT_OPS, VAL, extractParams, collectParamNames, classifyParam, observeNodeFacts, staticObjectProps, staticPropertyKey } from './analyze.js'
28
- import { recordGlobalRep } from './infer.js'
29
- import { isFuncRef } from './ir.js'
34
+ import { handlerArgs, refsName, ASSIGN_OPS, JZ_NULL, JZ_UNDEF, TYPEOF } from '../ast.js'
35
+ import { ctx, err, derive, emitArity, declGlobal } from '../ctx.js'
36
+ import { T } from '../ast.js'
37
+ import { extractParams, collectParamNames, classifyParam } from '../ast.js'
38
+ import { observeNodeFacts } from '../compile/program-facts.js'
39
+ import { staticObjectProps, staticPropertyKey } from '../static.js'
40
+ import { VAL } from '../reps.js'
41
+ import { STMT_OPS } from '../ast.js'
42
+ import { REJECT_IDENTS, REJECT_OPS, rejectHandlers } from '../op-policy.js'
43
+ import { recordGlobalRep } from '../compile/infer.js'
44
+ import { isFuncRef } from '../ir.js'
30
45
  import {
31
- CTORS, TIMER_NAMES,
46
+ CTORS, COLLECTION_CTORS, TIMER_NAMES,
32
47
  hasModule, includeModule,
33
48
  includeForArrayAccess, includeForArrayLiteral, includeForArrayPattern, includeForCallableValue,
34
49
  includeForGenericMethod, includeForKnownKeyIteration, includeForNamedCall, includeForNumericCoercion,
35
50
  includeForObjectLiteral, includeForObjectPattern, includeForOp, includeForProperty, includeForRuntimeCtor,
36
51
  includeForRuntimeKeyIteration, includeForStringOnly, includeForStringValue, includeForTimerRuntime,
37
- } from './autoload.js'
38
-
39
- let depth = 0 // arrow nesting depth (0=top-level, >0=inside function)
40
- let scopes = [] // block scope stack: [{names: Set, renames: Map}]
41
- let staticConstScopes = [] // lexical const facts: [{strings: Map, arrays: Map}]
42
- let assignedStaticGlobals = null
52
+ } from '../autoload.js'
53
+
54
+ // SIMD intrinsic namespaces pure namespaces backed by the `simd` module.
55
+ const SIMD_NS = new Set(['f32x4', 'i32x4', 'f64x2', 'v128'])
56
+
57
+ // Module-level prepare state. Six independent stacks/scalars that together form
58
+ // the prepare-pass working set. Lifecycle: reinitialized via `resetPrepState()`
59
+ // at the top of `prepare()` (line ~368) — any throw inside prepare is cleared
60
+ // on the next entry, so leak across compilations is impossible. Kept at module
61
+ // scope (rather than ctx.prepare.*) because 78 read sites would mean a single
62
+ // indirection on every scope query; the consolidated reset documents the set.
63
+ let depth // arrow nesting depth (0=top-level, >0=inside function)
64
+ let scopes // block scope stack: [{names: Set, renames: Map}]
65
+ let staticConstScopes // lexical const facts: [{strings: Map, arrays: Map}]
66
+ let assignedStaticGlobals
43
67
  // Per-arrow set of names already declared anywhere in the function body. Used
44
68
  // to force a rename when the same identifier is declared in two sibling blocks
45
69
  // (else-if arms, separate { ... } chunks): without renaming, both decls lower
46
70
  // to the same WASM local, but downstream optimizations (directClosures) gate
47
71
  // on per-decl `isReassigned`, not per-WASM-local — they'd read a stale binding.
48
- let funcLocalNames = []
72
+ let funcLocalNames
73
+ // Per-arrow set of local names bound to a function literal (`let g = () => …`).
74
+ // Lets the `.`-handler tell a function receiver — where `.caller`/`.callee` are
75
+ // prohibited introspection — from a data object that merely has such a field.
76
+ let funcValueNames
77
+
78
+ const resetPrepState = () => {
79
+ depth = 0
80
+ scopes = []
81
+ staticConstScopes = []
82
+ assignedStaticGlobals = new Set()
83
+ funcLocalNames = [new Set()]
84
+ funcValueNames = [new Set()]
85
+ }
49
86
 
50
87
  // ES spec: identifier with \uHHHH or \u{...} escape is equivalent to the decoded
51
88
  // form. subscript preserves raw spelling in the AST; normalize once before prep.
@@ -54,6 +91,55 @@ const decodeIdent = s => s.includes('\\u')
54
91
  ? s.replace(IDESC, (_, b, p) => String.fromCodePoint(parseInt(b || p, 16)))
55
92
  : s
56
93
 
94
+ // A for-loop bound `arr.length` may be snapshotted into a pre-loop local only when
95
+ // nothing in the loop can change it. Two ways it can change: a write to the receiver
96
+ // (`arr = …`, `arr.length = …`, `arr[k] = …`) or a call — push/pop/splice mutate
97
+ // directly, and any call can reach `arr` through an alias the compiler can't track
98
+ // locally (compilePendingClosures grows ctx.closure.bodies this way). Both predicates
99
+ // recurse the whole node; nested arrow *definitions* are harmless until invoked, and
100
+ // an invocation is itself a call node, so `callFree` already covers escaped mutators.
101
+ const callFree = node => {
102
+ if (!Array.isArray(node)) return true
103
+ if (node[0] === '()' || node[0] === 'new') return false
104
+ for (let i = 1; i < node.length; i++) if (!callFree(node[i])) return false
105
+ return true
106
+ }
107
+ // Calls that provably can't resize ANY receiver: read-only builtin methods
108
+ // (no mutators, no callback-takers — a callback could close over the receiver
109
+ // and push) and pure namespaces. Everything else (user fns, push/splice,
110
+ // map/forEach) may reach the bound receiver through an alias — disqualifies
111
+ // the length snapshot. A user object shadowing one of these names with a
112
+ // mutating closure is a documented divergence (same class as for-of's).
113
+ const _BOUND_PURE_NS = new Set(['Math', 'math', 'Number', 'String', 'JSON', 'console', 'Date', 'performance'])
114
+ const _BOUND_RO_METHODS = new Set([
115
+ 'charCodeAt', 'charAt', 'codePointAt', 'at', 'indexOf', 'lastIndexOf', 'includes',
116
+ 'startsWith', 'endsWith', 'slice', 'substring', 'trim', 'toUpperCase', 'toLowerCase',
117
+ 'join', 'concat', 'toString', 'get', 'has', 'now',
118
+ ])
119
+ const boundSafeCalls = node => {
120
+ if (!Array.isArray(node)) return true
121
+ if (node[0] === 'new') return false
122
+ if (node[0] === '()' || node[0] === '?.()') {
123
+ const callee = node[1]
124
+ const safe = Array.isArray(callee) && (callee[0] === '.' || callee[0] === '?.') &&
125
+ (_BOUND_RO_METHODS.has(callee[2]) ||
126
+ (typeof callee[1] === 'string' && _BOUND_PURE_NS.has(callee[1])))
127
+ if (!safe) return false
128
+ }
129
+ for (let i = 1; i < node.length; i++) if (!boundSafeCalls(node[i])) return false
130
+ return true
131
+ }
132
+ const writesReceiver = (node, recv) => {
133
+ if (!Array.isArray(node)) return false
134
+ const op = node[0]
135
+ if ((ASSIGN_OPS.has(op) || op === '++' || op === '--') &&
136
+ (node[1] === recv ||
137
+ (Array.isArray(node[1]) && (node[1][0] === '[]' || node[1][0] === '.') && node[1][1] === recv)))
138
+ return true
139
+ for (let i = 1; i < node.length; i++) if (writesReceiver(node[i], recv)) return true
140
+ return false
141
+ }
142
+
57
143
  const normalizeIdents = node => {
58
144
  if (!Array.isArray(node)) return
59
145
  // Literal-value wrapper [null, X] / [undefined, X]: X is a value, not an identifier
@@ -67,14 +153,26 @@ const normalizeIdents = node => {
67
153
 
68
154
  const hostReturnValType = spec => {
69
155
  if (!spec || typeof spec === 'function') return null
156
+ // Return type is the canonical string name ('number'/'string'/'bigint'/'f64').
157
+ // (Earlier this also accepted the constructor identity `ret === String` etc.,
158
+ // but that references host-only globals with no first-class value in jz — it
159
+ // broke self-hosting and was never used. String names are the portable form.)
70
160
  const ret = spec.returns ?? spec.return ?? spec.result
71
- if (ret === 'number' || ret === 'f64' || ret === Number) return VAL.NUMBER
72
- if (ret === 'string' || ret === String) return VAL.STRING
73
- if (ret === 'bigint' || ret === BigInt) return VAL.BIGINT
161
+ if (ret === 'number' || ret === 'f64') return VAL.NUMBER
162
+ if (ret === 'string') return VAL.STRING
163
+ if (ret === 'bigint') return VAL.BIGINT
74
164
  return null
75
165
  }
76
166
 
77
167
  const addHostImport = (mod, name, alias, spec) => {
168
+ // A numeric host constant (e.g. `Math.PI` via `{ imports: { math: Math } }`) has no callable
169
+ // ABI — record it so references fold to an f64 literal (see prep's identifier resolution) instead
170
+ // of emitting a 0-arg func import that can't be read as a value ("'PI' is not in scope").
171
+ if (typeof spec === 'number') {
172
+ if (!ctx.scope.hostConsts) ctx.scope.hostConsts = {}
173
+ ctx.scope.hostConsts[alias] = spec
174
+ return
175
+ }
78
176
  const nParams = typeof spec === 'function' ? spec.length : (spec?.params || 0)
79
177
  // User-supplied imports carry NaN-boxed values via i64 (not f64) so V8 cannot
80
178
  // canonicalize the NaN payload across the wasm↔JS function boundary —
@@ -99,7 +197,6 @@ const stripBoolNot = c => {
99
197
  return c
100
198
  }
101
199
  const stringValue = node => Array.isArray(node) && node[0] == null && typeof node[1] === 'string' ? node[1] : null
102
- const flatArgs = args => args.length === 1 && Array.isArray(args[0]) && args[0][0] === ',' ? args[0].slice(1) : args
103
200
  const MUTATING_ARRAY_METHODS = new Set(['copyWithin', 'fill', 'pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift'])
104
201
 
105
202
  function staticStringArrayValues(expr) {
@@ -234,7 +331,12 @@ function staticStringExpr(node) {
234
331
  if (op === '+') {
235
332
  const a = staticStringExpr(args[0])
236
333
  const b = staticStringExpr(args[1])
237
- return a != null && b != null ? a + b : null
334
+ // Accumulate from a fresh empty string (`'' + a + b`) rather than concatenating two
335
+ // source-derived substrings directly. Under self-host the latter can yield a string
336
+ // backed by transient parse-time storage that's invalid by the time emit['//'] reads
337
+ // it for regex compilation (OOB); forcing a fresh allocation, as the template-literal
338
+ // path already does, keeps it stable. Identical value in both legs.
339
+ return a != null && b != null ? '' + a + b : null
238
340
  }
239
341
  if (op === '`') {
240
342
  let out = ''
@@ -270,7 +372,12 @@ function importMetaUrl() {
270
372
 
271
373
  function resolveImportMeta(spec) {
272
374
  const base = importMetaUrl()
273
- try { return new URL(spec, base).href }
375
+ // URL resolution is a host capability (WHATWG URL parsing), injected via
376
+ // ctx.transform.resolveUrl rather than referencing the `URL` global — the same
377
+ // inversion as ctx.transform.parse. Keeps the self-host kernel (which bundles
378
+ // its module graph and never resolves import.meta at runtime) free of `URL`.
379
+ if (!ctx.transform.resolveUrl) err('import.meta resolution requires ctx.transform.resolveUrl (injected by the jz pipeline)')
380
+ try { return ctx.transform.resolveUrl(spec, base) }
274
381
  catch { err(`Cannot resolve import.meta specifier '${spec}' from '${base}'`) }
275
382
  }
276
383
 
@@ -279,6 +386,7 @@ function recordModuleInitFacts(root) {
279
386
  dynVars: new Set(), anyDyn: false, hasSchemaLiterals: false,
280
387
  hasFuncValue: false, timerNames: new Set(),
281
388
  maxDef: 0, maxCall: 0, hasRest: false, hasSpread: false,
389
+ writtenProps: new Set(),
282
390
  }
283
391
  const visitFuncValue = (node) => {
284
392
  if (facts.hasFuncValue || !Array.isArray(node)) return
@@ -323,13 +431,34 @@ function recordModuleInitFacts(root) {
323
431
  * @param {ASTNode} node - Raw AST from parser
324
432
  * @returns {ASTNode} Normalized AST
325
433
  */
434
+ // ES2020 §13.13: the nullish-coalescing `??` cannot be combined with `||` or `&&`
435
+ // without parentheses — V8 raises a SyntaxError. subscript/jessie doesn't enforce
436
+ // it, so jz would otherwise silently accept (and pick its own parse for) the mix.
437
+ // Run on the RAW input AST: a parenthesized operand parses as `['()', …]`, so a
438
+ // bare `??`/`||`/`&&` child is exactly the illegal unparenthesized form — and at
439
+ // this stage no compiler-synthesized `??` (e.g. destructuring defaults) exists yet,
440
+ // so `let [a = b || c] = arr` can't false-positive.
441
+ function validateCoalesceMixing(n) {
442
+ if (!Array.isArray(n)) return
443
+ const op = n[0]
444
+ if (op === '||' || op === '&&') {
445
+ for (let i = 1; i < n.length; i++) if (Array.isArray(n[i]) && n[i][0] === '??')
446
+ err(`'??' cannot be mixed with '${op}' without parentheses (ES2020) — wrap one side, e.g. (a ?? b) ${op} c`)
447
+ } else if (op === '??') {
448
+ for (let i = 1; i < n.length; i++) if (Array.isArray(n[i]) && (n[i][0] === '||' || n[i][0] === '&&'))
449
+ err(`'??' cannot be mixed with '||' / '&&' without parentheses (ES2020) — wrap one side, e.g. a ?? (b || c)`)
450
+ }
451
+ for (let i = 1; i < n.length; i++) validateCoalesceMixing(n[i])
452
+ }
453
+
326
454
  export default function prepare(node) {
327
- depth = 0
328
- scopes = []
329
- staticConstScopes = []
330
- assignedStaticGlobals = new Set()
331
- funcLocalNames = [new Set()]
455
+ resetPrepState()
456
+ // Inject the module-include primitive so stdlib modules can pull dependency
457
+ // modules (e.g. object → collection) without importing autoload.js — that
458
+ // import would cycle (autoload imports every module via module/index.js).
459
+ ctx.module.include = includeModule
332
460
  includeModule('core')
461
+ validateCoalesceMixing(node) // ES2020: reject unparenthesized `??` mixed with `||`/`&&`
333
462
  normalizeIdents(node)
334
463
  fuseSparseMapReads(node) // AST-level fusion; needs pre-resolution shape — defined at end of file
335
464
  seedStaticGlobalAssignments(node)
@@ -426,9 +555,9 @@ export default function prepare(node) {
426
555
  return ast
427
556
  }
428
557
 
429
- // Named constants → numeric literals
430
- export const JZ_NULL = Symbol('null')
431
- const CONSTANTS = { 'true': true, 'false': false, 'null': JZ_NULL, 'undefined': JZ_NULL }
558
+ // Named constants → numeric literals. The JZ_NULL/JZ_UNDEF atom sentinels live
559
+ // 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 }
432
561
  // NaN/Infinity stay as special f64 values in emit()
433
562
  const F64_CONSTANTS = { 'NaN': NaN, 'Infinity': Infinity }
434
563
 
@@ -472,7 +601,43 @@ function deleteStaticGlobal(name) {
472
601
  ctx.scope.shapeStrArrays?.delete(name)
473
602
  }
474
603
 
604
+ // Schema id when prhs is a bare object literal with static keys, else null.
605
+ function objLiteralSid(prhs) {
606
+ if (!Array.isArray(prhs) || prhs[0] !== '{}') return null
607
+ const props = staticObjectProps(prhs.slice(1))
608
+ return props ? ctx.schema.register(props.names) : null
609
+ }
610
+
611
+ // Shape-consensus accounting for every `name = …` assignment. `sid` is the
612
+ // RHS literal's schema id (null for any non-literal source). A name's schema
613
+ // binds only while ALL its assignments agree on that one literal shape: the
614
+ // first literal binds; any disagreeing assignment — non-literal RHS or a
615
+ // different-shape literal — unbinds and poisons. Poisoned names never rebind,
616
+ // so compile-time fixed-slot reads can't be aimed at one shape while the
617
+ // variable holds another (the misread class: `.x` returning a foreign
618
+ // object's slot-0 value). Compile consumes the END state — order-insensitive.
619
+ function bindAssignSchema(name, sid) {
620
+ const had = ctx.schema.vars.get(name)
621
+ if (had != null) {
622
+ if (had !== sid) { ctx.schema.vars.delete(name); ctx.schema.poisoned?.add(name) }
623
+ } else if (sid != null) {
624
+ if (!ctx.schema.poisoned?.has(name)) ctx.schema.vars.set(name, sid)
625
+ } else ctx.schema.poisoned?.add(name)
626
+ }
627
+
475
628
  const hasFunc = name => ctx.func.names.has(name)
629
+ // A builtin name (`Map`, `Array`, `Math`, …) is shadowed when the user bound it
630
+ // as a local (let/const/param, via `isDeclared`), a top-level function (via
631
+ // `hasFunc`), or a top-level let/const global (via `userGlobals`). A shadowed
632
+ // name must resolve to the user binding, so the constructor / named-call
633
+ // fast-paths bail and fall through to `resolveCallee`, which already routes a
634
+ // declared name to its local value. Mirrors the guard in
635
+ // `foldNamespaceIntrospection`.
636
+ const shadowsBuiltin = name => typeof name === 'string' &&
637
+ ((scopes.length && isDeclared(name)) || hasFunc(name) || ctx.scope.userGlobals?.has?.(name))
638
+ // A local bound to a function literal in any active arrow scope (the nested-
639
+ // closure counterpart to `hasFunc`, which only knows depth-0 lifted functions).
640
+ const isFuncValueLocal = name => typeof name === 'string' && funcValueNames.some(s => s.has(name))
476
641
 
477
642
  const renameFunc = (func, nextName) => {
478
643
  ctx.func.names.delete(func.name)
@@ -480,8 +645,8 @@ const renameFunc = (func, nextName) => {
480
645
  ctx.func.names.add(nextName)
481
646
  }
482
647
 
483
- /** Map JS typeof strings to jz type checks. Codes < 0 trigger specialized emitTypeofCmp paths. */
484
- const TYPEOF_MAP = { 'number': -1, 'string': -2, 'undefined': -3, 'boolean': -4, 'object': -5, 'function': -6, 'bigint': -7 }
648
+ // `typeof`-string code table lives in ast.js (TYPEOF) shared with
649
+ // emitTypeofCmp and flow-types so the codes have one home.
485
650
  // Spec §13.5.3: `typeof undeclared_x` returns 'undefined' without throwing.
486
651
  // True iff `name` is a bare identifier with no resolution path. Mirrors the
487
652
  // resolution chain inside `prep()` so we don't speculate emit-time failures.
@@ -489,7 +654,7 @@ function isUnresolvableBareIdent(name) {
489
654
  if (typeof name !== 'string') return false
490
655
  if (name in CONSTANTS || name in F64_CONSTANTS) return false
491
656
  if (name === 'Boolean' || name === 'Number') return false
492
- if (PROHIBITED[name]) return false
657
+ if (REJECT_IDENTS[name]) return false
493
658
  if (scopes.length && isDeclared(name)) return false
494
659
  if (ctx.scope.chain[name]) return false
495
660
  if (GLOBALS[name]) return false
@@ -508,9 +673,9 @@ function staticTypeofString(x) {
508
673
  // Spec §13.5.3: unresolvable bare ref → 'undefined'.
509
674
  if (isUnresolvableBareIdent(x)) return 'undefined'
510
675
  // Bare callable global: parseInt, parseFloat, isNaN, isFinite, Error, BigInt, etc.
511
- if (typeof x === 'string' && !ctx.func?.locals?.has(x) && GLOBALS[x] && ctx.core.emit?.[x]?.length > 0) return 'function'
676
+ if (typeof x === 'string' && !ctx.func?.locals?.has(x) && GLOBALS[x] && emitArity(ctx.core.emit?.[x]) > 0) return 'function'
512
677
  const px = prep(x)
513
- if (typeof px === 'string' && px.includes('.') && ctx.core.emit?.[px]?.length > 0) return 'function'
678
+ if (typeof px === 'string' && px.includes('.') && emitArity(ctx.core.emit?.[px]) > 0) return 'function'
514
679
  return null
515
680
  }
516
681
  // Builtin-namespace constructors expose `prototype`/`length`/`name` as own
@@ -534,14 +699,14 @@ function resolveTypeof(node) {
534
699
  if (Array.isArray(a) && a[0] === 'typeof' && Array.isArray(b) && b[0] == null && typeof b[1] === 'string') {
535
700
  const known = staticTypeofString(a[1])
536
701
  if (known != null) return [, eqLike ? known === b[1] : known !== b[1]]
537
- const code = TYPEOF_MAP[b[1]]
702
+ const code = TYPEOF[b[1]]
538
703
  if (code != null) return [op, ['typeof', a[1]], [, code]]
539
704
  }
540
705
  // 'string' == typeof x
541
706
  if (Array.isArray(b) && b[0] === 'typeof' && Array.isArray(a) && a[0] == null && typeof a[1] === 'string') {
542
707
  const known = staticTypeofString(b[1])
543
708
  if (known != null) return [, eqLike ? known === a[1] : known !== a[1]]
544
- const code = TYPEOF_MAP[a[1]]
709
+ const code = TYPEOF[a[1]]
545
710
  if (code != null) return [op, ['typeof', b[1]], [, code]]
546
711
  }
547
712
  return node
@@ -602,11 +767,19 @@ function prep(node) {
602
767
  if (typeof node === 'string') {
603
768
  if (node in CONSTANTS) return [, CONSTANTS[node]]
604
769
  if (node in F64_CONSTANTS) return [, F64_CONSTANTS[node]]
605
- if (PROHIBITED[node]) err(PROHIBITED[node])
770
+ if (REJECT_IDENTS[node]) err(REJECT_IDENTS[node])
606
771
  // Boolean/Number as value → identity arrow (for .filter(Boolean), .map(Number) etc.)
607
772
  if (node === 'Boolean' || node === 'Number') { includeForCallableValue(); return ['=>', 'x', 'x'] }
608
773
  // Block locals shadow module imports/globals, even when the local keeps the same name.
609
774
  if (scopes.length && isDeclared(node)) return resolveScope(node)
775
+ // A user top-level binding (`let Math = …`) shadows a same-named builtin
776
+ // namespace seeded into the scope chain (`Math → math`). Resolve to the
777
+ // user global, not the builtin. (Mangled globals drop their original name
778
+ // from userGlobals, so this fires only for un-renamed user bindings.)
779
+ if (ctx.scope.userGlobals?.has?.(node)) return node
780
+ // Host numeric constant (`Math.PI` etc.) → fold to its f64 literal. Placed after the
781
+ // local/user-global checks above so a same-named binding still shadows it.
782
+ if (ctx.scope.hostConsts && node in ctx.scope.hostConsts) return [, ctx.scope.hostConsts[node]]
610
783
  const resolved = ctx.scope.chain[node]
611
784
  if (resolved?.includes('.')) return resolved
612
785
  // Cross-module import: mangled name (e.g. __util_js$clone)
@@ -619,6 +792,10 @@ function prep(node) {
619
792
 
620
793
  const [op, ...args] = node
621
794
  if (op === 'void' && ctx.transform.strict) err('strict mode: `void` is prohibited. It diverges from JS by evaluating to 0.')
795
+ // jz's `==`/`!=` already never coerce (identical to `===`/`!==`), so default mode accepts them.
796
+ // strict enforces the canonical subset, where `===`/`!==` are the one spelling — reject the loose form.
797
+ if ((op === '==' || op === '!=') && ctx.transform.strict)
798
+ err(`strict mode: \`${op}\` is prohibited — use \`${op}=\`. (jz's \`${op}\` doesn't coerce, but the canonical subset is \`===\`/\`!==\` only.)`)
622
799
  if (op == null) {
623
800
  if (typeof args[0] === 'string') {
624
801
  includeForStringValue()
@@ -630,19 +807,9 @@ function prep(node) {
630
807
  return handler ? handler(...args) : [op, ...args.map(prep)]
631
808
  }
632
809
 
633
- // Strict-jz prohibitions. `class` and `arguments` are *also* listed here but
634
- // only reach this point when jzify is off — jzify lowers `class` to a factory
635
- // arrow and rewrites `arguments` to a rest param before prepare runs. The
636
- // remainder (`with`, `this`, `super`, `yield`, `eval`) have no safe lowering
637
- // and stay errors in both modes.
638
- const PROHIBITED = { 'with': '`with` not supported', 'class': '`class` not supported', 'yield': '`yield` not supported',
639
- 'this': '`this` not supported: use explicit parameter',
640
- 'super': '`super` not supported: no class inheritance',
641
- 'arguments': '`arguments` not supported: use rest params',
642
- 'eval': '`eval` not supported'
643
- }
810
+ // Identifier prohibitions: op-policy.js REJECT_IDENTS (prep string nodes).
644
811
 
645
- // Predefined globals seeded into scope.chain at ctx.reset(). Value is the scope alias
812
+ // Predefined globals seeded into scope.chain at ctx.reset().
646
813
  // used in ctx.core.emit[]. Dotted lookups (Math.sin) go through the '.' handler which
647
814
  // resolves via scope.chain → module 'math' → registers 'math.sin' emitter.
648
815
  // Not actually "implicit imports" — these are ambient globals that exist in every jz/JS
@@ -683,6 +850,13 @@ export const GLOBALS = {
683
850
  const patternItems = (node) => node?.[0] === ',' ? node.slice(1) : [node]
684
851
  const isDestructPattern = (node) => Array.isArray(node) && (node[0] === '[]' || node[0] === '{}')
685
852
 
853
+ // Element count of a prepared inline array literal `['[', e0, e1, …]` with no
854
+ // spread (spread → dynamic length). Returns null when not such a literal, so
855
+ // destructuring a non-literal source keeps its runtime element reads.
856
+ const inlineArrayLen = (e) =>
857
+ Array.isArray(e) && e[0] === '[' && !e.slice(1).some(x => Array.isArray(x) && x[0] === '...')
858
+ ? e.length - 1 : null
859
+
686
860
  const simpleArrayPatternItems = (pattern) => {
687
861
  if (!Array.isArray(pattern) || pattern[0] !== '[]' || pattern.length !== 2) return null
688
862
  const items = patternItems(pattern[1])
@@ -714,7 +888,7 @@ function scalarArrayDestruct(pattern, rhs) {
714
888
  function declareGlobal(name, user = true) {
715
889
  if (depth !== 0 || typeof name !== 'string') return name
716
890
  if (ctx.scope.globals.has(name)) err(`'${name}' conflicts with a compiler internal — choose a different name`)
717
- ctx.scope.globals.set(name, `(global $${name} (mut f64) (f64.const 0))`)
891
+ declGlobal(name, 'f64')
718
892
  if (user) ctx.scope.userGlobals.add(name)
719
893
  return name
720
894
  }
@@ -749,7 +923,7 @@ function pushPatternAssign(target, valueExpr, out, decls = null) {
749
923
  out.push(['=', target, valueExpr])
750
924
  }
751
925
 
752
- function expandDestruct(pattern, source, out, decls = null) {
926
+ function expandDestruct(pattern, source, out, decls = null, srcLen = null) {
753
927
  if (!isDestructPattern(pattern)) return
754
928
 
755
929
  if (pattern[0] === '[]') {
@@ -764,6 +938,15 @@ function expandDestruct(pattern, source, out, decls = null) {
764
938
  continue
765
939
  }
766
940
 
941
+ // Source is a known-length inline literal and this index is past its end →
942
+ // the element is statically `undefined` (so any `= default` applies). Folding
943
+ // it here skips a provably out-of-range read — which both avoids the runtime
944
+ // access and dodges an optimizer miscompile of the destructuring-temp shape.
945
+ if (srcLen != null && j >= srcLen) {
946
+ pushPatternAssign(item, [, JZ_UNDEF], out, decls)
947
+ continue
948
+ }
949
+
767
950
  pushPatternAssign(item, ['[]', source, [, j]], out, decls)
768
951
  }
769
952
  return
@@ -844,7 +1027,7 @@ function prepDecl(op, ...inits) {
844
1027
  ctx.scope.chain[i] = declName
845
1028
  }
846
1029
  if (ctx.scope.globals.has(declName)) err(`'${declName}' conflicts with a compiler internal — choose a different name`)
847
- ctx.scope.globals.set(declName, `(global $${declName} (mut f64) (f64.const 0))`)
1030
+ declGlobal(declName, 'f64')
848
1031
  ctx.scope.userGlobals.add(declName)
849
1032
  } else if (typeof declName === 'string') {
850
1033
  // Bare hoisted decl inside a function (var X jzified to `let X` at top
@@ -859,6 +1042,23 @@ function prepDecl(op, ...inits) {
859
1042
  continue
860
1043
  }
861
1044
  const [, name, init] = i
1045
+ // `const alias = fn` whose RHS is a bare identifier naming a known function
1046
+ // is a compile-time function alias — the ES `export { fn as alias }` written
1047
+ // in declaration form (a recurring kernel idiom: paramList = extractParams,
1048
+ // toBoolFromEmitted = truthyIR …). Resolve `alias` straight to the function
1049
+ // so calls compile to a direct call and the export table re-exports the same
1050
+ // mangled func. Otherwise it would box a closure into a module global that a
1051
+ // cross-module callee resolves to the bare, unmangled name → "not in scope".
1052
+ // Module scope + `const` only: depth>0 aliases already work as closure values,
1053
+ // and a reassignable `let` is a genuine value binding, not an alias.
1054
+ if (op === 'const' && depth === 0 && typeof name === 'string' && typeof init === 'string') {
1055
+ const fn = hasFunc(init) ? init : (hasFunc(ctx.scope.chain[init]) ? ctx.scope.chain[init] : null)
1056
+ if (fn) {
1057
+ ctx.scope.chain[name] = fn
1058
+ if (name in ctx.func.exports) ctx.func.exports[name] = fn
1059
+ continue
1060
+ }
1061
+ }
862
1062
  const staticStr = op === 'const' ? staticStringExpr(init) : null
863
1063
  const staticArr = op === 'const' ? staticStringArrayValues(init) : null
864
1064
  const normed = prep(init)
@@ -894,7 +1094,7 @@ function prepDecl(op, ...inits) {
894
1094
  const p = normed.slice(1).filter(p => Array.isArray(p) && p[0] === ':').map(p => p[1])
895
1095
  if (p.length) ctx.schema.vars.set(tmp, ctx.schema.register(p))
896
1096
  }
897
- expandDestruct(name, tmp, rest)
1097
+ expandDestruct(name, tmp, rest, null, inlineArrayLen(normed))
898
1098
  continue
899
1099
  }
900
1100
 
@@ -912,6 +1112,10 @@ function prepDecl(op, ...inits) {
912
1112
  scopes[scopes.length - 1].set(name, name)
913
1113
  }
914
1114
  if (typeof declName === 'string' && fnNames) fnNames.add(declName)
1115
+ // A nested arrow stays a closure value (defFunc only lifts depth-0). Record
1116
+ // the binding so `.caller`/`.callee` on it reads as prohibited introspection.
1117
+ if (typeof declName === 'string' && Array.isArray(normed) && normed[0] === '=>')
1118
+ funcValueNames[funcValueNames.length - 1]?.add(declName)
915
1119
  if (op === 'const') bindStaticConst(declName, staticStr, staticArr)
916
1120
  // Track const for reassignment checks — only module-scope consts (depth 0)
917
1121
  if (typeof declName === 'string' && depth === 0) {
@@ -942,20 +1146,29 @@ function prepDecl(op, ...inits) {
942
1146
  // Track object schemas (after prefix so schema is keyed to final name)
943
1147
  if (typeof declName === 'string' && Array.isArray(normed) && normed[0] === '{}' && normed.length > 1) {
944
1148
  const props = []
1149
+ const addProp = n => { if (!props.includes(n)) props.push(n) }
1150
+ let allKnown = true
945
1151
  for (const p of normed.slice(1)) {
946
- if (Array.isArray(p) && p[0] === ':') props.push(p[1])
1152
+ // Dedupe every key (explicit AND spread-sourced) so a `k: v` that overrides
1153
+ // a spread-provided key doesn't push a duplicate — that would shift the
1154
+ // indices of later keys past emitObjectSpread's deduped slot assignment
1155
+ // (its `addName` dedupes both), making `decl.laterKey` read the wrong slot.
1156
+ if (Array.isArray(p) && p[0] === ':') addProp(p[1])
947
1157
  else if (Array.isArray(p) && p[0] === '...') {
948
- // Merge spread source schema into this object's schema
949
1158
  const srcSchema = typeof p[1] === 'string' && ctx.schema.resolve(p[1])
950
- if (srcSchema) for (const n of srcSchema) { if (!props.includes(n)) props.push(n) }
1159
+ if (srcSchema) for (const n of srcSchema) addProp(n)
1160
+ else allKnown = false
951
1161
  }
952
1162
  }
953
- if (props.length && ctx.schema.register) ctx.schema.vars.set(declName, ctx.schema.register(props))
1163
+ // An unknown spread source makes the value a runtime HASH (see
1164
+ // emitObjectSpread). Binding a static schema would compile `decl.prop`
1165
+ // to a fixed slot load that misreads the hash, so leave reads dynamic.
1166
+ if (allKnown && props.length && ctx.schema.register) ctx.schema.vars.set(declName, ctx.schema.register(props))
954
1167
  }
955
1168
  // Module-scope variable → WASM global (mark as user-declared)
956
1169
  if (depth === 0 && typeof declName === 'string') {
957
1170
  if (ctx.scope.globals.has(declName)) err(`'${declName}' conflicts with a compiler internal — choose a different name`)
958
- ctx.scope.globals.set(declName, `(global $${declName} (mut f64) (f64.const 0))`)
1171
+ declGlobal(declName, 'f64')
959
1172
  ctx.scope.userGlobals.add(declName)
960
1173
  }
961
1174
  rest.push(['=', declName, normed])
@@ -972,7 +1185,7 @@ function prepDecl(op, ...inits) {
972
1185
  // `import.meta.resolve("spec")` → the resolved URL as a static string.
973
1186
  function foldImportMetaResolve(callee, args) {
974
1187
  if (!isImportMetaProp(callee, 'resolve')) return undefined
975
- const callArgs = flatArgs(args).filter(a => a != null)
1188
+ const callArgs = handlerArgs(args)
976
1189
  if (callArgs.length !== 1) err('`import.meta.resolve` requires one string literal argument')
977
1190
  const spec = stringValue(callArgs[0])
978
1191
  if (spec == null) err('`import.meta.resolve` supports only string literal arguments')
@@ -986,8 +1199,11 @@ function foldImportMetaResolve(callee, args) {
986
1199
  // Returns the replacement IR, or `undefined` for an ordinary call.
987
1200
  function dispatchConstructorCall(callee, args) {
988
1201
  if (typeof callee !== 'string') return undefined
1202
+ // A user binding named like a constructor (`let Map = …`, `let Array = …`)
1203
+ // shadows the builtin — don't lower `Map(x)` to `new.Map`.
1204
+ if (shadowsBuiltin(callee)) return undefined
989
1205
  if (callee === 'Array') {
990
- const callArgs = flatArgs(args).filter(a => a != null)
1206
+ const callArgs = handlerArgs(args)
991
1207
  if (callArgs.length === 1) return handlers['new'](['()', callee, callArgs[0]])
992
1208
  }
993
1209
  if (CTORS.includes(callee)) return handlers['new'](['()', callee, ...args])
@@ -1005,7 +1221,7 @@ function foldNamespaceIntrospection(callee, args) {
1005
1221
  if (!Array.isArray(callee) || callee[0] !== '.') return undefined
1006
1222
  const [, obj, prop] = callee
1007
1223
  if (obj === 'Array' && prop === 'isArray') {
1008
- const cargs = flatArgs(args).filter(a => a != null)
1224
+ const cargs = handlerArgs(args)
1009
1225
  const a0 = cargs.length === 1 ? cargs[0] : null
1010
1226
  if (typeof a0 === 'string' && GLOBALS[a0] && !(scopes.length && isDeclared(a0)) && !hasFunc(a0))
1011
1227
  return [, 0]
@@ -1013,7 +1229,7 @@ function foldNamespaceIntrospection(callee, args) {
1013
1229
  if (prop === 'hasOwnProperty' && typeof obj === 'string' && !(scopes.length && isDeclared(obj))) {
1014
1230
  const mod = ctx.scope.chain[obj]
1015
1231
  if (mod && !mod.includes('.') && hasModule(mod)) {
1016
- const cargs = flatArgs(args).filter(a => a != null)
1232
+ const cargs = handlerArgs(args)
1017
1233
  const member = cargs.length === 1 ? stringValue(cargs[0]) : null
1018
1234
  // Include the module so its emit keys (the namespace's member set) are
1019
1235
  // registered; unreferenced emitters/data dead-strip in compile.
@@ -1027,6 +1243,11 @@ function foldNamespaceIntrospection(callee, args) {
1027
1243
  // way: a bare identifier through the scope chain, an `obj.prop` member call
1028
1244
  // through host imports / named-call / generic-method / namespace tables, and
1029
1245
  // any other expression through `prep` (a callable runtime value).
1246
+ // Compiler-internal synthetic callees: emit-handled intrinsics, never user
1247
+ // function values — so a bare reference must not pull in the callable-value
1248
+ // (function table / closure) machinery.
1249
+ const INTRINSIC_CALLEES = new Set(['__iter_arr'])
1250
+
1030
1251
  function resolveCallee(callee, args) {
1031
1252
  if (typeof callee === 'string') {
1032
1253
  const local = scopes.length && isDeclared(callee)
@@ -1038,12 +1259,23 @@ function resolveCallee(callee, args) {
1038
1259
  if (hasModule(resolved) && !ctx.module.imports.some(i => i[3]?.[1] === `$${resolved}`)) includeModule(resolved)
1039
1260
  return callee
1040
1261
  }
1041
- if (depth > 0 && !resolved && !ctx.func.exports[callee] && !ctx.module.imports.some(i => i[3]?.[1] === `$${callee}`))
1262
+ if (depth > 0 && !resolved && !INTRINSIC_CALLEES.has(callee) && !ctx.func.exports[callee] && !ctx.module.imports.some(i => i[3]?.[1] === `$${callee}`))
1042
1263
  includeForCallableValue()
1043
1264
  return callee
1044
1265
  }
1045
1266
  if (Array.isArray(callee) && callee[0] === '.') {
1046
1267
  const [, obj, prop] = callee
1268
+ // A user binding named like a builtin namespace (`let Math = {…}`) shadows
1269
+ // it — resolve `Math.max(…)` as a method call on the local value, not the
1270
+ // builtin named-call. (Property reads route through the `.` handler's own
1271
+ // shadow check.)
1272
+ if (shadowsBuiltin(obj)) return prep(callee)
1273
+ // SIMD intrinsic namespaces resolve members directly to their emit key, ahead of
1274
+ // generic-method dispatch — they're pure namespaces (never runtime values), and
1275
+ // names like `f32x4.add` must not be mistaken for the generic `.add` (Set/Map).
1276
+ if (typeof obj === 'string' && typeof prop === 'string' && SIMD_NS.has(obj) && !(scopes.length && isDeclared(obj)) && !ctx.scope.userGlobals?.has?.(obj)) {
1277
+ includeModule(obj); return `${obj}.${prop}`
1278
+ }
1047
1279
  const key = typeof obj === 'string' && typeof prop === 'string' ? `${obj}.${prop}` : null
1048
1280
  if (key && ctx.module.hostImports?.[obj]?.[prop]) {
1049
1281
  const spec = ctx.module.hostImports[obj][prop]
@@ -1078,18 +1310,13 @@ function renestSoleCommaArg(args) {
1078
1310
  }
1079
1311
 
1080
1312
  const handlers = {
1313
+ ...rejectHandlers(err),
1081
1314
  // Spread operator: [...expr] in arrays, f(...args) in calls, {...obj} in objects
1082
1315
  '...'(expr) {
1083
1316
  includeForArrayLiteral()
1084
1317
  return ['...', prep(expr)]
1085
1318
  },
1086
1319
 
1087
- // Prohibited ops — duplicated from jzify deliberately: .jz source bypasses jzify,
1088
- // so prepare is the actual defense. Messages here fire for both .js and .jz.
1089
- 'async': () => err('async/await not supported: WASM is synchronous'),
1090
- 'await': () => err('async/await not supported: WASM is synchronous'),
1091
- 'class': () => err('class not supported: use object literals'),
1092
- 'yield': () => err('generators not supported: use loops'),
1093
1320
  'debugger': () => null,
1094
1321
  // Static-key delete (.x, ["x"], [literal]) would change the fixed schema → reject.
1095
1322
  // Computed-key delete (obj[expr]) — including jessie's `delete ctx[k]` — lowers
@@ -1104,12 +1331,7 @@ const handlers = {
1104
1331
  err('delete not supported: object shape is fixed')
1105
1332
  },
1106
1333
  'in'(key, obj) { return ['in', prep(key), prep(obj)] },
1107
- 'instanceof': () => err('instanceof not supported: use typeof'),
1108
- 'with': () => err('`with` not supported: deprecated'),
1109
- ':': () => err('labeled statements not supported'),
1110
1334
  'label'(name, body) { return ['label', name, prep(body)] },
1111
- 'var': () => err('`var` not supported: use let/const'),
1112
- 'function': () => err('`function` not supported: use arrow functions'),
1113
1335
 
1114
1336
  // Destructuring assignment: [a, ...b] = expr or {x, y} = expr
1115
1337
  '='(lhs, rhs) {
@@ -1147,7 +1369,7 @@ const handlers = {
1147
1369
  // emit a dynamic property read + indirect call instead of a direct call.
1148
1370
  if (ctx.func.names.has(name)) {
1149
1371
  ctx.func.multiProp.add(`${fnBase}.${lhs[2]}`)
1150
- do name = `${fnBase}$${lhs[2]}$${ctx.func.uniq++}`; while (ctx.func.names.has(name))
1372
+ do { name = `${fnBase}$${lhs[2]}$${ctx.func.uniq++}` } while (ctx.func.names.has(name))
1151
1373
  }
1152
1374
  // Build the target `.` node directly from the resolved base — re-`prep`ing
1153
1375
  // the lhs would resolve a multiProp `fn.prop` to an rvalue (closure
@@ -1165,22 +1387,25 @@ const handlers = {
1165
1387
  recordGlobalRep(plhs, prhs)
1166
1388
  if (Array.isArray(prhs) && prhs[0] === '{}') {
1167
1389
  const props = staticObjectProps(prhs.slice(1))
1168
- if (props) ctx.schema.vars.set(plhs, ctx.schema.register(props.names))
1169
- }
1170
- }
1390
+ if (props) bindAssignSchema(plhs, ctx.schema.register(props.names))
1391
+ } else bindAssignSchema(plhs, null)
1392
+ } else bindAssignSchema(plhs, objLiteralSid(prhs))
1171
1393
  // Static string/array facts hold only while every assignment is constant.
1172
1394
  if (!assignedStaticGlobals.has(plhs) && (staticStr != null || staticArr)) bindStaticGlobal(plhs, staticStr, staticArr)
1173
1395
  else deleteStaticGlobal(plhs)
1174
1396
  assignedStaticGlobals.add(plhs)
1175
1397
  }
1176
- // Local object-literal assignment to a not-yet-shaped variable — e.g. a `var`
1177
- // that jzify hoisted into `let x; x = {…}`. Recording the schema here lets the
1178
- // binding behave like `let x = {…}`: fixed-slot field access and for-in unroll.
1179
- // First assignment fixes the shape (mirrors the global rule above).
1180
- else if (typeof plhs === 'string' && Array.isArray(prhs) && prhs[0] === '{}'
1181
- && !ctx.schema.vars.has(plhs)) {
1182
- const props = staticObjectProps(prhs.slice(1))
1183
- if (props) ctx.schema.vars.set(plhs, ctx.schema.register(props.names))
1398
+ // Object-literal assignment to a variable — e.g. a `var` that jzify hoisted
1399
+ // into `let x; x = {…}`. Recording the schema lets the binding behave like
1400
+ // `let x = {…}`: fixed-slot field access and for-in unroll. SOUNDNESS: the
1401
+ // shape holds only while EVERY assignment to the name agrees one literal
1402
+ // shape, no other sources. Any disagreeing assignment (non-literal RHS such
1403
+ // as a table/Map lookup, or a different-shape literal) unbinds and poisons
1404
+ // the name; fixed-slot reads against one literal's layout would misread the
1405
+ // other sources' objects (e.g. `.x` returning another shape's slot-0 value).
1406
+ // Compile reads the END state, so the conflict check is order-insensitive.
1407
+ else if (typeof plhs === 'string') {
1408
+ bindAssignSchema(plhs, objLiteralSid(prhs))
1184
1409
  }
1185
1410
  return ['=', plhs, prhs]
1186
1411
  },
@@ -1191,18 +1416,12 @@ const handlers = {
1191
1416
  const catchClause = clauses.find(c => Array.isArray(c) && c[0] === 'catch')
1192
1417
  const finallyClause = clauses.find(c => Array.isArray(c) && c[0] === 'finally')
1193
1418
  const tryBody = prep(body)
1419
+ // prep(handler) ONCE — it has side effects (uniq++, scope pushes, includes), so
1420
+ // the no-finally catch branch must reuse `caught`, not re-prep (FE-3 fix).
1194
1421
  const caught = catchClause
1195
- ? (() => {
1196
- const [, errName, handler] = catchClause
1197
- return ['catch', tryBody, errName, prep(handler)]
1198
- })()
1422
+ ? ['catch', tryBody, catchClause[1], prep(catchClause[2])]
1199
1423
  : tryBody
1200
- if (finallyClause) return ['finally', caught, prep(finallyClause[1])]
1201
- if (catchClause) {
1202
- const [, errName, handler] = catchClause
1203
- return ['catch', tryBody, errName, prep(handler)]
1204
- }
1205
- return tryBody
1424
+ return finallyClause ? ['finally', caught, prep(finallyClause[1])] : caught
1206
1425
  },
1207
1426
  'throw'(expr) { return ['throw', prep(expr)] },
1208
1427
 
@@ -1238,6 +1457,24 @@ const handlers = {
1238
1457
  return handlers['from'](fromNode[1], fromNode[2])
1239
1458
  },
1240
1459
 
1460
+ // Mixed default+named import `import d, { n } from 'm'` — jessie emits it as a
1461
+ // statement-level comma `[',', ['import', d], ['from', spec, src]]` (the default
1462
+ // fragment lost its source). Reunite: bind the default, then the named specifiers,
1463
+ // both against the shared source. (prepareModule caches by specifier, so preparing
1464
+ // the source twice is a no-op — same as two separate `import` statements.)
1465
+ // Any other comma is a sequence expression: fall through to generic prep.
1466
+ ','(...items) {
1467
+ if (items.length === 2
1468
+ && Array.isArray(items[0]) && items[0][0] === 'import' && typeof items[0][1] === 'string'
1469
+ && Array.isArray(items[1]) && items[1][0] === 'from') {
1470
+ const source = items[1][2]
1471
+ handlers['from'](items[0][1], source)
1472
+ handlers['from'](items[1][1], source)
1473
+ return null
1474
+ }
1475
+ return [',', ...items.map(prep)]
1476
+ },
1477
+
1241
1478
  'from'(specifiers, source) {
1242
1479
  const mod = source?.[1]
1243
1480
  if (!mod || typeof mod !== 'string') return err('Invalid import source')
@@ -1296,8 +1533,8 @@ const handlers = {
1296
1533
  }
1297
1534
 
1298
1535
  // Tier 2: Source module (bundling)
1299
- if (ctx.module.importSources?.[mod]) {
1300
- const resolved = prepareModule(mod, ctx.module.importSources[mod])
1536
+ if (isBundledModule(mod)) {
1537
+ const resolved = prepareModule(mod, ctx.module.importSources?.[mod])
1301
1538
  // Default import: import name from 'mod' → bind to default export
1302
1539
  if (typeof specifiers === 'string') {
1303
1540
  const mangled = resolved.exports.get('default')
@@ -1374,6 +1611,17 @@ const handlers = {
1374
1611
  pushScope(); const b = prep(body); popScope()
1375
1612
  return ['while', c, b]
1376
1613
  },
1614
+ // do { body } while (cond) → flag-guarded while: `flag=true; while (flag||cond) { flag=false; body }`.
1615
+ // jzify lowers this in default mode (jzify/transform.js), but strict mode skips jzify — without
1616
+ // this prepare-stage twin, strict `do-while` reaches emit as a raw 'do' and dies ("Unknown op: do"),
1617
+ // contradicting the README's strict-subset list. Re-prep the synthetic tree so scope/normalize apply.
1618
+ 'do': (body, cond) => {
1619
+ const flag = `${T}do${ctx.func.uniq++}`
1620
+ return prep([';',
1621
+ ['let', ['=', flag, [null, true]]],
1622
+ ['while', ['||', flag, cond],
1623
+ ['{}', [';', ['=', flag, [null, false]], body]]]])
1624
+ },
1377
1625
 
1378
1626
  'export': decl => {
1379
1627
  if (Array.isArray(decl) && (decl[0] === 'let' || decl[0] === 'const'))
@@ -1385,8 +1633,8 @@ const handlers = {
1385
1633
  const mod = decl[2]?.[1]
1386
1634
  if (!mod || typeof mod !== 'string') return null
1387
1635
  // Source module re-export
1388
- if (ctx.module.importSources?.[mod]) {
1389
- const resolved = prepareModule(mod, ctx.module.importSources[mod])
1636
+ if (isBundledModule(mod)) {
1637
+ const resolved = prepareModule(mod, ctx.module.importSources?.[mod])
1390
1638
  if (decl[1] === '*') {
1391
1639
  // export * from './mod' → register all exports
1392
1640
  for (const [name, mangled] of resolved.exports) {
@@ -1439,7 +1687,7 @@ const handlers = {
1439
1687
  if (defFunc('default', prep(val))) return null
1440
1688
  }
1441
1689
  // export default expr → create global 'default'
1442
- ctx.scope.globals.set('default', `(global $default (mut f64) (f64.const 0))`)
1690
+ declGlobal('default', 'f64')
1443
1691
  ctx.scope.userGlobals.add('default')
1444
1692
  return ['=', 'default', prep(val)]
1445
1693
  }
@@ -1456,12 +1704,18 @@ const handlers = {
1456
1704
  depth++
1457
1705
  pushScope(fnScope)
1458
1706
  funcLocalNames.push(new Set(collectParamNames(raw)))
1707
+ funcValueNames.push(new Set())
1459
1708
 
1460
1709
  const nextParams = []
1461
1710
  const bodyPrefix = []
1462
1711
  for (const r of raw) {
1463
1712
  const c = classifyParam(r)
1464
1713
  if (c.kind === 'rest') {
1714
+ // A rest param is an array: the binding holds one, and every call site
1715
+ // builds the rest array via `['[', …]`. Pull in the array emitter even
1716
+ // when the body never names an array literal (e.g. `(...xs) => 0`),
1717
+ // otherwise the call-site rest construction hits "Unknown op: [".
1718
+ includeForArrayLiteral()
1465
1719
  nextParams.push(r)
1466
1720
  if (typeof c.name === 'string') fnScope.set(c.name, c.name)
1467
1721
  } else if (c.kind === 'plain') {
@@ -1497,6 +1751,7 @@ const handlers = {
1497
1751
  const result = ['=>', Array.isArray(params) && params[0] === '()' ? ['()', inner] : inner, preparedBody]
1498
1752
  popScope()
1499
1753
  funcLocalNames.pop()
1754
+ funcValueNames.pop()
1500
1755
  depth--
1501
1756
  return result
1502
1757
  },
@@ -1516,9 +1771,13 @@ const handlers = {
1516
1771
  })]
1517
1772
  },
1518
1773
 
1519
- // Optional chaining / typeof — need ptr module
1520
- '?.'(obj, prop) { return ['?.', prep(obj), prop] },
1521
- '?.[]'(obj, idx) { return ['?.[]', prep(obj), prep(idx)] },
1774
+ // Optional chaining / typeof — need ptr module. Optional member access pulls
1775
+ // the same modules as plain `.`/`[]` (a method like `includes` needs string +
1776
+ // array for emit's runtime dispatch); the only difference is the nullish guard,
1777
+ // which is emit's concern. Without this, `obj?.m(…)` reaches emit missing the
1778
+ // `.m` emitter and falls to the dynamic path that needs an unincluded module.
1779
+ '?.'(obj, prop) { includeForProperty(prop); return ['?.', prep(obj), prop] },
1780
+ '?.[]'(obj, idx) { includeForArrayAccess(); return ['?.[]', prep(obj), prep(idx)] },
1522
1781
  '?.()'(callee, callArgs) {
1523
1782
  // Parser wraps multi-args in a comma list, like '()'. Unwrap so emit gets flat positional args.
1524
1783
  const items = callArgs == null ? []
@@ -1557,7 +1816,12 @@ const handlers = {
1557
1816
  return ['+', pa, pb]
1558
1817
  },
1559
1818
  '-'(a, b) {
1560
- if (b === undefined) { const na = prep(a); return isLit(na) && typeof na[1] === 'number' ? [, -na[1]] : ['u-', na] }
1819
+ // Fold `-<numeric literal>` to a literal, but NOT a bigint: jz's own `typeof` reports
1820
+ // a bigint value as 'number' too (its carrier is an f64), so under self-host this test
1821
+ // alone wrongly folds `-5n`, and negating the bigint here yields garbage (-2^63+5).
1822
+ // `typeof !== 'bigint'` excludes it in both engines (real JS: 'bigint'; jz: matches
1823
+ // '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] }
1561
1825
  return ['-', prep(a), prep(b)]
1562
1826
  },
1563
1827
 
@@ -1565,17 +1829,19 @@ const handlers = {
1565
1829
  '?'(cond, then, els) { return ['?:', prep(stripBoolNot(cond)), prep(then), prep(els)] },
1566
1830
 
1567
1831
  // ++/-- prefix vs postfix: parser sends trailing null for postfix
1568
- // Postfix i++ = (++i) - 1: increment happens, arithmetic recovers old value
1569
- // Property increment: obj.prop++ obj.prop = obj.prop + 1
1832
+ // Postfix i++ = (++i) - 1: increment happens, arithmetic recovers old value.
1833
+ // Property obj.prop++ has no dedicated ++ node (the ++ emitter is name-based),
1834
+ // so it lowers to `obj.prop = obj.prop + 1` (returns the NEW value) — and the
1835
+ // same -1/+1 recovery wraps it for postfix to yield the OLD value.
1570
1836
  '++'(a, _post) {
1571
1837
  const n = prep(a)
1572
- if (Array.isArray(n) && (n[0] === '.' || n[0] === '[]')) return ['=', n, ['+', n, [, 1]]]
1573
- return _post !== undefined ? ['-', ['++', n], [, 1]] : ['++', n]
1838
+ const inc = Array.isArray(n) && (n[0] === '.' || n[0] === '[]') ? ['=', n, ['+', n, [, 1]]] : ['++', n]
1839
+ return _post !== undefined ? ['-', inc, [, 1]] : inc
1574
1840
  },
1575
1841
  '--'(a, _post) {
1576
1842
  const n = prep(a)
1577
- if (Array.isArray(n) && (n[0] === '.' || n[0] === '[]')) return ['=', n, ['-', n, [, 1]]]
1578
- return _post !== undefined ? ['+', ['--', n], [, 1]] : ['--', n]
1843
+ const dec = Array.isArray(n) && (n[0] === '.' || n[0] === '[]') ? ['=', n, ['-', n, [, 1]]] : ['--', n]
1844
+ return _post !== undefined ? ['+', dec, [, 1]] : dec
1579
1845
  },
1580
1846
 
1581
1847
  // Regex literal: ['//','pattern','flags?'] → include regex module, pass through
@@ -1583,13 +1849,22 @@ const handlers = {
1583
1849
  return ['//', pattern, flags]
1584
1850
  },
1585
1851
 
1586
- '**'(a, b) { return ['**', prep(a), prep(b)] },
1852
+ '**'(a, b) {
1853
+ // ES2016 §13.6: an unparenthesized unary expression cannot be the base of `**`
1854
+ // — `-x**2`, `~x**2`, `!x**2`, `+x**2`, `typeof x**2`, `void x**2`, `delete o[k]**2`
1855
+ // are all SyntaxErrors (the precedence is ambiguous). The parser leaves a grouping
1856
+ // as `['()', …]`, so a parenthesized base `(-x)**2` (and `-(x**2)`, where the unary
1857
+ // sits outside the `**`) arrives with a non-unary root op and is allowed.
1858
+ if (Array.isArray(a) && a.length === 2 && (a[0] === '-' || a[0] === '+' || a[0] === '!' || a[0] === '~' || a[0] === 'typeof' || a[0] === 'void' || a[0] === 'delete'))
1859
+ err(`Unary '${a[0]}' before '**' is a SyntaxError (ES2016 §13.6) — parenthesize: (${a[0]} x) ** 2 or ${a[0]} (x ** 2)`)
1860
+ return ['**', prep(a), prep(b)]
1861
+ },
1587
1862
 
1588
1863
  // Function call or grouping parens
1589
1864
  '()'(callee, ...args) {
1590
1865
  // Grouping: (expr) → ['()', expr] with no args. Call: f() → ['()', 'f', null] with null arg.
1591
1866
  if (args.length === 0) return prep(callee)
1592
- if (typeof callee === 'string' && PROHIBITED[callee]) err(PROHIBITED[callee])
1867
+ if (typeof callee === 'string' && REJECT_IDENTS[callee]) err(REJECT_IDENTS[callee])
1593
1868
 
1594
1869
  // Compile-time folds: the callee names something resolvable now. Each fold
1595
1870
  // is gated by callee shape, so at most one of the three fires.
@@ -1607,7 +1882,14 @@ const handlers = {
1607
1882
  includeForCallableValue(); break
1608
1883
  }
1609
1884
  }
1610
- const result = ['()', callee, ...preppedArgs]
1885
+ // A zero-arg call keeps its explicit `null` args slot: `['()', callee, null]`,
1886
+ // not the slot-less `['()', callee]`. The latter is indistinguishable from a
1887
+ // grouping `(expr)`, so a second `prep` pass (the destructuring-assignment
1888
+ // lowering re-`prep`s its result) would re-read `x.pop()` as the grouping
1889
+ // `(x.pop)` and drop the call. Keeping the slot makes `prep` idempotent for
1890
+ // calls and matches `setCallArgs`'s canonical shape; `commaList(node[2])`
1891
+ // reads it back as zero args everywhere downstream.
1892
+ const result = preppedArgs.length ? ['()', callee, ...preppedArgs] : ['()', callee, null]
1611
1893
 
1612
1894
  if (callee === 'Object.assign' && ctx.schema.register) inferAssignSchema(result)
1613
1895
 
@@ -1649,9 +1931,11 @@ const handlers = {
1649
1931
  },
1650
1932
 
1651
1933
  // Object literal - flatten comma, expand shorthand
1652
- '{}'(inner) {
1653
- // Detect block body vs object literal
1654
- if (Array.isArray(inner) && STMT_OPS.has(inner[0])) {
1934
+ '{}'(...args) {
1935
+ const inner = args[0]
1936
+ // Block body: a single statement-op child (object props always start with
1937
+ // ':' or '...', never a statement op, so this never misfires on a literal).
1938
+ if (args.length === 1 && Array.isArray(inner) && STMT_OPS.has(inner[0])) {
1655
1939
  // Block body: push block scope for let/const shadowing
1656
1940
  pushScope()
1657
1941
  const result = ['{}', prep(inner)]
@@ -1660,10 +1944,16 @@ const handlers = {
1660
1944
  }
1661
1945
 
1662
1946
  includeForObjectLiteral()
1663
- if (inner == null) return ['{}']
1664
- const items = Array.isArray(inner) && inner[0] === ','
1665
- ? inner.slice(1)
1666
- : [inner]
1947
+ if (args.length === 0 || inner == null) return ['{}']
1948
+ // The parser emits one comma-grouped child `['{}', [',', p1, p2]]`, but prep's
1949
+ // own output is spread `['{}', p1, p2]` (see `result` below). Accept both so
1950
+ // prep stays idempotent: the destructuring-assignment lowering ('=' handler)
1951
+ // re-preps a wrapper that already holds a normalized literal, and reading only
1952
+ // the first child here would drop every property but the first — mis-sizing the
1953
+ // schema to cap-1 and losing the rest.
1954
+ const items = args.length === 1
1955
+ ? (Array.isArray(inner) && inner[0] === ',' ? inner.slice(1) : [inner])
1956
+ : args
1667
1957
 
1668
1958
  // Computed keys: `{[k]: v}` where `k` isn't compile-time foldable. jz's
1669
1959
  // object layout is slot-based (fixed schema at the literal site), so a
@@ -1699,6 +1989,12 @@ const handlers = {
1699
1989
  if (key == null) err('computed property name not supported for fixed-shape object: use a compile-time string/number key')
1700
1990
  return [':', key, prep(p[2])]
1701
1991
  }
1992
+ // Accessors (`{ get x() {…} }` / `{ set x(v) {…} }`) parse to ['get'|'set', …].
1993
+ // jz objects are fixed-shape slot records with no accessor protocol, so they'd
1994
+ // otherwise fall through and compile to dead code (0 schema slots → `o.x` reads
1995
+ // undefined). Reject loudly — silent miscompile breaks "valid jz = valid JS".
1996
+ if (Array.isArray(p) && (p[0] === 'get' || p[0] === 'set'))
1997
+ err('object getter/setter not supported — jz objects have no accessors; use a method or a plain property + function')
1702
1998
  return prep(p)
1703
1999
  }
1704
2000
  let prepped = items.map(prop)
@@ -1725,27 +2021,56 @@ const handlers = {
1725
2021
  // For loop
1726
2022
  'for'(head, body) {
1727
2023
  pushScope()
2024
+ // A comma/sequence Expression in a for-IN head RHS — `for (x in a, b)` — is valid (the RHS is
2025
+ // an Expression): evaluate left-to-right for side effects, value as the last element. (for-OF's
2026
+ // RHS is an AssignmentExpression — no comma — so it is left alone.) jzify lands it as a bare `,`
2027
+ // node in the source slot. Don't wrap it in `()`: Object.keys((a, obj)) hides `obj` behind the
2028
+ // sequence and loses its static schema (a non-escaping literal scalarizes → 0 keys). Instead take
2029
+ // the LAST element as the (direct) iteration source and run the earlier elements once first.
2030
+ let forInSeqPre = null
2031
+ if (Array.isArray(head) && head[0] === 'in' && Array.isArray(head[2]) && head[2][0] === ',') {
2032
+ const parts = head[2].slice(1)
2033
+ head = [head[0], head[1], parts[parts.length - 1]]
2034
+ if (parts.length > 2) forInSeqPre = [',', ...parts.slice(0, -1)]
2035
+ else if (parts.length === 2) forInSeqPre = parts[0]
2036
+ }
1728
2037
  let r
1729
2038
  if (Array.isArray(head) && head[0] === ';') {
1730
2039
  let [, init, cond, step] = head
1731
2040
  cond = stripBoolNot(cond)
1732
- // Hoist .length / .size / .byteLength from for-condition:
1733
- // `i < arr.length` → `let __len = arr.length | 0; ... i < __len`
1734
- // The `| 0` forces i32 even for unknown-typed receivers (where __length
1735
- // returns f64). NaN→0 via i32.trunc_sat matches JS semantics: a NaN bound
1736
- // makes `i < NaN` false on both representations, so the loop is skipped
1737
- // either way. Keeping the hoisted bound i32 lets the counter `i` stay i32
1738
- // through the comparison and `i++`, eliminating the per-iteration
1739
- // f64.convert_i32_s + f64.lt + f64.add + i32.trunc_sat_f64_s sequence.
2041
+ // Keep a `.length` / `.size` / `.byteLength` for-bound i32 without snapshotting it:
2042
+ // `i < arr.length` → `i < (arr.length | 0)` (re-read every iteration)
2043
+ // The `| 0` forces i32 even for unknown-typed receivers (where __length returns
2044
+ // f64), so the counter `i` stays i32 through the comparison and `i++` — no
2045
+ // per-iteration f64.convert_i32_s + f64.lt + f64.add + i32.trunc_sat round-trip.
2046
+ // It must stay INLINE, not hoisted into a pre-loop local: JS re-reads the bound
2047
+ // each step, and a loop body can grow/shrink the array mid-iteration — including
2048
+ // through an alias the compiler can't see locally (e.g. `arr` shares identity with
2049
+ // a field a called helper pushes to, as compilePendingClosures does over
2050
+ // ctx.closure.bodies). A snapshot diverges from JS and silently truncates such loops.
1740
2051
  if (cond && Array.isArray(cond) && (cond[0] === '<' || cond[0] === '<=' || cond[0] === '>' || cond[0] === '>=')) {
1741
2052
  const lenExpr = cond[0] === '<' || cond[0] === '<=' ? cond[2] : cond[1]
1742
2053
  if (Array.isArray(lenExpr) && lenExpr[0] === '.' &&
1743
2054
  (lenExpr[2] === 'length' || lenExpr[2] === 'size' || lenExpr[2] === 'byteLength')) {
1744
- const lenVar = `${T}len${ctx.func.uniq++}`
1745
- const lenDecl = ['let', ['=', lenVar, ['|', lenExpr, [, 0]]]]
1746
- init = init ? [';', init, lenDecl] : lenDecl
1747
- if (cond[0] === '<' || cond[0] === '<=') cond = [cond[0], cond[1], lenVar]
1748
- else cond = [cond[0], lenVar, cond[2]]
2055
+ const recv = lenExpr[1]
2056
+ const bound = ['|', lenExpr, [, 0]]
2057
+ const lengthStable = typeof recv === 'string' &&
2058
+ boundSafeCalls(body) && boundSafeCalls(step) && !writesReceiver(body, recv) && !writesReceiver(step, recv)
2059
+ if (lengthStable) {
2060
+ // Body can't change the bound → snapshot it once into an i32 local. Keeps
2061
+ // the counter `i` i32 through compare + `i++` (no per-iteration f64 round
2062
+ // trip) and gives the vectorizer the hoisted trip count it matches on.
2063
+ const lenVar = `${T}len${ctx.func.uniq++}`
2064
+ const lenDecl = ['let', ['=', lenVar, bound]]
2065
+ init = init ? [';', init, lenDecl] : lenDecl
2066
+ if (cond[0] === '<' || cond[0] === '<=') cond = [cond[0], cond[1], lenVar]
2067
+ else cond = [cond[0], lenVar, cond[2]]
2068
+ } else {
2069
+ // Body may grow/shrink the array (push/pop, or alias mutation through a
2070
+ // call) → re-read every iteration, as JS does. Still `| 0` for an i32 bound.
2071
+ if (cond[0] === '<' || cond[0] === '<=') cond = [cond[0], cond[1], bound]
2072
+ else cond = [cond[0], bound, cond[2]]
2073
+ }
1749
2074
  }
1750
2075
  }
1751
2076
  r = ['for', init ? prep(init) : null, cond ? prep(cond) : null, step ? prep(step) : null, prep(body)]
@@ -1757,61 +2082,64 @@ const handlers = {
1757
2082
  const varName = Array.isArray(decl) && (decl[0] === 'let' || decl[0] === 'const') ? decl[1] : decl
1758
2083
  const idx = `${T}i${ctx.func.uniq++}`
1759
2084
  const lenVar = `${T}len${ctx.func.uniq++}`
1760
- const trivial = typeof src === 'string'
1761
- const arrVar = trivial ? src : `${T}arr${ctx.func.uniq++}`
2085
+ const arrVar = `${T}arr${ctx.func.uniq++}`
2086
+ // Normalize the source to an index-iterable once: a Set→keys / Map→[k,v]
2087
+ // array, while an Array/String/TypedArray passes through untouched (no
2088
+ // copy). Without this, `coll[i]` on a Set/Map reads raw open-addressing
2089
+ // slot words instead of live entries.
1762
2090
  // Wrap .length in `| 0` so the hoisted bound is i32 even for unknown
1763
2091
  // receivers (same rationale as the for-cond hoist above).
1764
2092
  const lenE = ['|', ['.', arrVar, 'length'], [, 0]]
1765
- const decls = trivial
1766
- ? ['let', ['=', idx, [, 0]], ['=', lenVar, lenE]]
1767
- : ['let', ['=', arrVar, src], ['=', idx, [, 0]], ['=', lenVar, lenE]]
2093
+ const decls = ['let', ['=', arrVar, ['()', '__iter_arr', src]], ['=', idx, [, 0]], ['=', lenVar, lenE]]
1768
2094
  const cond = ['<', idx, lenVar]
1769
2095
  const step = ['++', idx]
1770
2096
  const inner = [';', ['let', ['=', varName, ['[]', arrVar, idx]]], body]
1771
2097
  r = prep(['for', [';', decls, cond, step], inner])
1772
2098
  } else if (Array.isArray(head) && head[0] === 'in') {
1773
- // for (let k in obj) unroll at compile time when schema known, else HASH runtime iteration
2099
+ // `forin` relies on runtime key enumeration outside the pure canonical subset. strict
2100
+ // rejects it (consistent with `obj[k]` / unknown-receiver methods); use `Object.keys(obj)`.
2101
+ if (ctx.transform.strict) err('strict mode: `for…in` is not in the canonical subset — it relies on runtime key enumeration. Iterate `Object.keys(obj)` explicitly instead.')
2102
+ // for (let k in src) → enumerate src's own keys via Object.keys (schema ∪ any keys added
2103
+ // later for objects; "0".."n-1" for arrays/strings; [] for Set/Map) and iterate the resulting
2104
+ // array by index. One uniform path keeps for-in consistent with Object.keys (so dynamically
2105
+ // added keys appear in both), and break/continue work as in any for-loop. Object.keys'
2106
+ // enumeration stdlib is pulled only when for-in is actually used.
1774
2107
  const [, decl, src] = head
1775
- const varName = Array.isArray(decl) && (decl[0] === 'let' || decl[0] === 'const') ? decl[1] : decl
1776
- const srcName = typeof src === 'string' ? (ctx.scope.chain[src] || src) : null
1777
- const sid = typeof srcName === 'string' ? ctx.schema.vars.get(srcName) : null
1778
- if (sid != null) {
1779
- // Known schema compile-time unrolling with string keys
1780
- const keys = ctx.schema.list[sid]
1781
- if (!keys || !keys.length) { popScope(); return null }
1782
- includeForKnownKeyIteration()
1783
- if (!hasLoopJump(body)) {
1784
- // No break/continue flat unroll, no loop frame needed.
1785
- const stmts = []
1786
- for (let i = 0; i < keys.length; i++) {
1787
- stmts.push(i === 0
1788
- ? ['let', ['=', varName, [, keys[i]]]]
1789
- : ['=', varName, [, keys[i]]])
1790
- stmts.push(cloneNode(body))
1791
- }
1792
- r = prep([';', ...stmts])
1793
- } else {
1794
- // break/continue present an unrolled loop still needs its frames.
1795
- // Wrap each iteration in a labeled block (continue target) and the
1796
- // whole run in an outer labeled block (break target): `break` exits
1797
- // the construct, `continue` falls through to the next iteration.
1798
- const brkL = `${T}fibrk${ctx.func.uniq++}`
1799
- const decl = prep(['let', ['=', varName, [, keys[0]]]])
1800
- const parts = [decl]
1801
- for (let i = 0; i < keys.length; i++) {
1802
- const contL = `${T}ficont${ctx.func.uniq++}`
1803
- const iter = prep(i === 0
1804
- ? cloneNode(body)
1805
- : [';', ['=', varName, [, keys[i]]], cloneNode(body)])
1806
- parts.push(['label', contL, retargetLoopJumps(iter, brkL, contL)])
1807
- }
1808
- r = ['label', brkL, [';', ...parts]]
1809
- }
1810
- } else {
1811
- // Dynamic object → HASH runtime iteration
1812
- includeForRuntimeKeyIteration()
1813
- r = ['for-in', varName, prep(src), prep(body)]
1814
- }
2108
+ const isDecl = Array.isArray(decl) && (decl[0] === 'let' || decl[0] === 'const')
2109
+ // `for ((x) in …)` the LHS may be a cover-parenthesized identifier; unwrap to the target.
2110
+ let lhs = decl; while (Array.isArray(lhs) && lhs[0] === '()') lhs = lhs[1]
2111
+ const target = isDecl ? decl[1] : lhs
2112
+ // A member/computed LHS (`for (x.y in …)`, `for (obj[k] in …)`) assigns each key into the
2113
+ // existing place; let/const and a bare name take a fresh per-iteration `let` binding.
2114
+ const isMemberTarget = Array.isArray(target) && (target[0] === '.' || target[0] === '[]')
2115
+ // for-in over null/undefined is a no-op — ES ForIn/OfHeadEvaluation returns a break
2116
+ // completion before enumerating — but Object.keys(null|undefined) throws. So a nullish
2117
+ // source must enumerate the empty set. A static null (`[null,null]`) / undefined (`[]`)
2118
+ // skips Object.keys entirely; a bare identifier is guarded by a runtime `== null` test
2119
+ // (evaluated twice side-effect-free keeping Object.keys' *direct* receiver so its
2120
+ // static key schema still resolves); object/array literals and other expressions, which
2121
+ // are never nullish or carry no static schema to lose, stay direct.
2122
+ // A nullish literal node is `[<nullish-op>, value]` with both slots nullish: `null` is
2123
+ // `[null, null]`, `undefined` is `[null]` (empty value slot). A numeric/string literal
2124
+ // `[null, v]` has a non-nullish value slot, so `src[1] == null` discriminates them.
2125
+ const nullish = Array.isArray(src) && src[0] == null && src[1] == null
2126
+ const keysExpr = nullish ? ['[]', null]
2127
+ : typeof src === 'string'
2128
+ ? ['?', ['==', src, [null, null]], ['[]', null], ['()', ['.', 'Object', 'keys'], src]]
2129
+ : ['()', ['.', 'Object', 'keys'], src]
2130
+ const ks = `${T}fik${ctx.func.uniq++}`, ix = `${T}fii${ctx.func.uniq++}`, lenV = `${T}fil${ctx.func.uniq++}`
2131
+ const decls = ['let',
2132
+ ['=', ks, keysExpr],
2133
+ ['=', ix, [, 0]],
2134
+ ['=', lenV, ['|', ['.', ks, 'length'], [, 0]]]]
2135
+ const bindEach = isMemberTarget
2136
+ ? ['=', target, ['[]', ks, ix]] // x.y = key / obj[k] = key
2137
+ : ['let', ['=', target, ['[]', ks, ix]]] // let k = key (fresh per-iteration binding)
2138
+ const forNode = ['for', [';', decls, ['<', ix, lenV], ['++', ix]],
2139
+ [';', bindEach, body]]
2140
+ // Run the dropped sequence prefix (earlier comma elements) once for side effects, before
2141
+ // the loop. Built raw and prepped as a unit so prep inserts the value-drop on the prefix.
2142
+ r = prep(forInSeqPre ? [';', forInSeqPre, forNode] : forNode)
1815
2143
  } else {
1816
2144
  // Some parser/jzify shapes for `for (;;)` and `for (; cond; )` arrive
1817
2145
  // as a null or bare-condition head instead of the canonical
@@ -1826,14 +2154,22 @@ const handlers = {
1826
2154
  // Property access - resolve namespaces or object/array properties
1827
2155
  '.'(obj, prop) {
1828
2156
  prop = typeof prop === 'string' ? prop : staticPropertyKey(prop)
1829
- if (prop === 'caller' || prop === 'callee') err('`.caller` and `.callee` are prohibited: deprecated stack introspection')
2157
+ // `.caller`/`.callee` on a function value (or `arguments`) are deprecated
2158
+ // stack introspection — prohibited as bad practice. On a plain data object
2159
+ // they are ordinary field names (e.g. an ESTree call node's `.callee`), so
2160
+ // the ban keys off a known-function receiver, not the bare property name.
2161
+ if ((obj === 'arguments' || hasFunc(obj) || isFuncValueLocal(obj)) && (prop === 'caller' || prop === 'callee'))
2162
+ err('`.caller`/`.callee` are prohibited: deprecated function stack introspection')
1830
2163
  if (prop === 'url' && isImportMeta(obj)) return staticString(importMetaUrl())
2164
+ // A user binding named like a builtin namespace (`let Math = {…}`) shadows it
2165
+ // — read the property off the local value, not the builtin namespace table.
2166
+ if (shadowsBuiltin(obj)) { includeForProperty(prop); return ['.', prep(obj), prop] }
1831
2167
  const mod = ctx.scope.chain[obj]
1832
2168
  // Only treat as module namespace if it's a known built-in module (not a mangled import name)
1833
2169
  if (typeof obj === 'string' && mod && !mod.includes('.') && hasModule(mod)) {
1834
2170
  includeModule(mod)
1835
2171
  const key = mod + '.' + prop
1836
- if (ctx.core.emit[key]?.length > 0) includeForCallableValue()
2172
+ if (emitArity(ctx.core.emit[key]) > 0) includeForCallableValue()
1837
2173
  return key
1838
2174
  }
1839
2175
  // Source module namespace: import * as X → X.prop resolved to mangled name
@@ -1849,7 +2185,22 @@ const handlers = {
1849
2185
  'new'(ctor, ...args) {
1850
2186
  let name = ctor, ctorArgs = args
1851
2187
  if (Array.isArray(ctor) && ctor[0] === '()') { name = ctor[1]; ctorArgs = ctor.slice(2) }
1852
- if (name === 'Date' && ctorArgs.length === 1 && ctorArgs[0] == null) ctorArgs = []
2188
+ // No GC weakness is unobservable, so we fold WeakSet/WeakMap to Set/Map right here:
2189
+ // construction and every .add/.has/.get/.set/.delete reuse the concrete emit path.
2190
+ // The fold lives in prepare, not jzify — so `strict` (which only skips jzify) wouldn't
2191
+ // drop it on its own; we reject explicitly. It's a deviation anyway (accepts primitive
2192
+ // keys, exposes .size/iteration), not a true subset member — there, use Set/Map directly.
2193
+ if (name === 'WeakSet' || name === 'WeakMap') {
2194
+ const concrete = name === 'WeakSet' ? 'Set' : 'Map'
2195
+ if (ctx.transform.strict) err(`strict mode: ${name} is not in the canonical subset — use ${concrete} (jz has no GC, so weak references are unobservable).`)
2196
+ name = concrete
2197
+ }
2198
+ // A lone `null` ctorArg is the parser's no-args sentinel (`new Map()`), and
2199
+ // `new Map(null)`/`new Map(undefined)` are spec-equivalent to it (null/undefined
2200
+ // → empty collection). Drop it so the emit hits the empty-collection fast path
2201
+ // rather than lowering `prep(null)` → `[, 0]` and routing through `__map_from`.
2202
+ // Typed arrays keep the sentinel: there `[, 0]` is a legitimate zero length.
2203
+ if (ctorArgs.length === 1 && ctorArgs[0] == null && (name === 'Date' || COLLECTION_CTORS.includes(name))) ctorArgs = []
1853
2204
  // Flatten comma-grouped args: [',', a, b, c] → [a, b, c]
1854
2205
  if (ctorArgs.length === 1 && Array.isArray(ctorArgs[0]) && ctorArgs[0][0] === ',')
1855
2206
  ctorArgs = ctorArgs[0].slice(1)
@@ -2009,6 +2360,20 @@ function collectReturns(node, out) {
2009
2360
 
2010
2361
  const isLit = n => Array.isArray(n) && n[0] == null
2011
2362
 
2363
+ /** Self-host: pre-parsed module AST for a specifier, or undefined. Linear scan over
2364
+ * [specifier, ast] pairs — array indexing + string `===` are the ABI-safe primitives
2365
+ * the kernel can read off a host-marshalled argument (dynamic-key object reads aren't). */
2366
+ function moduleAstFor(specifier) {
2367
+ const asts = ctx.module.importAsts
2368
+ if (!asts) return undefined
2369
+ for (let i = 0; i < asts.length; i++) if (asts[i][0] === specifier) return asts[i][1]
2370
+ return undefined
2371
+ }
2372
+
2373
+ /** True when `mod` is bundled in-process — as source (host parses it) or as a
2374
+ * pre-parsed AST (self-host kernel). Either path routes through prepareModule. */
2375
+ const isBundledModule = mod => !!ctx.module.importSources?.[mod] || moduleAstFor(mod) !== undefined
2376
+
2012
2377
  /** Compile-time bundling: parse + prepare an imported module, collect exports. */
2013
2378
  function prepareModule(specifier, source) {
2014
2379
  includeModule('core')
@@ -2020,8 +2385,24 @@ function prepareModule(specifier, source) {
2020
2385
 
2021
2386
  ctx.module.moduleStack.push(specifier)
2022
2387
 
2023
- // Name mangling prefix: ./math.jz _math_jz
2024
- const prefix = specifier.replace(/[^a-zA-Z0-9]/g, '_')
2388
+ // Name mangling prefix. Long specifiers (the bundler keys modules by
2389
+ // ABSOLUTE path — 40-60 byte '_Users_…' / '_home_runner_…' prefixes on every
2390
+ // symbol) compact to 'm<N>_<basename>': symbol strings shrink ~4×, which is
2391
+ // a direct hot-path win in the SELF-HOST — watr resolves every `call $name`
2392
+ // and `local.get $name` through name-keyed maps, paying hash+compare per
2393
+ // byte, and shared 35-byte path prefixes defeated the hash-probe early-outs.
2394
+ // Deterministic per compile (registration order); short relative specifiers
2395
+ // keep the readable form.
2396
+ const sanitized = specifier.replace(/[^a-zA-Z0-9]/g, '_')
2397
+ let prefix
2398
+ if (sanitized.length <= 24) prefix = sanitized
2399
+ else {
2400
+ if (!ctx.module.prefixIds) ctx.module.prefixIds = new Map()
2401
+ let id = ctx.module.prefixIds.get(specifier)
2402
+ if (id == null) { id = ctx.module.prefixIds.size; ctx.module.prefixIds.set(specifier, id) }
2403
+ const base = sanitized.replace(/_(js|mjs|jz)$/, '').match(/[a-zA-Z0-9]+$/)?.[0] ?? ''
2404
+ prefix = `m${id}_${base.slice(-16)}`
2405
+ }
2025
2406
 
2026
2407
  // Save caller state
2027
2408
  const savedScope = ctx.scope.chain, savedExports = ctx.func.exports
@@ -2031,8 +2412,18 @@ function prepareModule(specifier, source) {
2031
2412
  ctx.func.exports = {}
2032
2413
  ctx.module.currentPrefix = prefix
2033
2414
 
2034
- // Parse + prepare imported source (may trigger recursive imports)
2035
- let ast = parse(source)
2415
+ try {
2416
+ // Parse + prepare imported source (may trigger recursive imports). The parser
2417
+ // is injected via ctx.transform.parse (the host pipeline sets it) rather than
2418
+ // imported, so prepare carries no hard dependency on a concrete parser — the
2419
+ // same inversion as ctx.transform.jzify. The self-host kernel can't parse, so it
2420
+ // pre-parses the whole graph on the host and passes the ASTs via importAsts;
2421
+ // we consult those first and only parse `source` when no AST was supplied.
2422
+ let ast = moduleAstFor(specifier)
2423
+ if (ast === undefined) {
2424
+ if (!ctx.transform.parse) err('compile-time module bundling requires ctx.transform.parse (injected by the jz pipeline)')
2425
+ ast = ctx.transform.parse(source)
2426
+ }
2036
2427
  if (ctx.transform.jzify) ast = ctx.transform.jzify(ast)
2037
2428
  const savedDepth = depth; depth = 0
2038
2429
  const moduleInit = prep(ast)
@@ -2046,9 +2437,9 @@ function prepareModule(specifier, source) {
2046
2437
  const func = ctx.func.list.find(f => f.name === localName)
2047
2438
  if (func) { renameFunc(func, mangled); func._modulePrefix = prefix }
2048
2439
  if (ctx.scope.globals.has(localName)) {
2049
- const wat = ctx.scope.globals.get(localName).replace(`$${localName}`, `$${mangled}`)
2440
+ // Records carry no name — a rename is a pure Map re-key.
2441
+ ctx.scope.globals.set(mangled, ctx.scope.globals.get(localName))
2050
2442
  ctx.scope.globals.delete(localName)
2051
- ctx.scope.globals.set(mangled, wat)
2052
2443
  if (ctx.scope.userGlobals.has(localName)) { ctx.scope.userGlobals.delete(localName); ctx.scope.userGlobals.add(mangled) }
2053
2444
  if (ctx.scope.globalTypes.has(localName)) { ctx.scope.globalTypes.set(mangled, ctx.scope.globalTypes.get(localName)); ctx.scope.globalTypes.delete(localName) }
2054
2445
  }
@@ -2097,9 +2488,8 @@ function prepareModule(specifier, source) {
2097
2488
  const func = ctx.func.list.find(f => f.name === alias)
2098
2489
  if (func) renameFunc(func, mangled)
2099
2490
  if (ctx.scope.globals.has(alias)) {
2100
- const wat = ctx.scope.globals.get(alias).replace(`$${alias}`, `$${mangled}`)
2491
+ ctx.scope.globals.set(mangled, ctx.scope.globals.get(alias))
2101
2492
  ctx.scope.globals.delete(alias)
2102
- ctx.scope.globals.set(mangled, wat)
2103
2493
  if (ctx.scope.userGlobals.has(alias)) { ctx.scope.userGlobals.delete(alias); ctx.scope.userGlobals.add(mangled) }
2104
2494
  }
2105
2495
  }
@@ -2165,15 +2555,18 @@ function prepareModule(specifier, source) {
2165
2555
  recordModuleInitFacts(moduleInit)
2166
2556
  }
2167
2557
 
2168
- // Restore caller state
2169
- ctx.scope.chain = savedScope
2170
- ctx.func.exports = savedExports
2171
- ctx.module.currentPrefix = savedModulePrefix
2172
- ctx.module.moduleStack.pop()
2173
-
2174
2558
  const result = { exports: moduleExports }
2175
2559
  ctx.module.resolvedModules.set(specifier, result)
2176
2560
  return result
2561
+ } finally {
2562
+ // ALWAYS restore caller state (FE-6): if `prep(ast)` or a recursive import threw
2563
+ // mid-prep, skipping this would leave ctx.scope/exports/prefix/moduleStack
2564
+ // corrupted for the rest of the pipeline.
2565
+ ctx.scope.chain = savedScope
2566
+ ctx.func.exports = savedExports
2567
+ ctx.module.currentPrefix = savedModulePrefix
2568
+ ctx.module.moduleStack.pop()
2569
+ }
2177
2570
  }
2178
2571
 
2179
2572
  // =============================================================================
@@ -2241,7 +2634,7 @@ function tryFusePair(decl, forNode, seq, declIdx) {
2241
2634
  if (!hasAnyIndexedRead(forBody, NAME) && !hasAnyIndexedRead(cond, NAME)) return null
2242
2635
  // `NAME` must not be read after the for-loop in the same block.
2243
2636
  for (let k = declIdx + 2; k < seq.length; k++) {
2244
- if (refsName(seq[k], NAME)) return null
2637
+ if (refsName(seq[k], NAME, { skipArrow: false })) return null
2245
2638
  }
2246
2639
  // RECV must not be reassigned inside the for-loop (would invalidate substitution).
2247
2640
  if (assignsName(forNode, RECV) || assignsName(forNode, NAME)) return null
@@ -2283,12 +2676,6 @@ function hasAnyIndexedRead(n, NAME) {
2283
2676
  for (let i = 1; i < n.length; i++) if (hasAnyIndexedRead(n[i], NAME)) return true
2284
2677
  return false
2285
2678
  }
2286
- function refsName(n, NAME) {
2287
- if (typeof n === 'string') return n === NAME
2288
- if (!Array.isArray(n)) return false
2289
- for (let i = 1; i < n.length; i++) if (refsName(n[i], NAME)) return true
2290
- return false
2291
- }
2292
2679
  function assignsName(n, NAME) {
2293
2680
  if (!Array.isArray(n)) return false
2294
2681
  const op = n[0]