jz 0.5.0 → 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 +281 -142
  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 +461 -185
  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 +591 -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} +600 -205
  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 -2997
  77. package/src/jzify.js +0 -1553
  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 —
@@ -91,8 +189,14 @@ const addHostImport = (mod, name, alias, spec) => {
91
189
 
92
190
  const isImportMeta = node => Array.isArray(node) && node[0] === '.' && node[1] === 'import' && node[2] === 'meta'
93
191
  const isImportMetaProp = (node, prop) => Array.isArray(node) && node[0] === '.' && isImportMeta(node[1]) && node[2] === prop
192
+ // In a pure boolean position (consumer reads only truthiness) `!!e` is exactly `e`.
193
+ // Drop redundant double-negation; recurse so `!!!!e → e`. NOT valid for `&&`/`||`
194
+ // operands — those are value-preserving (`!!a && b` returns `false`, not `a`).
195
+ const stripBoolNot = c => {
196
+ while (Array.isArray(c) && c[0] === '!' && Array.isArray(c[1]) && c[1][0] === '!') c = c[1][1]
197
+ return c
198
+ }
94
199
  const stringValue = node => Array.isArray(node) && node[0] == null && typeof node[1] === 'string' ? node[1] : null
95
- const flatArgs = args => args.length === 1 && Array.isArray(args[0]) && args[0][0] === ',' ? args[0].slice(1) : args
96
200
  const MUTATING_ARRAY_METHODS = new Set(['copyWithin', 'fill', 'pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift'])
97
201
 
98
202
  function staticStringArrayValues(expr) {
@@ -227,7 +331,12 @@ function staticStringExpr(node) {
227
331
  if (op === '+') {
228
332
  const a = staticStringExpr(args[0])
229
333
  const b = staticStringExpr(args[1])
230
- 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
231
340
  }
232
341
  if (op === '`') {
233
342
  let out = ''
@@ -263,7 +372,12 @@ function importMetaUrl() {
263
372
 
264
373
  function resolveImportMeta(spec) {
265
374
  const base = importMetaUrl()
266
- 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) }
267
381
  catch { err(`Cannot resolve import.meta specifier '${spec}' from '${base}'`) }
268
382
  }
269
383
 
@@ -272,6 +386,7 @@ function recordModuleInitFacts(root) {
272
386
  dynVars: new Set(), anyDyn: false, hasSchemaLiterals: false,
273
387
  hasFuncValue: false, timerNames: new Set(),
274
388
  maxDef: 0, maxCall: 0, hasRest: false, hasSpread: false,
389
+ writtenProps: new Set(),
275
390
  }
276
391
  const visitFuncValue = (node) => {
277
392
  if (facts.hasFuncValue || !Array.isArray(node)) return
@@ -316,13 +431,34 @@ function recordModuleInitFacts(root) {
316
431
  * @param {ASTNode} node - Raw AST from parser
317
432
  * @returns {ASTNode} Normalized AST
318
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
+
319
454
  export default function prepare(node) {
320
- depth = 0
321
- scopes = []
322
- staticConstScopes = []
323
- assignedStaticGlobals = new Set()
324
- 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
325
460
  includeModule('core')
461
+ validateCoalesceMixing(node) // ES2020: reject unparenthesized `??` mixed with `||`/`&&`
326
462
  normalizeIdents(node)
327
463
  fuseSparseMapReads(node) // AST-level fusion; needs pre-resolution shape — defined at end of file
328
464
  seedStaticGlobalAssignments(node)
@@ -419,9 +555,9 @@ export default function prepare(node) {
419
555
  return ast
420
556
  }
421
557
 
422
- // Named constants → numeric literals
423
- export const JZ_NULL = Symbol('null')
424
- 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 }
425
561
  // NaN/Infinity stay as special f64 values in emit()
426
562
  const F64_CONSTANTS = { 'NaN': NaN, 'Infinity': Infinity }
427
563
 
@@ -465,7 +601,43 @@ function deleteStaticGlobal(name) {
465
601
  ctx.scope.shapeStrArrays?.delete(name)
466
602
  }
467
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
+
468
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))
469
641
 
470
642
  const renameFunc = (func, nextName) => {
471
643
  ctx.func.names.delete(func.name)
@@ -473,8 +645,8 @@ const renameFunc = (func, nextName) => {
473
645
  ctx.func.names.add(nextName)
474
646
  }
475
647
 
476
- /** Map JS typeof strings to jz type checks. Codes < 0 trigger specialized emitTypeofCmp paths. */
477
- 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.
478
650
  // Spec §13.5.3: `typeof undeclared_x` returns 'undefined' without throwing.
479
651
  // True iff `name` is a bare identifier with no resolution path. Mirrors the
480
652
  // resolution chain inside `prep()` so we don't speculate emit-time failures.
@@ -482,7 +654,7 @@ function isUnresolvableBareIdent(name) {
482
654
  if (typeof name !== 'string') return false
483
655
  if (name in CONSTANTS || name in F64_CONSTANTS) return false
484
656
  if (name === 'Boolean' || name === 'Number') return false
485
- if (PROHIBITED[name]) return false
657
+ if (REJECT_IDENTS[name]) return false
486
658
  if (scopes.length && isDeclared(name)) return false
487
659
  if (ctx.scope.chain[name]) return false
488
660
  if (GLOBALS[name]) return false
@@ -501,9 +673,9 @@ function staticTypeofString(x) {
501
673
  // Spec §13.5.3: unresolvable bare ref → 'undefined'.
502
674
  if (isUnresolvableBareIdent(x)) return 'undefined'
503
675
  // Bare callable global: parseInt, parseFloat, isNaN, isFinite, Error, BigInt, etc.
504
- 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'
505
677
  const px = prep(x)
506
- 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'
507
679
  return null
508
680
  }
509
681
  // Builtin-namespace constructors expose `prototype`/`length`/`name` as own
@@ -527,14 +699,14 @@ function resolveTypeof(node) {
527
699
  if (Array.isArray(a) && a[0] === 'typeof' && Array.isArray(b) && b[0] == null && typeof b[1] === 'string') {
528
700
  const known = staticTypeofString(a[1])
529
701
  if (known != null) return [, eqLike ? known === b[1] : known !== b[1]]
530
- const code = TYPEOF_MAP[b[1]]
702
+ const code = TYPEOF[b[1]]
531
703
  if (code != null) return [op, ['typeof', a[1]], [, code]]
532
704
  }
533
705
  // 'string' == typeof x
534
706
  if (Array.isArray(b) && b[0] === 'typeof' && Array.isArray(a) && a[0] == null && typeof a[1] === 'string') {
535
707
  const known = staticTypeofString(b[1])
536
708
  if (known != null) return [, eqLike ? known === a[1] : known !== a[1]]
537
- const code = TYPEOF_MAP[a[1]]
709
+ const code = TYPEOF[a[1]]
538
710
  if (code != null) return [op, ['typeof', b[1]], [, code]]
539
711
  }
540
712
  return node
@@ -595,11 +767,19 @@ function prep(node) {
595
767
  if (typeof node === 'string') {
596
768
  if (node in CONSTANTS) return [, CONSTANTS[node]]
597
769
  if (node in F64_CONSTANTS) return [, F64_CONSTANTS[node]]
598
- if (PROHIBITED[node]) err(PROHIBITED[node])
770
+ if (REJECT_IDENTS[node]) err(REJECT_IDENTS[node])
599
771
  // Boolean/Number as value → identity arrow (for .filter(Boolean), .map(Number) etc.)
600
772
  if (node === 'Boolean' || node === 'Number') { includeForCallableValue(); return ['=>', 'x', 'x'] }
601
773
  // Block locals shadow module imports/globals, even when the local keeps the same name.
602
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]]
603
783
  const resolved = ctx.scope.chain[node]
604
784
  if (resolved?.includes('.')) return resolved
605
785
  // Cross-module import: mangled name (e.g. __util_js$clone)
@@ -612,6 +792,10 @@ function prep(node) {
612
792
 
613
793
  const [op, ...args] = node
614
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.)`)
615
799
  if (op == null) {
616
800
  if (typeof args[0] === 'string') {
617
801
  includeForStringValue()
@@ -623,19 +807,9 @@ function prep(node) {
623
807
  return handler ? handler(...args) : [op, ...args.map(prep)]
624
808
  }
625
809
 
626
- // Strict-jz prohibitions. `class` and `arguments` are *also* listed here but
627
- // only reach this point when jzify is off — jzify lowers `class` to a factory
628
- // arrow and rewrites `arguments` to a rest param before prepare runs. The
629
- // remainder (`with`, `this`, `super`, `yield`, `eval`) have no safe lowering
630
- // and stay errors in both modes.
631
- const PROHIBITED = { 'with': '`with` not supported', 'class': '`class` not supported', 'yield': '`yield` not supported',
632
- 'this': '`this` not supported: use explicit parameter',
633
- 'super': '`super` not supported: no class inheritance',
634
- 'arguments': '`arguments` not supported: use rest params',
635
- 'eval': '`eval` not supported'
636
- }
810
+ // Identifier prohibitions: op-policy.js REJECT_IDENTS (prep string nodes).
637
811
 
638
- // Predefined globals seeded into scope.chain at ctx.reset(). Value is the scope alias
812
+ // Predefined globals seeded into scope.chain at ctx.reset().
639
813
  // used in ctx.core.emit[]. Dotted lookups (Math.sin) go through the '.' handler which
640
814
  // resolves via scope.chain → module 'math' → registers 'math.sin' emitter.
641
815
  // Not actually "implicit imports" — these are ambient globals that exist in every jz/JS
@@ -676,6 +850,13 @@ export const GLOBALS = {
676
850
  const patternItems = (node) => node?.[0] === ',' ? node.slice(1) : [node]
677
851
  const isDestructPattern = (node) => Array.isArray(node) && (node[0] === '[]' || node[0] === '{}')
678
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
+
679
860
  const simpleArrayPatternItems = (pattern) => {
680
861
  if (!Array.isArray(pattern) || pattern[0] !== '[]' || pattern.length !== 2) return null
681
862
  const items = patternItems(pattern[1])
@@ -707,7 +888,7 @@ function scalarArrayDestruct(pattern, rhs) {
707
888
  function declareGlobal(name, user = true) {
708
889
  if (depth !== 0 || typeof name !== 'string') return name
709
890
  if (ctx.scope.globals.has(name)) err(`'${name}' conflicts with a compiler internal — choose a different name`)
710
- ctx.scope.globals.set(name, `(global $${name} (mut f64) (f64.const 0))`)
891
+ declGlobal(name, 'f64')
711
892
  if (user) ctx.scope.userGlobals.add(name)
712
893
  return name
713
894
  }
@@ -742,7 +923,7 @@ function pushPatternAssign(target, valueExpr, out, decls = null) {
742
923
  out.push(['=', target, valueExpr])
743
924
  }
744
925
 
745
- function expandDestruct(pattern, source, out, decls = null) {
926
+ function expandDestruct(pattern, source, out, decls = null, srcLen = null) {
746
927
  if (!isDestructPattern(pattern)) return
747
928
 
748
929
  if (pattern[0] === '[]') {
@@ -757,6 +938,15 @@ function expandDestruct(pattern, source, out, decls = null) {
757
938
  continue
758
939
  }
759
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
+
760
950
  pushPatternAssign(item, ['[]', source, [, j]], out, decls)
761
951
  }
762
952
  return
@@ -837,7 +1027,7 @@ function prepDecl(op, ...inits) {
837
1027
  ctx.scope.chain[i] = declName
838
1028
  }
839
1029
  if (ctx.scope.globals.has(declName)) err(`'${declName}' conflicts with a compiler internal — choose a different name`)
840
- ctx.scope.globals.set(declName, `(global $${declName} (mut f64) (f64.const 0))`)
1030
+ declGlobal(declName, 'f64')
841
1031
  ctx.scope.userGlobals.add(declName)
842
1032
  } else if (typeof declName === 'string') {
843
1033
  // Bare hoisted decl inside a function (var X jzified to `let X` at top
@@ -852,6 +1042,23 @@ function prepDecl(op, ...inits) {
852
1042
  continue
853
1043
  }
854
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
+ }
855
1062
  const staticStr = op === 'const' ? staticStringExpr(init) : null
856
1063
  const staticArr = op === 'const' ? staticStringArrayValues(init) : null
857
1064
  const normed = prep(init)
@@ -887,7 +1094,7 @@ function prepDecl(op, ...inits) {
887
1094
  const p = normed.slice(1).filter(p => Array.isArray(p) && p[0] === ':').map(p => p[1])
888
1095
  if (p.length) ctx.schema.vars.set(tmp, ctx.schema.register(p))
889
1096
  }
890
- expandDestruct(name, tmp, rest)
1097
+ expandDestruct(name, tmp, rest, null, inlineArrayLen(normed))
891
1098
  continue
892
1099
  }
893
1100
 
@@ -905,6 +1112,10 @@ function prepDecl(op, ...inits) {
905
1112
  scopes[scopes.length - 1].set(name, name)
906
1113
  }
907
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)
908
1119
  if (op === 'const') bindStaticConst(declName, staticStr, staticArr)
909
1120
  // Track const for reassignment checks — only module-scope consts (depth 0)
910
1121
  if (typeof declName === 'string' && depth === 0) {
@@ -935,20 +1146,29 @@ function prepDecl(op, ...inits) {
935
1146
  // Track object schemas (after prefix so schema is keyed to final name)
936
1147
  if (typeof declName === 'string' && Array.isArray(normed) && normed[0] === '{}' && normed.length > 1) {
937
1148
  const props = []
1149
+ const addProp = n => { if (!props.includes(n)) props.push(n) }
1150
+ let allKnown = true
938
1151
  for (const p of normed.slice(1)) {
939
- 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])
940
1157
  else if (Array.isArray(p) && p[0] === '...') {
941
- // Merge spread source schema into this object's schema
942
1158
  const srcSchema = typeof p[1] === 'string' && ctx.schema.resolve(p[1])
943
- 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
944
1161
  }
945
1162
  }
946
- 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))
947
1167
  }
948
1168
  // Module-scope variable → WASM global (mark as user-declared)
949
1169
  if (depth === 0 && typeof declName === 'string') {
950
1170
  if (ctx.scope.globals.has(declName)) err(`'${declName}' conflicts with a compiler internal — choose a different name`)
951
- ctx.scope.globals.set(declName, `(global $${declName} (mut f64) (f64.const 0))`)
1171
+ declGlobal(declName, 'f64')
952
1172
  ctx.scope.userGlobals.add(declName)
953
1173
  }
954
1174
  rest.push(['=', declName, normed])
@@ -965,7 +1185,7 @@ function prepDecl(op, ...inits) {
965
1185
  // `import.meta.resolve("spec")` → the resolved URL as a static string.
966
1186
  function foldImportMetaResolve(callee, args) {
967
1187
  if (!isImportMetaProp(callee, 'resolve')) return undefined
968
- const callArgs = flatArgs(args).filter(a => a != null)
1188
+ const callArgs = handlerArgs(args)
969
1189
  if (callArgs.length !== 1) err('`import.meta.resolve` requires one string literal argument')
970
1190
  const spec = stringValue(callArgs[0])
971
1191
  if (spec == null) err('`import.meta.resolve` supports only string literal arguments')
@@ -979,8 +1199,11 @@ function foldImportMetaResolve(callee, args) {
979
1199
  // Returns the replacement IR, or `undefined` for an ordinary call.
980
1200
  function dispatchConstructorCall(callee, args) {
981
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
982
1205
  if (callee === 'Array') {
983
- const callArgs = flatArgs(args).filter(a => a != null)
1206
+ const callArgs = handlerArgs(args)
984
1207
  if (callArgs.length === 1) return handlers['new'](['()', callee, callArgs[0]])
985
1208
  }
986
1209
  if (CTORS.includes(callee)) return handlers['new'](['()', callee, ...args])
@@ -998,7 +1221,7 @@ function foldNamespaceIntrospection(callee, args) {
998
1221
  if (!Array.isArray(callee) || callee[0] !== '.') return undefined
999
1222
  const [, obj, prop] = callee
1000
1223
  if (obj === 'Array' && prop === 'isArray') {
1001
- const cargs = flatArgs(args).filter(a => a != null)
1224
+ const cargs = handlerArgs(args)
1002
1225
  const a0 = cargs.length === 1 ? cargs[0] : null
1003
1226
  if (typeof a0 === 'string' && GLOBALS[a0] && !(scopes.length && isDeclared(a0)) && !hasFunc(a0))
1004
1227
  return [, 0]
@@ -1006,7 +1229,7 @@ function foldNamespaceIntrospection(callee, args) {
1006
1229
  if (prop === 'hasOwnProperty' && typeof obj === 'string' && !(scopes.length && isDeclared(obj))) {
1007
1230
  const mod = ctx.scope.chain[obj]
1008
1231
  if (mod && !mod.includes('.') && hasModule(mod)) {
1009
- const cargs = flatArgs(args).filter(a => a != null)
1232
+ const cargs = handlerArgs(args)
1010
1233
  const member = cargs.length === 1 ? stringValue(cargs[0]) : null
1011
1234
  // Include the module so its emit keys (the namespace's member set) are
1012
1235
  // registered; unreferenced emitters/data dead-strip in compile.
@@ -1020,6 +1243,11 @@ function foldNamespaceIntrospection(callee, args) {
1020
1243
  // way: a bare identifier through the scope chain, an `obj.prop` member call
1021
1244
  // through host imports / named-call / generic-method / namespace tables, and
1022
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
+
1023
1251
  function resolveCallee(callee, args) {
1024
1252
  if (typeof callee === 'string') {
1025
1253
  const local = scopes.length && isDeclared(callee)
@@ -1031,12 +1259,23 @@ function resolveCallee(callee, args) {
1031
1259
  if (hasModule(resolved) && !ctx.module.imports.some(i => i[3]?.[1] === `$${resolved}`)) includeModule(resolved)
1032
1260
  return callee
1033
1261
  }
1034
- 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}`))
1035
1263
  includeForCallableValue()
1036
1264
  return callee
1037
1265
  }
1038
1266
  if (Array.isArray(callee) && callee[0] === '.') {
1039
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
+ }
1040
1279
  const key = typeof obj === 'string' && typeof prop === 'string' ? `${obj}.${prop}` : null
1041
1280
  if (key && ctx.module.hostImports?.[obj]?.[prop]) {
1042
1281
  const spec = ctx.module.hostImports[obj][prop]
@@ -1071,18 +1310,13 @@ function renestSoleCommaArg(args) {
1071
1310
  }
1072
1311
 
1073
1312
  const handlers = {
1313
+ ...rejectHandlers(err),
1074
1314
  // Spread operator: [...expr] in arrays, f(...args) in calls, {...obj} in objects
1075
1315
  '...'(expr) {
1076
1316
  includeForArrayLiteral()
1077
1317
  return ['...', prep(expr)]
1078
1318
  },
1079
1319
 
1080
- // Prohibited ops — duplicated from jzify deliberately: .jz source bypasses jzify,
1081
- // so prepare is the actual defense. Messages here fire for both .js and .jz.
1082
- 'async': () => err('async/await not supported: WASM is synchronous'),
1083
- 'await': () => err('async/await not supported: WASM is synchronous'),
1084
- 'class': () => err('class not supported: use object literals'),
1085
- 'yield': () => err('generators not supported: use loops'),
1086
1320
  'debugger': () => null,
1087
1321
  // Static-key delete (.x, ["x"], [literal]) would change the fixed schema → reject.
1088
1322
  // Computed-key delete (obj[expr]) — including jessie's `delete ctx[k]` — lowers
@@ -1097,12 +1331,7 @@ const handlers = {
1097
1331
  err('delete not supported: object shape is fixed')
1098
1332
  },
1099
1333
  'in'(key, obj) { return ['in', prep(key), prep(obj)] },
1100
- 'instanceof': () => err('instanceof not supported: use typeof'),
1101
- 'with': () => err('`with` not supported: deprecated'),
1102
- ':': () => err('labeled statements not supported'),
1103
1334
  'label'(name, body) { return ['label', name, prep(body)] },
1104
- 'var': () => err('`var` not supported: use let/const'),
1105
- 'function': () => err('`function` not supported: use arrow functions'),
1106
1335
 
1107
1336
  // Destructuring assignment: [a, ...b] = expr or {x, y} = expr
1108
1337
  '='(lhs, rhs) {
@@ -1140,7 +1369,7 @@ const handlers = {
1140
1369
  // emit a dynamic property read + indirect call instead of a direct call.
1141
1370
  if (ctx.func.names.has(name)) {
1142
1371
  ctx.func.multiProp.add(`${fnBase}.${lhs[2]}`)
1143
- 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))
1144
1373
  }
1145
1374
  // Build the target `.` node directly from the resolved base — re-`prep`ing
1146
1375
  // the lhs would resolve a multiProp `fn.prop` to an rvalue (closure
@@ -1158,22 +1387,25 @@ const handlers = {
1158
1387
  recordGlobalRep(plhs, prhs)
1159
1388
  if (Array.isArray(prhs) && prhs[0] === '{}') {
1160
1389
  const props = staticObjectProps(prhs.slice(1))
1161
- if (props) ctx.schema.vars.set(plhs, ctx.schema.register(props.names))
1162
- }
1163
- }
1390
+ if (props) bindAssignSchema(plhs, ctx.schema.register(props.names))
1391
+ } else bindAssignSchema(plhs, null)
1392
+ } else bindAssignSchema(plhs, objLiteralSid(prhs))
1164
1393
  // Static string/array facts hold only while every assignment is constant.
1165
1394
  if (!assignedStaticGlobals.has(plhs) && (staticStr != null || staticArr)) bindStaticGlobal(plhs, staticStr, staticArr)
1166
1395
  else deleteStaticGlobal(plhs)
1167
1396
  assignedStaticGlobals.add(plhs)
1168
1397
  }
1169
- // Local object-literal assignment to a not-yet-shaped variable — e.g. a `var`
1170
- // that jzify hoisted into `let x; x = {…}`. Recording the schema here lets the
1171
- // binding behave like `let x = {…}`: fixed-slot field access and for-in unroll.
1172
- // First assignment fixes the shape (mirrors the global rule above).
1173
- else if (typeof plhs === 'string' && Array.isArray(prhs) && prhs[0] === '{}'
1174
- && !ctx.schema.vars.has(plhs)) {
1175
- const props = staticObjectProps(prhs.slice(1))
1176
- 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))
1177
1409
  }
