feat(codemode): support JSON callbacks

This commit is contained in:
Aiden Cline
2026-07-20 17:03:03 -05:00
parent f5a487ffcd
commit d102b48dea
6 changed files with 340 additions and 53 deletions
+7 -2
View File
@@ -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
+1 -2
View File
@@ -25,7 +25,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"
@@ -168,7 +167,7 @@ export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unkno
if (ref.namespace === "Map" || ref.namespace === "Set" || ref.namespace === "URLSearchParams") {
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node)
}
return invokeJsonMethod(ref.name, args, node)
throw new InterpreterRuntimeError(`JSON.${ref.name} requires the effectful JSON dispatcher.`, node)
}
const requireDataArgument = (name: string, index: number, arg: unknown, node: AstNode): unknown => {
@@ -43,6 +43,7 @@ import {
invokeGroupBy,
invokeIntrinsic,
} from "./methods.js"
import { invokeJsonMethod } from "../stdlib/json.js"
import {
constructPromise,
invokePromiseInstanceMethod,
@@ -1607,6 +1608,7 @@ export class Interpreter<R> {
}
if (callable instanceof GlobalMethodReference) {
if (callable.namespace === "console") return self.invokeConsole(callable.name, args, node)
if (callable.namespace === "JSON") return yield* invokeJsonMethod(self.runner, callable.name, args, node)
if (callable.namespace === "Object" && args[0] instanceof ToolReference) {
return self.invokeObjectMethodOnTools(callable.name, args[0], node)
}
+135 -39
View File
@@ -1,45 +1,141 @@
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 const invokeJsonMethod = (name: string, args: Array<unknown>, 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")
}
}
}
export const invokeJsonMethod = <R>(
runner: CallbackRunner<R>,
name: string,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
if (name === "parse") return parse(runner, args, node)
if (name === "stringify") return stringify(runner, args, node)
throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node)
}
const parse = <R>(
runner: CallbackRunner<R>,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
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<unknown>, key: string): Effect.Effect<unknown, unknown, R> =>
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 = <R>(
runner: CallbackRunner<R>,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
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 visit = (holder: SafeObject | Array<unknown>, key: string): Effect.Effect<unknown, unknown, R> =>
Effect.gen(function* () {
const value = yield* apply([key, toJSONValue(holder[key as keyof typeof holder])])
copyIn(value, "JSON.stringify replacer result", true)
if (value === undefined) return undefined
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)) {
const result: Array<unknown> = []
for (let index = 0; index < value.length; index += 1) {
result.push((yield* visit(value, String(index))) ?? null)
}
return result
}
if (!isPlainObject(value)) return {}
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
}
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)
+10 -10
View File
@@ -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}')
})
})
@@ -0,0 +1,185 @@
/*
* 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-object-deleted-property.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"
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 "<replaced>"
return item
})
`),
).toBe('{"a":1,"b":"<replaced>"}')
})
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("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 keys are rejected before callbacks can expose them", async () => {
expect(
await value(`
let parseBlocked = false
let stringifyBlocked = false
try { JSON.parse('{"__proto__":1}', (key, item) => item) } catch (error) { parseBlocked = true }
try {
const input = JSON.parse('{"constructor":1}')
JSON.stringify(input, (key, item) => item)
} catch (error) { stringifyBlocked = true }
return [parseBlocked, stringifyBlocked]
`),
).toEqual([true, true])
})
})