jz 0.8.1 → 0.9.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 (72) hide show
  1. package/README.md +56 -9
  2. package/bench/README.md +121 -50
  3. package/bench/bench.svg +27 -27
  4. package/cli.js +3 -1
  5. package/dist/interop.js +1 -1
  6. package/dist/jz.js +7541 -6271
  7. package/index.js +165 -74
  8. package/interop.js +189 -17
  9. package/jzify/arguments.js +32 -1
  10. package/jzify/async.js +409 -0
  11. package/jzify/classes.js +136 -18
  12. package/jzify/generators.js +639 -0
  13. package/jzify/hoist-vars.js +73 -1
  14. package/jzify/index.js +123 -4
  15. package/jzify/names.js +1 -0
  16. package/jzify/transform.js +239 -1
  17. package/layout.js +48 -3
  18. package/module/array.js +299 -44
  19. package/module/atomics.js +144 -0
  20. package/module/collection.js +1429 -155
  21. package/module/core.js +431 -40
  22. package/module/date.js +142 -120
  23. package/module/fs.js +144 -0
  24. package/module/function.js +6 -3
  25. package/module/index.js +4 -1
  26. package/module/json.js +270 -49
  27. package/module/math.js +892 -32
  28. package/module/number.js +532 -163
  29. package/module/object.js +353 -95
  30. package/module/regex.js +157 -7
  31. package/module/schema.js +100 -2
  32. package/module/string.js +301 -84
  33. package/module/typedarray.js +264 -72
  34. package/module/web.js +36 -0
  35. package/package.json +5 -5
  36. package/src/abi/string.js +71 -5
  37. package/src/ast.js +11 -5
  38. package/src/autoload.js +28 -1
  39. package/src/bridge.js +7 -2
  40. package/src/compile/analyze-scans.js +22 -7
  41. package/src/compile/analyze.js +136 -30
  42. package/src/compile/dyn-closure-tables.js +277 -0
  43. package/src/compile/emit-assign.js +281 -15
  44. package/src/compile/emit.js +979 -54
  45. package/src/compile/index.js +271 -29
  46. package/src/compile/infer.js +34 -3
  47. package/src/compile/inplace-store.js +329 -0
  48. package/src/compile/narrow.js +543 -15
  49. package/src/compile/peel-stencil.js +5 -0
  50. package/src/compile/plan/index.js +45 -3
  51. package/src/compile/plan/inline.js +8 -1
  52. package/src/compile/plan/literals.js +39 -0
  53. package/src/compile/plan/scope.js +430 -23
  54. package/src/compile/program-facts.js +732 -38
  55. package/src/ctx.js +64 -9
  56. package/src/helper-counters.js +7 -1
  57. package/src/ir.js +113 -5
  58. package/src/kind-traits.js +66 -3
  59. package/src/kind.js +84 -12
  60. package/src/op-policy.js +7 -4
  61. package/src/optimize/index.js +1060 -750
  62. package/src/optimize/recurse.js +2 -2
  63. package/src/optimize/vectorize.js +972 -67
  64. package/src/prepare/index.js +792 -63
  65. package/src/prepare/math-kernel.js +331 -0
  66. package/src/prepare/pre-eval.js +714 -0
  67. package/src/snapshot.js +194 -0
  68. package/src/type.js +1170 -56
  69. package/src/wat/assemble.js +403 -65
  70. package/src/wat/codegen.js +74 -14
  71. package/transform.js +113 -4
  72. package/wasi.js +3 -0
package/module/string.js CHANGED
@@ -3,8 +3,17 @@
3
3
  *
4
4
  * Type=4 (STRING) covers both encodings; aux bit LAYOUT.SSO_BIT discriminates:
5
5
  * bit clear: heap-allocated, length in header [-4:len], offset → bytes.
6
- * bit set: inline ≤4 ASCII chars packed in offset (no memory),
7
- * length in aux low bits (0..4).
6
+ * bit set: inline ≤6 ASCII chars packed in the payload (no memory),
7
+ * length at payload bits 42-44.
8
+ *
9
+ * INVARIANT (load-bearing): every producer normalizes a ≤6-byte all-ASCII result
10
+ * to SSO — literals, slices, concat, append, case, pad, repeat, number/JSON/URI
11
+ * formatting, byte decode, host marshaling (interop.js). Consequence: two strings
12
+ * where exactly one is SSO can never be content-equal, so `__str_eq` decides any
13
+ * mixed compare by bits alone and `x === 'shortLit'` lowers to a bare i64.eq
14
+ * (emit.js emitLooseEq). Breaking the invariant in ONE producer silently breaks
15
+ * string equality — new string constructors MUST route short ASCII results
16
+ * through SSO (see __sso_norm / the concat short-pack).
8
17
  *
9
18
  * Methods use type-qualified keys (.string:slice) for array-colliding names,
10
19
  * generic keys (.toUpperCase) for non-colliding ones.
@@ -17,10 +26,13 @@ import { emit, bool, method, deps, wat, bind } from '../src/bridge.js'
17
26
  import { valTypeOf } from '../src/kind.js'
18
27
  import { VAL } from '../src/reps.js'
19
28
  import { ctx, inc, PTR, LAYOUT, err, declGlobal } from '../src/ctx.js'
20
- import { ssoBitI64Hex, sliceBitI64Hex, ptrNanHex, STR_INTERN_BIT } from '../layout.js'
29
+ import { ssoBitI64Hex, sliceBitI64Hex, hcacheBitI64Hex, ptrNanHex, STR_INTERN_BIT, STR_HCACHE_BIT } from '../layout.js'
21
30
 
22
31
  const SSO_BIT_I64 = ssoBitI64Hex()
23
32
  const SLICE_BIT_I64 = sliceBitI64Hex()
33
+ const HCACHE_BIT_I64 = hcacheBitI64Hex()
34
+ // (SSO_BIT | SLICE_BIT) << AUX_SHIFT as i64 hex — BigInt-free (self-host runs this)
35
+ const SSO_SLICE_I64 = '0x' + (LAYOUT.SSO_BIT | LAYOUT.SLICE_BIT).toString(16) + '00000000'
24
36
 
25
37
  // === SSO codec — single source of truth for the inline-string bit layout ===
26
38
  // ASCII chars packed at 7 bits each into the NaN-box payload: char i at payload bit
@@ -28,13 +40,16 @@ const SLICE_BIT_I64 = sliceBitI64Hex()
28
40
  // (SLICE_BIT at 45 stays 0). 6 chars fit (6*7=42, + 3-bit len). ASCII-only — a byte
29
41
  // ≥0x80 falls back to a heap string. The 7-bit-uniform layout makes equal short strings
30
42
  // content-bit-equal (so `op === 'tag'` is a bare i64.eq) and never touches memory.
31
- const MAX_SSO = 6
43
+ export const MAX_SSO = 6
32
44
  const SSO_LEN_SHIFT = 10 // length occupies aux bits 10-12 (= payload bits 42-44)
45
+ const SSO_CHAR_MASK = '0x3ffffffffff' // payload bits 0-41: the 6 × 7-bit char lanes
33
46
  // JS: ASCII string → { aux, offset }, or null when ineligible (too long / non-ASCII).
34
47
  // BigInt-free (i32 ops only) so the self-hosted compiler — which runs this to encode
35
48
  // every string literal — stays on jz's numeric core. offset = payload bits 0-31, aux =
36
49
  // bits 32-46; char i at bit i*7 (chars 0-3 fit the offset, char 4 straddles, char 5 → aux).