1178
1410
  return ['=', plhs, prhs]
1179
1411
  },
@@ -1184,18 +1416,12 @@ const handlers = {
1184
1416
  const catchClause = clauses.find(c => Array.isArray(c) && c[0] === 'catch')
1185
1417
  const finallyClause = clauses.find(c => Array.isArray(c) && c[0] === 'finally')
1186
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).
1187
1421
  const caught = catchClause
1188
- ? (() => {
1189
- const [, errName, handler] = catchClause
1190
- return ['catch', tryBody, errName, prep(handler)]
1191
- })()
1422
+ ? ['catch', tryBody, catchClause[1], prep(catchClause[2])]
1192
1423
  : tryBody
1193
- if (finallyClause) return ['finally', caught, prep(finallyClause[1])]
1194
- if (catchClause) {
1195
- const [, errName, handler] = catchClause
1196
- return ['catch', tryBody, errName, prep(handler)]
1197
- }
1198
- return tryBody
1424
+ return finallyClause ? ['finally', caught, prep(finallyClause[1])] : caught
1199
1425
  },
1200
1426
  'throw'(expr) { return ['throw', prep(expr)] },
1201
1427
 
@@ -1231,6 +1457,24 @@ const handlers = {
1231
1457
  return handlers['from'](fromNode[1], fromNode[2])
1232
1458
  },
