diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 39097858b8a..936d7386c11 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -214,6 +214,7 @@ ultimate source of truth. - [x] `Object.keys` over arrays and tool references. - [x] Object identity is preserved by in-CodeMode Object helpers. - [x] Prototype traversal and mutation through `__proto__`, `constructor`, and `prototype` are blocked. +- [x] Circular references are rejected when created (`o.self = o`, `array.push(array)`), not at serialization as in JS. - [ ] Legal own data fields named `__proto__`, `constructor`, or `prototype` are rejected at JSON/tool boundaries and cannot be created, read, or written in CodeMode; tool path segments with those names remain supported. - [x] `Object.is` for supported data values. @@ -364,7 +365,8 @@ ultimate source of truth. or without `new`. - [x] `AggregateError` with the `(errors, message?)` signature and an own `errors` array, constructed directly or by an all-rejected `Promise.any`; direct construction accepts custom synchronous iterators and generators. -- [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization. +- [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization. Errors have no + `stack`; the diagnostic carries the source location instead. - [x] `instanceof` for Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, Promise, and Error types. - [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program diff --git a/packages/codemode/src/interpreter/references.ts b/packages/codemode/src/interpreter/references.ts index 073bc17995b..5bdb4c88e96 100644 --- a/packages/codemode/src/interpreter/references.ts +++ b/packages/codemode/src/interpreter/references.ts @@ -77,6 +77,23 @@ export const rejectCircularInsertion = ( } } +export const describeValue = (value: unknown): string => { + if (value === null) return "null" + if (Array.isArray(value)) return "an array" + if (value instanceof Values.Promise) return "an un-awaited Promise" + if (value instanceof ToolReference) return "a tool reference" + if (value instanceof Values.Date) return "a Date" + if (value instanceof Values.RegExp) return "a RegExp" + if (value instanceof Values.Map) return "a Map" + if (value instanceof Values.Set) return "a Set" + if (value instanceof Values.URL) return "a URL" + if (value instanceof Values.URLSearchParams) return "a URLSearchParams" + if (value instanceof CodeModeGenerator) return "a generator" + if (isRuntimeReference(value)) return "a function" + if (typeof value === "object") return "a data object" + return `a ${typeof value}` +} + export const typeofValue = (value: unknown): string => { if ( value instanceof HostFunction || diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index e16e8176b39..6a0dcb58aa0 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -71,7 +71,13 @@ import { HostFunction, HostNamespace } from "./host.js" import { invokeIntrinsic } from "./methods.js" import { preserveConsumerError, type Runner } from "./runner.js" import { invokePromiseInstanceMethod, PromiseRuntime, resolvePromise, resolvePromiseValue } from "./promises.js" -import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, typeofValue } from "./references.js" +import { + containsOpaqueReference, + describeValue, + isRuntimeReference, + rejectCircularInsertion, + typeofValue, +} from "./references.js" import { ScopeStack } from "./scope.js" import { arrayMethods, mapMethods, setMethods } from "../stdlib/collections.js" import { dateMethods } from "../stdlib/date.js" @@ -1025,7 +1031,7 @@ class Frame { if (pattern.type === "ObjectPattern") { if (value === null || typeof value !== "object" || isRuntimeReference(value)) { throw new InterpreterRuntimeError( - "Object destructuring requires a data object or array value.", + `Object destructuring requires a data object or array value, received ${describeValue(value)}.`, pattern, "InvalidDataValue", ) @@ -1091,7 +1097,7 @@ class Frame { if (pattern.type === "ObjectPattern") { if (value === null || typeof value !== "object" || isRuntimeReference(value)) { throw new InterpreterRuntimeError( - "Object destructuring requires a data object or array value.", + `Object destructuring requires a data object or array value, received ${describeValue(value)}.`, pattern, "InvalidDataValue", ) @@ -1896,7 +1902,11 @@ class Frame { const spread = yield* self.evaluateExpression(property.argument) if (spread === null || spread === undefined || Values.isValue(spread)) continue if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) { - throw new InterpreterRuntimeError("Object spread requires a data object.", property, "InvalidDataValue") + throw new InterpreterRuntimeError( + `Object spread requires a data object, received ${describeValue(spread)}.`, + property, + "InvalidDataValue", + ) } for (const [key, value] of Object.entries(spread)) { if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, property) @@ -2126,7 +2136,7 @@ class Frame { if (isRuntimeReference(objectValue)) { throw new InterpreterRuntimeError( - "Runtime references are opaque and do not expose properties.", + `Cannot read properties of ${describeValue(objectValue)}; only data values expose properties.`, objectNode, "InvalidDataValue", ) diff --git a/packages/codemode/src/stdlib/array.ts b/packages/codemode/src/stdlib/array.ts index b945d0b41c5..32bd764e11f 100644 --- a/packages/codemode/src/stdlib/array.ts +++ b/packages/codemode/src/stdlib/array.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" import { HostFunction, sync, syncCall } from "../interpreter/host.js" import { type AstNode, CodeModeGenerator, InterpreterRuntimeError } from "../interpreter/model.js" +import { describeValue } from "../interpreter/references.js" import { applyCollectionCallback, preserveConsumerError, type Runner } from "../interpreter/runner.js" -import { Values } from "../values.js" const constructArray = (args: Array, node: AstNode): Array => { if (args.length !== 1) return [...args] @@ -16,13 +16,6 @@ const constructArray = (args: Array, node: AstNode): Array => } const arrayLikeSource = (source: unknown, node: AstNode): { readonly length: number; readonly source: object } => { - if (source instanceof Values.Promise) { - throw new InterpreterRuntimeError( - "Array.from received an un-awaited Promise; await it before creating the array.", - node, - "InvalidDataValue", - ) - } if ( source !== null && typeof source === "object" && @@ -35,7 +28,7 @@ const arrayLikeSource = (source: unknown, node: AstNode): { readonly length: num return { length: normalized, source } } throw new InterpreterRuntimeError( - "Array.from expects an array, string, Map, Set, or array-like value.", + `Array.from expects an array, string, Map, Set, or array-like value, received ${describeValue(source)}.`, node, "InvalidDataValue", ) diff --git a/packages/codemode/src/stdlib/collections.ts b/packages/codemode/src/stdlib/collections.ts index ab93db1924e..e1854efdcbc 100644 --- a/packages/codemode/src/stdlib/collections.ts +++ b/packages/codemode/src/stdlib/collections.ts @@ -2,7 +2,7 @@ import { Effect } from "effect" import { isBlockedMember, type SafeObject } from "../data.js" import { HostFunction, requiresNew } from "../interpreter/host.js" import { type AstNode, InterpreterRuntimeError, isRecord } from "../interpreter/model.js" -import { isRuntimeReference } from "../interpreter/references.js" +import { describeValue, isRuntimeReference } from "../interpreter/references.js" import { applyCollectionCallback, preserveConsumerError, type Runner, toPrimitive } from "../interpreter/runner.js" import { Values } from "../values.js" import { coerceToString } from "./value.js" @@ -73,7 +73,11 @@ const coerceGroupByPropertyKey = ( ): Effect.Effect => { if (value instanceof Values.Promise) return Effect.succeed("[object Promise]") if (!Values.isValue(value) && isRuntimeReference(value)) { - throw new InterpreterRuntimeError("Object.groupBy callback must return a data value.", node, "InvalidDataValue") + throw new InterpreterRuntimeError( + `Object.groupBy callback must return a data value, received ${describeValue(value)}.`, + node, + "InvalidDataValue", + ) } return Effect.map(toPrimitive(runner, value, "string", node), coerceToString) } diff --git a/packages/codemode/src/stdlib/object.ts b/packages/codemode/src/stdlib/object.ts index 1c3199e5a3c..5e234e458e9 100644 --- a/packages/codemode/src/stdlib/object.ts +++ b/packages/codemode/src/stdlib/object.ts @@ -2,7 +2,12 @@ import { Effect } from "effect" import { isBlockedMember, toProgram } from "../data.js" import { HostFunction, sync, syncCall } from "../interpreter/host.js" import { type AstNode, AsyncIteratorSymbol, InterpreterRuntimeError, IteratorSymbol } from "../interpreter/model.js" -import { containsOpaqueReference, rejectCircularInsertion, typeofValue } from "../interpreter/references.js" +import { + containsOpaqueReference, + describeValue, + rejectCircularInsertion, + typeofValue, +} from "../interpreter/references.js" import { preserveConsumerError, type Runner } from "../interpreter/runner.js" import { ToolReference } from "../tool-runtime.js" import { Values } from "../values.js" @@ -12,20 +17,14 @@ import { coerceToString } from "./value.js" const requireObject = (name: string, input: unknown, node: AstNode): Record => { if (Array.isArray(input)) return input as unknown as Record if (Values.isValue(input)) return {} - if (input instanceof Values.Promise) { + const prototype = input === null || typeof input !== "object" ? undefined : Object.getPrototypeOf(input) + if (prototype !== null && prototype !== Object.prototype) { throw new InterpreterRuntimeError( - `Object.${name} received an un-awaited Promise; await it before inspecting the result.`, + `Object.${name} expects a data object or array, received ${describeValue(input)}.`, node, "InvalidDataValue", ) } - if (input === null || typeof input !== "object") { - throw new InterpreterRuntimeError(`Object.${name} expects a data object or array.`, node, "InvalidDataValue") - } - const prototype = Object.getPrototypeOf(input) - if (prototype !== null && prototype !== Object.prototype) { - throw new InterpreterRuntimeError(`Object.${name} expects a data object or array.`, node, "InvalidDataValue") - } return input as Record } diff --git a/packages/codemode/test/enumeration.test.ts b/packages/codemode/test/enumeration.test.ts index 981297c7f8f..3636bbef280 100644 --- a/packages/codemode/test/enumeration.test.ts +++ b/packages/codemode/test/enumeration.test.ts @@ -85,9 +85,22 @@ describe("Object.keys over arrays", () => { expect(await value(`return Object.keys({ a: 1, b: 2 })`)).toEqual(["a", "b"]) }) - test("non-object inputs still fail clearly", async () => { - const failure = await error(`return Object.keys("nope")`) - expect(failure.message).toContain("Object.keys expects a data object or array") + test("non-object inputs name what was received", async () => { + expect((await error(`return Object.keys("nope")`)).message).toContain( + "Object.keys expects a data object or array, received a string.", + ) + expect((await error(`return Object.entries(42)`)).message).toContain("received a number.") + expect((await error(`return Object.values(null)`)).message).toContain("received null.") + expect((await error(`return Object.keys(tools.github.list_issues({ value: "x" }))`)).message).toContain( + "received an un-awaited Promise.", + ) + expect((await error(`return Object.entries(() => 1)`)).message).toContain("received a function.") + expect((await error(`return { ...[1] }`)).message).toContain( + "Object spread requires a data object, received an array.", + ) + expect((await error(`const { a } = new Map(); return a`)).message).toContain("received a Map.") + expect((await error(`return Array.from(7)`)).message).toContain("received a number.") + expect((await error(`return (() => 1).x`)).message).toContain("Cannot read properties of a function") }) })