From 065b108bbacbb59bbdae217924c8ea0241ebd5b9 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:25:14 -0500 Subject: [PATCH] feat(codemode): support JSON callbacks (#38006) --- packages/codemode/interpreter-support.md | 9 +- packages/codemode/src/interpreter/methods.ts | 6 +- packages/codemode/src/interpreter/model.ts | 6 +- .../codemode/src/interpreter/references.ts | 10 +- packages/codemode/src/interpreter/runtime.ts | 19 +- packages/codemode/src/stdlib/json.ts | 184 ++++++++++--- packages/codemode/test/callbacks.test.ts | 20 +- .../test/json-callbacks-test262.test.ts | 243 ++++++++++++++++++ packages/codemode/test/parity.test.ts | 3 + 9 files changed, 435 insertions(+), 65 deletions(-) create mode 100644 packages/codemode/test/json-callbacks-test262.test.ts diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 6807549515..ceb5c205aa 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -247,10 +247,15 @@ ultimate source of truth. - [x] `JSON.parse` and `JSON.stringify` for supported data objects; the blocked data-key gap listed above still applies. - [x] Numeric/string indentation for `JSON.stringify`. +- [x] `JSON.parse` reviver callbacks, including postorder traversal, deletion through `undefined`, and root replacement. + Revivers receive `(key, value)` but no `this` holder because CodeMode functions intentionally have no `this`. +- [x] `JSON.stringify` function and array replacers. Function replacers receive `(key, value)` in preorder, including + the root, but no `this` holder. Array replacers preserve requested property order, deduplicate names, coerce + number primitives, and ignore non-string/non-number entries. Primitive wrapper entries remain unsupported. +- [x] JSON callbacks retain the blocked-key boundary: parsed or stringified data containing `__proto__`, `constructor`, + or `prototype` is rejected before callback traversal. - [x] Captured `console.log`, `console.info`, `console.debug`, `console.warn`, and `console.error`. - [x] Captured `console.dir` and `console.table`. -- [ ] `JSON.parse` reviver callbacks. -- [ ] `JSON.stringify` function/array replacers. ## Date diff --git a/packages/codemode/src/interpreter/methods.ts b/packages/codemode/src/interpreter/methods.ts index 46a78e4e84..dc2f6bb5c6 100644 --- a/packages/codemode/src/interpreter/methods.ts +++ b/packages/codemode/src/interpreter/methods.ts @@ -8,6 +8,7 @@ import { GlobalNamespace, IntrinsicReference, InterpreterRuntimeError, + JsonMethodReference, PromiseCapabilityFunction, PromiseNamespace, UriFunction, @@ -25,7 +26,6 @@ import { isCodeModeValue, } from "../values.js" import { dateSetterArgumentCount, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js" -import { invokeJsonMethod } from "../stdlib/json.js" import { invokeMathMethod } from "../stdlib/math.js" import { invokeNumberMethod, invokeNumberStatic } from "../stdlib/number.js" import { invokeObjectMethod } from "../stdlib/object.js" @@ -54,6 +54,7 @@ export type SupportedCallback = | UriFunction | PromiseCapabilityFunction | GlobalMethodReference + | JsonMethodReference | IntrinsicReference | ErrorConstructorReference | GlobalNamespace @@ -65,6 +66,7 @@ export const isSupportedCallback = (value: unknown): value is SupportedCallback value instanceof UriFunction || value instanceof PromiseCapabilityFunction || value instanceof GlobalMethodReference || + value instanceof JsonMethodReference || value instanceof IntrinsicReference || value instanceof ErrorConstructorReference || // Callable namespaces dispatch like JS: Array/Object/Date/RegExp construct, @@ -168,7 +170,7 @@ export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array { diff --git a/packages/codemode/src/interpreter/model.ts b/packages/codemode/src/interpreter/model.ts index 1f04cb69e2..2ceca39fbc 100644 --- a/packages/codemode/src/interpreter/model.ts +++ b/packages/codemode/src/interpreter/model.ts @@ -99,11 +99,15 @@ export class GlobalNamespace { export class GlobalMethodReference { constructor( - readonly namespace: GlobalNamespaceName | "Number" | "String", + readonly namespace: Exclude | "Number" | "String", readonly name: string, ) {} } +export class JsonMethodReference { + constructor(readonly name: "parse" | "stringify") {} +} + export class CoercionFunction { constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat" | "isFinite" | "isNaN") {} } diff --git a/packages/codemode/src/interpreter/references.ts b/packages/codemode/src/interpreter/references.ts index b2ac2d22a2..f081622f07 100644 --- a/packages/codemode/src/interpreter/references.ts +++ b/packages/codemode/src/interpreter/references.ts @@ -7,6 +7,7 @@ import { GlobalNamespace, InterpreterRuntimeError, IntrinsicReference, + JsonMethodReference, PromiseCapabilityFunction, PromiseInstanceMethodReference, PromiseMethodReference, @@ -23,6 +24,7 @@ export const isRuntimeReference = (value: unknown): boolean => value instanceof IntrinsicReference || value instanceof GlobalNamespace || value instanceof GlobalMethodReference || + value instanceof JsonMethodReference || value instanceof PromiseNamespace || value instanceof PromiseMethodReference || value instanceof PromiseInstanceMethodReference || @@ -82,12 +84,7 @@ export const containsOpaqueReference = (value: unknown): boolean => { } // Reject cycles before mutation so later boundary walks remain safe. -export const rejectCircularInsertion = ( - container: object, - value: unknown, - label: string, - node: AstNode, -): void => { +export const rejectCircularInsertion = (container: object, value: unknown, label: string, node: AstNode): void => { const pending: Array> = [[value].values()] const seen = new Set() while (pending.length > 0) { @@ -111,6 +108,7 @@ export const typeofValue = (value: unknown): string => { value instanceof CoercionFunction || value instanceof IntrinsicReference || value instanceof GlobalMethodReference || + value instanceof JsonMethodReference || value instanceof PromiseMethodReference || value instanceof PromiseInstanceMethodReference || value instanceof PromiseNamespace || diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index d1052e8430..851ec30b3c 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -19,6 +19,7 @@ import { IntrinsicReference, InterpreterRuntimeError, isRecord, + JsonMethodReference, type MemberReference, OptionalShortCircuit, PromiseCapabilityFunction, @@ -55,7 +56,7 @@ import { ScopeStack } from "./scope.js" import { arrayMethods, mapMethods, mapStatics, setMethods, spreadItems } from "../stdlib/collections.js" import { consoleMethods, formatConsoleMessage } from "../stdlib/console.js" import { dateMethods, dateStatics } from "../stdlib/date.js" -import { jsonStatics } from "../stdlib/json.js" +import { invokeJsonMethod, jsonStatics, type JsonMethodName } from "../stdlib/json.js" import { mathConstants, mathMethods } from "../stdlib/math.js" import { numberConstants, numberMethods, numberStatics } from "../stdlib/number.js" import { objectMethodsPreservingIdentity, objectStatics } from "../stdlib/object.js" @@ -102,7 +103,6 @@ import { const globalStaticMembers: Partial>> = { Object: objectStatics, Math: mathMethods, - JSON: jsonStatics, Array: arrayStatics, console: consoleMethods, Date: dateStatics, @@ -1624,6 +1624,9 @@ export class Interpreter { } return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`) } + if (callable instanceof JsonMethodReference) { + return yield* invokeJsonMethod(self.runner, callable.name, args, node) + } if (callable instanceof CoercionFunction) { return boundedData(invokeCoercion(callable, args, node), `${callable.name} result`) } @@ -1889,6 +1892,7 @@ export class Interpreter { | PromiseInstanceMethodReference | IntrinsicReference | GlobalMethodReference + | JsonMethodReference | ComputedValue | typeof OptionalShortCircuit | undefined, @@ -1936,6 +1940,10 @@ export class Interpreter { if (objectValue.name === "Math" && mathConstants.has(key)) { return new ComputedValue((Math as unknown as Record)[key]) } + if (objectValue.name === "JSON") { + if (jsonStatics.has(key)) return new JsonMethodReference(key as JsonMethodName) + return new ComputedValue(undefined) + } if (globalStaticMembers[objectValue.name]?.has(key)) { return new GlobalMethodReference(objectValue.name, key) } @@ -2065,7 +2073,8 @@ export class Interpreter { reference instanceof PromiseMethodReference || reference instanceof PromiseInstanceMethodReference || reference instanceof IntrinsicReference || - reference instanceof GlobalMethodReference + reference instanceof GlobalMethodReference || + reference instanceof JsonMethodReference ) return reference if (Array.isArray(reference.target)) { @@ -2100,6 +2109,7 @@ export class Interpreter { reference instanceof PromiseInstanceMethodReference || reference instanceof IntrinsicReference || reference instanceof GlobalMethodReference || + reference instanceof JsonMethodReference || reference.target instanceof CodeModeURL ) { throw new InterpreterRuntimeError("Only data fields may be deleted in CodeMode.", target, "InvalidDataValue") @@ -2127,7 +2137,8 @@ export class Interpreter { reference instanceof PromiseMethodReference || reference instanceof PromiseInstanceMethodReference || reference instanceof IntrinsicReference || - reference instanceof GlobalMethodReference + reference instanceof GlobalMethodReference || + reference instanceof JsonMethodReference ) { throw new InterpreterRuntimeError("Only data fields may be assigned in CodeMode.", node) } diff --git a/packages/codemode/src/stdlib/json.ts b/packages/codemode/src/stdlib/json.ts index 964e6a4fb0..fd917a877c 100644 --- a/packages/codemode/src/stdlib/json.ts +++ b/packages/codemode/src/stdlib/json.ts @@ -1,45 +1,149 @@ -import { type AstNode, InterpreterRuntimeError, supportedSyntaxMessage } from "../interpreter/model.js" +import { Effect } from "effect" +import type { CallbackRunner } from "../interpreter/methods.js" +import { applyCollectionCallback } from "../interpreter/methods.js" +import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" import { typeofValue } from "../interpreter/references.js" -import { copyIn, copyOut } from "../tool-runtime.js" +import { copyIn, copyOut, type SafeObject } from "../tool-runtime.js" +import { + CodeModeDate, + CodeModeMap, + CodeModeRegExp, + CodeModeSet, + CodeModeURL, + CodeModeURLSearchParams, +} from "../values.js" export const jsonStatics = new Set(["parse", "stringify"]) +export type JsonMethodName = "parse" | "stringify" -export const invokeJsonMethod = (name: string, args: Array, node: AstNode): unknown => { - switch (name) { - case "stringify": { - const replacer = args[1] - if (Array.isArray(replacer) || typeofValue(replacer) === "function") { - throw new InterpreterRuntimeError( - "JSON.stringify replacers are not supported in CodeMode.", - node, - "UnsupportedSyntax", - [supportedSyntaxMessage], - ) - } - const space = args[2] - const indent = typeof space === "number" || typeof space === "string" ? space : undefined - return JSON.stringify(copyOut(copyIn(args[0], "JSON.stringify value"), "json"), null, indent) - } - case "parse": { - const text = args[0] - if (typeof text !== "string") throw new InterpreterRuntimeError("JSON.parse expects a string.", node) - if (typeofValue(args[1]) === "function") { - throw new InterpreterRuntimeError( - "JSON.parse revivers are not supported in CodeMode.", - node, - "UnsupportedSyntax", - [supportedSyntaxMessage], - ) - } - try { - return copyIn(JSON.parse(text), "JSON.parse result") - } catch (error) { - throw new InterpreterRuntimeError( - `JSON.parse received invalid JSON: ${error instanceof Error ? error.message : String(error)}`, - node, - ).as("SyntaxError") - } - } - } - throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node) +export const invokeJsonMethod = ( + runner: CallbackRunner, + name: JsonMethodName, + args: Array, + node: AstNode, +): Effect.Effect => { + return name === "parse" ? parse(runner, args, node) : stringify(runner, args, node) } + +const parse = ( + runner: CallbackRunner, + args: Array, + node: AstNode, +): Effect.Effect => { + const text = args[0] + if (typeof text !== "string") throw new InterpreterRuntimeError("JSON.parse expects a string.", node) + + const parsed = (() => { + try { + return copyIn(JSON.parse(text), "JSON.parse result") + } catch (error) { + throw new InterpreterRuntimeError( + `JSON.parse received invalid JSON: ${error instanceof Error ? error.message : String(error)}`, + node, + ).as("SyntaxError") + } + })() + if (typeofValue(args[1]) !== "function") return Effect.succeed(parsed) + + const apply = applyCollectionCallback(runner, args[1], "JSON.parse", node) + const root: SafeObject = Object.create(null) as SafeObject + root[""] = parsed + const visit = (holder: SafeObject | Array, key: string): Effect.Effect => + Effect.gen(function* () { + const value = holder[key as keyof typeof holder] + if (Array.isArray(value)) { + const length = value.length + for (let index = 0; index < length; index += 1) { + const revived = yield* visit(value, String(index)) + if (revived === undefined) Reflect.deleteProperty(value, index) + else value[index] = revived + } + } else if (isPlainObject(value)) { + for (const name of Object.keys(value)) { + const revived = yield* visit(value, name) + if (revived === undefined) Reflect.deleteProperty(value, name) + else value[name] = revived + } + } + return yield* apply([key, value]) + }) + return visit(root, "") +} + +const stringify = ( + runner: CallbackRunner, + args: Array, + node: AstNode, +): Effect.Effect => { + const space = args[2] + const indent = typeof space === "number" || typeof space === "string" ? space : undefined + const replacer = args[1] + const callable = typeofValue(replacer) === "function" + const checked = copyIn(args[0], "JSON.stringify value", callable) + const input = callable ? args[0] : checked + + if (Array.isArray(replacer)) { + const properties = replacer + .filter((item): item is string | number => typeof item === "string" || typeof item === "number") + .map(String) + return Effect.succeed(JSON.stringify(copyOut(input, "json"), properties, indent)) + } + if (!callable) { + return Effect.succeed(JSON.stringify(copyOut(input, "json"), null, indent)) + } + + const apply = applyCollectionCallback(runner, replacer, "JSON.stringify", node) + const root: SafeObject = Object.create(null) as SafeObject + root[""] = input + const stack = new Set() + const visit = (holder: SafeObject | Array, key: string): Effect.Effect => + Effect.gen(function* () { + const value = yield* apply([key, toJSONValue(holder[key as keyof typeof holder])]) + if (value === undefined || typeofValue(value) === "function") return undefined + copyIn(value, "JSON.stringify replacer result", true) + if (typeof value === "number") return Number.isFinite(value) ? value : null + if (value === null || typeof value === "string" || typeof value === "boolean") return value + if (Array.isArray(value)) { + if (stack.has(value)) + throw new InterpreterRuntimeError("Converting circular structure to JSON.", node).as("TypeError") + stack.add(value) + const result: Array = [] + for (let index = 0; index < value.length; index += 1) { + result.push((yield* visit(value, String(index))) ?? null) + } + stack.delete(value) + return result + } + if (!isPlainObject(value)) return {} + if (stack.has(value)) + throw new InterpreterRuntimeError("Converting circular structure to JSON.", node).as("TypeError") + stack.add(value) + const result: SafeObject = Object.create(null) as SafeObject + for (const name of Object.keys(value)) { + const item = yield* visit(value, name) + if (item !== undefined) result[name] = item + } + stack.delete(value) + return result + }) + + return Effect.map(visit(root, ""), (value) => JSON.stringify(value, null, indent)) +} + +const toJSONValue = (value: unknown): unknown => { + if (value instanceof CodeModeDate) { + return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null + } + if (value instanceof CodeModeURL) return value.url.href + return value +} + +const isPlainObject = (value: unknown): value is SafeObject => + value !== null && + typeof value === "object" && + !(value instanceof CodeModeDate) && + !(value instanceof CodeModeRegExp) && + !(value instanceof CodeModeMap) && + !(value instanceof CodeModeSet) && + !(value instanceof CodeModeURL) && + !(value instanceof CodeModeURLSearchParams) diff --git a/packages/codemode/test/callbacks.test.ts b/packages/codemode/test/callbacks.test.ts index 613933699a..3c64391718 100644 --- a/packages/codemode/test/callbacks.test.ts +++ b/packages/codemode/test/callbacks.test.ts @@ -285,21 +285,21 @@ describe("still-rejected callables get the wrap hint", () => { expect(diagnostic.message).toContain("wrap it in an arrow function") }) - test("callable JSON.stringify replacers are rejected, never silently ignored", async () => { - expect((await error(`return JSON.stringify({ a: 1 }, Math.abs)`)).message).toContain( - "JSON.stringify replacers are not supported", - ) + test("JSON callbacks use the unified callback gate", async () => { + expect( + await value(`return JSON.stringify({ a: -1 }, (key, item) => typeof item === "number" ? Math.abs(item) : item)`), + ).toBe('{"a":1}') expect((await toolError(`return JSON.stringify({ a: 1 }, tools.host.echo)`)).message).toContain( - "JSON.stringify replacers are not supported", + "wrap it in an arrow function", + ) + expect((await toolError(`return JSON.parse('{"a":1}', tools.host.echo)`)).message).toContain( + "wrap it in an arrow function", ) }) - test("callable JSON.parse revivers are rejected, never silently ignored", async () => { - expect((await error(`return JSON.parse('{"a":1}', (key, v) => 99)`)).message).toContain( - "JSON.parse revivers are not supported", - ) + test("non-callable JSON callback arguments are ignored", async () => { expect(await value(`return JSON.parse('{"a":1}', undefined)`)).toEqual({ a: 1 }) - // A non-callable reviver is silently ignored, matching JS's IsCallable check. expect(await value(`return JSON.parse('{"a":1}', 42)`)).toEqual({ a: 1 }) + expect(await value(`return JSON.stringify({ a: 1 }, 42)`)).toBe('{"a":1}') }) }) diff --git a/packages/codemode/test/json-callbacks-test262.test.ts b/packages/codemode/test/json-callbacks-test262.test.ts new file mode 100644 index 0000000000..b3741020b4 --- /dev/null +++ b/packages/codemode/test/json-callbacks-test262.test.ts @@ -0,0 +1,243 @@ +/* + * Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75: + * - test/built-ins/JSON/parse/reviver-call-order.js + * - test/built-ins/JSON/parse/reviver-call-err.js + * - test/built-ins/JSON/stringify/replacer-array-duplicates.js + * - test/built-ins/JSON/stringify/replacer-array-empty.js + * - test/built-ins/JSON/stringify/replacer-array-number.js + * - test/built-ins/JSON/stringify/replacer-array-order.js + * - test/built-ins/JSON/stringify/replacer-array-undefined.js + * - test/built-ins/JSON/stringify/replacer-array-wrong-type.js + * - test/built-ins/JSON/stringify/replacer-function-abrupt.js + * - test/built-ins/JSON/stringify/replacer-function-arguments.js + * - test/built-ins/JSON/stringify/replacer-function-array-circular.js + * - test/built-ins/JSON/stringify/replacer-function-object-deleted-property.js + * - test/built-ins/JSON/stringify/replacer-function-object-circular.js + * - test/built-ins/JSON/stringify/replacer-function-result-undefined.js + * - test/built-ins/JSON/stringify/replacer-function-result.js + * + * Function-context assertions are omitted because CodeMode functions intentionally + * have no `this`. Number/String wrapper cases are omitted because CodeMode does not + * support primitive wrapper objects. Proxy and Symbol cases are outside CodeMode. + * + * Copyright (C) 2012 Ecma International. All rights reserved. + * Copyright (C) 2016 the V8 project authors. All rights reserved. + * Copyright (C) 2019 Aleksey Shvayka. All rights reserved. + * Copyright 2019 Kevin Gibbons. All rights reserved. + * Test262 portions are governed by the BSD license in LICENSE.test262. + */ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { CodeMode } from "../src/index.js" +import { invokeJsonMethod } from "../src/stdlib/json.js" + +const value = async (code: string) => { + const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} })) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} + +describe("Test262 JSON.parse reviver adaptations", () => { + test("test/built-ins/JSON/parse/reviver-call-order.js", async () => { + expect( + await value(` + const calls = [] + JSON.parse('{"p1":0,"p2":0,"p1":0,"2":0,"1":0}', (key, item) => { + calls.push(key) + return item + }) + return calls + `), + ).toEqual(["1", "2", "p1", "p2", ""]) + }) + + test("deletes properties and array elements when the reviver returns undefined", async () => { + expect( + await value(` + const result = JSON.parse('{"keep":1,"drop":2,"items":[1,2,3]}', (key, item) => { + if (key === "drop" || key === "1") return undefined + return item + }) + return [result, Object.hasOwn(result, "drop"), Object.hasOwn(result.items, 1), result.items.length] + `), + ).toEqual([{ keep: 1, items: [1, null, 3] }, false, false, 3]) + }) + + test("replaces the root and may delete it", async () => { + expect(await value(`return JSON.parse('{"a":1}', (key, item) => key === "" ? item.a : item)`)).toBe(1) + expect( + await value(`return JSON.parse('{"a":1}', (key, item) => key === "" ? undefined : item) === undefined`), + ).toBe(true) + }) + + test("test/built-ins/JSON/parse/reviver-call-err.js", async () => { + expect( + await value(`try { JSON.parse("0", () => { throw "stop" }) } catch (error) { return error } return "missed"`), + ).toBe("stop") + }) +}) + +describe("Test262 JSON.stringify replacer adaptations", () => { + test("array replacers filter keys in their own order and apply recursively", async () => { + expect(await value(`return JSON.stringify({ b: 1, a: 2, c: 3 }, ["c", "b", "a"])`)).toBe('{"c":3,"b":1,"a":2}') + expect(await value(`return JSON.stringify({ a: { b: 2, c: 3 } }, ["c", "b", "a"])`)).toBe('{"a":{"c":3,"b":2}}') + expect(await value(`return JSON.stringify({ a: 1 }, [])`)).toBe("{}") + }) + + test("array replacers deduplicate and coerce number primitives", async () => { + expect(await value(`return JSON.stringify({ a: 1, b: 2 }, ["a", "a", "b"])`)).toBe('{"a":1,"b":2}') + expect( + await value( + `return JSON.stringify({ "0": 0, "1": 1, "-4": 2, "0.3": 3, "-Infinity": 4, "NaN": 5 }, [-0, 1, -4, 0.3, -Infinity, NaN])`, + ), + ).toBe('{"0":0,"1":1,"-4":2,"0.3":3,"-Infinity":4,"NaN":5}') + }) + + test("array replacers ignore unsupported entry types", async () => { + expect( + await value(`return JSON.stringify({ true: 1, false: 2, null: 3, kept: 4 }, [true, false, null, {}, "kept"])`), + ).toBe('{"kept":4}') + expect(await value(`return JSON.stringify({ a: 1 }, undefined)`)).toBe('{"a":1}') + }) + + test("function replacers receive keys and values in preorder including the root", async () => { + expect( + await value(` + const calls = [] + const output = JSON.stringify({ a: [1, 2], b: true }, (key, item) => { + calls.push([key, Array.isArray(item) ? "array" : typeof item]) + return item + }) + return [output, calls] + `), + ).toEqual([ + '{"a":[1,2],"b":true}', + [ + ["", "object"], + ["a", "array"], + ["0", "number"], + ["1", "number"], + ["b", "boolean"], + ], + ]) + }) + + test("test/built-ins/JSON/stringify/replacer-function-object-deleted-property.js", async () => { + expect( + await value(` + const input = { a: 1, b: 2 } + return JSON.stringify(input, (key, item) => { + if (key === "a") delete input.b + if (key === "b" && item === undefined) return "" + return item + }) + `), + ).toBe('{"a":1,"b":""}') + }) + + test("test/built-ins/JSON/stringify/replacer-function-object-circular.js", async () => { + expect( + await value(` + const direct = { prop: {} } + const indirect = { p1: { p2: {} } } + const errors = [] + try { JSON.stringify(direct, () => direct) } catch (error) { errors.push(error.name) } + try { + JSON.stringify(indirect, (key, item) => key === "p2" ? indirect : item) + } catch (error) { errors.push(error.name) } + return errors + `), + ).toEqual(["TypeError", "TypeError"]) + }) + + test("test/built-ins/JSON/stringify/replacer-function-array-circular.js", async () => { + expect( + await value(` + const circular = [{}] + try { JSON.stringify(circular, () => circular) } catch (error) { return error.name } + return "missed" + `), + ).toBe("TypeError") + }) + + test("repeated objects outside the active ancestor chain are not circular", async () => { + expect( + await value(` + const shared = { value: 1 } + return JSON.stringify({ left: shared, right: shared }, (key, item) => item) + `), + ).toBe('{"left":{"value":1},"right":{"value":1}}') + }) + + test("function replacers replace values and map undefined to omission or null", async () => { + expect( + await value(`return JSON.stringify({ a: 1, nested: { b: [1] } }, (key, item) => item === 1 ? undefined : item)`), + ).toBe('{"nested":{"b":[null]}}') + expect(await value(`return JSON.stringify(null, (key) => key === "" ? { value: 1 } : 2)`)).toBe('{"value":2}') + expect(await value(`return JSON.stringify(1, () => undefined) === undefined`)).toBe(true) + }) + + test("callable replacer results follow JSON omission semantics", async () => { + expect( + await value(` + const fn = () => 1 + return [ + JSON.stringify({ user: 1, builtin: 2 }, (key, item) => key === "user" ? fn : key === "builtin" ? Math.abs : item), + JSON.stringify([1, 2], (key, item) => key === "0" ? fn : key === "1" ? Number : item), + JSON.stringify(null, () => "abc".includes) === undefined, + ] + `), + ).toEqual(["{}", "[null,null]", true]) + }) + + test("built-in toJSON conversion runs before the function replacer", async () => { + expect( + await value(` + const seen = [] + const output = JSON.stringify({ date: new Date(0), url: new URL("https://example.test/a") }, (key, item) => { + if (key === "date" || key === "url") seen.push(typeof item) + return item + }) + return [output, seen] + `), + ).toEqual(['{"date":"1970-01-01T00:00:00.000Z","url":"https://example.test/a"}', ["string", "string"]]) + }) + + test("test/built-ins/JSON/stringify/replacer-function-abrupt.js", async () => { + expect( + await value(`try { JSON.stringify({}, () => { throw "stop" }) } catch (error) { return error } return "missed"`), + ).toBe("stop") + }) +}) + +describe("CodeMode JSON callback boundaries", () => { + test("this remains unsupported rather than exposing callback holders", async () => { + const result = await Effect.runPromise( + CodeMode.execute({ code: `return JSON.parse("1", function (key, item) { return this })`, tools: {} }), + ) + expect(result).toMatchObject({ ok: false, error: { kind: "UnsupportedSyntax" } }) + }) + + test("blocked parse keys are rejected before reviver traversal", async () => { + expect( + await value( + `try { JSON.parse('{"__proto__":1}', (key, item) => item) } catch (error) { return true } return false`, + ), + ).toBe(true) + }) + + test("JSON.stringify directly rejects blocked input keys", () => { + expect(() => + invokeJsonMethod( + { + invokeFunction: () => Effect.die("unused"), + invokeCallable: () => Effect.die("unused"), + settlePromise: () => Effect.die("unused"), + }, + "stringify", + [Object.fromEntries([["constructor", 1]])], + { type: "CallExpression" }, + ), + ).toThrow("blocked property 'constructor'") + }) +}) diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index 632d7b309b..a801f9b720 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -889,6 +889,9 @@ describe("coercion parity: unknown static members read as undefined", () => { "TypeError: Math.sum is not a function.", ) expect(await value(`try { Math["sum"]([1]) } catch (e) { return e.message }`)).toBe("Math.sum is not a function.") + expect(await value(`try { JSON.rawJSON("1") } catch (e) { return e.message }`)).toBe( + "JSON.rawJSON is not a function.", + ) }) test("blocked members still throw instead of reading as undefined", async () => {