Skip to content

fix(runtime): #5591 — JSON/String test262 compliance fixes - #5818

Closed
proggeramlug wants to merge 3 commits into
mainfrom
fix/5591-test262-builtins-tail
Closed

fix(runtime): #5591 — JSON/String test262 compliance fixes#5818
proggeramlug wants to merge 3 commits into
mainfrom
fix/5591-test262-builtins-tail

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Closes #5591 (partial — targets the String and JSON categories from the 133-case sweep).

Summary

Six spec-accuracy fixes for test262 built-ins:

  • JSON.stringify(Symbol()) → undefined — ECMA-262 §25.5.2.2 step 5. Previously fell through the pointer serializer and emitted {} or an empty string instead of returning undefined.

  • JSON string spacer truncated to 10 chars — §25.5.2.1 step 6b. The STRING_TAG spacer branch was writing the full string; .chars().take(10) now caps it. SSO short-string paths (≤5 bytes) were already within the limit.

  • Replacer returning new Boolean/Number/String(…) → emits primitive, not {} — §25.5.2.2 step 4. Added boxed_primitive_json_value unwrapping at the top of dispatch_pointer_with_replacer, after the replacer has been applied and before the GC-type dispatch.

  • new String(Symbol())TypeError — §22.1.1.1 step 2b. ToString(symbol) throws; the new form now checks js_is_symbol before allocating the wrapper. Bare String(symbol) (SymbolDescriptiveString) is unaffected.

  • String.prototype.toString brand-checks this — §22.1.3.3 thisStringValue. Added string_proto_to_string_thunk + string_receiver_or_throw in primitive_proto_thunks.rs. Accepts string primitives, new String(…) wrappers, and String.prototype itself (returns ""); throws TypeError for everything else.

  • String.prototype.valueOf same brand-check — same §22.1.3.3 requirement. Shares string_receiver_or_throw; installed alongside toString via string_proto_value_of_thunk.

Files changed

File What changed
perry-runtime/src/builtins/formatting/boxed_primitives.rs new String(Symbol()) → TypeError
perry-runtime/src/json/replacer.rs Symbol→undefined, space truncation, boxed-primitive unwrap
perry-runtime/src/object/global_this.rs Install brand-checked thunks on String.prototype
perry-runtime/src/object/primitive_proto_thunks.rs string_receiver_or_throw, thunks, method-value entries

Test plan

  • cargo check -p perry-runtime — no errors
  • cargo test --release --workspace — CI
  • test262 gap sweep (./scripts/run_gap_tests.sh) — CI

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved eval behavior in global-script mode so indirect eval now matches expected completion values and global binding behavior.
    • Fixed JSON.stringify to return undefined for symbols, unwrap boxed primitives correctly, and limit string indentation to 10 characters.
    • Corrected String object behavior, including proper handling of new String(symbol) and brand-checked toString/valueOf results.

Ralph and others added 3 commits June 24, 2026 01:33
… body to its global completion value