37
- const ssoEncode = (str) => {
50
+ // Exported: collection.js's strHashLiteral reuses this to build the exact lo/hi pair
51
+ // __str_hash's SSO branch mixes — single source of truth for the packing (see its use).
52
+ export const ssoEncode = (str) => {
38
53
  if (str.length > MAX_SSO || !/^[\x00-\x7f]*$/.test(str)) return null
39
54
  let offset = 0, auxChars = 0
40
55
  for (let i = 0; i < str.length; i++) {
@@ -165,11 +180,13 @@ export default (ctx) => {
165
180
  __str_trim: ['__str_slice', '__str_byteLen', '__char_at'],
166
181
  __str_trimStart: ['__str_slice', '__str_byteLen', '__char_at'],
167
182
  __str_trimEnd: ['__str_slice', '__str_byteLen', '__char_at'],
168
- __str_repeat: ['__str_byteLen', '__str_copy', '__alloc'],
183
+ __str_repeat: ['__str_byteLen', '__str_copy', '__alloc', '__sso_norm'],
169
184
  __str_replace: ['__str_indexof', '__str_slice', '__str_concat'],
170
185
  __str_replaceall: ['__str_indexof', '__str_slice', '__str_concat'],
171
186
  __str_split: ['__str_slice', '__str_byteLen', '__char_at', '__alloc'],
172
187
  __str_idx: ['__char1byte'],
188
+ __sso_norm: [],
189
+ __bytes_decode: ['__ptr_offset', '__len', '__alloc', '__mkptr', '__sso_norm'],
173
190
  __str_eq: ['__str_eq_cold'],
174
191
  __str_eq_cold: ['__char_at', '__str_byteLen'],
175
192
  __str_cmp: ['__char_at', '__str_byteLen'],
@@ -179,8 +196,8 @@ export default (ctx) => {
179
196
  __str_pad: ['__str_byteLen', '__str_copy', '__alloc'],
180
197
  __str_join: ['__str_concat', '__to_str', '__str_byteLen', '__len', '__ptr_offset', '__mkptr'], // FN template: __mkptr body-called, must be manual (self-host auto-scan diverges)
181
198
  __str_encode: ['__str_byteLen', '__str_copy'],
182
- __encodeURIComponent: ['__to_str', '__str_byteLen', '__char_at', '__alloc', '__mkptr'],
183
- __decodeURIComponent: ['__to_str', '__str_byteLen', '__char_at', '__alloc', '__mkptr', '__uri_hex'],
199
+ __encodeURIComponent: ['__to_str', '__str_byteLen', '__char_at', '__alloc', '__mkptr', '__sso_norm'],
200
+ __decodeURIComponent: ['__to_str', '__str_byteLen', '__char_at', '__alloc', '__mkptr', '__uri_hex', '__sso_norm'],
184
201
  __uri_hex: [],
185
202
  __to_str: ['__ftoa', '__static_str', '__str_join', '__mkptr'],
186
203
  __str_byteLen: ['__ptr_type', '__ptr_aux', '__str_len'],
@@ -200,13 +217,17 @@ export default (ctx) => {
200
217
  // so a heap-free program stays heap-free — no allocator, no memory, no exports.
201
218
  inc('__mkptr', '__alloc')
202
219
 
203
- // === String literal: "abc" → SSO if ≤4 ASCII, else static data ===
220
+ // === String literal: "abc" → SSO if ≤6 ASCII, else static data ===
204
221
 
205
222
  bind('str', (str) => {
206
223
  if (ctx.features.sso) {
207
224
  const sso = ssoEncode(str)
208
225
  if (sso) return mkPtrIR(PTR.STRING, sso.aux, sso.offset)
209
226
  }
227
+ // Falls through to the static-data path below ONLY when ssoEncode returned null
228
+ // (>6 bytes or non-ASCII) — so an interned static NEVER carries ≤6-ASCII content,
229
+ // and its cached hash (below) stays byte-FNV without needing the SSO mix
230
+ // (__str_hash's SSO branch, module/collection.js, never sees an interned pointer).
210
231
  const bytes = new TextEncoder().encode(str)
211
232
  const len = bytes.length
212
233
  if (!ctx.memory.shared) {
@@ -226,7 +247,9 @@ export default (ctx) => {
226
247
  const offset = ctx.runtime.data.length
227
248
  if (interned) {
228
249
  // byte-FNV + clamp — must equal __str_hash's output exactly (it hashes
229
- // UTF-8 bytes, then clamps ≤1 → +2 for the empty/tombstone sentinels)
250
+ // UTF-8 bytes, then clamps ≤1 → +2 for the empty/tombstone sentinels).
251
+ // Always the byte-FNV branch, never the SSO mix: this string is here
252
+ // because ssoEncode rejected it (see the guard above).
230
253
  let h = 0x811c9dc5 | 0
231
254
  for (let i = 0; i < len; i++) h = Math.imul(h ^ bytes[i], 0x01000193) | 0
232
255
  if (h <= 1) h = (h + 2) | 0
@@ -363,17 +386,25 @@ export default (ctx) => {
363
386
  // the engine's wasm inliner — the call overhead disappears at every site
364
387
  // while the byte-walk lives in __str_eq_cold. Mirrors the __ptr_offset_fwd
365
388
  // split; a body containing a loop is excluded from V8's inliner.
366
- wat('__str_eq', `(func $__str_eq (param $a i64) (param $b i64) (result i32)
389
+ wat('__str_eq', () => `(func $__str_eq (param $a i64) (param $b i64) (result i32)
367
390
  (local $axA i32) (local $axB i32) (local $offA i32) (local $offB i32)
368
391
  (if (i64.eq (local.get $a) (local.get $b))
369
392
  (then (return (i32.const 1))))
370
393
  (local.set $axA (i32.wrap_i64 (i64.shr_u (local.get $a) (i64.const ${LAYOUT.AUX_SHIFT}))))
371
394
  (local.set $axB (i32.wrap_i64 (i64.shr_u (local.get $b) (i64.const ${LAYOUT.AUX_SHIFT}))))
395
+ ${ctx.features.sso ? `
396
+ ;; ANY SSO operand ⇒ bit-ne decided content-ne: ≤${MAX_SSO}-ASCII content is
397
+ ;; always SSO (module invariant), so an SSO can equal neither a bit-different
398
+ ;; SSO nor any heap string. Kills the mixed SSO×heap byte-walk class outright.
399
+ (if (i32.or
400
+ (i32.and (local.get $axA) (i32.const ${LAYOUT.SSO_BIT}))
401
+ (i32.and (local.get $axB) (i32.const ${LAYOUT.SSO_BIT})))
402
+ (then (return (i32.const 0))))` : `
372
403
  ;; both SSO ⇒ bit-ne already decided content-ne
373
404
  (if (i32.and
374
405
  (i32.and (local.get $axA) (i32.const ${LAYOUT.SSO_BIT}))
375
406
  (i32.and (local.get $axB) (i32.const ${LAYOUT.SSO_BIT})))
376
- (then (return (i32.const 0))))
407
+ (then (return (i32.const 0))))`}
377
408
  ;; both CANONICAL interned heap strings ⇒ bit-ne ⇒ content-ne
378
409
  (if (i32.and
379
410
  (i32.eq (i32.and (local.get $axA) (i32.const ${LAYOUT.SSO_BIT | LAYOUT.SLICE_BIT | 0x1})) (i32.const 0x1))
@@ -531,9 +562,10 @@ export default (ctx) => {
531
562
  (local.set $nlen (i32.sub (local.get $end) (local.get $start)))
532
563
  ${sliceSsoPackWat()}
533
564
  ${internProbeWat('(i32.add (i32.wrap_i64 (i64.and (local.get $ptr) (i64.const ' + LAYOUT.OFFSET_MASK + '))) (local.get $start))', '(i32.eqz (local.get $isSso))')}
534
- (local.set $off (call $__alloc (i32.add (i32.const 4) (local.get $nlen))))
535
- (i32.store (local.get $off) (local.get $nlen))
536
- (local.set $off (i32.add (local.get $off) (i32.const 4)))
565
+ (local.set $off (call $__alloc (i32.add (i32.const 8) (local.get $nlen))))
566
+ (i32.store (local.get $off) (i32.const 0))
567
+ (i32.store offset=4 (local.get $off) (local.get $nlen))
568
+ (local.set $off (i32.add (local.get $off) (i32.const 8)))
537
569
  (local.set $srcOff (i32.wrap_i64 (i64.and (local.get $ptr) (i64.const ${LAYOUT.OFFSET_MASK}))))
538
570
  (local.set $isSso (i32.wrap_i64 (i64.shr_u
539
571
  (i64.and (local.get $ptr) (i64.const ${SSO_BIT_I64}))
@@ -550,7 +582,7 @@ export default (ctx) => {
550
582
  (br $loop))))
551
583
  (else
552
584
  (memory.copy (local.get $off) (i32.add (local.get $srcOff) (local.get $start)) (local.get $nlen))))
553
- (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $off)))`)
585
+ (call $__mkptr (i32.const ${PTR.STRING}) (i32.const ${STR_HCACHE_BIT}) (local.get $off)))`)
554
586
 
555
587
  // No-copy slice: returns a VIEW into the receiver's buffer instead of copying
556
588
  // bytes. Only emitted when escape analysis proves the result never outlives the
@@ -570,13 +602,14 @@ export default (ctx) => {
570
602
  (then (return (call $__mkptr (i32.const ${PTR.STRING}) (i32.const ${LAYOUT.SSO_BIT}) (i32.const 0)))))
571
603
  (local.set $nlen (i32.sub (local.get $end) (local.get $start)))
572
604
  (local.set $tag (i32.wrap_i64 (i64.and (i64.shr_u (local.get $ptr) (i64.const ${LAYOUT.TAG_SHIFT})) (i64.const ${LAYOUT.TAG_MASK}))))
573
- ;; View-eligible: STRING parent, not SSO, length fits aux[12:0]. ≤4-byte
574
- ;; results route to __str_slice's SSO pack instead (bit-equal short tokens).
605
+ ;; View-eligible: STRING parent, not SSO, length fits aux[12:0]. ≤${MAX_SSO}-byte
606
+ ;; results route to __str_slice's SSO pack instead (bit-equal short tokens
607
+ ;; a short VIEW would break the ≤${MAX_SSO}-ASCII⇒SSO invariant).
575
608
  (if (i32.and
576
609
  (i32.and
577
610
  (i32.and
578
611
  (i32.eq (local.get $tag) (i32.const ${PTR.STRING}))
579
- (i32.gt_u (local.get $nlen) (i32.const 4)))
612
+ (i32.gt_u (local.get $nlen) (i32.const ${MAX_SSO})))
580
613
  (i64.eqz (i64.and (local.get $ptr) (i64.const ${SSO_BIT_I64}))))
581
614
  (i32.le_u (local.get $nlen) (i32.const ${LAYOUT.SLICE_LEN_MASK})))
582
615
  (then
@@ -921,33 +954,41 @@ export default (ctx) => {
921
954
  // Source SSO/heap dispatch hoisted out of the byte loop (was a per-byte __char_at).
922
955
  wat('__str_case', `(func $__str_case (param $ptr i64) (param $lo i32) (param $hi i32) (param $delta i32) (result f64)
923
956
  (local $len i32) (local $off i32) (local $i i32) (local $c i32)
924
- (local $srcOff i32)
957
+ (local $srcOff i32) (local $sp64 i64)
925
958
  (local.set $len (call $__str_byteLen (local.get $ptr)))
926
959
  (if (i32.eqz (local.get $len))
927
960
  (then (return (call $__mkptr (i32.const ${PTR.STRING}) (i32.const ${LAYOUT.SSO_BIT}) (i32.const 0)))))
928
- (local.set $off (call $__alloc (i32.add (i32.const 4) (local.get $len))))
929
- (i32.store (local.get $off) (local.get $len))
930
- (local.set $off (i32.add (local.get $off) (i32.const 4)))
931
- (local.set $srcOff (i32.wrap_i64 (i64.and (local.get $ptr) (i64.const ${LAYOUT.OFFSET_MASK}))))
961
+ ;; SSO in SSO out: case-map the 7-bit lanes in registers (no allocation);
962
+ ;; ASCII stays ASCII, so the result is always SSO-eligible (invariant).
932
963
  (if (i64.ne (i64.and (local.get $ptr) (i64.const ${SSO_BIT_I64})) (i64.const 0))
933
964
  (then
965
+ (local.set $sp64 (i64.const 0))
934
966
  (block $dsso (loop $lsso
935
967
  (br_if $dsso (i32.ge_s (local.get $i) (local.get $len)))
936
968
  (local.set $c ${ssoCharWat('(local.get $ptr)', '(local.get $i)')})
937
969
  (if (i32.and (i32.ge_u (local.get $c) (local.get $lo)) (i32.le_u (local.get $c) (local.get $hi)))
938
970
  (then (local.set $c (i32.add (local.get $c) (local.get $delta)))))
939
- (i32.store8 (i32.add (local.get $off) (local.get $i)) (local.get $c))
940
- (local.set $i (i32.add (local.get $i) (i32.const 1)))
941
- (br $lsso))))
942
- (else
943
- (block $dh (loop $lh
944
- (br_if $dh (i32.ge_s (local.get $i) (local.get $len)))
945
- (local.set $c (i32.load8_u (i32.add (local.get $srcOff) (local.get $i))))
946
- (if (i32.and (i32.ge_u (local.get $c) (local.get $lo)) (i32.le_u (local.get $c) (local.get $hi)))
947
- (then (local.set $c (i32.add (local.get $c) (local.get $delta)))))
948
- (i32.store8 (i32.add (local.get $off) (local.get $i)) (local.get $c))
971
+ (local.set $sp64 (i64.or (local.get $sp64)
972
+ (i64.shl (i64.extend_i32_u (local.get $c)) (i64.mul (i64.extend_i32_u (local.get $i)) (i64.const 7)))))
949
973
  (local.set $i (i32.add (local.get $i) (i32.const 1)))
950
- (br $lh)))))
974
+ (br $lsso)))
975
+ (return (f64.reinterpret_i64 (i64.or
976
+ (i64.or
977
+ (i64.const ${ptrNanHex(PTR.STRING, LAYOUT.SSO_BIT)})
978
+ (i64.shl (i64.extend_i32_u (local.get $len)) (i64.const 42)))
979
+ (local.get $sp64))))))
980
+ (local.set $off (call $__alloc (i32.add (i32.const 4) (local.get $len))))
981
+ (i32.store (local.get $off) (local.get $len))
982
+ (local.set $off (i32.add (local.get $off) (i32.const 4)))
983
+ (local.set $srcOff (i32.wrap_i64 (i64.and (local.get $ptr) (i64.const ${LAYOUT.OFFSET_MASK}))))
984
+ (block $dh (loop $lh
985
+ (br_if $dh (i32.ge_s (local.get $i) (local.get $len)))
986
+ (local.set $c (i32.load8_u (i32.add (local.get $srcOff) (local.get $i))))
987
+ (if (i32.and (i32.ge_u (local.get $c) (local.get $lo)) (i32.le_u (local.get $c) (local.get $hi)))
988
+ (then (local.set $c (i32.add (local.get $c) (local.get $delta)))))
989
+ (i32.store8 (i32.add (local.get $off) (local.get $i)) (local.get $c))
990
+ (local.set $i (i32.add (local.get $i) (i32.const 1)))
991
+ (br $lh)))
951
992
  (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $off)))`)
952
993
 
953
994
  wat('__str_trim', `(func $__str_trim (param $ptr i64) (result f64)
@@ -1010,6 +1051,9 @@ export default (ctx) => {
1010
1051
  (local.get $len))
1011
1052
  (local.set $i (i32.add (local.get $i) (i32.const 1)))
1012
1053
  (br $loop)))
1054
+ ;; short ASCII result → SSO (invariant); the heap copy stays as arena garbage
1055
+ (if (i32.le_u (local.get $total) (i32.const ${MAX_SSO}))
1056
+ (then (return (call $__sso_norm (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $off))))))
1013
1057
  (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $off)))`)
1014
1058
 
1015
1059
  // Coerce value to string: numbers → __ftoa, nullish → static strings,
@@ -1075,21 +1119,54 @@ export default (ctx) => {
1075
1119
  // only, no heap); fires whenever the upstream `'+'` couldn't fold at
1076
1120
  // compile time (one or both operands runtime values). Critical for the
1077
1121
  // identifier-style `'$' + x` / `prefix + s` patterns hot in parsers.
1122
+ // Both-SSO short result: splice the two 42-bit char payloads — no walk, no alloc.
1078
1123
  const ssoResultFast = `
1079
1124
  (if (i32.and
1080
1125
  (i32.and
1081
1126
  (i64.ne (i64.and (local.get $a) (i64.const ${SSO_BIT_I64})) (i64.const 0))
1082
1127
  (i64.ne (i64.and (local.get $b) (i64.const ${SSO_BIT_I64})) (i64.const 0)))
1083
- (i32.le_u (local.get $total) (i32.const 4)))
1128
+ (i32.le_u (local.get $total) (i32.const ${MAX_SSO})))
1084
1129
  (then
1085
- (return (call $__mkptr
1086
- (i32.const ${PTR.STRING})
1087
- (i32.or (i32.const ${LAYOUT.SSO_BIT}) (i32.shl (local.get $total) (i32.const ${SSO_LEN_SHIFT})))
1088
- (i32.or
1089
- (i32.wrap_i64 (i64.and (local.get $a) (i64.const 0xFFFFFFFF)))
1090
- (i32.shl
1091
- (i32.wrap_i64 (i64.and (local.get $b) (i64.const 0xFFFFFFFF)))
1092
- (i32.mul (local.get $alen) (i32.const 7))))))))`
1130
+ (return (f64.reinterpret_i64 (i64.or
1131
+ (i64.or
1132
+ (i64.const ${ptrNanHex(PTR.STRING, LAYOUT.SSO_BIT)})
1133
+ (i64.shl (i64.extend_i32_u (local.get $total)) (i64.const 42)))
1134
+ (i64.or
1135
+ (i64.and (local.get $a) (i64.const ${SSO_CHAR_MASK}))
1136
+ (i64.shl
1137
+ (i64.and (local.get $b) (i64.const ${SSO_CHAR_MASK}))
1138
+ (i64.extend_i32_u (i32.mul (local.get $alen) (i32.const 7))))))))))`
1139
+
1140
+ // Mixed/heap-source short result: walk the ≤6 source bytes (SSO lane or heap
1141
+ // load per side), bail to the caller's heap path on a non-ASCII byte. Upholds
1142
+ // the module invariant for concat regardless of operand representation.
1143
+ const concatSsoPack = `
1144
+ (if (i32.le_u (local.get $total) (i32.const ${MAX_SSO}))
1145
+ (then (block $kspill
1146
+ (local.set $ksp (i64.const 0))
1147
+ (local.set $ki (i32.const 0))
1148
+ (local.set $kao (i32.wrap_i64 (i64.and (local.get $a) (i64.const ${LAYOUT.OFFSET_MASK}))))
1149
+ (local.set $kbo (i32.wrap_i64 (i64.and (local.get $b) (i64.const ${LAYOUT.OFFSET_MASK}))))
1150
+ (loop $kl
1151
+ (if (i32.lt_u (local.get $ki) (local.get $total))
1152
+ (then
1153
+ (local.set $kc (if (result i32) (i32.lt_u (local.get $ki) (local.get $alen))
1154
+ (then (if (result i32) (i64.ne (i64.and (local.get $a) (i64.const ${SSO_BIT_I64})) (i64.const 0))
1155
+ (then ${ssoCharWat('(local.get $a)', '(local.get $ki)')})
1156
+ (else (i32.load8_u (i32.add (local.get $kao) (local.get $ki))))))
1157
+ (else (if (result i32) (i64.ne (i64.and (local.get $b) (i64.const ${SSO_BIT_I64})) (i64.const 0))
1158
+ (then ${ssoCharWat('(local.get $b)', '(i32.sub (local.get $ki) (local.get $alen))')})
1159
+ (else (i32.load8_u (i32.add (local.get $kbo) (i32.sub (local.get $ki) (local.get $alen)))))))))
1160
+ (br_if $kspill (i32.ge_u (local.get $kc) (i32.const 0x80)))
1161
+ (local.set $ksp (i64.or (local.get $ksp)
1162
+ (i64.shl (i64.extend_i32_u (local.get $kc)) (i64.mul (i64.extend_i32_u (local.get $ki)) (i64.const 7)))))
1163
+ (local.set $ki (i32.add (local.get $ki) (i32.const 1)))
1164
+ (br $kl))))
1165
+ (return (f64.reinterpret_i64 (i64.or
1166
+ (i64.or
1167
+ (i64.const ${ptrNanHex(PTR.STRING, LAYOUT.SSO_BIT)})
1168
+ (i64.shl (i64.extend_i32_u (local.get $total)) (i64.const 42)))
1169
+ (local.get $ksp)))))))`
1093
1170
 
1094
1171
  const concatFast = !ctx.memory.shared && ctx.transform.alloc !== false ? `
1095
1172
  (local.set $ta (i32.wrap_i64 (i64.and (i64.shr_u (local.get $a) (i64.const ${LAYOUT.TAG_SHIFT})) (i64.const ${LAYOUT.TAG_MASK}))))
@@ -1113,6 +1190,9 @@ export default (ctx) => {
1113
1190
  (i32.add (local.get $aoff) (local.get $alen))
1114
1191
  (local.get $blen))
1115
1192
  (i32.store (i32.sub (local.get $aoff) (i32.const 4)) (local.get $total))
1193
+ ;; bytes changed in place — drop the cached hash (cell exists iff HCACHE bit)
1194
+ (if (i64.ne (i64.and (local.get $a) (i64.const ${HCACHE_BIT_I64})) (i64.const 0))
1195
+ (then (i32.store (i32.sub (local.get $aoff) (i32.const 8)) (i32.const 0))))
1116
1196
  (global.set $__heap (local.get $newHeap))
1117
1197
  (return (f64.reinterpret_i64 (local.get $a)))))` : ''
1118
1198
 
@@ -1120,13 +1200,16 @@ export default (ctx) => {
1120
1200
  // bump-extend concats (reached when `a` is NOT heap-top) and the `_fresh` variants
1121
1201
  // (which never bump-extend at all — emit routes a NON-self-accumulating `t = s + x`
1122
1202
  // here so it can't mutate the live `s` the way the heap-top extend would).
1203
+ // [hash=0 u32][len u32][bytes] + STR_HCACHE_BIT: __str_hash fills the hash cell on
1204
+ // first hash so repeated keying of a built string skips the byte-FNV walk.
1123
1205
  const allocCopyTail = `
1124
- (local.set $off (call $__alloc (i32.add (i32.const 4) (local.get $total))))
1125
- (i32.store (local.get $off) (local.get $total))
1126
- (local.set $off (i32.add (local.get $off) (i32.const 4)))
1206
+ (local.set $off (call $__alloc (i32.add (i32.const 8) (local.get $total))))
1207
+ (i32.store (local.get $off) (i32.const 0))
1208
+ (i32.store offset=4 (local.get $off) (local.get $total))
1209
+ (local.set $off (i32.add (local.get $off) (i32.const 8)))
1127
1210
  (call $__str_copy (local.get $a) (local.get $off) (local.get $alen))
1128
1211
  (call $__str_copy (local.get $b) (i32.add (local.get $off) (local.get $alen)) (local.get $blen))
1129
- (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $off)))`
1212
+ (call $__mkptr (i32.const ${PTR.STRING}) (i32.const ${STR_HCACHE_BIT}) (local.get $off)))`
1130
1213
 
1131
1214
  // Fused single-byte append: `buf += str[i]` lowers to this when both sides are
1132
1215
  // VAL.STRING and the rhs is a string-index. Skips __str_idx's 1-char SSO
@@ -1157,35 +1240,44 @@ export default (ctx) => {
1157
1240
  (call $__memgrow (local.get $newHeap))
1158
1241
  (i32.store8 (i32.add (local.get $aoff) (local.get $alen)) (local.get $byte))
1159
1242
  (i32.store (i32.sub (local.get $aoff) (i32.const 4)) (i32.add (local.get $alen) (i32.const 1)))
1243
+ ;; bytes changed in place — drop the cached hash (cell exists iff HCACHE bit)
1244
+ (if (i64.ne (i64.and (local.get $a) (i64.const ${HCACHE_BIT_I64})) (i64.const 0))
1245
+ (then (i32.store (i32.sub (local.get $aoff) (i32.const 8)) (i32.const 0))))
1160
1246
  (global.set $__heap (local.get $newHeap))
1161
1247
  (return (f64.reinterpret_i64 (local.get $a)))))))` : ''}
1162
- ;; SSO (STRING with SSO bit) with len < 4 and ASCII byte: pack into SSO without allocation
1248
+ ;; SSO (STRING with SSO bit) with len < ${MAX_SSO} and ASCII byte: pack into SSO without allocation.
1249
+ ;; NB: the aux test must be a BOOLEAN (i32.ne) — bitwise-ANDing the raw 0x4000 mask
1250
+ ;; result with the boolean (i32.eq …) yields 1 & 0x4000 = 0, silently killing the path.
1163
1251
  (if (i32.and
1164
1252
  (i32.eq (local.get $ta) (i32.const ${PTR.STRING}))
1165
- (i32.and
1166
- (i32.wrap_i64 (i64.shr_u (local.get $a) (i64.const ${LAYOUT.AUX_SHIFT})))
1167
- (i32.const ${LAYOUT.SSO_BIT})))
1253
+ (i32.ne
1254
+ (i32.and
1255
+ (i32.wrap_i64 (i64.shr_u (local.get $a) (i64.const ${LAYOUT.AUX_SHIFT})))
1256
+ (i32.const ${LAYOUT.SSO_BIT}))
1257
+ (i32.const 0)))
1168
1258
  (then
1169
1259
  (local.set $alen ${ssoLenWat('(local.get $a)')})
1170
1260
  (if (i32.and
1171
- (i32.lt_u (local.get $alen) (i32.const 4))
1261
+ (i32.lt_u (local.get $alen) (i32.const ${MAX_SSO}))
1172
1262
  (i32.lt_u (local.get $byte) (i32.const 0x80)))
1173
1263
  (then
1174
- (return (call $__mkptr
1175
- (i32.const ${PTR.STRING})
1176
- (i32.or (i32.const ${LAYOUT.SSO_BIT}) (i32.shl (i32.add (local.get $alen) (i32.const 1)) (i32.const ${SSO_LEN_SHIFT})))
1177
- (i32.or
1178
- (local.get $aoff)
1179
- (i32.shl (local.get $byte) (i32.mul (local.get $alen) (i32.const 7))))))))))
1264
+ (return (f64.reinterpret_i64 (i64.or
1265
+ (i64.or
1266
+ (i64.const ${ptrNanHex(PTR.STRING, LAYOUT.SSO_BIT)})
1267
+ (i64.shl (i64.extend_i32_u (i32.add (local.get $alen) (i32.const 1))) (i64.const 42)))
1268
+ (i64.or
1269
+ (i64.and (local.get $a) (i64.const ${SSO_CHAR_MASK}))
1270
+ (i64.shl (i64.extend_i32_u (local.get $byte)) (i64.extend_i32_u (i32.mul (local.get $alen) (i32.const 7))))))))))))
1180
1271
  ;; Slow path: allocate new heap STRING with original bytes + 1 new byte
1181
1272
  (local.set $alen (call $__str_byteLen (local.get $a)))
1182
1273
  (local.set $total (i32.add (local.get $alen) (i32.const 1)))
1183
- (local.set $off (call $__alloc (i32.add (i32.const 4) (local.get $total))))
1184
- (i32.store (local.get $off) (local.get $total))
1185
- (local.set $off (i32.add (local.get $off) (i32.const 4)))
1274
+ (local.set $off (call $__alloc (i32.add (i32.const 8) (local.get $total))))
1275
+ (i32.store (local.get $off) (i32.const 0))
1276
+ (i32.store offset=4 (local.get $off) (local.get $total))
1277
+ (local.set $off (i32.add (local.get $off) (i32.const 8)))
1186
1278
  (call $__str_copy (local.get $a) (local.get $off) (local.get $alen))
1187
1279
  (i32.store8 (i32.add (local.get $off) (local.get $alen)) (local.get $byte))
1188
- (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $off)))`)
1280
+ (call $__mkptr (i32.const ${PTR.STRING}) (i32.const ${STR_HCACHE_BIT}) (local.get $off)))`)
1189
1281
 
1190
1282
  // __str_concat / __str_concat_raw bump-EXTEND `a` in place when it is the heap-top own string
1191
1283
  // (the O(N) accumulator path). Emit calls these ONLY when the source is a self-accumulation
@@ -1194,6 +1286,7 @@ export default (ctx) => {
1194
1286
  wat('__str_concat', `(func $__str_concat (param $a i64) (param $b i64) (result f64)
1195
1287
  (local $alen i32) (local $blen i32) (local $total i32) (local $off i32)
1196
1288
  (local $ta i32) (local $aoff i32) (local $newHeap i32)
1289
+ (local $ki i32) (local $kc i32) (local $ksp i64) (local $kao i32) (local $kbo i32)
1197
1290
  ;; Coerce operands to strings if needed
1198
1291
  (local.set $a (call $__to_str (local.get $a)))
1199
1292
  (local.set $b (call $__to_str (local.get $b)))
@@ -1203,17 +1296,20 @@ export default (ctx) => {
1203
1296
  (if (i32.eqz (local.get $total))
1204
1297
  (then (return (call $__mkptr (i32.const ${PTR.STRING}) (i32.const ${LAYOUT.SSO_BIT}) (i32.const 0)))))
1205
1298
  ${ssoResultFast}
1299
+ ${concatSsoPack}
1206
1300
  ${concatFast}${allocCopyTail}`)
1207
1301
 
1208
1302
  wat('__str_concat_raw', `(func $__str_concat_raw (param $a i64) (param $b i64) (result f64)
1209
1303
  (local $alen i32) (local $blen i32) (local $total i32) (local $off i32)
1210
1304
  (local $ta i32) (local $aoff i32) (local $newHeap i32)
1305
+ (local $ki i32) (local $kc i32) (local $ksp i64) (local $kao i32) (local $kbo i32)
1211
1306
  (local.set $alen (call $__str_byteLen (local.get $a)))
1212
1307
  (local.set $blen (call $__str_byteLen (local.get $b)))
1213
1308
  (local.set $total (i32.add (local.get $alen) (local.get $blen)))
1214
1309
  (if (i32.eqz (local.get $total))
1215
1310
  (then (return (call $__mkptr (i32.const ${PTR.STRING}) (i32.const ${LAYOUT.SSO_BIT}) (i32.const 0)))))
1216
1311
  ${ssoResultFast}
1312
+ ${concatSsoPack}
1217
1313
  ${concatFast}${allocCopyTail}`)
1218
1314
 
1219
1315
  // Non-mutating twins: same SSO-pair fast path, but NEVER bump-extend — always alloc+copy a fresh
@@ -1221,6 +1317,7 @@ export default (ctx) => {
1221
1317
  // result is not assigned straight back to `s`), so string immutability holds for live operands.
1222
1318
  wat('__str_concat_fresh', `(func $__str_concat_fresh (param $a i64) (param $b i64) (result f64)
1223
1319
  (local $alen i32) (local $blen i32) (local $total i32) (local $off i32)
1320
+ (local $ki i32) (local $kc i32) (local $ksp i64) (local $kao i32) (local $kbo i32)
1224
1321
  (local.set $a (call $__to_str (local.get $a)))
1225
1322
  (local.set $b (call $__to_str (local.get $b)))
1226
1323
  (local.set $alen (call $__str_byteLen (local.get $a)))
@@ -1228,16 +1325,19 @@ export default (ctx) => {
1228
1325
  (local.set $total (i32.add (local.get $alen) (local.get $blen)))
1229
1326
  (if (i32.eqz (local.get $total))
1230
1327
  (then (return (call $__mkptr (i32.const ${PTR.STRING}) (i32.const ${LAYOUT.SSO_BIT}) (i32.const 0)))))
1231
- ${ssoResultFast}${allocCopyTail}`)
1328
+ ${ssoResultFast}
1329
+ ${concatSsoPack}${allocCopyTail}`)
1232
1330
 
1233
1331
  wat('__str_concat_raw_fresh', `(func $__str_concat_raw_fresh (param $a i64) (param $b i64) (result f64)
1234
1332
  (local $alen i32) (local $blen i32) (local $total i32) (local $off i32)
1333
+ (local $ki i32) (local $kc i32) (local $ksp i64) (local $kao i32) (local $kbo i32)
1235
1334
  (local.set $alen (call $__str_byteLen (local.get $a)))
1236
1335
  (local.set $blen (call $__str_byteLen (local.get $b)))
1237
1336
  (local.set $total (i32.add (local.get $alen) (local.get $blen)))
1238
1337
  (if (i32.eqz (local.get $total))
1239
1338
  (then (return (call $__mkptr (i32.const ${PTR.STRING}) (i32.const ${LAYOUT.SSO_BIT}) (i32.const 0)))))
1240
- ${ssoResultFast}${allocCopyTail}`)
1339
+ ${ssoResultFast}
1340
+ ${concatSsoPack}${allocCopyTail}`)
1241
1341
 
1242
1342
  wat('__str_replace', `(func $__str_replace (param $str i64) (param $search i64) (param $repl i64) (result f64)
1243
1343
  (local $idx i32) (local $slen i32)
@@ -1432,7 +1532,7 @@ export default (ctx) => {
1432
1532
  wat('__str_pad', `(func $__str_pad (param $str i64) (param $target i32) (param $pad i64) (param $before i32) (result f64)
1433
1533
  (local $slen i32) (local $plen i32) (local $fill i32) (local $off i32) (local $i i32)
1434
1534
  (local $str_off i32) (local $pad_off i32)
1435
- (local $pbits i64) (local $poff i32) (local $psso i32) (local $sp i32) (local $sb i32)
1535
+ (local $pbits i64) (local $poff i32) (local $psso i32) (local $sp64 i64) (local $sb i32)
1436
1536
  (local.set $slen (call $__str_byteLen (local.get $str)))
1437
1537
  (if (i32.ge_s (local.get $slen) (local.get $target))
1438
1538
  (then (return (f64.reinterpret_i64 (local.get $str)))))
@@ -1460,23 +1560,28 @@ export default (ctx) => {
1460
1560
  (local.set $i (i32.add (local.get $i) (i32.const 1)))
1461
1561
  (br $l2)))
1462
1562
  ${!ctx.memory.shared ? `
1463
- ;; SSO + reclaim for ≤4 ASCII results: pack into the pointer and free this pad
1563
+ ;; SSO + reclaim for ≤${MAX_SSO} ASCII results: pack into the pointer and free this pad
1464
1564
  ;; allocation (at heap top) so padStart in a builder loop is allocation-neutral
1465
1565
  ;; — the accumulator stays at heap top and keeps bump-extending (O(n)).
1466
- (if (i32.le_u (local.get $target) (i32.const 4))
1566
+ (if (i32.le_u (local.get $target) (i32.const ${MAX_SSO}))
1467
1567
  (then
1468
1568
  (block $heap
1469
- (local.set $i (i32.const 0)) (local.set $sp (i32.const 0))
1569
+ (local.set $i (i32.const 0)) (local.set $sp64 (i64.const 0))
1470
1570
  (loop $pk
1471
1571
  (if (i32.lt_u (local.get $i) (local.get $target))
1472
1572
  (then
1473
1573
  (local.set $sb (i32.load8_u (i32.add (local.get $off) (local.get $i))))
1474
1574
  (br_if $heap (i32.ge_u (local.get $sb) (i32.const 0x80)))
1475
- (local.set $sp (i32.or (local.get $sp) (i32.shl (local.get $sb) (i32.mul (local.get $i) (i32.const 7)))))
1575
+ (local.set $sp64 (i64.or (local.get $sp64)
1576
+ (i64.shl (i64.extend_i32_u (local.get $sb)) (i64.mul (i64.extend_i32_u (local.get $i)) (i64.const 7)))))
1476
1577
  (local.set $i (i32.add (local.get $i) (i32.const 1)))
1477
1578
  (br $pk))))
1478
1579
  (global.set $__heap (i32.sub (local.get $off) (i32.const 4)))
1479
- (return (call $__mkptr (i32.const ${PTR.STRING}) (i32.or (i32.const ${LAYOUT.SSO_BIT}) (i32.shl (local.get $target) (i32.const 10))) (local.get $sp))))))` : ''}
1580
+ (return (f64.reinterpret_i64 (i64.or
1581
+ (i64.or
1582
+ (i64.const ${ptrNanHex(PTR.STRING, LAYOUT.SSO_BIT)})
1583
+ (i64.shl (i64.extend_i32_u (local.get $target)) (i64.const 42)))
1584
+ (local.get $sp64)))))))` : ''}
1480
1585
  (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $off)))`)
1481
1586
 
1482
1587
  // Base helpers (__sso_char/__str_char/__char_at/__str_byteLen) are referenced
@@ -1492,6 +1597,17 @@ export default (ctx) => {
1492
1597
  // the receiver type can't be statically inferred (e.g. a callback param).
1493
1598
  bind('.string:toString', (str) => asF64(emit(str)))
1494
1599
  bind('.string:valueOf', (str) => asF64(emit(str)))
1600
+ // String.prototype.normalize — identity: every normalization form (NFC/NFD/
1601
+ // NFKC/NFKD) is the identity on ASCII, and jz strings are UTF-8 bytes with
1602
+ // ASCII-only case/space semantics (README divergences). No Unicode tables.
1603
+ // The form argument is ignored (a bogus form's RangeError needs the same
1604
+ // tables to justify checking — out of scope with them). Generic twin
1605
+ // ToString-coerces an untyped receiver (spec step 1), like .toString.
1606
+ bind('.string:normalize', (str) => asF64(emit(str)))
1607
+ bind('.normalize', (val) => {
1608
+ inc('__to_str')
1609
+ return typed(['f64.reinterpret_i64', ['call', '$__to_str', asI64(emit(val))]], 'f64')
1610
+ })
1495
1611
  bind('.toString', (val) => {
1496
1612
  inc('__to_str')
1497
1613
  return typed(['f64.reinterpret_i64', ['call', '$__to_str', asI64(emit(val))]], 'f64')
@@ -1739,26 +1855,57 @@ export default (ctx) => {
1739
1855
  // A VAL.BOOL part rides the 0/1 carrier, so __to_str would render "1"/"0".
1740
1856
  // bool selects the interned "true"/"false" literal (constant-folded
1741
1857
  // when the operand is known); every other part goes through __to_str.
1742
- const partStrI64 = (p) => valTypeOf(p) === VAL.BOOL ? asI64(bool(p)) : toStrI64(p, emit(p))
1858
+ // `v` is the part's pre-emitted value when the caller already emitted it.
1859
+ const partStrI64 = (p, v) => valTypeOf(p) === VAL.BOOL ? asI64(bool(p)) : toStrI64(p, v ?? emit(p))
1743
1860
 
1744
1861
  bind('strcat', (...parts) => {
1745
- inc('__to_str', '__str_byteLen', '__alloc', '__mkptr', '__str_copy')
1746
1862
  if (!parts.length) return mkPtrIR(PTR.STRING, LAYOUT.SSO_BIT, 0)
1747
1863
  if (parts.length === 1) return typed(['f64.reinterpret_i64', partStrI64(parts[0])], 'f64')
1864
+ inc('__alloc', '__mkptr', '__sso_norm')
1865
+
1866
+ // LITERAL ASCII parts (the serializer separators: ',', '\n', 'x=' …) carry their
1867
+ // bytes and length at compile time: no value temp, no __str_byteLen call, no
1868
+ // __str_copy call — the length const-folds into the total and the bytes store
1869
+ // directly at dst (grouped 4/2/1-wide). Profiled on strbuild: the copy+len
1870
+ // calls on 1-6 byte parts were 38.7% of the row — pure call overhead.
1871
+ const litBytes = (p) => {
1872
+ if (!Array.isArray(p) || p[0] !== 'str' || typeof p[1] !== 'string' || p[1].length === 0) return null
1873
+ const s = p[1]
1874
+ for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) > 0x7f) return null
1875
+ return s
1876
+ }
1877
+ const lits = parts.map(litBytes)
1748
1878
 
1749
- const vals = parts.map(() => temp('s'))
1750
- const lens = parts.map(() => tempI32('sl'))
1879
+ const vals = [], nums = []
1880
+ const lens = parts.map((_, i) => lits[i] != null ? null : tempI32('sl'))
1751
1881
  const total = tempI32('st')
1752
1882
  const off = tempI32('so')
1753
1883
  const dst = tempI32('sd')
1754
1884
  const ir = []
1885
+ let litTotal = 0
1755
1886
 
1756
1887
  for (let i = 0; i < parts.length; i++) {
1757
- ir.push(['local.set', `$${vals[i]}`, ['f64.reinterpret_i64', partStrI64(parts[i])]])
1888
+ if (lits[i] != null) { litTotal += lits[i].length; continue }
1889
+ const vt = valTypeOf(parts[i])
1890
+ const v = vt === VAL.BOOL ? null : emit(parts[i])
1891
+ // i32-PROVEN part (exactly toStrI64's __i32_to_str class): keep the raw value,
1892
+ // not a temp string — __ilen joins the total and __itoa_s renders the digits
1893
+ // directly at dst. Drops the per-number __i32_to_str (alloc+itoa+mkstr),
1894
+ // __str_byteLen and __str_copy — the whole temp-string round trip.
1895
+ if ((vt === VAL.NUMBER || vt == null) && v.type === 'i32' && v.ptrKind == null) {
1896
+ inc('__ilen', '__itoa_s')
1897
+ nums[i] = tempI32('sn')
1898
+ ir.push(['local.set', `$${nums[i]}`, v])
1899
+ ir.push(['local.set', `$${lens[i]}`, ['call', '$__ilen', ['local.get', `$${nums[i]}`]]])
1900
+ continue
1901
+ }
1902
+ inc('__str_byteLen', '__str_copy')
1903
+ vals[i] = temp('s')
1904
+ ir.push(['local.set', `$${vals[i]}`, ['f64.reinterpret_i64', partStrI64(parts[i], v)]])
1758
1905
  ir.push(['local.set', `$${lens[i]}`, ['call', '$__str_byteLen', ['i64.reinterpret_f64', ['local.get', `$${vals[i]}`]]]])
1759
1906
  }
1760
- ir.push(['local.set', `$${total}`, ['i32.const', 0]])
1761
- for (const len of lens)
1907
+ ir.push(['local.set', `$${total}`, ['i32.const', litTotal]])
1908
+ for (const len of lens) if (len != null)
1762
1909
  ir.push(['local.set', `$${total}`, ['i32.add', ['local.get', `$${total}`], ['local.get', `$${len}`]]])
1763
1910
  const alloc = [
1764
1911
  ['local.set', `$${off}`, ['call', '$__alloc', ['i32.add', ['i32.const', 4], ['local.get', `$${total}`]]]],
@@ -1766,14 +1913,43 @@ export default (ctx) => {
1766
1913
  ['local.set', `$${off}`, ['i32.add', ['local.get', `$${off}`], ['i32.const', 4]]],
1767
1914
  ['local.set', `$${dst}`, ['local.get', `$${off}`]],
1768
1915
  ]
1916
+ const dstAt = (k) => k ? ['i32.add', ['local.get', `$${dst}`], ['i32.const', k]] : ['local.get', `$${dst}`]
1769
1917
  for (let i = 0; i < parts.length; i++) {
1918
+ if (lits[i] != null) {
1919
+ const s = lits[i]
1920
+ let k = 0 // grouped little-endian stores: 4-byte words, 2-byte tail, then 1
1921
+ for (; k + 4 <= s.length; k += 4)
1922
+ alloc.push(['i32.store', dstAt(k), ['i32.const',
1923
+ (s.charCodeAt(k) | (s.charCodeAt(k + 1) << 8) | (s.charCodeAt(k + 2) << 16) | (s.charCodeAt(k + 3) << 24)) | 0]])
1924
+ if (k + 2 <= s.length) {
1925
+ alloc.push(['i32.store16', dstAt(k), ['i32.const', s.charCodeAt(k) | (s.charCodeAt(k + 1) << 8)]])
1926
+ k += 2
1927
+ }
1928
+ if (k < s.length)
1929
+ alloc.push(['i32.store8', dstAt(k), ['i32.const', s.charCodeAt(k)]])
1930
+ alloc.push(['local.set', `$${dst}`, ['i32.add', ['local.get', `$${dst}`], ['i32.const', s.length]]])
1931
+ continue
1932
+ }
1933
+ if (nums[i] != null) {
1934
+ // digits render at dst; the returned byte count (== $lens[i]) advances it
1935
+ alloc.push(['local.set', `$${dst}`, ['i32.add',
1936
+ ['call', '$__itoa_s', ['local.get', `$${nums[i]}`], ['local.get', `$${dst}`]], ['local.get', `$${dst}`]]])
1937
+ continue
1938
+ }
1770
1939
  alloc.push(['call', '$__str_copy', ['i64.reinterpret_f64', ['local.get', `$${vals[i]}`]], ['local.get', `$${dst}`], ['local.get', `$${lens[i]}`]])
1771
1940
  alloc.push(['local.set', `$${dst}`, ['i32.add', ['local.get', `$${dst}`], ['local.get', `$${lens[i]}`]]])
1772
1941
  }
1773
- alloc.push(['call', '$__mkptr', ['i32.const', PTR.STRING], ['i32.const', 0], ['local.get', `$${off}`]])
1774
- ir.push(['if', ['result', 'f64'], ['i32.eqz', ['local.get', `$${total}`]],
1775
- ['then', mkPtrIR(PTR.STRING, LAYOUT.SSO_BIT, 0)],
1776
- ['else', ['block', ['result', 'f64'], ...alloc]]])
1942
+ // ≤6-ASCII template results (`\`$\${n}\`` name-building) must SSO-normalize —
1943
+ // the module invariant; a leaked short heap string breaks bare-i64.eq ===.
1944
+ alloc.push(['call', '$__sso_norm',
1945
+ ['call', '$__mkptr', ['i32.const', PTR.STRING], ['i32.const', 0], ['local.get', `$${off}`]]])
1946
+ // A non-empty literal or a number part (≥1 digit) proves the result non-empty —
1947
+ // skip the empty-total branch entirely.
1948
+ ir.push(litTotal > 0 || nums.some(t => t != null)
1949
+ ? ['block', ['result', 'f64'], ...alloc]
1950
+ : ['if', ['result', 'f64'], ['i32.eqz', ['local.get', `$${total}`]],
1951
+ ['then', mkPtrIR(PTR.STRING, LAYOUT.SSO_BIT, 0)],
1952
+ ['else', ['block', ['result', 'f64'], ...alloc]]])
1777
1953
  return typed(['block', ['result', 'f64'], ...ir], 'f64')
1778
1954
  })
1779
1955
 
@@ -1851,6 +2027,44 @@ export default (ctx) => {
1851
2027
  (i32.store8 (i32.add (local.get $off) (i32.const 4)) (local.get $b))
1852
2028
  (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (i32.add (local.get $off) (i32.const 4))))))`)
1853
2029
 
2030
+ // Normalize a fresh plain-heap STRING to SSO when its content is ≤${MAX_SSO} ASCII
2031
+ // bytes — the epilogue every string PRODUCER that hand-writes heap bytes (number/
2032
+ // JSON/URI formatting, byte decode, repeat) routes through to uphold the module
2033
+ // invariant. Non-STRING values, SSO, slices, long or non-ASCII strings pass through
2034
+ // untouched. Pure loads, no calls — cheap enough for every producer return.
2035
+ wat('__sso_norm', `(func $__sso_norm (param $s f64) (result f64)
2036
+ (local $b i64) (local $len i32) (local $off i32) (local $i i32) (local $c i32) (local $sp i64)
2037
+ (local.set $b (i64.reinterpret_f64 (local.get $s)))
2038
+ ;; plain heap STRING only: tag=STRING, SSO/SLICE clear, header present
2039
+ (if (i32.eqz (i32.and
2040
+ (i32.eq
2041
+ (i32.wrap_i64 (i64.and (i64.shr_u (local.get $b) (i64.const ${LAYOUT.TAG_SHIFT})) (i64.const ${LAYOUT.TAG_MASK})))
2042
+ (i32.const ${PTR.STRING}))
2043
+ (i32.and
2044
+ (f64.ne (local.get $s) (local.get $s))
2045
+ (i64.eqz (i64.and (local.get $b) (i64.const ${SSO_SLICE_I64}))))))
2046
+ (then (return (local.get $s))))
2047
+ (local.set $off (i32.wrap_i64 (i64.and (local.get $b) (i64.const ${LAYOUT.OFFSET_MASK}))))
2048
+ (if (i32.lt_u (local.get $off) (i32.const 4)) (then (return (local.get $s))))
2049
+ (local.set $len (i32.load (i32.sub (local.get $off) (i32.const 4))))
2050
+ (if (i32.gt_u (local.get $len) (i32.const ${MAX_SSO})) (then (return (local.get $s))))
2051
+ (local.set $sp (i64.const 0))
2052
+ (block $spill
2053
+ (block $d (loop $l
2054
+ (br_if $d (i32.ge_u (local.get $i) (local.get $len)))
2055
+ (local.set $c (i32.load8_u (i32.add (local.get $off) (local.get $i))))
2056
+ (br_if $spill (i32.ge_u (local.get $c) (i32.const 0x80)))
2057
+ (local.set $sp (i64.or (local.get $sp)
2058
+ (i64.shl (i64.extend_i32_u (local.get $c)) (i64.mul (i64.extend_i32_u (local.get $i)) (i64.const 7)))))
2059
+ (local.set $i (i32.add (local.get $i) (i32.const 1)))
2060
+ (br $l)))
2061
+ (return (f64.reinterpret_i64 (i64.or
2062
+ (i64.or
2063
+ (i64.const ${ptrNanHex(PTR.STRING, LAYOUT.SSO_BIT)})
2064
+ (i64.shl (i64.extend_i32_u (local.get $len)) (i64.const 42)))
2065
+ (local.get $sp)))))
2066
+ (local.get $s))`)
2067
+
1854
2068
  // String.fromCharCode(...codes) — variadic; each arg is ToUint16(ToNumber(code))
1855
2069
  // → a 1-byte string, concatenated left to right (mirrors String.fromCodePoint).
1856
2070
  bind('String.fromCharCode', (...codes) => {
@@ -1975,7 +2189,7 @@ export default (ctx) => {
1975
2189
  (local.set $i (i32.add (local.get $i) (i32.const 1)))
1976
2190
  (br $loop)))
1977
2191
  (i32.store (local.get $base) (local.get $j))
1978
- (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $out)))`)
2192
+ (call $__sso_norm (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $out))))`)
1979
2193
 
1980
2194
  bind('encodeURIComponent', (value) => {
1981
2195
  inc('__encodeURIComponent')
@@ -2068,7 +2282,7 @@ export default (ctx) => {
2068
2282
  (local.set $outLen (i32.add (local.get $outLen) (i32.const 1)))))
2069
2283
  (br $loop)))
2070
2284
  (i32.store (local.get $base) (local.get $outLen))
2071
- (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $dst)))`)
2285
+ (call $__sso_norm (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $dst))))`)
2072
2286
 
2073
2287
  bind('decodeURIComponent', (value) => {
2074
2288
  ctx.runtime.throws = true
@@ -2164,6 +2378,9 @@ export default (ctx) => {
2164
2378
  (i32.store (local.get $dst) (local.get $len))
2165
2379
  (local.set $dst (i32.add (local.get $dst) (i32.const 4)))
2166
2380
  (memory.copy (local.get $dst) (local.get $off) (local.get $len))
2381
+ ;; short ASCII decode → SSO (invariant)
2382
+ (if (i32.le_u (local.get $len) (i32.const ${MAX_SSO}))
2383
+ (then (return (call $__sso_norm (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $dst))))))
2167
2384
  (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $dst)))`)
2168
2385
 
2169
2386
  bind('.decode', (obj, arr) => {