jz 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/module/core.js CHANGED
@@ -722,6 +722,32 @@ export default (ctx) => {
722
722
  if (tpEmitter && tpEmitter.length <= 1) return tpEmitter(obj)
723
723
  }
724
724
 
725
+ // valueOf/toString are ToPrimitive hooks (ES2024 7.1.1) that an own data
726
+ // property shadows. On a heap receiver carrying a dynamic-prop sidecar
727
+ // (array/typed/object), reading `obj.valueOf`/`obj.toString` must return an
728
+ // assigned override when present, else the inherited builtin. Without this,
729
+ // the arity-1 builtin emitter below (returns the receiver) masks the
730
+ // override. The method-call path in src/emit.js runs the parallel probe and
731
+ // additionally covers statically-unknown receivers (e.g. `arr[0].valueOf()`);
732
+ // a bare read of an unknown-type receiver can't yield a builtin-as-value
733
+ // here anyway, so this read path stays scoped to known sidecar types.
734
+ if ((prop === 'valueOf' || prop === 'toString') && ctx.closure.call &&
735
+ (ptVt === VAL.ARRAY || ptVt === VAL.TYPED || ptVt === VAL.OBJECT)) {
736
+ const builtin = ctx.core.emit[`.${ptVt}:${prop}`] || ctx.core.emit[`.${prop}`]
737
+ if (builtin && builtin.length <= 1) {
738
+ const o = temp('vo'), p = temp('vp')
739
+ inc('__dyn_get_expr', '__ptr_type')
740
+ return typed(['block', ['result', 'f64'],
741
+ ['local.set', `$${o}`, asF64(emit(obj))],
742
+ ['local.set', `$${p}`, ['f64.reinterpret_i64',
743
+ ['call', '$__dyn_get_expr', ['i64.reinterpret_f64', ['local.get', `$${o}`]], asI64(emit(['str', prop]))]]],
744
+ ['if', ['result', 'f64'],
745
+ ['i32.eq', ['call', '$__ptr_type', ['i64.reinterpret_f64', ['local.get', `$${p}`]]], ['i32.const', PTR.CLOSURE]],
746
+ ['then', ['local.get', `$${p}`]],
747
+ ['else', asF64(builtin(o))]]], 'f64')
748
+ }
749
+ }
750
+
725
751
  // Module-registered property emitter (.size, etc.)
726
752
  const propKey = `.${prop}`
727
753
  const propEmitter = ctx.core.emit[propKey]
package/module/math.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * @module math
13
13
  */
14
14
 
15
- import { typed, asF64, asI32, toI32, toNumF64, temp, arrayLoop, isLit, litVal } from '../src/ir.js'
15
+ import { typed, asF64, asI32, toI32, toNumF64, temp, arrayLoop, isLit, litVal, isPureIR } from '../src/ir.js'
16
16
  import { emit } from '../src/emit.js'
17
17
  import { emitter } from '../src/ctx.js'
18
18
  import { repOf } from '../src/analyze.js'
