From c1faad5d62e657289b170eb02a6a0e3a1922c561 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 7 Jul 2026 11:55:52 -0500 Subject: [PATCH] feat(codemode): support JSON callbacks --- packages/codemode/codemode.md | 3 +- packages/codemode/src/interpreter/runtime.ts | 146 ++++++++++++++++- packages/codemode/src/stdlib/json.ts | 22 +-- packages/codemode/src/tool-runtime.ts | 2 +- packages/codemode/test/stdlib.test.ts | 155 +++++++++++++++++++ 5 files changed, 306 insertions(+), 22 deletions(-) diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index f7f798f2e1..d62a1e894d 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -153,8 +153,7 @@ current omissions to implement, not intentional product boundaries. shims. Consider `Promise.any` in the same pass. - [ ] Support async iteration and `for await...of`. Define behavior first for the runtime's supported promise and collection values, then extend it to bounded host streams when a stream boundary exists. -- [ ] Support callback-bearing standard-library variants that models commonly generate: the mapper argument to - `Array.from(...)` and replacers for `JSON.stringify(...)`, including Effect-aware callbacks where needed. +- [ ] Support the mapper argument to `Array.from(...)`, including Effect-aware callbacks. - [ ] Add `Object.is` after runtime method and tool references have stable identity semantics. - [ ] Add deterministic modern collection conveniences where they improve orchestration: `Object.groupBy`, Set composition methods, and `Array.prototype.toSpliced`. diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 2825e744b5..7a07845a51 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -5,6 +5,7 @@ import { copyIn, copyOut, isBlockedMember, + MAX_VALUE_DEPTH, ToolReference, ToolRuntime, ToolRuntimeError, @@ -2027,6 +2028,7 @@ class Interpreter { } if (callable instanceof GlobalMethodReference) { if (callable.namespace === "console") return self.invokeConsole(callable.name, args, node) + if (callable.namespace === "JSON") return yield* self.invokeJsonMethod(callable.name, args, node) if (callable.namespace === "Object" && args[0] instanceof ToolReference) { return self.invokeObjectMethodOnTools(callable.name, args[0], node) } @@ -2492,6 +2494,129 @@ class Interpreter { }) } + private invokeJsonMethod( + name: string, + args: Array, + node: AstNode, + ): Effect.Effect { + const callback = args[1] + const callable = + callback instanceof CodeModeFunction || + callback instanceof CoercionFunction || + callback instanceof GlobalMethodReference || + callback instanceof UriFunction + if (name === "parse" && callable) return this.parseJsonWithReviver(args, node) + if (name === "stringify" && (callable || Array.isArray(callback))) { + return this.stringifyJsonWithReplacer(args, node) + } + return Effect.sync(() => invokeJsonMethod(name, args, node)) + } + + private parseJsonWithReviver(args: Array, node: AstNode): Effect.Effect { + const apply = this.applySettledCollectionCallback(args[1], "JSON.parse", node) + const visit = (key: string, item: unknown): Effect.Effect => + Effect.gen(function* () { + if (Array.isArray(item)) { + for (let index = 0; index < item.length; index += 1) { + if (!(index in item)) continue + const revived = yield* visit(String(index), item[index]) + if (revived === undefined) { + delete item[index] + continue + } + item[index] = revived + } + return yield* apply([key, item]) + } + if (item !== null && typeof item === "object" && !isSandboxValue(item)) { + const object = item as SafeObject + for (const childKey of Object.keys(object)) { + const revived = yield* visit(childKey, object[childKey]) + if (revived === undefined) { + delete object[childKey] + continue + } + object[childKey] = revived + } + } + return yield* apply([key, item]) + }) + return visit("", invokeJsonMethod("parse", [args[0]], node)) + } + + private stringifyJsonWithReplacer(args: Array, node: AstNode): Effect.Effect { + const callback = args[1] + const apply = Array.isArray(callback) + ? undefined + : this.applySettledCollectionCallback(callback, "JSON.stringify", node) + const ancestors = new Set() + const propertyList = Array.isArray(callback) + ? Array.from( + new Set( + callback + .filter((item): item is string | number => typeof item === "string" || typeof item === "number") + .map(String), + ), + ) + : undefined + const visit = (key: string, item: unknown, depth: number): Effect.Effect => + Effect.gen(function* () { + const resolved = yield* (() => { + if (apply === undefined) return Effect.succeed(item) + return apply([ + key, + item instanceof SandboxDate || item instanceof SandboxURL + ? copyIn(item, "JSON.stringify value") + : item, + ]) + })() + if (typeofValue(resolved) === "function") return undefined + if (resolved === null || typeof resolved !== "object") return resolved + if (isSandboxValue(resolved)) { + return apply === undefined + ? copyIn(resolved, "JSON.stringify replacer result") + : (Object.create(null) as SafeObject) + } + if (!Array.isArray(resolved)) { + const prototype = Object.getPrototypeOf(resolved) + if (prototype !== Object.prototype && prototype !== null) { + return copyIn(resolved, "JSON.stringify replacer result") + } + } + if (depth > MAX_VALUE_DEPTH) { + throw new ToolRuntimeError( + "InvalidDataValue", + `JSON.stringify replacer result exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`, + ) + } + if (ancestors.has(resolved)) { + throw new ToolRuntimeError("InvalidDataValue", "JSON.stringify replacer result contains a circular value.") + } + ancestors.add(resolved) + return yield* Effect.gen(function* () { + if (Array.isArray(resolved)) { + const output: Array = [] + const length = resolved.length + for (let index = 0; index < length; index += 1) { + output[index] = yield* visit(String(index), resolved[index], depth + 1) + } + return output + } + const output: SafeObject = Object.create(null) as SafeObject + for (const childKey of propertyList ?? Object.keys(resolved)) { + if (isBlockedMember(childKey)) continue + if (apply === undefined && !Object.hasOwn(resolved, childKey)) continue + const child = yield* visit(childKey, (resolved as SafeObject)[childKey], depth + 1) + if (child !== undefined) output[childKey] = child + } + return output + }).pipe(Effect.ensuring(Effect.sync(() => ancestors.delete(resolved)))) + }) + return Effect.map(visit("", args[0], 0), (value) => + invokeJsonMethod("stringify", [value, propertyList, args[2]], node), + ) + } + // Runs a collection callback accepting a user function or supported builtin callable, // mirroring the array-method callback contract. private applyCollectionCallback( @@ -2502,6 +2627,7 @@ class Interpreter { if ( !(callback instanceof CodeModeFunction) && !(callback instanceof CoercionFunction) && + !(callback instanceof GlobalMethodReference) && !(callback instanceof UriFunction) ) { throw new InterpreterRuntimeError(`${name} expects a function callback.`, node) @@ -2509,9 +2635,23 @@ class Interpreter { return (callbackArgs) => callback instanceof CoercionFunction ? Effect.succeed(invokeCoercion(callback, callbackArgs, node)) - : callback instanceof UriFunction - ? Effect.succeed(invokeUriFunction(callback, callbackArgs, node)) - : this.invokeFunction(callback, callbackArgs) + : callback instanceof GlobalMethodReference + ? Effect.succeed(invokeGlobalMethod(callback, callbackArgs, node)) + : callback instanceof UriFunction + ? Effect.succeed(invokeUriFunction(callback, callbackArgs, node)) + : this.invokeFunction(callback, callbackArgs) + } + + private applySettledCollectionCallback( + callback: unknown, + name: string, + node: AstNode, + ): (args: Array) => Effect.Effect { + const apply = this.applyCollectionCallback(callback, name, node) + return (args) => + Effect.flatMap(apply(args), (value) => + value instanceof SandboxPromise ? this.settlePromise(value, node) : Effect.succeed(value), + ) } private invokeMapMethod( diff --git a/packages/codemode/src/stdlib/json.ts b/packages/codemode/src/stdlib/json.ts index 8a479d2c8c..2797562ed6 100644 --- a/packages/codemode/src/stdlib/json.ts +++ b/packages/codemode/src/stdlib/json.ts @@ -1,9 +1,4 @@ -import { - type AstNode, - CodeModeFunction, - InterpreterRuntimeError, - supportedSyntaxMessage, -} from "../interpreter/model.js" +import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" import { copyIn, copyOut } from "../tool-runtime.js" export const jsonStatics = new Set(["stringify", "parse"]) @@ -12,18 +7,13 @@ export const invokeJsonMethod = (name: string, args: Array, node: AstNo if (!jsonStatics.has(name)) throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node) switch (name) { case "stringify": { - const replacer = args[1] - if (Array.isArray(replacer) || replacer instanceof CodeModeFunction) { - 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")), null, indent) + return JSON.stringify( + copyOut(copyIn(args[0], "JSON.stringify value")), + Array.isArray(args[1]) ? args[1] : null, + indent, + ) } case "parse": { const text = args[0] diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 7d67ba6c79..95025712c2 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -119,7 +119,7 @@ export class ToolReference { * limit) purely because it produces a clearer diagnostic than a native stack-overflow * RangeError would. */ -const MAX_VALUE_DEPTH = 32 +export const MAX_VALUE_DEPTH = 32 export class ToolRuntimeError extends Error { constructor( diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index 59262f3197..a598ace387 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -19,6 +19,161 @@ const error = async (code: string) => { return result.error } +describe("JSON", () => { + test("parse revivers run post-order including the root", async () => { + expect( + await value(` + const order = [] + const parsed = JSON.parse('{"item":{"count":2}}', (key, item) => { + order.push(key) + if (key === "count") return item + 1 + if (key === "") return { parsed: item } + return item + }) + return { parsed, order } + `), + ).toEqual({ parsed: { parsed: { item: { count: 3 } } }, order: ["count", "item", ""] }) + expect(await value(`return JSON.parse("1", (key, value) => undefined) === undefined`)).toBe(true) + }) + + test("parse reviver deletion removes properties and creates array holes", async () => { + expect( + await value(` + const parsed = JSON.parse('{"keep":1,"drop":2,"items":[1,2,3]}', (key, item) => + key === "drop" || key === "1" ? undefined : item + ) + return { json: JSON.stringify(parsed), hasSecond: 1 in parsed.items, length: parsed.items.length } + `), + ).toEqual({ json: '{"keep":1,"items":[1,null,3]}', hasSecond: false, length: 3 }) + }) + + test("stringify function replacers run root-first and apply JSON deletion rules", async () => { + expect( + await value(` + const order = [] + const json = JSON.stringify({ keep: 1, drop: 2, items: [1, 2] }, (key, item) => { + order.push(key) + if (key === "drop" || key === "1") return undefined + return item + }) + return { json, order } + `), + ).toEqual({ json: '{"keep":1,"items":[1,null]}', order: ["", "keep", "drop", "items", "0", "1"] }) + expect( + await value(` + return [ + JSON.stringify(1, (key, item) => key === "" ? new Date(0) : item), + JSON.stringify(1, (key, item) => key === "" ? new URL("https://example.test") : item), + ] + `), + ).toEqual(["{}", "{}"]) + }) + + test("stringify function replacers observe live mutations and sandbox values", async () => { + expect( + await value(` + const item = { a: 1, b: 2 } + const visited = [] + const mutated = JSON.stringify(item, (key, value) => { + visited.push(key) + if (key === "a") item.b = 3 + return value + }) + const mapped = JSON.stringify(new Map([["x", 1]]), (key, value) => + value instanceof Map ? Object.fromEntries(value) : value + ) + const removed = { a: 1, b: 2 } + const deleted = JSON.stringify(removed, (key, value) => { + if (key === "a") removed.b = undefined + if (key === "b") visited.push(value === undefined ? "missing" : "present") + return value + }) + return { mutated, mapped, deleted, visited } + `), + ).toEqual({ + mutated: '{"a":1,"b":3}', + mapped: '{"x":1}', + deleted: '{"a":1}', + visited: ["", "a", "b", "missing"], + }) + }) + + test("stringify replacer arrays coerce, dedupe, and recursively filter object keys", async () => { + expect( + await value(` + return JSON.stringify( + { 1: "one", keep: { keep: 2, drop: 3 }, drop: 4, list: [{ keep: 5, drop: 6 }] }, + ["keep", 1, "keep", null, {}, "list"], + ) + `), + ).toBe('{"keep":{"keep":2},"1":"one","list":[{"keep":5}]}') + }) + + test("JSON callbacks settle async tool calls sequentially", async () => { + const calls: Array = [] + const transform = Tool.make({ + description: "Transform a number", + input: Schema.Number, + output: Schema.Number, + run: (input) => + Effect.sync(() => { + calls.push(input) + return input * 10 + }), + }) + const parse = await Effect.runPromise( + CodeMode.execute({ + tools: { host: { transform } }, + code: `return JSON.parse("[1,2]", async (key, item) => key === "" ? item : await tools.host.transform(item))`, + }), + ) + expect(parse.ok && parse.value).toEqual([10, 20]) + const stringify = await Effect.runPromise( + CodeMode.execute({ + tools: { host: { transform } }, + code: `return JSON.stringify([1,2], async (key, item) => key === "" ? item : await tools.host.transform(item))`, + }), + ) + expect(stringify.ok && stringify.value).toBe("[10,20]") + expect(calls).toEqual([1, 2, 1, 2]) + }) + + test("non-callable JSON callback arguments are ignored", async () => { + expect(await value(`return [JSON.parse("{\\"a\\":1}", 42), JSON.stringify({ a: 1 }, { nope: true })]`)).toEqual([ + { a: 1 }, + '{"a":1}', + ]) + expect(await value(`return [JSON.parse("1", Boolean), JSON.stringify({ a: 1 }, String)]`)).toEqual([false, '""']) + }) + + test("supported global methods work as JSON callbacks", async () => { + expect( + await value(`return [JSON.parse("[1,2]", Number.isFinite), JSON.stringify({ a: 1 }, Number.isFinite)]`), + ).toEqual([false, "false"]) + }) + + test("stringify replacers can prune values before the depth limit", async () => { + expect( + await value(` + let item = 1 + for (let index = 0; index < 33; index++) item = { x: item } + let calls = 0 + return JSON.stringify(item, (key, value) => ++calls === 34 ? undefined : value) + `), + ).toBe(`${'{"x":'.repeat(32)}{}${"}".repeat(32)}`) + }) + + test("stringify omits callable replacer results", async () => { + expect( + await value(` + return JSON.stringify({ object: 1, array: [1] }, (key, value) => + key === "object" || key === "0" ? Number.isFinite : value + ) + `), + ).toBe('{"array":[null]}') + }) +}) + describe("Date", () => { test("Date.now() returns a number", async () => { expect(await value(`return typeof Date.now()`)).toBe("number")