Residual of the #5579 regression batch: the `language/eval-code/indirect`
completion-value cluster. Indirect eval `(0, eval)('<const>')` runs as global
code, but Perry's general indirect-eval path deferred every valid body to the
runtime global-`eval` thunk, which only models the `this`/`globalThis` idiom and
returns `undefined` for everything else. So `(0,eval)('x = 1')` yielded undefined
instead of 1 and never mutated the global `x` — diverging from the script-mode
Node oracle (`vm.runInThisContext`, #5346/#5511).

Perry already models a constant *direct* eval body with a scope-capturing
completion IIFE (#1679). For indirect eval that IIFE is only sound where the
captured enclosing scope already IS the global scope: module top level
(`scope_depth == 0`, no enclosing class / `with` / ESM) under
`PERRY_GLOBAL_SCRIPT_THIS`, where module-top `var`s and `this` are the global
bindings (#5608/#5609). There `try_indirect_eval_general` now folds the body via
the shared `build_eval_completion_iife` (sloppy unless the body opens with its
own Use Strict Directive — indirect eval never inherits the caller's strictness).

The fold is restricted to *declaration-free* bodies. A scope-capturing IIFE
places any `var`/`function`/`class`/`let`/`const` the body declares inside the
wrapper, but real global eval routes `var`/`function` to the global var
environment and `let`/`const`/`class` to the eval's own fresh lexical
environment — and Perry additionally registers class names at module scope, so a
folded `class C {}` would leak `C` to the top level (breaking
`indirect/lex-env-distinct-cls`). A declaration-free body has no such binding to
misplace; it only reads/assigns the globals it names. A new recursive
`eval_body_declares_bindings` scan (through blocks, loops, `try`, `switch`,
`with`, labeled and `if` statements where `var`/`function` hoist) gates this, so
any declaration defers to the runtime thunk as before.

Test262 `language/eval-code/indirect`: +2 (`cptn-nrml-expr-prim`,
`cptn-nrml-expr-obj`) with zero regressions across `language/eval-code` and
`annexB/language/eval-code` (declaration-bearing bodies, incl. the annexB
function-hoisting set, are unchanged). Outside global-script mode (the default,
and every standalone build) behavior is byte-for-byte unchanged.

Regression test: crates/perry/tests/issue_5579_indirect_eval_global_completion.rs
— completion values + global mutation, object identity, always-sloppy `with`/
undeclared-assignment side effects, and that a nested-scope indirect eval does
NOT capture the enclosing function's locals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- stmt_declares_binding: replace the `_ => false` catch-all with explicit
  no-declaration arms (Expr/Empty/Debugger/Return/Break/Continue/Throw) so a
  future ast::Stmt variant that can nest a declaration is a compile error here
  rather than a silent miss in the eval-fold safety gate.
- indirect_eval_nested_does_not_capture_locals: add a positive
  `typeof: undefined` + `DONE` assertion (not just the negative `!number`
  check) and align the doc comment with the clean-exit expectation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Six spec-accuracy fixes from the test262 builtins-tail sweep:

1. JSON.stringify(Symbol()) → undefined (ECMA-262 §25.5.2.2 step 5).
   Previously fell through to the object/pointer serializer and emitted
   an empty string or "{}" instead of returning undefined.

2. JSON space string spacer truncated to 10 characters (§25.5.2.1 step 6b).
   The STRING_TAG branch was writing the full string; `.chars().take(10)`
   now caps it. SSO paths (≤5 bytes) are already within the limit.

3. Replacer function returning a boxed-primitive wrapper (new Boolean/Number/
   String) → emits the unwrapped value, not "{}". Added an early
   `boxed_primitive_json_value` check at the top of
   `dispatch_pointer_with_replacer` so the unwrapping happens after the
   replacer has been applied (§25.5.2.2 step 4).

4. new String(Symbol()) → TypeError (§22.1.1.1 step 2b). ToString of a
   Symbol is a TypeError; the `new` form now checks `js_is_symbol` before
   allocating the wrapper object. The bare-call form `String(symbol)` is
   unaffected (already returns SymbolDescriptiveString before reaching this
   function).

5. String.prototype.toString() brand-checks `this` (§22.1.3.3 thisStringValue).
   Added `string_proto_to_string_thunk` and `string_receiver_or_throw` in
   `primitive_proto_thunks.rs`. Accepts string primitives, new String(…)
   wrappers, and String.prototype itself (returns ""). Throws TypeError for
   everything else (number, boolean, null, undefined, Symbol, plain object).

6. String.prototype.valueOf() same brand-check (same §22.1.3.3 requirement).
   Shares `string_receiver_or_throw`; installed via `string_proto_value_of_thunk`.

Both thunks are wired up in the "String" arm of
`populate_builtin_prototype_methods` (global_this.rs), overwriting the
previous no-op `toString`/`valueOf` entries that were installed by
`install_noop_proto_methods`. The `primitive_proto_method_value` fast-path
(used by collection_iter.rs for folded prototype-method reads) is also
updated with ("String", "toString") and ("String", "valueOf") entries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Narrows indirect-eval completion-value folding in try_indirect_eval_general to declaration-free bodies at global-script module top level. Adds String.prototype brand-checked toString/valueOf thunks and wires them into the prototype, while also throwing TypeError for new String(symbol). Fixes JSON.stringify for symbol inputs, boxed primitive wrappers, and string spacer truncation.

Changes

Indirect eval completion folding

Layer / File(s) Summary
Declaration-free gating in try_indirect_eval_general
crates/perry-hir/src/lower/const_fold_fn.rs
try_indirect_eval_general folds only at scope_depth==0 global-script top level with no enclosing class/with; new eval_body_declares_bindings/stmt_declares_binding helpers scan the AST and gate folding on declaration-free bodies.
Regression tests
crates/perry/tests/issue_5579_indirect_eval_global_completion.rs
Four tests cover primitive completions with global binding mutation, object identity, sloppy-mode side effects, and the invariant that indirect eval inside a function does not capture function locals.

String.prototype brand-checked thunks

Layer / File(s) Summary
Receiver validation and thunks
crates/perry-runtime/src/object/primitive_proto_thunks.rs
Adds CLASS_ID_BOXED_STRING, string_receiver_or_throw, primitive_proto_method_value entries, and string_proto_to_string_thunk/string_proto_value_of_thunk extern C thunks.
Prototype wiring and Symbol guard
crates/perry-runtime/src/object/global_this.rs, crates/perry-runtime/src/builtins/formatting/boxed_primitives.rs
global_this overwrites toString/valueOf with brand-checking thunks after OBJECT_PROTO_METHODS installation; boxed_primitives adds an early Symbol check that throws TypeError before string coercion.

JSON.stringify spec fixes

Layer / File(s) Summary
Symbol early-return, boxed wrapper unwrap, spacer truncation
crates/perry-runtime/src/json/replacer.rs
Returns TAG_UNDEFINED for symbol inputs; unwraps boxed primitives before GC dispatch; truncates string spacer to 10 characters.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related issues

  • #5591 (test262 built-ins tail): The String.prototype brand-checking thunks, new String(Symbol) TypeError, and JSON.stringify fixes (symbol return, boxed wrapper serialization, spacer truncation) directly address failures in built-ins/String and built-ins/JSON subclusters.

Possibly related PRs

  • PerryTS/perry#5627: Modifies the same try_indirect_eval_general function in const_fold_fn.rs with the same declaration-free folding restriction and regression test approach.
  • PerryTS/perry#5723: Modifies dispatch_pointer_with_replacer in replacer.rs, the same function this PR changes to special-case boxed primitive wrappers.
  • PerryTS/perry#5803: Overlaps directly with this PR's String prototype brand-checking, new String(Symbol) TypeError, and JSON.stringify boxed primitive/spacer changes.

Poem

🐇 A rabbit hops through eval's gate,
Declarations block — it has to wait!
String.valueOf checks the brand with care,
JSON won't stringify symbols in the air.
Ten spacer chars? That's all you get!
The spec is law — no regrets yet. 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The indirect eval folding fix and regression test are unrelated to the JSON/String test262 scope and appear out of scope. Remove or justify the indirect-eval change and test in a separate PR, or add a linked issue that explicitly covers that behavior.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main JSON/String test262 compliance changes.
Description check ✅ Passed The description includes a summary, change list, related issue, and test plan; the missing checklist is non-critical.
Linked Issues check ✅ Passed The PR addresses the linked #5591 subset for JSON and String edge cases and receiver behavior, which fits the issue's stated scope.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/5591-test262-builtins-tail
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch fix/5591-test262-builtins-tail

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
crates/perry/tests/issue_5579_indirect_eval_global_completion.rs (1)

154-184: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for declaration-containing eval bodies.

The production change hinges on eval_body_declares_bindings(&body_stmts), but these regressions only exercise declaration-free folding and nested-scope deferral. Please add one global-script indirect eval containing a declaration, e.g. class or var, to pin that it is not completion-IIFE folded/leaked as described by the safety gate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry/tests/issue_5579_indirect_eval_global_completion.rs` around
lines 154 - 184, The current regression tests cover declaration-free indirect
eval and nested-scope deferral, but they do not pin the safety gate driven by
eval_body_declares_bindings(&body_stmts). Add a new global-script test near
indirect_eval_nested_does_not_capture_locals that uses an indirect eval body
containing a declaration such as var or class, and assert it does not get
completion-IIFE folded or leak bindings into the surrounding scope. Use the
existing compile_and_run pattern and reference the same eval/global-script setup
so the test exercises the declaration-containing path directly.
crates/perry-runtime/src/builtins/formatting/boxed_primitives.rs (1)

278-285: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Avoid the hardcoded byte length.

41 matches the message length today, but a hardcoded count is fragile under future edits. Derive it from the literal instead.

♻️ Proposed change
     if unsafe { crate::symbol::js_is_symbol(value) } != 0 {
-        let msg = crate::string::js_string_from_bytes(
-            b"Cannot convert a Symbol value to a string".as_ptr(),
-            41,
-        );
+        let bytes = b"Cannot convert a Symbol value to a string";
+        let msg = crate::string::js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32);
         let err = crate::error::js_typeerror_new(msg);
         crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64))
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/builtins/formatting/boxed_primitives.rs` around
lines 278 - 285, The Symbol-to-string TypeError in
boxed_primitives::js_string_from_bytes uses a hardcoded byte length that can
drift from the message text. Update the message construction in the Symbol check
branch to derive the length from the literal itself rather than passing a fixed
count, keeping the js_symbol path and js_typeerror_new/js_throw flow unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/json/replacer.rs`:
- Around line 160-164: The boxed-primitive unwrapping in
`json::replacer::write_replaced` is incorrectly sending boxed BigInt values into
`write_replaced_scalar`, which serializes them as digits instead of rejecting
them. Update the `boxed_primitive_json_value` handling so `Object(1n)` is
excluded from this scalar path and instead follows the BigInt error behavior
used by `JSON.stringify`, while keeping the existing unwrapping for Boolean,
Number, and String wrappers.
- Around line 1167-1169: Move the symbol early-return below the root replacer
handling in js_json_stringify_full so the root value still goes through toJSON
and the function replacer first. The current js_is_symbol(value) check
short-circuits before the root replacer can run, which prevents
JSON.stringify(Symbol(), () => 1) from returning the replacer result. Keep the
symbol-to-undefined behavior only after the root replacer path has been applied,
using the existing js_json_stringify_full and js_is_symbol logic to locate the
change.
- Around line 1223-1225: The spacer truncation in the JSON replacer is using
chars().take(10), which counts Unicode scalar values instead of UTF-16 code
units and can violate the JSON.stringify spec for non-BMP characters. Update the
truncation logic in the indent/spacer handling in replacer.rs to clamp by UTF-16
code units, keeping the existing ECMA-262 step 6b behavior and ensuring astral
symbols are counted correctly.

---

Nitpick comments:
In `@crates/perry-runtime/src/builtins/formatting/boxed_primitives.rs`:
- Around line 278-285: The Symbol-to-string TypeError in
boxed_primitives::js_string_from_bytes uses a hardcoded byte length that can
drift from the message text. Update the message construction in the Symbol check
branch to derive the length from the literal itself rather than passing a fixed
count, keeping the js_symbol path and js_typeerror_new/js_throw flow unchanged.

In `@crates/perry/tests/issue_5579_indirect_eval_global_completion.rs`:
- Around line 154-184: The current regression tests cover declaration-free
indirect eval and nested-scope deferral, but they do not pin the safety gate
driven by eval_body_declares_bindings(&body_stmts). Add a new global-script test
near indirect_eval_nested_does_not_capture_locals that uses an indirect eval
body containing a declaration such as var or class, and assert it does not get
completion-IIFE folded or leak bindings into the surrounding scope. Use the
existing compile_and_run pattern and reference the same eval/global-script setup
so the test exercises the declaration-containing path directly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3a504b71-e9ea-4b2b-9b0f-e78f5ae91617

📥 Commits

Reviewing files that changed from the base of the PR and between 23cf727 and f4a9134.

📒 Files selected for processing (6)
  • crates/perry-hir/src/lower/const_fold_fn.rs
  • crates/perry-runtime/src/builtins/formatting/boxed_primitives.rs
  • crates/perry-runtime/src/json/replacer.rs
  • crates/perry-runtime/src/object/global_this.rs
  • crates/perry-runtime/src/object/primitive_proto_thunks.rs
  • crates/perry/tests/issue_5579_indirect_eval_global_completion.rs

Comment on lines +160 to +164
// ECMA-262 §25.5.2.2 step 4: unwrap boxed primitive wrappers after the
// replacer has been applied. `new Boolean(true)` → "true", etc. Must come
// before the GC-type dispatch so the empty-keys fallback doesn't emit "{}".
if let Some(prim) = crate::builtins::boxed_primitive_json_value(replaced) {
write_replaced_scalar(buf, prim);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant file and locate the helper and scalar writer.
ast-grep outline crates/perry-runtime/src/json/replacer.rs --view expanded
printf '\n--- boxed primitive helper refs ---\n'
rg -n "boxed_primitive_json_value|write_replaced_scalar|BIGINT_TAG|TypeError|BigInt" crates/perry-runtime/src/json/replacer.rs crates/perry-runtime/src -g '!**/target/**'

printf '\n--- relevant slices ---\n'
sed -n '130,220p' crates/perry-runtime/src/json/replacer.rs
printf '\n--- helper definition if present ---\n'
rg -n "fn boxed_primitive_json_value|pub fn boxed_primitive_json_value" crates/perry-runtime/src -g '!**/target/**'

Repository: PerryTS/perry

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '90,180p' crates/perry-runtime/src/json/replacer.rs
printf '\n---\n'
sed -n '560,610p' crates/perry-runtime/src/json/replacer.rs

Repository: PerryTS/perry

Length of output: 6305


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '200,240p' crates/perry-runtime/src/builtins/formatting/boxed_primitives.rs
printf '\n---\n'
sed -n '1,140p' crates/perry-runtime/src/json/replacer.rs
printf '\n---\n'
sed -n '330,390p' crates/perry-runtime/src/json/replacer.rs
printf '\n---\n'
sed -n '1150,1215p' crates/perry-runtime/src/json/replacer.rs

Repository: PerryTS/perry

Length of output: 13349


Don’t route boxed BigInt through the scalar writer.
boxed_primitive_json_value includes Object(1n), and this branch unwraps it before write_replaced_scalar, which then emits BIGINT_TAG as digits. JSON.stringify should throw on BigInt instead of producing JSON text here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/json/replacer.rs` around lines 160 - 164, The
boxed-primitive unwrapping in `json::replacer::write_replaced` is incorrectly
sending boxed BigInt values into `write_replaced_scalar`, which serializes them
as digits instead of rejecting them. Update the `boxed_primitive_json_value`
handling so `Object(1n)` is excluded from this scalar path and instead follows
the BigInt error behavior used by `JSON.stringify`, while keeping the existing
unwrapping for Boolean, Number, and String wrappers.

Comment on lines +1167 to +1169
// JSON.stringify(symbol) returns undefined per spec (ECMA-262 §25.5.2.2 step 5)
if crate::symbol::js_is_symbol(value) != 0 {
return TAG_UNDEFINED as i64;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE='crates/perry-runtime/src/json/replacer.rs'
echo '== outline =='
ast-grep outline "$FILE" --view expanded || true
echo
echo '== relevant ranges =='
rg -n "js_json_stringify_full|js_is_symbol|replacer|TAG_UNDEFINED|root" "$FILE"
echo
echo '== surrounding lines 1120-1205 =='
sed -n '1120,1205p' "$FILE"
echo
echo '== search callers =='
rg -n "js_json_stringify_full\\(" crates/perry-runtime/src

Repository: PerryTS/perry

Length of output: 15559


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '1288,1348p' crates/perry-runtime/src/json/replacer.rs

Repository: PerryTS/perry

Length of output: 2790


Move the symbol check below the root replacer call. js_json_stringify_full already applies toJSON and the function replacer to the root value, but this early js_is_symbol(value) return skips that path, so JSON.stringify(Symbol(), () => 1) still returns undefined instead of the replacer result.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/json/replacer.rs` around lines 1167 - 1169, Move the
symbol early-return below the root replacer handling in js_json_stringify_full
so the root value still goes through toJSON and the function replacer first. The
current js_is_symbol(value) check short-circuits before the root replacer can
run, which prevents JSON.stringify(Symbol(), () => 1) from returning the
replacer result. Keep the symbol-to-undefined behavior only after the root
replacer path has been applied, using the existing js_json_stringify_full and
js_is_symbol logic to locate the change.

Comment on lines +1223 to +1225
// ECMA-262 §25.5.2.1 step 6b: truncate string spacer to 10 characters.
let full = str_from_header(sp_ptr).unwrap_or("");
indent_str = full.chars().take(10).collect();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant file first.
ast-grep outline crates/perry-runtime/src/json/replacer.rs --view expanded || true

# Locate the spacer truncation and nearby helpers/tests.
rg -n "take\\(10\\)|spacer|indent_str|str_from_header|JSON.stringify|ECMA-262" crates/perry-runtime/src/json/replacer.rs crates/perry-runtime/src -g '!**/target/**' || true

# Read the relevant section with line numbers.
sed -n '1180,1260p' crates/perry-runtime/src/json/replacer.rs

Repository: PerryTS/perry

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find any tests or helper utilities that exercise spacer truncation semantics.
rg -n "indent_str|spacer|JSON.stringify|utf16|len_utf16|chars\\(\\)\\.take\\(10\\)" crates/perry-runtime -g '!**/target/**' || true

Repository: PerryTS/perry

Length of output: 33165


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the string header accessor used by replacer.
sed -n '470,510p' crates/perry-runtime/src/json/mod.rs

# Show the UTF-16-based string helpers already used elsewhere in the runtime.
sed -n '200,230p' crates/perry-runtime/src/string/mod.rs

# Tiny semantic probe: JS string length is UTF-16 code units, not Unicode scalar values.
node - <<'JS'
const samples = [
  "😀".repeat(10),
  "😀".repeat(9) + "a",
  "a😀".repeat(5),
];
for (const s of samples) {
  console.log(JSON.stringify({
    sample: s,
    chars: [...s].length,
    codeUnits: s.length,
    take10: [...s].slice(0, 10).join(""),
    take10Units: s.slice(0, 10),
  }));
}
JS

Repository: PerryTS/perry

Length of output: 2742


Truncate the spacer by UTF-16 code units
chars().take(10) counts Unicode scalar values, so astral symbols can exceed the JS JSON.stringify 10-unit limit. Use a UTF-16-aware clamp here so non-BMP spacer strings match spec.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/json/replacer.rs` around lines 1223 - 1225, The
spacer truncation in the JSON replacer is using chars().take(10), which counts
Unicode scalar values instead of UTF-16 code units and can violate the
JSON.stringify spec for non-BMP characters. Update the truncation logic in the
indent/spacer handling in replacer.rs to clamp by UTF-16 code units, keeping the
existing ECMA-262 step 6b behavior and ensuring astral symbols are counted
correctly.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Closing: all 5 fixes in this PR (#5591 — JSON symbol→undefined, space truncation, boxed-primitive in replacer, new String(Symbol)→TypeError, String.prototype brand-checks) were independently shipped in #5803 which has already merged to main. This branch was based 178 commits behind origin/main (at 911a979) so CI was also failing due to stale code unrelated to these changes.

@TheHypnoo
TheHypnoo deleted the fix/5591-test262-builtins-tail branch July 21, 2026 12:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test262 built-ins tail — 133 fails (String/Function.toString/Proxy/JSON/TypedArray/AsyncFromSyncIterator)

1 participant