From dd42cbb6996f1c8211cca483f2e2d6af1bc56e2d Mon Sep 17 00:00:00 2001 From: hiett Date: Wed, 15 Jul 2026 08:16:26 +0100 Subject: [PATCH] JSON.stringify: unwrap wrapper objects and reject cyclical structures Two SerializeJSONProperty conformance fixes (sec-serializejsonproperty): - Step 4.a-4.c: a Number/String/Boolean wrapper object (new Number(n), new String(s), new Boolean(b)) now serializes as its wrapped primitive instead of an empty object, so JSON.stringify(new Boolean(true)) is "true", including wrappers nested in an object/array or returned from a toJSON/replacer. Adds json_unwrap_primitive; matches test262 JSON/stringify/value-boolean-object. - SerializeJSONObject/SerializeJSONArray step 1: a cyclical structure now throws a TypeError (catchable by JS try/catch) instead of looping forever. json_serialize_cell keeps an ancestor stack in the process dictionary, pushed on entry and popped on exit, so a value that merely appears twice in sibling positions (a diamond) is still serialized. Clears the six JSON/stringify circular-reference hangs. Adds spec-driven tests mirroring the test262 cases. --- src/twocore_rt_js_ffi.erl | 81 ++++++++++++++++++++++++++++++------- test/js_compiler_test.gleam | 73 +++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 15 deletions(-) diff --git a/src/twocore_rt_js_ffi.erl b/src/twocore_rt_js_ffi.erl index 7873c10..db8adc3 100644 --- a/src/twocore_rt_js_ffi.erl +++ b/src/twocore_rt_js_ffi.erl @@ -7004,9 +7004,12 @@ excluded_keys(_) -> %% * anything else is treated as no replacer. %% `space` sets the indentation gap: a Number N yields min(10, floor(N)) spaces %% (0/negative → none); a String yields its first 10 code units; else none. -%% Deviation: `this`-bound `toJSON`/replacer receivers and String/Number/Boolean -%% wrapper objects are not modelled (this runtime has no such wrappers). +%% String/Number/Boolean wrapper objects ARE unwrapped to their primitive per +%% SerializeJSONProperty step 4 (see `json_unwrap_primitive`). A cyclical +%% structure raises a TypeError (see `json_serialize_cell`). Deviation: +%% `this`-bound `toJSON`/replacer receivers are still not modelled. json_stringify(V, Replacer, Space) -> + erlang:erase(json_stack), {RepFn, PropList} = json_replacer(Replacer), Gap = json_gap(Space), St = {RepFn, PropList, Gap}, @@ -7123,17 +7126,37 @@ json_call_tojson(Key, V, Stored) -> end. json_serialize_value(V, St, Indent) -> - case js_type(V) of - number -> json_num(V); - boolean -> to_string(V); - string -> json_str(V); - null -> <<"null">>; - undefined -> skip; - function -> skip; - object -> json_serialize_cell(V, St, Indent); - other -> skip + case json_unwrap_primitive(V) of + {primitive, Prim} -> + json_serialize_value(Prim, St, Indent); + no -> + case js_type(V) of + number -> json_num(V); + boolean -> to_string(V); + string -> json_str(V); + null -> <<"null">>; + undefined -> skip; + function -> skip; + object -> json_serialize_cell(V, St, Indent); + other -> skip + end end. +%% SerializeJSONProperty steps 4.a–4.c: a Number/String/Boolean wrapper OBJECT +%% (`new Number(n)` / `new String(s)` / `new Boolean(b)`) is serialized as its +%% wrapped primitive [[NumberData]]/[[StringData]]/[[BooleanData]], NOT as an +%% object — so `JSON.stringify(new Boolean(true))` yields `"true"`, not `"{}"`, +%% and a wrapper nested inside an object/array or returned from a replacer/toJSON +%% is serialized by its primitive too. Returns `{primitive, Prim}` for a wrapper +%% cell, `no` for any other value. +json_unwrap_primitive(V) when is_reference(V) -> + case erlang:get(?CELL_KEY(V)) of + {js_wrapper, _Kind, Prim} -> {primitive, Prim}; + _ -> no + end; +json_unwrap_primitive(_) -> + no. + json_num(js_nan) -> <<"null">>; json_num(js_inf) -> <<"null">>; json_num(js_neg_inf) -> <<"null">>; @@ -7165,11 +7188,39 @@ unicode_escape(B) -> %% Serialize an object/array cell. Arrays and plain objects recurse; every other %% cell kind (regex, Map, Set, …) has no enumerable own properties and renders as %% an empty object `{}`, matching JSON.stringify on an ordinary object. +%% +%% SerializeJSONObject/SerializeJSONArray step 1: "If stack contains value, throw +%% a TypeError exception because the structure is cyclical." The stack is the set +%% of object/array cells currently being serialized (an ancestor chain), tracked +%% in the process dictionary under `json_stack`. A cell is pushed on entry and +%% popped on exit (via `after`, so it is restored on a thrown TypeError too), so a +%% value that merely appears twice in DIFFERENT branches (a diamond, not a cycle) +%% is NOT rejected. A truly cyclical reference recurses back to an ancestor still +%% on the stack and raises `type_error`, which a JS `try`/`catch` observes as a +%% TypeError — without this, a self-referential object looped forever. json_serialize_cell(Ref, St, Indent) -> - case erlang:get(?CELL_KEY(Ref)) of - {js_array, Len, _Map} -> json_serialize_array(Ref, Len, St, Indent); - M when is_map(M) -> json_serialize_object(Ref, M, St, Indent); - _ -> <<"{}">> + Stack = + case erlang:get(json_stack) of + undefined -> []; + L -> L + end, + case lists:member(Ref, Stack) of + true -> + type_error(Ref); + false -> + erlang:put(json_stack, [Ref | Stack]), + try + case erlang:get(?CELL_KEY(Ref)) of + {js_array, Len, _Map} -> + json_serialize_array(Ref, Len, St, Indent); + M when is_map(M) -> + json_serialize_object(Ref, M, St, Indent); + _ -> + <<"{}">> + end + after + erlang:put(json_stack, Stack) + end end. %% SerializeJSONArray: each index is serialized via SerializeJSONProperty (so the diff --git a/test/js_compiler_test.gleam b/test/js_compiler_test.gleam index f455b5a..5f182a2 100644 --- a/test/js_compiler_test.gleam +++ b/test/js_compiler_test.gleam @@ -7328,3 +7328,76 @@ pub fn wave14_arrow_callback_ignores_thisarg_test() { val("[1, 2, 3].map((x) => x * 2, { k: 9 }).join(',')") |> should.equal(dyn("2,4,6")) } + +// ==== JSON (wave 15) ==== + +// SerializeJSONProperty step 4.c (sec-serializejsonproperty): a Boolean wrapper +// object is serialized as its primitive [[BooleanData]], so `new Boolean(true)` +// stringifies to "true", not "{}". Mirrors JSON/stringify/value-boolean-object.js. +pub fn json_wrapper_boolean_test() { + val("JSON.stringify(new Boolean(true))") |> should.equal(dyn("true")) + val("JSON.stringify(new Boolean(false))") |> should.equal(dyn("false")) +} + +// A Boolean wrapper nested inside an object (returned via toJSON) is unwrapped to +// its primitive per step 4.c — `{"key":false}`, not `{"key":{}}`. +pub fn json_wrapper_boolean_nested_test() { + val( + "JSON.stringify({toJSON: function() { return {key: new Boolean(false)}; }})", + ) + |> should.equal(dyn("{\"key\":false}")) +} + +// A Boolean wrapper produced by a replacer function is unwrapped (step 4 runs +// after the replacer) — `[true]`. Mirrors the third value-boolean-object case. +pub fn json_wrapper_boolean_from_replacer_test() { + val( + "JSON.stringify([1], function(_k, v) { return v === 1 ? new Boolean(true) : v; })", + ) + |> should.equal(dyn("[true]")) +} + +// SerializeJSONProperty step 4.a/4.b: Number and String wrapper objects serialize +// as their primitive [[NumberData]]/[[StringData]]. +pub fn json_wrapper_number_string_test() { + val("JSON.stringify(new Number(14))") |> should.equal(dyn("14")) + val("JSON.stringify(new String('hi'))") |> should.equal(dyn("\"hi\"")) +} + +// SerializeJSONObject step 1: a directly self-referential object is cyclical and +// MUST throw a TypeError (not loop forever). Mirrors value-object-circular.js. +pub fn json_circular_object_throws_test() { + let m = + compile( + "function f() { var d = {}; d.prop = d; try { JSON.stringify(d); return 0; } catch (e) { return e instanceof TypeError ? 1 : 0; } }", + ) + to_float(call(m, "f", [])) |> should.equal(1.0) +} + +// SerializeJSONArray step 1: a self-referential array is cyclical and MUST throw a +// TypeError. Mirrors value-array-circular.js. +pub fn json_circular_array_throws_test() { + let m = + compile( + "function f() { var a = []; a.push(a); try { JSON.stringify(a); return 0; } catch (e) { return e instanceof TypeError ? 1 : 0; } }", + ) + to_float(call(m, "f", [])) |> should.equal(1.0) +} + +// An indirectly cyclical structure (a getter/reference chain back to an ancestor) +// also throws. Mirrors the `indirect` case of value-object-circular.js. +pub fn json_circular_indirect_throws_test() { + let m = + compile( + "function f() { var root = {}; root.a = { b: root }; try { JSON.stringify(root); return 0; } catch (e) { return e instanceof TypeError ? 1 : 0; } }", + ) + to_float(call(m, "f", [])) |> should.equal(1.0) +} + +// The cycle check is a STACK of ancestors, not a global seen-set: the SAME object +// referenced twice in sibling positions (a diamond, not a cycle) serializes fine. +pub fn json_shared_ref_no_false_cycle_test() { + let m = + compile("function f() { var x = { a: 1 }; return JSON.stringify([x, x]); }") + call(m, "f", []) |> should.equal(dyn("[{\"a\":1},{\"a\":1}]")) +}