1233
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
+
1234
1478
  'from'(specifiers, source) {
1235
1479
  const mod = source?.[1]
1236
1480
  if (!mod || typeof mod !== 'string') return err('Invalid import source')
@@ -1289,8 +1533,8 @@ const handlers = {
1289
1533
  }
1290
1534
 
1291
1535
  // Tier 2: Source module (bundling)
1292
- if (ctx.module.importSources?.[mod]) {
1293
- const resolved = prepareModule(mod, ctx.module.importSources[mod])
1536
+ if (isBundledModule(mod)) {
1537
+ const resolved = prepareModule(mod, ctx.module.importSources?.[mod])
1294
1538
  // Default import: import name from 'mod' → bind to default export
1295
1539
  if (typeof specifiers === 'string') {
1296
1540
  const mangled = resolved.exports.get('default')
@@ -1357,16 +1601,27 @@ const handlers = {
1357
1601
 
1358
1602
  // Block-scoped control flow: push scope for bodies so inner let/const shadows correctly
1359
1603
  'if': (cond, then, els) => {
1360
- const c = prep(cond)
1604
+ const c = prep(stripBoolNot(cond))
1361
1605
  pushScope(); const t = prep(then); popScope()
1362
1606
  if (els != null) { pushScope(); const e = prep(els); popScope(); return ['if', c, t, e] }
1363
1607
  return ['if', c, t]
1364
1608
  },
1365
1609
  'while': (cond, body) => {
1366
- const c = prep(cond)
1610
+ const c = prep(stripBoolNot(cond))
1367
1611
  pushScope(); const b = prep(body); popScope()
1368
1612
  return ['while', c, b]
1369
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
+ },
1370
1625
 
1371
1626
  'export': decl => {
1372
1627
  if (Array.isArray(decl) && (decl[0] === 'let' || decl[0] === 'const'))
@@ -1378,8 +1633,8 @@ const handlers = {
1378
1633
  const mod = decl[2]?.[1]
1379
1634
  if (!mod || typeof mod !== 'string') return null
1380
1635
  // Source module re-export
1381
- if (ctx.module.importSources?.[mod]) {
1382
- const resolved = prepareModule(mod, ctx.module.importSources[mod])
1636
+ if (isBundledModule(mod)) {
1637
+ const resolved = prepareModule(mod, ctx.module.importSources?.[mod])
1383
1638
  if (decl[1] === '*') {
1384
1639
  // export * from './mod' → register all exports
1385
1640
  for (const [name, mangled] of resolved.exports) {
@@ -1432,7 +1687,7 @@ const handlers = {
1432
1687
  if (defFunc('default', prep(val))) return null
1433
1688
  }
1434
1689
  // export default expr → create global 'default'
1435
- ctx.scope.globals.set('default', `(global $default (mut f64) (f64.const 0))`)
1690
+ declGlobal('default', 'f64')
1436
1691
  ctx.scope.userGlobals.add('default')
1437
1692
  return ['=', 'default', prep(val)]
1438
1693
  }
@@ -1449,12 +1704,18 @@ const handlers = {
1449
1704
  depth++
1450
1705
  pushScope(fnScope)
1451
1706
  funcLocalNames.push(new Set(collectParamNames(raw)))
1707
+ funcValueNames.push(new Set())
1452
1708
 
1453
1709
  const nextParams = []
1454
1710
  const bodyPrefix = []
1455
1711
  for (const r of raw) {
1456
1712
  const c = classifyParam(r)
1457
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()
1458
1719
  nextParams.push(r)
1459
1720
  if (typeof c.name === 'string') fnScope.set(c.name, c.name)
1460
1721
  } else if (c.kind === 'plain') {
@@ -1490,6 +1751,7 @@ const handlers = {
1490
1751
  const result = ['=>', Array.isArray(params) && params[0] === '()' ? ['()', inner] : inner, preparedBody]
1491
1752
  popScope()
1492
1753
  funcLocalNames.pop()
1754
+ funcValueNames.pop()
1493
1755
  depth--
1494
1756
  return result
1495
1757
  },
@@ -1509,9 +1771,13 @@ const handlers = {
1509
1771
  })]
1510
1772
  },
1511
1773
 
1512
- // Optional chaining / typeof — need ptr module
1513
- '?.'(obj, prop) { return ['?.', prep(obj), prop] },
1514
- '?.[]'(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)] },
1515
1781
  '?.()'(callee, callArgs) {
1516
1782
  // Parser wraps multi-args in a comma list, like '()'. Unwrap so emit gets flat positional args.
1517
1783
  const items = callArgs == null ? []
@@ -1550,25 +1816,32 @@ const handlers = {
1550
1816
  return ['+', pa, pb]
1551
1817
  },
1552
1818
  '-'(a, b) {
1553
- 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] }
1554
1825
  return ['-', prep(a), prep(b)]
1555
1826
  },
1556
1827
 
1557
1828
  // Ternary: parser emits '?' not '?:'
1558
- '?'(cond, then, els) { return ['?:', prep(cond), prep(then), prep(els)] },
1829
+ '?'(cond, then, els) { return ['?:', prep(stripBoolNot(cond)), prep(then), prep(els)] },
1559
1830
 
1560
1831
  // ++/-- prefix vs postfix: parser sends trailing null for postfix
1561
- // Postfix i++ = (++i) - 1: increment happens, arithmetic recovers old value
1562
- // 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.
1563
1836
  '++'(a, _post) {
1564
1837
  const n = prep(a)
1565
- if (Array.isArray(n) && (n[0] === '.' || n[0] === '[]')) return ['=', n, ['+', n, [, 1]]]
1566
- 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
1567
1840
  },
1568
1841
  '--'(a, _post) {
1569
1842
  const n = prep(a)
1570
- if (Array.isArray(n) && (n[0] === '.' || n[0] === '[]')) return ['=', n, ['-', n, [, 1]]]
1571
- 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
1572
1845
  },
1573
1846
 
1574
1847
  // Regex literal: ['//','pattern','flags?'] → include regex module, pass through
@@ -1576,13 +1849,22 @@ const handlers = {
1576
1849
  return ['//', pattern, flags]
1577
1850
  },
1578
1851
 
1579
- '**'(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
+ },
1580
1862
 
1581
1863
  // Function call or grouping parens
1582
1864
  '()'(callee, ...args) {
1583
1865
  // Grouping: (expr) → ['()', expr] with no args. Call: f() → ['()', 'f', null] with null arg.
1584
1866
  if (args.length === 0) return prep(callee)
1585
- if (typeof callee === 'string' && PROHIBITED[callee]) err(PROHIBITED[callee])
1867
+ if (typeof callee === 'string' && REJECT_IDENTS[callee]) err(REJECT_IDENTS[callee])
1586
1868
 
1587
1869
  // Compile-time folds: the callee names something resolvable now. Each fold
1588
1870
  // is gated by callee shape, so at most one of the three fires.
@@ -1600,7 +1882,14 @@ const handlers = {
1600
1882
  includeForCallableValue(); break
1601
1883
  }
1602
1884
  }
1603
- 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]
1604
1893
 
1605
1894
  if (callee === 'Object.assign' && ctx.schema.register) inferAssignSchema(result)
1606
1895
 
@@ -1642,9 +1931,11 @@ const handlers = {
1642
1931
  },
1643
1932
 
1644
1933
  // Object literal - flatten comma, expand shorthand
1645
- '{}'(inner) {
1646
- // Detect block body vs object literal
1647
- 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])) {
1648
1939
  // Block body: push block scope for let/const shadowing
1649
1940
  pushScope()
1650
1941
  const result = ['{}', prep(inner)]
@@ -1653,10 +1944,16 @@ const handlers = {
1653
1944
  }
1654
1945
 
1655
1946
  includeForObjectLiteral()
1656
- if (inner == null) return ['{}']
1657
- const items = Array.isArray(inner) && inner[0] === ','
1658
- ? inner.slice(1)
1659
- : [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
1660
1957
 
1661
1958
  // Computed keys: `{[k]: v}` where `k` isn't compile-time foldable. jz's
1662
1959
  // object layout is slot-based (fixed schema at the literal site), so a
@@ -1692,6 +1989,12 @@ const handlers = {
1692
1989
  if (key == null) err('computed property name not supported for fixed-shape object: use a compile-time string/number key')
1693
1990
  return [':', key, prep(p[2])]
1694
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')
1695
1998
  return prep(p)
1696
1999
  }
1697
2000
  let prepped = items.map(prop)
@@ -1718,26 +2021,56 @@ const handlers = {
1718
2021
  // For loop
1719
2022
  'for'(head, body) {
1720
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
+ }
1721
2037
  let r
1722
2038
  if (Array.isArray(head) && head[0] === ';') {
1723
2039
  let [, init, cond, step] = head
1724
- // Hoist .length / .size / .byteLength from for-condition:
1725
- // `i < arr.length` `let __len = arr.length | 0; ... i < __len`
1726
- // The `| 0` forces i32 even for unknown-typed receivers (where __length
1727
- // returns f64). NaN→0 via i32.trunc_sat matches JS semantics: a NaN bound
1728
- // makes `i < NaN` false on both representations, so the loop is skipped
1729
- // either way. Keeping the hoisted bound i32 lets the counter `i` stay i32
1730
- // through the comparison and `i++`, eliminating the per-iteration
1731
- // f64.convert_i32_s + f64.lt + f64.add + i32.trunc_sat_f64_s sequence.
2040
+ cond = stripBoolNot(cond)
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.
1732
2051
  if (cond && Array.isArray(cond) && (cond[0] === '<' || cond[0] === '<=' || cond[0] === '>' || cond[0] === '>=')) {
1733
2052
  const lenExpr = cond[0] === '<' || cond[0] === '<=' ? cond[2] : cond[1]
1734
2053
  if (Array.isArray(lenExpr) && lenExpr[0] === '.' &&
1735
2054
  (lenExpr[2] === 'length' || lenExpr[2] === 'size' || lenExpr[2] === 'byteLength')) {
1736
- const lenVar = `${T}len${ctx.func.uniq++}`
1737
- const lenDecl = ['let', ['=', lenVar, ['|', lenExpr, [, 0]]]]
1738
- init = init ? [';', init, lenDecl] : lenDecl
1739
- if (cond[0] === '<' || cond[0] === '<=') cond = [cond[0], cond[1], lenVar]
1740
- 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
+ }
1741
2074
  }
1742
2075
  }
1743
2076
  r = ['for', init ? prep(init) : null, cond ? prep(cond) : null, step ? prep(step) : null, prep(body)]
@@ -1749,61 +2082,64 @@ const handlers = {
1749
2082
  const varName = Array.isArray(decl) && (decl[0] === 'let' || decl[0] === 'const') ? decl[1] : decl
1750
2083
  const idx = `${T}i${ctx.func.uniq++}`
1751
2084
  const lenVar = `${T}len${ctx.func.uniq++}`
1752
- const trivial = typeof src === 'string'
1753
- 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.
1754
2090
  // Wrap .length in `| 0` so the hoisted bound is i32 even for unknown
1755
2091
  // receivers (same rationale as the for-cond hoist above).
1756
2092
  const lenE = ['|', ['.', arrVar, 'length'], [, 0]]
1757
- const decls = trivial
1758
- ? ['let', ['=', idx, [, 0]], ['=', lenVar, lenE]]
1759
- : ['let', ['=', arrVar, src], ['=', idx, [, 0]], ['=', lenVar, lenE]]
2093
+ const decls = ['let', ['=', arrVar, ['()', '__iter_arr', src]], ['=', idx, [, 0]], ['=', lenVar, lenE]]
1760
2094
  const cond = ['<', idx, lenVar]
1761
2095
  const step = ['++', idx]
1762
2096
  const inner = [';', ['let', ['=', varName, ['[]', arrVar, idx]]], body]
1763
2097
  r = prep(['for', [';', decls, cond, step], inner])
1764
2098
  } else if (Array.isArray(head) && head[0] === 'in') {
1765
- // 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.
1766
2107
  const [, decl, src] = head
1767
- const varName = Array.isArray(decl) && (decl[0] === 'let' || decl[0] === 'const') ? decl[1] : decl
1768
- const srcName = typeof src === 'string' ? (ctx.scope.chain[src] || src) : null
1769
- const sid = typeof srcName === 'string' ? ctx.schema.vars.get(srcName) : null
1770
- if (sid != null) {
1771
- // Known schema compile-time unrolling with string keys
1772
- const keys = ctx.schema.list[sid]
1773
- if (!keys || !keys.length) { popScope(); return null }
1774
- includeForKnownKeyIteration()
1775
- if (!hasLoopJump(body)) {
1776
- // No break/continue flat unroll, no loop frame needed.
1777
- const stmts = []
1778
- for (let i = 0; i < keys.length; i++) {
1779
- stmts.push(i === 0
1780
- ? ['let', ['=', varName, [, keys[i]]]]
1781
- : ['=', varName, [, keys[i]]])
1782
- stmts.push(cloneNode(body))
1783
- }
1784
- r = prep([';', ...stmts])
1785
- } else {
1786
- // break/continue present an unrolled loop still needs its frames.
1787
- // Wrap each iteration in a labeled block (continue target) and the
1788
- // whole run in an outer labeled block (break target): `break` exits
1789
- // the construct, `continue` falls through to the next iteration.
1790
- const brkL = `${T}fibrk${ctx.func.uniq++}`
1791
- const decl = prep(['let', ['=', varName, [, keys[0]]]])
1792
- const parts = [decl]
1793
- for (let i = 0; i < keys.length; i++) {
1794
- const contL = `${T}ficont${ctx.func.uniq++}`
1795
- const iter = prep(i === 0
1796
- ? cloneNode(body)
1797
- : [';', ['=', varName, [, keys[i]]], cloneNode(body)])
1798
- parts.push(['label', contL, retargetLoopJumps(iter, brkL, contL)])
1799
- }
1800
- r = ['label', brkL, [';', ...parts]]
1801
- }
1802
- } else {
1803
- // Dynamic object → HASH runtime iteration
1804
- includeForRuntimeKeyIteration()
1805
- r = ['for-in', varName, prep(src), prep(body)]
1806
- }
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)
1807
2143
  } else {
1808
2144
  // Some parser/jzify shapes for `for (;;)` and `for (; cond; )` arrive
1809
2145
  // as a null or bare-condition head instead of the canonical
@@ -1818,14 +2154,22 @@ const handlers = {
1818
2154
  // Property access - resolve namespaces or object/array properties
1819
2155
  '.'(obj, prop) {
1820
2156
  prop = typeof prop === 'string' ? prop : staticPropertyKey(prop)
1821
- 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')
1822
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] }
1823
2167
  const mod = ctx.scope.chain[obj]
1824
2168
  // Only treat as module namespace if it's a known built-in module (not a mangled import name)
1825
2169
  if (typeof obj === 'string' && mod && !mod.includes('.') && hasModule(mod)) {
1826
2170
  includeModule(mod)
1827
2171
  const key = mod + '.' + prop
1828
- if (ctx.core.emit[key]?.length > 0) includeForCallableValue()
2172
+ if (emitArity(ctx.core.emit[key]) > 0) includeForCallableValue()
1829
2173
  return key
1830
2174
  }
1831
2175
  // Source module namespace: import * as X → X.prop resolved to mangled name
@@ -1841,7 +2185,22 @@ const handlers = {
1841
2185
  'new'(ctor, ...args) {
1842
2186
  let name = ctor, ctorArgs = args
1843
2187
  if (Array.isArray(ctor) && ctor[0] === '()') { name = ctor[1]; ctorArgs = ctor.slice(2) }
1844
- 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 = []
1845
2204
  // Flatten comma-grouped args: [',', a, b, c] → [a, b, c]
1846
2205
  if (ctorArgs.length === 1 && Array.isArray(ctorArgs[0]) && ctorArgs[0][0] === ',')
1847
2206
  ctorArgs = ctorArgs[0].slice(1)
@@ -2001,6 +2360,20 @@ function collectReturns(node, out) {
2001
2360
 
2002
2361
  const isLit = n => Array.isArray(n) && n[0] == null
2003
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
+
2004
2377
  /** Compile-time bundling: parse + prepare an imported module, collect exports. */
2005
2378
  function prepareModule(specifier, source) {
2006
2379
  includeModule('core')
@@ -2012,8 +2385,24 @@ function prepareModule(specifier, source) {
2012
2385
 
2013
2386
  ctx.module.moduleStack.push(specifier)
2014
2387
 
2015
- // Name mangling prefix: ./math.jz _math_jz
2016
- 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
+ }
2017
2406
 
2018
2407
  // Save caller state
2019
2408
  const savedScope = ctx.scope.chain, savedExports = ctx.func.exports
@@ -2023,8 +2412,18 @@ function prepareModule(specifier, source) {
2023
2412
  ctx.func.exports = {}
2024
2413
  ctx.module.currentPrefix = prefix
2025
2414
 
2026
- // Parse + prepare imported source (may trigger recursive imports)
2027
- 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
+ }
2028
2427
  if (ctx.transform.jzify) ast = ctx.transform.jzify(ast)
2029
2428
  const savedDepth = depth; depth = 0
2030
2429
  const moduleInit = prep(ast)
@@ -2038,9 +2437,9 @@ function prepareModule(specifier, source) {
2038
2437
  const func = ctx.func.list.find(f => f.name === localName)
2039
2438
  if (func) { renameFunc(func, mangled); func._modulePrefix = prefix }
2040
2439
  if (ctx.scope.globals.has(localName)) {
2041
- 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))
2042
2442
  ctx.scope.globals.delete(localName)
2043
- ctx.scope.globals.set(mangled, wat)
2044
2443
  if (ctx.scope.userGlobals.has(localName)) { ctx.scope.userGlobals.delete(localName); ctx.scope.userGlobals.add(mangled) }
2045
2444
  if (ctx.scope.globalTypes.has(localName)) { ctx.scope.globalTypes.set(mangled, ctx.scope.globalTypes.get(localName)); ctx.scope.globalTypes.delete(localName) }
2046
2445
  }
@@ -2089,9 +2488,8 @@ function prepareModule(specifier, source) {
2089
2488
  const func = ctx.func.list.find(f => f.name === alias)
2090
2489
  if (func) renameFunc(func, mangled)
2091
2490
  if (ctx.scope.globals.has(alias)) {
2092
- const wat = ctx.scope.globals.get(alias).replace(`$${alias}`, `$${mangled}`)
2491
+ ctx.scope.globals.set(mangled, ctx.scope.globals.get(alias))
2093
2492
  ctx.scope.globals.delete(alias)
2094
- ctx.scope.globals.set(mangled, wat)
2095
2493
  if (ctx.scope.userGlobals.has(alias)) { ctx.scope.userGlobals.delete(alias); ctx.scope.userGlobals.add(mangled) }
2096
2494
  }
2097
2495
  }
@@ -2157,15 +2555,18 @@ function prepareModule(specifier, source) {
2157
2555
  recordModuleInitFacts(moduleInit)
2158
2556
  }
2159
2557
 
2160
- // Restore caller state
2161
- ctx.scope.chain = savedScope
2162
- ctx.func.exports = savedExports
2163
- ctx.module.currentPrefix = savedModulePrefix
2164
- ctx.module.moduleStack.pop()
2165
-
2166
2558
  const result = { exports: moduleExports }
2167
2559
  ctx.module.resolvedModules.set(specifier, result)
2168
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
+ }
2169
2570
  }
2170
2571
 
2171
2572
  // =============================================================================
@@ -2233,7 +2634,7 @@ function tryFusePair(decl, forNode, seq, declIdx) {
2233
2634
  if (!hasAnyIndexedRead(forBody, NAME) && !hasAnyIndexedRead(cond, NAME)) return null
2234
2635
  // `NAME` must not be read after the for-loop in the same block.
2235
2636
  for (let k = declIdx + 2; k < seq.length; k++) {
2236
- if (refsName(seq[k], NAME)) return null
2637
+ if (refsName(seq[k], NAME, { skipArrow: false })) return null
2237
2638
  }
2238
2639
  // RECV must not be reassigned inside the for-loop (would invalidate substitution).
2239
2640
  if (assignsName(forNode, RECV) || assignsName(forNode, NAME)) return null
@@ -2275,12 +2676,6 @@ function hasAnyIndexedRead(n, NAME) {
2275
2676
  for (let i = 1; i < n.length; i++) if (hasAnyIndexedRead(n[i], NAME)) return true
2276
2677
  return false
2277
2678
  }
2278
- function refsName(n, NAME) {
2279
- if (typeof n === 'string') return n === NAME
2280
- if (!Array.isArray(n)) return false
2281
- for (let i = 1; i < n.length; i++) if (refsName(n[i], NAME)) return true
2282
- return false
2283
- }
2284
2679
  function assignsName(n, NAME) {
2285
2680
  if (!Array.isArray(n)) return false
2286
2681
  const op = n[0]