@@ -182,8 +182,56 @@ export default (ctx) => {
182
182
  ctx.core.emit['math.log10'] = emitter(['math.log10'], a => call('math.log10', a))
183
183
  ctx.core.emit['math.log1p'] = emitter(['math.log1p'], a => call('math.log1p', a))
184
184
 
185
- // Power
186
- ctx.core.emit['math.pow'] = emitter(['math.pow'], (a, b) => call('math.pow', a, b))
185
+ // Power. Constant-integer-exponent `Math.pow(x,n)` / `x ** n` (|n| ≤ POW_FOLD_MAX)
186
+ // lower to inline square-and-multiply instead of a $math.pow call. The fold is
187
+ // bit-identical to $math.pow's integer fast path: that path runs the same LSB-first
188
+ // square-and-multiply, and an f64 product's magnitude is the rounded product of the
189
+ // operand magnitudes regardless of sign — so multiplying the *signed* base reproduces
190
+ // both the exact bits and the result sign (negative iff x<0 ∧ n odd, which is exactly
191
+ // its `neg_base`). A program whose only pow use is folded then never pulls the
192
+ // math.pow/exp/log stdlib. `**`'s exponent is parsed as a bare number (incl. negatives).
193
+ const POW_FOLD_MAX = 8
194
+ const get = name => ['local.get', `$${name}`]
195
+ const constInt = b => {
196
+ const v = typeof b === 'number' ? b
197
+ : (Array.isArray(b) && b.length === 2 && b[0] == null && typeof b[1] === 'number') ? b[1]
198
+ : null
199
+ return v != null && Number.isInteger(v) ? v : null
200
+ }
201
+ const foldPow = (a, n) => {
202
+ const baseIR = toNumF64(a, emit(a))
203
+ // pow(x,0) === 1 for every x (NaN/±0/±Inf included). Keep the base's side
204
+ // effects (a call, a throwing valueOf), discard its value, yield 1.
205
+ if (n === 0) return isPureIR(baseIR)
206
+ ? typed(['f64.const', 1], 'f64')
207
+ : typed(['block', ['result', 'f64'], ['drop', baseIR], ['f64.const', 1]], 'f64')
208
+ const b = temp('pw')
209
+ const stmts = [['local.set', `$${b}`, baseIR]]
210
+ // square-and-multiply, LSB-first — mirrors $math.pow's loop association exactly,
211
+ // so the rounding tree (and thus the last bit) matches.
212
+ let sq = b, res = null, minted = false
213
+ for (let m = Math.abs(n); m > 0; m >>= 1) {
214
+ if (m & 1) {
215
+ if (res === null) res = sq // lowest set bit: result := this square (skip ×1)
216
+ else { const r = temp('pw'); stmts.push(['local.set', `$${r}`, ['f64.mul', get(res), get(sq)]]); res = r; minted = true }
217
+ }
218
+ if (m >> 1) { const s = temp('pw'); stmts.push(['local.set', `$${s}`, ['f64.mul', get(sq), get(sq)]]); sq = s; minted = true }
219
+ }
220
+ let result = get(res)
221
+ if (n < 0) { result = ['f64.div', ['f64.const', 1], result]; minted = true } // y<0 → reciprocal, as $math.pow does
222
+ // A NaN minted by f64.mul/div has a platform-nondeterministic sign; jz's value
223
+ // model requires the one canonical number-NaN, so `canon` folds it back. Skip when
224
+ // the base provably can't be NaN (same test min/max uses) or when no op was minted
225
+ // (|n|=1 hands the base straight through, already canonical).
226
+ const inner = typed(['block', ['result', 'f64'], ...stmts, result], 'f64')
227
+ return (minted && !neverNaN(a, baseIR)) ? canon(inner) : inner
228
+ }
229
+ const powCall = emitter(['math.pow'], (a, b) => call('math.pow', a, b))
230
+ ctx.core.emit['math.pow'] = (a, b) => {
231
+ const n = constInt(b)
232
+ return n !== null && Math.abs(n) <= POW_FOLD_MAX ? foldPow(a, n) : powCall(a, b)
233
+ }
234
+ ctx.core.emit['math.pow'].deps = powCall.deps // metadata parity (tabulation/analysis)
187
235
  ctx.core.emit['**'] = ctx.core.emit['math.pow']
188
236
  ctx.core.emit['math.cbrt'] = emitter(['math.cbrt'], a => call('math.cbrt', a))
189
237
  ctx.core.emit['math.hypot'] = emitter(['math.hypot'], (a, b, ...rest) => {
package/module/string.js CHANGED
@@ -1628,6 +1628,11 @@ export default (ctx) => {
1628
1628
 
1629
1629
  ctx.core.emit['.encode'] = (obj, str) => {
1630
1630
  inc('__str_encode')
1631
+ // .encode() yields a runtime PTR.TYPED/u8 array (see __mkptr above). Downstream
1632
+ // indexing/spread dispatch through __typed_idx, whose element-unaware fallback
1633
+ // (f64.load, stride 8) is only valid when no typed array can flow in. Enabling
1634
+ // the feature pulls the element-aware variant — same invariant `.length` follows.
1635
+ ctx.features.typedarray = true
1631
1636
  return typed(['call', '$__str_encode', asI64(emit(str))], 'f64')
1632
1637
  }
1633
1638
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jz",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Functional JS subset compiling to WASM",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/src/emit.js CHANGED
@@ -789,11 +789,16 @@ function emitSpreadCopy(dest, posLocal, srcLocal, srcLenLocal, staticVT) {
789
789
  ['then', (inc('__str_idx'), ['call', '$__str_idx', srcI64(), ['local.get', `$${sidx}`]])],
790
790
  ['else', (inc('__typed_idx'), ['call', '$__typed_idx', srcI64(), ['local.get', `$${sidx}`]])]]
791
791
  : (inc('__typed_idx'), ['call', '$__typed_idx', srcI64(), ['local.get', `$${sidx}`]])
792
- return ['block', `$break${loopId}`, ['loop', `$loop${loopId}`,
793
- ['br_if', `$break${loopId}`, ['i32.ge_s', ['local.get', `$${sidx}`], ['local.get', `$${srcLenLocal}`]]],
794
- ['f64.store', destAddr(['i32.add', ['local.get', `$${posLocal}`], ['local.get', `$${sidx}`]]), elem],
795
- ['local.set', `$${sidx}`, ['i32.add', ['local.get', `$${sidx}`], ['i32.const', 1]]],
796
- ['br', `$loop${loopId}`]]]
792
+ // Reset the counter on each entry — WASM zeroes locals once at function
793
+ // entry, but this loop re-executes when the spread sits inside a JS loop;
794
+ // a stale `sidx` (= prior srcLen) would skip the copy entirely.
795
+ return ['block', `$break${loopId}`,
796
+ ['local.set', `$${sidx}`, ['i32.const', 0]],
797
+ ['loop', `$loop${loopId}`,
798
+ ['br_if', `$break${loopId}`, ['i32.ge_s', ['local.get', `$${sidx}`], ['local.get', `$${srcLenLocal}`]]],
799
+ ['f64.store', destAddr(['i32.add', ['local.get', `$${posLocal}`], ['local.get', `$${sidx}`]]), elem],
800
+ ['local.set', `$${sidx}`, ['i32.add', ['local.get', `$${sidx}`], ['i32.const', 1]]],
801
+ ['br', `$loop${loopId}`]]]
797
802
  }
798
803
  const advance = ['local.set', `$${posLocal}`,
799
804
  ['i32.add', ['local.get', `$${posLocal}`], ['local.get', `$${srcLenLocal}`]]]
@@ -2067,7 +2072,16 @@ export const emitter = {
2067
2072
  // i32 / lit values are already numeric — the toNumF64 wrap is skipped to keep
2068
2073
  // the numeric fast path at one wasm instruction. Non-numeric (NaN-boxed string,
2069
2074
  // unknown type) routes through __to_num so "2026" | 0 === 2026.
2070
- '~': a => { const v = emit(a); return isLit(v) ? emitNum(~litVal(v)) : typed(['i32.xor', toI32(isI32Num(v) ? v : toNumF64(a, v)), typed(['i32.const', -1], 'i32')], 'i32') },
2075
+ // `~~x` is the idiomatic int32 truncation: the two xor-with-(-1) cancel, leaving
2076
+ // a single toI32 (whose NaN/Infinity guard runs once, unchanged). Fold it here so
2077
+ // DSP/bytebeat `~~` doesn't emit a dead double-xor watr won't remove.
2078
+ '~': a => {
2079
+ if (Array.isArray(a) && a[0] === '~') {
2080
+ const inner = a[1], iv = emit(inner)
2081
+ return isLit(iv) ? emitNum(~~litVal(iv)) : typed(toI32(isI32Num(iv) ? iv : toNumF64(inner, iv)), 'i32')
2082
+ }
2083
+ const v = emit(a); return isLit(v) ? emitNum(~litVal(v)) : typed(['i32.xor', toI32(isI32Num(v) ? v : toNumF64(a, v)), typed(['i32.const', -1], 'i32')], 'i32')
2084
+ },
2071
2085
  ...Object.fromEntries([
2072
2086
  ['&', 'and'], ['|', 'or'], ['^', 'xor'], ['<<', 'shl'], ['>>', 'shr_s'],
2073
2087
  ].map(([op, fn]) => [op, (a, b) => {
@@ -2571,6 +2585,35 @@ export const emitter = {
2571
2585
  }
2572
2586
  }
2573
2587
 
2588
+ // valueOf/toString are ToPrimitive hooks (ES2024 7.1.1) that an own data
2589
+ // property shadows. An assigned `obj.valueOf`/`obj.toString` must win over
2590
+ // the builtin emitter for any receiver that can carry a dynamic-prop
2591
+ // sidecar — a sidecar-bearing static type (array/typed/object) OR a
2592
+ // statically-unknown receiver (e.g. an array-element read `arr[0]`, whose
2593
+ // type is only known at runtime). Probe the sidecar and call it when it
2594
+ // holds a closure, else fall back to the builtin (generic when untyped:
2595
+ // `.valueOf` returns the receiver, `.toString` runs type-aware __to_str).
2596
+ // Parallels the member-READ check in module/core.js emitPropAccess (which
2597
+ // stays scoped to known sidecar types). (watr's `str()` attaches
2598
+ // `bytes.valueOf = () => s`, recovered via `.valueOf()`.)
2599
+ if ((method === 'valueOf' || method === 'toString') && ctx.closure.call
2600
+ && !parsed.hasSpread && parsed.normal.length === 0
2601
+ && (vt === VAL.ARRAY || vt === VAL.TYPED || vt === VAL.OBJECT || !vt)) {
2602
+ const builtin = (vt && ctx.core.emit[`.${vt}:${method}`]) || ctx.core.emit[`.${method}`]
2603
+ if (builtin) {
2604
+ const objTmp = temp('vobj'), propTmp = temp('vprop')
2605
+ inc('__dyn_get_expr', '__ptr_type')
2606
+ return typed(['block', ['result', 'f64'],
2607
+ ['local.set', `$${objTmp}`, asF64(emit(obj))],
2608
+ ['local.set', `$${propTmp}`, ['f64.reinterpret_i64',
2609
+ ['call', '$__dyn_get_expr', ['i64.reinterpret_f64', ['local.get', `$${objTmp}`]], asI64(emit(['str', method]))]]],
2610
+ ['if', ['result', 'f64'],
2611
+ ['i32.eq', ['call', '$__ptr_type', ['i64.reinterpret_f64', ['local.get', `$${propTmp}`]]], ['i32.const', PTR.CLOSURE]],
2612
+ ['then', ctx.closure.call(typed(['local.get', `$${propTmp}`], 'f64'), [])],
2613
+ ['else', asF64(callMethod(objTmp, builtin))]]], 'f64')
2614
+ }
2615
+ }
2616
+
2574
2617
  // Known type → static dispatch
2575
2618
  if (vt && ctx.core.emit[`.${vt}:${method}`]) {
2576
2619
  return callMethod(obj, ctx.core.emit[`.${vt}:${method}`])
package/src/jzify.js CHANGED
@@ -368,6 +368,18 @@ function functionBodyBlock(body) {
368
368
  /** Prototype identity check: X.prototype.Y */
369
369
  const isProto = n => Array.isArray(n) && n[0] === '.' && Array.isArray(n[1]) && n[1][0] === '.' && n[1][2] === 'prototype'
370
370
 
371
+ /** `obj.M <eq> Ctor.prototype.M` is not a real reference comparison — jz has no
372
+ * prototype objects — it asks whether `obj` overrides `M`, which is exactly
373
+ * `obj.hasOwnProperty('M')`. Returns that call node when the shape matches
374
+ * (a member access `obj.M` against a same-named prototype method), else null. */
375
+ const methodOverrideHasOwn = (a, b) => {
376
+ const proto = isProto(a) ? a : isProto(b) ? b : null
377
+ if (!proto) return null
378
+ const other = proto === a ? b : a
379
+ if (!Array.isArray(other) || other[0] !== '.' || other[2] !== proto[2]) return null
380
+ return ['()', ['.', transform(other[1]), 'hasOwnProperty'], [null, proto[2]]]
381
+ }
382
+
371
383
  const TYPED_ARRAYS = new Set(['Float64Array','Float32Array','Int32Array','Uint32Array',
372
384
  'Int16Array','Uint16Array','Int8Array','Uint8Array',
373
385
  'ArrayBuffer','BigInt64Array','BigUint64Array','DataView'])
@@ -906,18 +918,8 @@ const handlers = {
906
918
 
907
919
  'switch'(disc, ...cases) {
908
920
  const clean = cases.map(c => {
909
- if (c[0] === 'case' && Array.isArray(c[2]) && c[2][0] === ';') {
910
- const body = c[2].slice(1).filter(s => typeof s !== 'number')
911
- const stripped = stripTerminalSwitchBreak(body.length === 1 ? body[0] : [';', ...body])
912
- return ['case', c[1], stripped]
913
- }
914
- if (c[0] === 'default' && Array.isArray(c[1]) && c[1][0] === ';') {
915
- const body = c[1].slice(1).filter(s => s != null && typeof s !== 'number')
916
- const stripped = stripTerminalSwitchBreak(body.length === 1 ? body[0] : [';', ...body])
917
- return ['default', stripped]
918
- }
919
- if (c[0] === 'case') return ['case', c[1], stripTerminalSwitchBreak(c[2])]
920
- if (c[0] === 'default') return ['default', stripTerminalSwitchBreak(c[1])]
921
+ if (c[0] === 'case') return ['case', c[1], normalizeCaseBody(c[2])]
922
+ if (c[0] === 'default') return ['default', normalizeCaseBody(c[1])]
921
923
  return c
922
924
  })
923
925
  return transformSwitch(disc, clean)
@@ -925,12 +927,14 @@ const handlers = {
925
927
 
926
928
  // Equality keeps the JS loose/strict distinction (jz core now lowers both):
927
929
  // `==`/`!=` stay loose, `===`/`!==` stay strict. A comparison against a prototype
928
- // object (e.g. `x.constructor === Object`) folds to a boolean — jz has no
929
- // prototype objects, so identity against one is decided statically.
930
- '=='(a, b) { return isProto(a) || isProto(b) ? 1 : ['==', transform(a), transform(b)] },
931
- '!='(a, b) { return isProto(a) || isProto(b) ? 0 : ['!=', transform(a), transform(b)] },
932
- '==='(a, b) { if (isProto(a) || isProto(b)) return 1 },
933
- '!=='(a, b) { if (isProto(a) || isProto(b)) return 0 },
930
+ // object folds to a boolean — jz has no prototype objects, so identity against one
931
+ // is decided statically. The one exception is `obj.M <eq> Ctor.prototype.M`: that
932
+ // probes whether `obj` overrides the builtin `M`, so it lowers to a runtime
933
+ // `obj.hasOwnProperty('M')` (equal not overridden, unequal overridden).
934
+ '=='(a, b) { const own = methodOverrideHasOwn(a, b); if (own) return ['!', own]; return isProto(a) || isProto(b) ? 1 : ['==', transform(a), transform(b)] },
935
+ '!='(a, b) { const own = methodOverrideHasOwn(a, b); if (own) return own; return isProto(a) || isProto(b) ? 0 : ['!=', transform(a), transform(b)] },
936
+ '==='(a, b) { const own = methodOverrideHasOwn(a, b); if (own) return ['!', own]; if (isProto(a) || isProto(b)) return 1 },
937
+ '!=='(a, b) { const own = methodOverrideHasOwn(a, b); if (own) return own; if (isProto(a) || isProto(b)) return 0 },
934
938
 
935
939
  // new → call (keep TypedArrays)
936
940
  'new'(ctor, ...cargs) {
@@ -1474,18 +1478,12 @@ function cloneAst(node) {
1474
1478
  return node.map(cloneAst)
1475
1479
  }
1476
1480
 
1477
- function stripTerminalSwitchBreak(body) {
1478
- if (!Array.isArray(body)) return body
1479
- if (body[0] === 'break') return null
1480
- if (body[0] === '{}') {
1481
- const inner = stripTerminalSwitchBreak(body[1])
1482
- if (inner == null) return ['{}', [';']]
1483
- return ['{}', Array.isArray(inner) && inner[0] === ';' ? inner : [';', inner]]
1484
- }
1485
- if (body[0] !== ';') return body
1486
-
1487
- const stmts = body.slice(1)
1488
- if (Array.isArray(stmts.at(-1)) && stmts.at(-1)[0] === 'break') stmts.pop()
1481
+ /** Flatten a switch clause body to a single node, dropping ASI position markers.
1482
+ * Unlike a plain statement list this keeps any `break` intact — transformSwitch
1483
+ * needs the breaks to gate fall-through; it rewrites them to a sticky flag. */
1484
+ function normalizeCaseBody(body) {
1485
+ if (!Array.isArray(body) || body[0] !== ';') return body
1486
+ const stmts = body.slice(1).filter(s => s != null && typeof s !== 'number')
1489
1487
  return stmts.length === 0 ? null : stmts.length === 1 ? stmts[0] : [';', ...stmts]
1490
1488
  }
1491
1489
 
@@ -1523,31 +1521,60 @@ function rewriteSwitchBreaks(node, flag) {
1523
1521
  return node.map((part, i) => i === 0 ? part : rewriteSwitchBreaks(part, flag))
1524
1522
  }
1525
1523
 
1526
- /** Transform switch statement to if/else chain. */
1524
+ /** Transform a switch into structured control flow with faithful fall-through.
1525
+ *
1526
+ * A pure if/else-if chain (the former lowering) can't express fall-through,
1527
+ * stacked labels, or a `default` clause that isn't last \u2014 it ran only the first
1528
+ * matching body. The correct model is two-phase, evaluated once with no goto:
1529
+ *
1530
+ * 1. ENTRY \u2014 compare the discriminant against each `case` label in source
1531
+ * order; the first `===` match fixes the entry index. No case matches \u2192
1532
+ * entry = the `default` clause's source index (or past-end if none).
1533
+ * 2. RUN \u2014 walk clauses in source order; `entry <= i` runs clause i, so every
1534
+ * clause from the entry onward executes (fall-through). A `break` flips the
1535
+ * sticky `brk` flag (via rewriteSwitchBreaks) and gates the rest.
1536
+ *
1537
+ * The discriminant is bound to a temp only when re-reading it isn't free/safe; a
1538
+ * bare identifier is compared directly \u2014 a synthetic temp would shed its STRING
1539
+ * val-type and mis-fold string `case`s to `false` under strict-=== folding. */
1527
1540
  let swIdx = 0
1528
1541
  function transformSwitch(discriminant, cases) {
1529
1542
  const disc = transform(discriminant)
1530
- const tmp = `\uE000sw${swIdx++}`
1543
+ const simple = typeof disc === 'string' || (Array.isArray(disc) && disc[0] == null)
1544
+ const tmp = simple ? disc : `\uE000sw${swIdx++}`
1545
+ const start = `\uE000swst${swIdx++}`
1531
1546
  const needsBreakFlag = cases.some(c => hasOwnSwitchBreak(c[0] === 'case' ? c[2] : c[1]))
1532
1547
  const brk = needsBreakFlag ? `\uE000swbrk${swIdx++}` : null
1533
1548
 
1534
- // Collect case/default
1535
- const stmts = [['let', ['=', tmp, disc]]]
1536
- if (brk) stmts.push(['let', ['=', brk, [null, false]]])
1537
- let chain = null
1549
+ const n = cases.length
1550
+ let defaultIdx = -1
1551
+ const bodies = cases.map((c, i) => {
1552
+ if (c[0] === 'default') { defaultIdx = i; return transform(c[1]) }
1553
+ return transform(c[2])
1554
+ })
1538
1555
 
1539
- for (let i = cases.length - 1; i >= 0; i--) {
1540
- const c = cases[i]
1541
- if (c[0] === 'default') {
1542
- const body = transform(c[1])
1543
- chain = brk ? rewriteSwitchBreaks(body, brk) : body
1544
- } else if (c[0] === 'case') {
1545
- const cond = ['===', tmp, transform(c[1])]
1546
- const body = transform(c[2])
1547
- const lowered = brk ? rewriteSwitchBreaks(body, brk) : body
1548
- chain = chain != null ? ['if', cond, lowered, chain] : ['if', cond, lowered]
1549
- }
1556
+ const stmts = []
1557
+ if (!simple) stmts.push(['let', ['=', tmp, disc]])
1558
+
1559
+ // Phase 1 \u2014 entry index. Init to default's position (or n = "no clause runs"),
1560
+ // then let the first matching label override it via an if/else-if chain.
1561
+ stmts.push(['let', ['=', start, [null, defaultIdx >= 0 ? defaultIdx : n]]])
1562
+ let chain = null
1563
+ for (let i = n - 1; i >= 0; i--) {
1564
+ if (cases[i][0] !== 'case') continue
1565
+ const hit = ['=', start, [null, i]]
1566
+ const cond = ['===', tmp, transform(cases[i][1])]
1567
+ chain = chain != null ? ['if', cond, hit, chain] : ['if', cond, hit]
1550
1568
  }
1551
1569
  if (chain) stmts.push(chain)
1570
+ if (brk) stmts.push(['let', ['=', brk, [null, false]]])
1571
+
1572
+ // Phase 2 \u2014 run clauses from the entry index, falling through until a break.
1573
+ for (let i = 0; i < n; i++) {
1574
+ if (bodies[i] == null) continue
1575
+ const body = brk ? rewriteSwitchBreaks(bodies[i], brk) : bodies[i]
1576
+ const reached = ['<=', start, [null, i]]
1577
+ stmts.push(['if', brk ? ['&&', ['!', brk], reached] : reached, body])
1578
+ }
1552
1579
  return [';', ...stmts]
1553
1580
  }
package/src/prepare.js CHANGED
@@ -91,6 +91,13 @@ const addHostImport = (mod, name, alias, spec) => {
91
91
 
92
92
  const isImportMeta = node => Array.isArray(node) && node[0] === '.' && node[1] === 'import' && node[2] === 'meta'
93
93
  const isImportMetaProp = (node, prop) => Array.isArray(node) && node[0] === '.' && isImportMeta(node[1]) && node[2] === prop
94
+ // In a pure boolean position (consumer reads only truthiness) `!!e` is exactly `e`.
95
+ // Drop redundant double-negation; recurse so `!!!!e → e`. NOT valid for `&&`/`||`
96
+ // operands — those are value-preserving (`!!a && b` returns `false`, not `a`).
97
+ const stripBoolNot = c => {
98
+ while (Array.isArray(c) && c[0] === '!' && Array.isArray(c[1]) && c[1][0] === '!') c = c[1][1]
99
+ return c
100
+ }
94
101
  const stringValue = node => Array.isArray(node) && node[0] == null && typeof node[1] === 'string' ? node[1] : null
95
102
  const flatArgs = args => args.length === 1 && Array.isArray(args[0]) && args[0][0] === ',' ? args[0].slice(1) : args
96
103
  const MUTATING_ARRAY_METHODS = new Set(['copyWithin', 'fill', 'pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift'])
@@ -1357,13 +1364,13 @@ const handlers = {
1357
1364
 
1358
1365
  // Block-scoped control flow: push scope for bodies so inner let/const shadows correctly
1359
1366
  'if': (cond, then, els) => {
1360
- const c = prep(cond)
1367
+ const c = prep(stripBoolNot(cond))
1361
1368
  pushScope(); const t = prep(then); popScope()
1362
1369
  if (els != null) { pushScope(); const e = prep(els); popScope(); return ['if', c, t, e] }
1363
1370
  return ['if', c, t]
1364
1371
  },
1365
1372
  'while': (cond, body) => {
1366
- const c = prep(cond)
1373
+ const c = prep(stripBoolNot(cond))
1367
1374
  pushScope(); const b = prep(body); popScope()
1368
1375
  return ['while', c, b]
1369
1376
  },
@@ -1555,7 +1562,7 @@ const handlers = {
1555
1562
  },
1556
1563
 
1557
1564
  // Ternary: parser emits '?' not '?:'
1558
- '?'(cond, then, els) { return ['?:', prep(cond), prep(then), prep(els)] },
1565
+ '?'(cond, then, els) { return ['?:', prep(stripBoolNot(cond)), prep(then), prep(els)] },
1559
1566
 
1560
1567
  // ++/-- prefix vs postfix: parser sends trailing null for postfix
1561
1568
  // Postfix i++ = (++i) - 1: increment happens, arithmetic recovers old value
@@ -1721,6 +1728,7 @@ const handlers = {
1721
1728
  let r
1722
1729
  if (Array.isArray(head) && head[0] === ';') {
1723
1730
  let [, init, cond, step] = head
1731
+ cond = stripBoolNot(cond)
1724
1732
  // Hoist .length / .size / .byteLength from for-condition:
1725
1733
  // `i < arr.length` → `let __len = arr.length | 0; ... i < __len`
1726
1734
  // The `| 0` forces i32 even for unknown-typed receivers (where __length