feat(codemode): support JSON callbacks (#38006)
This commit is contained in:
parent
39fdd67123
commit
065b108bba
9 changed files with 431 additions and 61 deletions
|
|
@ -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] `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] 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.log`, `console.info`, `console.debug`, `console.warn`, and `console.error`.
|
||||||
- [x] Captured `console.dir` and `console.table`.
|
- [x] Captured `console.dir` and `console.table`.
|
||||||
- [ ] `JSON.parse` reviver callbacks.
|
|
||||||
- [ ] `JSON.stringify` function/array replacers.
|
|
||||||
|
|
||||||
## Date
|
## Date
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
GlobalNamespace,
|
GlobalNamespace,
|
||||||
IntrinsicReference,
|
IntrinsicReference,
|
||||||
InterpreterRuntimeError,
|
InterpreterRuntimeError,
|
||||||
|
JsonMethodReference,
|
||||||
PromiseCapabilityFunction,
|
PromiseCapabilityFunction,
|
||||||
PromiseNamespace,
|
PromiseNamespace,
|
||||||
UriFunction,
|
UriFunction,
|
||||||
|
|
@ -25,7 +26,6 @@ import {
|
||||||
isCodeModeValue,
|
isCodeModeValue,
|
||||||
} from "../values.js"
|
} from "../values.js"
|
||||||
import { dateSetterArgumentCount, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
|
import { dateSetterArgumentCount, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
|
||||||
import { invokeJsonMethod } from "../stdlib/json.js"
|
|
||||||
import { invokeMathMethod } from "../stdlib/math.js"
|
import { invokeMathMethod } from "../stdlib/math.js"
|
||||||
import { invokeNumberMethod, invokeNumberStatic } from "../stdlib/number.js"
|
import { invokeNumberMethod, invokeNumberStatic } from "../stdlib/number.js"
|
||||||
import { invokeObjectMethod } from "../stdlib/object.js"
|
import { invokeObjectMethod } from "../stdlib/object.js"
|
||||||
|
|
@ -54,6 +54,7 @@ export type SupportedCallback =
|
||||||
| UriFunction
|
| UriFunction
|
||||||
| PromiseCapabilityFunction
|
| PromiseCapabilityFunction
|
||||||
| GlobalMethodReference
|
| GlobalMethodReference
|
||||||
|
| JsonMethodReference
|
||||||
| IntrinsicReference
|
| IntrinsicReference
|
||||||
| ErrorConstructorReference
|
| ErrorConstructorReference
|
||||||
| GlobalNamespace
|
| GlobalNamespace
|
||||||
|
|
@ -65,6 +66,7 @@ export const isSupportedCallback = (value: unknown): value is SupportedCallback
|
||||||
value instanceof UriFunction ||
|
value instanceof UriFunction ||
|
||||||
value instanceof PromiseCapabilityFunction ||
|
value instanceof PromiseCapabilityFunction ||
|
||||||
value instanceof GlobalMethodReference ||
|
value instanceof GlobalMethodReference ||
|
||||||
|
value instanceof JsonMethodReference ||
|
||||||
value instanceof IntrinsicReference ||
|
value instanceof IntrinsicReference ||
|
||||||
value instanceof ErrorConstructorReference ||
|
value instanceof ErrorConstructorReference ||
|
||||||
// Callable namespaces dispatch like JS: Array/Object/Date/RegExp construct,
|
// Callable namespaces dispatch like JS: Array/Object/Date/RegExp construct,
|
||||||
|
|
@ -168,7 +170,7 @@ export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unkno
|
||||||
if (ref.namespace === "Map" || ref.namespace === "Set" || ref.namespace === "URLSearchParams") {
|
if (ref.namespace === "Map" || ref.namespace === "Set" || ref.namespace === "URLSearchParams") {
|
||||||
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node)
|
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node)
|
||||||
}
|
}
|
||||||
return invokeJsonMethod(ref.name, args, node)
|
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node)
|
||||||
}
|
}
|
||||||
|
|
||||||
const requireDataArgument = (name: string, index: number, arg: unknown, node: AstNode): unknown => {
|
const requireDataArgument = (name: string, index: number, arg: unknown, node: AstNode): unknown => {
|
||||||
|
|
|
||||||
|
|
@ -99,11 +99,15 @@ export class GlobalNamespace {
|
||||||
|
|
||||||
export class GlobalMethodReference {
|
export class GlobalMethodReference {
|
||||||
constructor(
|
constructor(
|
||||||
readonly namespace: GlobalNamespaceName | "Number" | "String",
|
readonly namespace: Exclude<GlobalNamespaceName, "JSON"> | "Number" | "String",
|
||||||
readonly name: string,
|
readonly name: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class JsonMethodReference {
|
||||||
|
constructor(readonly name: "parse" | "stringify") {}
|
||||||
|
}
|
||||||
|
|
||||||
export class CoercionFunction {
|
export class CoercionFunction {
|
||||||
constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat" | "isFinite" | "isNaN") {}
|
constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat" | "isFinite" | "isNaN") {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
GlobalNamespace,
|
GlobalNamespace,
|
||||||
InterpreterRuntimeError,
|
InterpreterRuntimeError,
|
||||||
IntrinsicReference,
|
IntrinsicReference,
|
||||||
|
JsonMethodReference,
|
||||||
PromiseCapabilityFunction,
|
PromiseCapabilityFunction,
|
||||||
PromiseInstanceMethodReference,
|
PromiseInstanceMethodReference,
|
||||||
PromiseMethodReference,
|
PromiseMethodReference,
|
||||||
|
|
@ -23,6 +24,7 @@ export const isRuntimeReference = (value: unknown): boolean =>
|
||||||
value instanceof IntrinsicReference ||
|
value instanceof IntrinsicReference ||
|
||||||
value instanceof GlobalNamespace ||
|
value instanceof GlobalNamespace ||
|
||||||
value instanceof GlobalMethodReference ||
|
value instanceof GlobalMethodReference ||
|
||||||
|
value instanceof JsonMethodReference ||
|
||||||
value instanceof PromiseNamespace ||
|
value instanceof PromiseNamespace ||
|
||||||
value instanceof PromiseMethodReference ||
|
value instanceof PromiseMethodReference ||
|
||||||
value instanceof PromiseInstanceMethodReference ||
|
value instanceof PromiseInstanceMethodReference ||
|
||||||
|
|
@ -82,12 +84,7 @@ export const containsOpaqueReference = (value: unknown): boolean => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject cycles before mutation so later boundary walks remain safe.
|
// Reject cycles before mutation so later boundary walks remain safe.
|
||||||
export const rejectCircularInsertion = (
|
export const rejectCircularInsertion = (container: object, value: unknown, label: string, node: AstNode): void => {
|
||||||
container: object,
|
|
||||||
value: unknown,
|
|
||||||
label: string,
|
|
||||||
node: AstNode,
|
|
||||||
): void => {
|
|
||||||
const pending: Array<Iterator<unknown>> = [[value].values()]
|
const pending: Array<Iterator<unknown>> = [[value].values()]
|
||||||
const seen = new Set<object>()
|
const seen = new Set<object>()
|
||||||
while (pending.length > 0) {
|
while (pending.length > 0) {
|
||||||
|
|
@ -111,6 +108,7 @@ export const typeofValue = (value: unknown): string => {
|
||||||
value instanceof CoercionFunction ||
|
value instanceof CoercionFunction ||
|
||||||
value instanceof IntrinsicReference ||
|
value instanceof IntrinsicReference ||
|
||||||
value instanceof GlobalMethodReference ||
|
value instanceof GlobalMethodReference ||
|
||||||
|
value instanceof JsonMethodReference ||
|
||||||
value instanceof PromiseMethodReference ||
|
value instanceof PromiseMethodReference ||
|
||||||
value instanceof PromiseInstanceMethodReference ||
|
value instanceof PromiseInstanceMethodReference ||
|
||||||
value instanceof PromiseNamespace ||
|
value instanceof PromiseNamespace ||
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
IntrinsicReference,
|
IntrinsicReference,
|
||||||
InterpreterRuntimeError,
|
InterpreterRuntimeError,
|
||||||
isRecord,
|
isRecord,
|
||||||
|
JsonMethodReference,
|
||||||
type MemberReference,
|
type MemberReference,
|
||||||
OptionalShortCircuit,
|
OptionalShortCircuit,
|
||||||
PromiseCapabilityFunction,
|
PromiseCapabilityFunction,
|
||||||
|
|
@ -55,7 +56,7 @@ import { ScopeStack } from "./scope.js"
|
||||||
import { arrayMethods, mapMethods, mapStatics, setMethods, spreadItems } from "../stdlib/collections.js"
|
import { arrayMethods, mapMethods, mapStatics, setMethods, spreadItems } from "../stdlib/collections.js"
|
||||||
import { consoleMethods, formatConsoleMessage } from "../stdlib/console.js"
|
import { consoleMethods, formatConsoleMessage } from "../stdlib/console.js"
|
||||||
import { dateMethods, dateStatics } from "../stdlib/date.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 { mathConstants, mathMethods } from "../stdlib/math.js"
|
||||||
import { numberConstants, numberMethods, numberStatics } from "../stdlib/number.js"
|
import { numberConstants, numberMethods, numberStatics } from "../stdlib/number.js"
|
||||||
import { objectMethodsPreservingIdentity, objectStatics } from "../stdlib/object.js"
|
import { objectMethodsPreservingIdentity, objectStatics } from "../stdlib/object.js"
|
||||||
|
|
@ -102,7 +103,6 @@ import {
|
||||||
const globalStaticMembers: Partial<Record<GlobalNamespaceName, Set<string>>> = {
|
const globalStaticMembers: Partial<Record<GlobalNamespaceName, Set<string>>> = {
|
||||||
Object: objectStatics,
|
Object: objectStatics,
|
||||||
Math: mathMethods,
|
Math: mathMethods,
|
||||||
JSON: jsonStatics,
|
|
||||||
Array: arrayStatics,
|
Array: arrayStatics,
|
||||||
console: consoleMethods,
|
console: consoleMethods,
|
||||||
Date: dateStatics,
|
Date: dateStatics,
|
||||||
|
|
@ -1624,6 +1624,9 @@ export class Interpreter<R> {
|
||||||
}
|
}
|
||||||
return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`)
|
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) {
|
if (callable instanceof CoercionFunction) {
|
||||||
return boundedData(invokeCoercion(callable, args, node), `${callable.name} result`)
|
return boundedData(invokeCoercion(callable, args, node), `${callable.name} result`)
|
||||||
}
|
}
|
||||||
|
|
@ -1889,6 +1892,7 @@ export class Interpreter<R> {
|
||||||
| PromiseInstanceMethodReference
|
| PromiseInstanceMethodReference
|
||||||
| IntrinsicReference
|
| IntrinsicReference
|
||||||
| GlobalMethodReference
|
| GlobalMethodReference
|
||||||
|
| JsonMethodReference
|
||||||
| ComputedValue
|
| ComputedValue
|
||||||
| typeof OptionalShortCircuit
|
| typeof OptionalShortCircuit
|
||||||
| undefined,
|
| undefined,
|
||||||
|
|
@ -1936,6 +1940,10 @@ export class Interpreter<R> {
|
||||||
if (objectValue.name === "Math" && mathConstants.has(key)) {
|
if (objectValue.name === "Math" && mathConstants.has(key)) {
|
||||||
return new ComputedValue((Math as unknown as Record<string, number>)[key])
|
return new ComputedValue((Math as unknown as Record<string, number>)[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)) {
|
if (globalStaticMembers[objectValue.name]?.has(key)) {
|
||||||
return new GlobalMethodReference(objectValue.name, key)
|
return new GlobalMethodReference(objectValue.name, key)
|
||||||
}
|
}
|
||||||
|
|
@ -2065,7 +2073,8 @@ export class Interpreter<R> {
|
||||||
reference instanceof PromiseMethodReference ||
|
reference instanceof PromiseMethodReference ||
|
||||||
reference instanceof PromiseInstanceMethodReference ||
|
reference instanceof PromiseInstanceMethodReference ||
|
||||||
reference instanceof IntrinsicReference ||
|
reference instanceof IntrinsicReference ||
|
||||||
reference instanceof GlobalMethodReference
|
reference instanceof GlobalMethodReference ||
|
||||||
|
reference instanceof JsonMethodReference
|
||||||
)
|
)
|
||||||
return reference
|
return reference
|
||||||
if (Array.isArray(reference.target)) {
|
if (Array.isArray(reference.target)) {
|
||||||
|
|
@ -2100,6 +2109,7 @@ export class Interpreter<R> {
|
||||||
reference instanceof PromiseInstanceMethodReference ||
|
reference instanceof PromiseInstanceMethodReference ||
|
||||||
reference instanceof IntrinsicReference ||
|
reference instanceof IntrinsicReference ||
|
||||||
reference instanceof GlobalMethodReference ||
|
reference instanceof GlobalMethodReference ||
|
||||||
|
reference instanceof JsonMethodReference ||
|
||||||
reference.target instanceof CodeModeURL
|
reference.target instanceof CodeModeURL
|
||||||
) {
|
) {
|
||||||
throw new InterpreterRuntimeError("Only data fields may be deleted in CodeMode.", target, "InvalidDataValue")
|
throw new InterpreterRuntimeError("Only data fields may be deleted in CodeMode.", target, "InvalidDataValue")
|
||||||
|
|
@ -2127,7 +2137,8 @@ export class Interpreter<R> {
|
||||||
reference instanceof PromiseMethodReference ||
|
reference instanceof PromiseMethodReference ||
|
||||||
reference instanceof PromiseInstanceMethodReference ||
|
reference instanceof PromiseInstanceMethodReference ||
|
||||||
reference instanceof IntrinsicReference ||
|
reference instanceof IntrinsicReference ||
|
||||||
reference instanceof GlobalMethodReference
|
reference instanceof GlobalMethodReference ||
|
||||||
|
reference instanceof JsonMethodReference
|
||||||
) {
|
) {
|
||||||
throw new InterpreterRuntimeError("Only data fields may be assigned in CodeMode.", node)
|
throw new InterpreterRuntimeError("Only data fields may be assigned in CodeMode.", node)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { 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 jsonStatics = new Set(["parse", "stringify"])
|
||||||
|
export type JsonMethodName = "parse" | "stringify"
|
||||||
|
|
||||||
export const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
export const invokeJsonMethod = <R>(
|
||||||
switch (name) {
|
runner: CallbackRunner<R>,
|
||||||
case "stringify": {
|
name: JsonMethodName,
|
||||||
const replacer = args[1]
|
args: Array<unknown>,
|
||||||
if (Array.isArray(replacer) || typeofValue(replacer) === "function") {
|
node: AstNode,
|
||||||
throw new InterpreterRuntimeError(
|
): Effect.Effect<unknown, unknown, R> => {
|
||||||
"JSON.stringify replacers are not supported in CodeMode.",
|
return name === "parse" ? parse(runner, args, node) : stringify(runner, args, node)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 stack = new Set<object>()
|
||||||
|
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])])
|
||||||
|
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<unknown> = []
|
||||||
|
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)
|
||||||
|
|
|
||||||
|
|
@ -285,21 +285,21 @@ describe("still-rejected callables get the wrap hint", () => {
|
||||||
expect(diagnostic.message).toContain("wrap it in an arrow function")
|
expect(diagnostic.message).toContain("wrap it in an arrow function")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("callable JSON.stringify replacers are rejected, never silently ignored", async () => {
|
test("JSON callbacks use the unified callback gate", async () => {
|
||||||
expect((await error(`return JSON.stringify({ a: 1 }, Math.abs)`)).message).toContain(
|
expect(
|
||||||
"JSON.stringify replacers are not supported",
|
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(
|
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 () => {
|
test("non-callable JSON callback arguments are ignored", async () => {
|
||||||
expect((await error(`return JSON.parse('{"a":1}', (key, v) => 99)`)).message).toContain(
|
|
||||||
"JSON.parse revivers are not supported",
|
|
||||||
)
|
|
||||||
expect(await value(`return JSON.parse('{"a":1}', undefined)`)).toEqual({ a: 1 })
|
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.parse('{"a":1}', 42)`)).toEqual({ a: 1 })
|
||||||
|
expect(await value(`return JSON.stringify({ a: 1 }, 42)`)).toBe('{"a":1}')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
243
packages/codemode/test/json-callbacks-test262.test.ts
Normal file
243
packages/codemode/test/json-callbacks-test262.test.ts
Normal file
|
|
@ -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 "<replaced>"
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
`),
|
||||||
|
).toBe('{"a":1,"b":"<replaced>"}')
|
||||||
|
})
|
||||||
|
|
||||||
|
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'")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -889,6 +889,9 @@ describe("coercion parity: unknown static members read as undefined", () => {
|
||||||
"TypeError: Math.sum is not a function.",
|
"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 { 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 () => {
|
test("blocked members still throw instead of reading as undefined", async () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue