feat(codemode): unify callback acceptance and support built-in references (#36771)

This commit is contained in:
Aiden Cline
2026-07-14 15:25:39 -05:00
committed by GitHub
parent 0d4f9797f4
commit ea89a2f619
11 changed files with 530 additions and 124 deletions
+39 -12
View File
@@ -77,16 +77,28 @@ ultimate source of truth.
- [x] Synchronous and `async` functions.
- [x] Closures, recursion, default parameters, rest parameters, and destructured parameters.
- [x] Expression and block function bodies.
- [x] User callbacks for the supported Array, Map, Set, URLSearchParams, sort, and string-replacement APIs.
- [x] `Boolean`, `Number`, `String`, `parseInt`, `parseFloat`, and URI helpers as callbacks where applicable.
- [x] User callbacks for the supported Array, Map, Set, URLSearchParams, sort, string-replacement, and `Array.from`
mapper APIs, with one shared acceptance rule everywhere including promise reactions.
- [x] `Boolean`, `Number`, `String`, `parseInt`, `parseFloat`, and URI helpers as callbacks.
- [x] Built-in method references as callbacks, such as `values.map(Math.abs)`, `records.map(JSON.stringify)`,
`items.forEach(console.log)`, and `Promise.resolve(-1).then(Math.abs)`. Extra callback arguments a built-in
does not consume are ignored, like JS; consumed arguments stay strictly validated (`Math.floor` still rejects a
string). Intrinsic references keep their receiver (`"abc".includes` works as a predicate), unlike detached JS
methods, which lose `this`.
- [x] Constructors work as callbacks with JS call semantics: `Error` types construct (`messages.map(Error)`),
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Promise`) throw a `TypeError`,
like JS.
- [x] Tool references and detached `Promise` statics are rejected as callbacks with a hint to wrap them in an
arrow function.
- [x] Async string replacement callbacks; replacements are evaluated sequentially.
- [ ] `this`, `super`, constructor functions, or function prototype methods such as `call`, `apply`, and `bind`.
- [x] The optional `thisArg` of iteration methods is accepted and ignored: CodeMode functions have no `this`, so
ignoring it matches JS arrow-function semantics exactly.
- [ ] `this`, `super`, user-defined constructor functions, or function prototype methods such as `call`, `apply`,
and `bind`.
- [ ] Classes and private fields.
- [ ] Generator functions and `yield`.
- [ ] Async predicates, reducers, and comparators with automatic awaiting. Async mapping can be joined explicitly with
`Promise.all`, but a promise is not a meaningful predicate or sort result.
- [ ] General built-in callable references as callbacks, such as `values.map(Math.abs)` or
`records.map(JSON.stringify)`.
## Expressions and operators
@@ -137,14 +149,17 @@ ultimate source of truth.
- [x] `new Promise((resolve, reject) => ...)`: the executor runs synchronously and receives first-class resolve/reject
callables that settle the promise exactly once (they may escape the executor and settle later); an executor
throw rejects unless the promise already settled, resolving with a promise adopts it, and resolving with the
promise itself rejects with a `TypeError`. Resolver callables work as `.then`/`.catch` handlers and collection
callbacks but remain opaque references that cannot cross the data boundary.
promise itself rejects with a `TypeError`. Resolver callables work anywhere callbacks are accepted, including
`.then`/`.catch` handlers and collection callbacks, but remain opaque references that cannot cross the data
boundary.
- [ ] Thenable assimilation (objects with a `then` method are plain data, not promises).
- [ ] Async iterables, host streams, and stream consumption.
## Objects and properties
- [x] Own-field reads and writes on plain data objects.
- [x] `Object()` and `new Object()` return `{}` for nullish arguments and pass objects through unchanged;
primitive wrapper objects (`Object(1)`) are rejected explicitly.
- [x] Computed property names and object spread.
- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`.
- [x] `Object.keys` over arrays and tool references.
@@ -157,7 +172,12 @@ ultimate source of truth.
## Arrays
- [x] Static methods: `Array.isArray`, `Array.of`, and `Array.from`.
- [x] The `Array` constructor with or without `new`: `Array(a, b)` collects arguments, `Array(n)` creates a
sparse array of that length (invalid lengths throw a `RangeError`). Holes behave like JS in iteration,
spread, join, and JSON; `sort` densifies holes into trailing `undefined`, and results returned to the
host normalize holes to `null`.
- [x] Static methods: `Array.isArray`, `Array.of`, and `Array.from`, including the `Array.from` mapper form with
`(value, index)` arguments.
- [x] Iteration/transformation: `map`, `filter`, `flatMap`, and `forEach`.
- [x] Searching/tests: `find`, `findIndex`, `findLast`, `findLastIndex`, `some`, `every`, `includes`, `indexOf`, and
`lastIndexOf`.
@@ -167,7 +187,7 @@ ultimate source of truth.
- [x] Mutation: `push`, `pop`, `shift`, `unshift`, `splice`, `fill`, and `copyWithin`.
- [x] Materialized iteration helpers: `keys`, `values`, and `entries` return arrays rather than iterators.
- [x] `length`, numeric indexing, index assignment, spread, and `for...of`.
- [ ] The mapper and `thisArg` forms of `Array.from`.
- [x] The `thisArg` argument of `Array.from` is accepted and ignored, like JS arrows.
- [ ] `Array.prototype.toSpliced`.
- [ ] Canonical index handling: a key such as `"01"` must not alias index `1`.
- [ ] Complete sparse-array parity. Promise combinators do consume holes as `undefined` members, as in JS.
@@ -219,6 +239,8 @@ ultimate source of truth.
- [x] `Date.now`, `Date.parse`, and `Date.UTC`.
- [x] `new Date()` from the current time, epoch milliseconds, a date string, another Date, or local components.
- [x] `Date()` without `new` returns the current time as a string, like JS, but in deterministic ISO format
rather than the host's locale/timezone string.
- [x] `getTime`, `valueOf`, `toISOString`, `toJSON`, and deterministic ISO `toString`.
- [x] Local getters: `getFullYear`, `getMonth`, `getDate`, `getDay`, `getHours`, `getMinutes`, `getSeconds`, and
`getMilliseconds`.
@@ -234,7 +256,7 @@ ultimate source of truth.
## Regular expressions
- [x] Literal and `new RegExp(pattern, flags)` construction.
- [x] Literal and `RegExp(pattern, flags)` construction, with or without `new`.
- [x] `test`, `exec`, and `toString`.
- [x] Readable `source`, `flags`, `lastIndex`, `global`, `ignoreCase`, `multiline`, `sticky`, `unicode`, and `dotAll`.
- [x] Captures, named groups, match indexes, and stateful global matching.
@@ -295,8 +317,13 @@ These are actionable implementation items. Check them off only when behavior and
`null` in render-only or OpenAPI tool calls.
- [ ] Make regular-expression execution genuinely timeout-safe, or narrow the timeout guarantee explicitly.
- [ ] Complete lexical declaration and destructuring semantics listed above.
- [ ] Make callback acceptance and async callback behavior consistent across built-ins.
- [ ] Reject every unsupported callback argument explicitly rather than silently ignoring it.
- [x] Make callback acceptance consistent across built-ins: collections, sort, string replacers, `Array.from`
mappers, and promise reactions share one acceptance rule.
- [x] Reject every unsupported callable callback argument explicitly rather than silently ignoring it
(`JSON.stringify` replacers and `JSON.parse` revivers fail loudly). The iteration-method `thisArg` is
accepted and ignored: CodeMode functions have no `this`, so ignoring it matches JS arrow semantics.
- [ ] Make async callback behavior consistent across built-ins; only string replacers settle async callback results
today.
- [ ] Resolve the built-in correctness gaps listed in the Array, String, Number, Date, and RegExp sections.
- [ ] Make tool search tokenization Unicode-aware.
- [ ] Design explicit tagged representations and size limits before adding binary values or streams.
+114 -69
View File
@@ -3,14 +3,16 @@ import {
type AstNode,
CodeModeFunction,
CoercionFunction,
ErrorConstructorReference,
GlobalMethodReference,
GlobalNamespace,
IntrinsicReference,
InterpreterRuntimeError,
PromiseCapabilityFunction,
supportedSyntaxMessage,
PromiseNamespace,
UriFunction,
} from "./model.js"
import { rejectCircularInsertion } from "./references.js"
import { rejectCircularInsertion, typeofValue } from "./references.js"
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
import {
CodeModeDate,
@@ -28,14 +30,47 @@ import { invokeNumberMethod, invokeNumberStatic } from "../stdlib/number.js"
import { invokeObjectMethod } from "../stdlib/object.js"
import { invokeRegExpMethod, matchToValue, toHostRegex } from "../stdlib/regexp.js"
import { invokeStringStatic } from "../stdlib/string.js"
import { invokeUriFunction, invokeURLMethod, invokeURLStatic, uriArgument } from "../stdlib/url.js"
import { boundedData, coerceToNumber, coerceToString, invokeCoercion } from "../stdlib/value.js"
import { invokeURLMethod, invokeURLStatic, uriArgument } from "../stdlib/url.js"
import { boundedData, coerceToNumber, coerceToString, errorBrandName } from "../stdlib/value.js"
export type CallbackRunner<R> = {
readonly invokeFunction: (fn: CodeModeFunction, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
readonly invokeCallable: (
callable: unknown,
args: Array<unknown>,
node: AstNode,
) => Effect.Effect<unknown, unknown, R>
readonly settlePromise: (promise: CodeModePromise) => Effect.Effect<unknown, unknown, never>
}
// The single acceptance list for callbacks: collections, sort, string replacers,
// Array.from mappers, and promise reactions all admit exactly these callables.
// Admission means dispatchable, not necessarily invocable: new-requiring
// constructors pass the gate and throw a TypeError on call, like JS.
export type SupportedCallback =
| CodeModeFunction
| CoercionFunction
| UriFunction
| PromiseCapabilityFunction
| GlobalMethodReference
| IntrinsicReference
| ErrorConstructorReference
| GlobalNamespace
| PromiseNamespace
export const isSupportedCallback = (value: unknown): value is SupportedCallback =>
value instanceof CodeModeFunction ||
value instanceof CoercionFunction ||
value instanceof UriFunction ||
value instanceof PromiseCapabilityFunction ||
value instanceof GlobalMethodReference ||
value instanceof IntrinsicReference ||
value instanceof ErrorConstructorReference ||
// Callable namespaces dispatch like JS: Array/Object/Date/RegExp construct,
// new-requiring constructors throw a TypeError. Math/JSON/console stay non-callable.
(value instanceof GlobalNamespace && typeofValue(value) === "function") ||
value instanceof PromiseNamespace
export const invokeIntrinsic = <R>(
runner: CallbackRunner<R>,
ref: IntrinsicReference,
@@ -43,11 +78,14 @@ export const invokeIntrinsic = <R>(
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
if (typeof ref.receiver === "string") {
if (
(ref.name === "replace" || ref.name === "replaceAll") &&
(args[1] instanceof CodeModeFunction || args[1] instanceof CoercionFunction || args[1] instanceof UriFunction)
) {
return invokeStringReplacer(runner, ref.receiver, ref.name, args, node)
if (ref.name === "replace" || ref.name === "replaceAll") {
if (isSupportedCallback(args[1])) return invokeStringReplacer(runner, ref.receiver, ref.name, args, node)
if (typeofValue(args[1]) === "function") {
throw new InterpreterRuntimeError(
`String.${ref.name} cannot use this callable as a replacer; wrap it in an arrow function, e.g. (match) => tools.ns.tool(match).`,
node,
)
}
}
return Effect.succeed(invokeStringMethod(ref.receiver, ref.name, args, node))
}
@@ -269,49 +307,60 @@ const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): u
return Array.isArray(args[0])
case "of":
return [...args]
case "from": {
if (args.length > 1) {
throw new InterpreterRuntimeError(
"Array.from(...) does not support a map function in CodeMode; call .map() on the result instead.",
node,
"UnsupportedSyntax",
[supportedSyntaxMessage],
)
}
if (args[0] instanceof CodeModeMap) return Array.from(args[0].map.entries(), ([key, item]) => [key, item])
if (args[0] instanceof CodeModeSet) return Array.from(args[0].set.values())
if (args[0] instanceof CodeModeURLSearchParams) {
return Array.from(args[0].params.entries(), ([key, value]) => [key, value])
}
const source = args[0]
if (source instanceof CodeModePromise) {
throw new InterpreterRuntimeError(
"Array.from received an un-awaited Promise; await it before creating the array.",
node,
"InvalidDataValue",
)
}
if (typeof source === "string") return Array.from(source)
if (Array.isArray(source)) return [...source]
if (
source !== null &&
typeof source === "object" &&
(Object.getPrototypeOf(source) === Object.prototype || Object.getPrototypeOf(source) === null) &&
typeof (source as { length?: unknown }).length === "number"
) {
return Array.from(source as ArrayLike<unknown>)
}
throw new InterpreterRuntimeError(
"Array.from expects an array, string, Map, Set, or array-like value.",
node,
"InvalidDataValue",
)
}
case "from":
return arrayFromItems(args[0], node)
default:
throw new InterpreterRuntimeError(`Array.${name} is not available in CodeMode.`, node)
}
}
const arrayFromItems = (source: unknown, node: AstNode): Array<unknown> => {
if (source instanceof CodeModeMap) return Array.from(source.map.entries(), ([key, item]) => [key, item])
if (source instanceof CodeModeSet) return Array.from(source.set.values())
if (source instanceof CodeModeURLSearchParams) {
return Array.from(source.params.entries(), ([key, value]) => [key, value])
}
if (source instanceof CodeModePromise) {
throw new InterpreterRuntimeError(
"Array.from received an un-awaited Promise; await it before creating the array.",
node,
"InvalidDataValue",
)
}
if (typeof source === "string") return Array.from(source)
if (Array.isArray(source)) return [...source]
if (
source !== null &&
typeof source === "object" &&
(Object.getPrototypeOf(source) === Object.prototype || Object.getPrototypeOf(source) === null) &&
typeof (source as { length?: unknown }).length === "number"
) {
return Array.from(source as ArrayLike<unknown>)
}
throw new InterpreterRuntimeError(
"Array.from expects an array, string, Map, Set, or array-like value.",
node,
"InvalidDataValue",
)
}
export const invokeArrayFrom = <R>(
runner: CallbackRunner<R>,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
const items = arrayFromItems(args[0], node)
if (args.length < 2 || args[1] === undefined) return Effect.succeed(items)
const apply = applyCollectionCallback(runner, args[1], "Array.from", node)
return Effect.gen(function* () {
const values: Array<unknown> = []
for (let index = 0; index < items.length; index += 1) {
values.push(yield* apply([items[index], index]))
}
return values
})
}
const invokeStringReplacer = <R>(
runner: CallbackRunner<R>,
value: string,
@@ -367,9 +416,12 @@ const invokeStringReplacer = <R>(
args[1] instanceof CodeModeFunction && args[1].async && replacement instanceof CodeModePromise
? yield* runner.settlePromise(replacement)
: replacement
// Error values are branded plain objects; boundedData would strip the brand before coercion.
output.push(
value.slice(end, match.offset),
coerceToString(boundedData(resolved, `String.${name} replacer result`)),
errorBrandName(resolved)
? coerceToString(resolved)
: coerceToString(boundedData(resolved, `String.${name} replacer result`)),
)
end = match.offset + match.match.length
}
@@ -384,22 +436,16 @@ export const applyCollectionCallback = <R>(
name: string,
node: AstNode,
): ((args: Array<unknown>) => Effect.Effect<unknown, unknown, R>) => {
if (
!(callback instanceof CodeModeFunction) &&
!(callback instanceof CoercionFunction) &&
!(callback instanceof UriFunction) &&
!(callback instanceof PromiseCapabilityFunction)
) {
if (!isSupportedCallback(callback)) {
if (typeofValue(callback) === "function") {
throw new InterpreterRuntimeError(
`${name} cannot use this callable as a callback; wrap it in an arrow function, e.g. (value) => tools.ns.tool(value).`,
node,
)
}
throw new InterpreterRuntimeError(`${name} expects a function callback.`, node)
}
return (callbackArgs) =>
callback instanceof CoercionFunction
? Effect.succeed(invokeCoercion(callback, callbackArgs, node))
: callback instanceof UriFunction
? Effect.succeed(invokeUriFunction(callback, callbackArgs, node))
: callback instanceof PromiseCapabilityFunction
? Effect.sync(() => callback.settle(callbackArgs[0]))
: runner.invokeFunction(callback, callbackArgs)
return (callbackArgs) => runner.invokeCallable(callback, callbackArgs, node)
}
const invokeMapMethod = <R>(
@@ -603,12 +649,12 @@ const invokeArrayMethod = <R>(
case "reverse":
return Effect.succeed(target.reverse())
case "sort":
return Effect.map(sortArray(runner, target, args[0], node), (sorted) => {
return Effect.map(sortArray(runner, target, args[0], "Array.sort", node), (sorted) => {
target.splice(0, target.length, ...sorted)
return target
})
case "toSorted":
return sortArray(runner, target, args[0], node)
return sortArray(runner, target, args[0], "Array.toSorted", node)
case "toReversed":
return Effect.succeed([...target].reverse())
case "with": {
@@ -782,12 +828,10 @@ const sortArray = <R>(
runner: CallbackRunner<R>,
target: Array<unknown>,
comparator: unknown,
name: string,
node: AstNode,
): Effect.Effect<Array<unknown>, unknown, R> => {
if (comparator !== undefined && !(comparator instanceof CodeModeFunction)) {
throw new InterpreterRuntimeError("Array.sort expects an arrow function comparator.", node)
}
if (!(comparator instanceof CodeModeFunction)) {
if (comparator === undefined) {
return Effect.sync(() =>
[...target].sort((a, b) => {
const left = coerceToString(a)
@@ -796,6 +840,7 @@ const sortArray = <R>(
}),
)
}
const apply = applyCollectionCallback(runner, comparator, name, node)
const mergeSort = (items: Array<unknown>): Effect.Effect<Array<unknown>, unknown, R> => {
if (items.length <= 1) return Effect.succeed(items)
const midpoint = Math.floor(items.length / 2)
@@ -807,7 +852,7 @@ const sortArray = <R>(
let rightIndex = 0
while (leftIndex < left.length && rightIndex < right.length) {
// Treat a NaN comparator result as equal to preserve stable ordering.
const order = coerceToNumber(yield* runner.invokeFunction(comparator, [left[leftIndex], right[rightIndex]]))
const order = coerceToNumber(yield* apply([left[leftIndex], right[rightIndex]]))
if (Number.isNaN(order) || order <= 0) merged.push(left[leftIndex++])
else merged.push(right[rightIndex++])
}
+7 -18
View File
@@ -4,16 +4,14 @@ import type { SafeObject } from "../tool-runtime.js"
import {
type AstNode,
CodeModeFunction,
CoercionFunction,
InterpreterRuntimeError,
ProgramThrow,
PromiseCapabilityFunction,
PromiseInstanceMethodReference,
PromiseMethodReference,
UriFunction,
} from "./model.js"
import { caughtErrorValue, normalizeError } from "./errors.js"
import { applyCollectionCallback, type CallbackRunner } from "./methods.js"
import { applyCollectionCallback, isSupportedCallback, type CallbackRunner, type SupportedCallback } from "./methods.js"
import { typeofValue } from "./references.js"
import { spreadItems } from "../stdlib/collections.js"
import { createAggregateErrorValue } from "../stdlib/value.js"
@@ -258,20 +256,11 @@ class PromiseAnyFulfilled {
constructor(readonly value: unknown) {}
}
type ReactionHandler = CodeModeFunction | CoercionFunction | UriFunction | PromiseCapabilityFunction
const reactionHandler = (value: unknown, method: string, node: AstNode): ReactionHandler | undefined => {
if (
value instanceof CodeModeFunction ||
value instanceof CoercionFunction ||
value instanceof UriFunction ||
value instanceof PromiseCapabilityFunction
) {
return value
}
const reactionHandler = (value: unknown, method: string, node: AstNode): SupportedCallback | undefined => {
if (isSupportedCallback(value)) return value
if (typeofValue(value) === "function") {
throw new InterpreterRuntimeError(
`${method} handlers must be plain functions; wrap other callables in an arrow function, e.g. (value) => tools.ns.tool(value).`,
`${method} cannot use this callable as a handler; wrap it in an arrow function, e.g. (value) => tools.ns.tool(value).`,
node,
)
}
@@ -294,8 +283,8 @@ const chainReaction = <R>(
runner: CallbackRunner<R>,
promises: PromiseRuntime<R>,
source: CodeModePromise,
onFulfilled: ReactionHandler | undefined,
onRejected: ReactionHandler | undefined,
onFulfilled: SupportedCallback | undefined,
onRejected: SupportedCallback | undefined,
method: string,
node: AstNode,
): Effect.Effect<CodeModePromise, never, R> => {
@@ -320,7 +309,7 @@ const chainFinally = <R>(
runner: CallbackRunner<R>,
promises: PromiseRuntime<R>,
source: CodeModePromise,
cleanup: ReactionHandler | undefined,
cleanup: SupportedCallback | undefined,
method: string,
node: AstNode,
): Effect.Effect<CodeModePromise, never, R> =>
+65 -4
View File
@@ -34,7 +34,7 @@ import {
UriFunction,
} from "./model.js"
import { caughtErrorValue, constructErrorValue } from "./errors.js"
import { type CallbackRunner, invokeGlobalMethod, invokeIntrinsic } from "./methods.js"
import { type CallbackRunner, invokeArrayFrom, invokeGlobalMethod, invokeIntrinsic } from "./methods.js"
import {
constructPromise,
invokePromiseInstanceMethod,
@@ -153,6 +153,7 @@ export class Interpreter<R> {
private readonly promises: PromiseRuntime<R>
private readonly runner: CallbackRunner<R> = {
invokeFunction: (fn, args) => this.invokeFunction(fn, args),
invokeCallable: (callable, args, node) => this.invokeCallable(callable, args, node),
settlePromise: (promise) => this.settlePromise(promise),
}
@@ -997,6 +998,13 @@ export class Interpreter<R> {
if (errorConstructors.has(name)) {
return Effect.map(this.evaluateCallArguments(argNodes), (args) => constructErrorValue(name, args, node))
}
// Array and Object construct identically with or without new, like JS.
if (name === "Array") {
return Effect.map(this.evaluateCallArguments(argNodes), (args) => self.constructArray(args, node))
}
if (name === "Object") {
return Effect.map(this.evaluateCallArguments(argNodes), (args) => self.constructObject(args, node))
}
if (valueConstructors.has(name)) {
return Effect.gen(function* () {
const args = yield* self.evaluateCallArguments(argNodes)
@@ -1019,6 +1027,27 @@ export class Interpreter<R> {
throw unsupportedSyntax("NewExpression", node)
}
private constructArray(args: Array<unknown>, node: AstNode): Array<unknown> {
if (args.length !== 1) return [...args]
const first = args[0]
if (typeof first !== "number") return [first]
if (!Number.isInteger(first) || first < 0 || first > 4294967295) {
throw new InterpreterRuntimeError("Invalid array length.", node).as("RangeError")
}
// Sparse like JS: Array(3) has holes, and combinator loops already skip them.
return new Array(first)
}
private constructObject(args: Array<unknown>, node: AstNode): unknown {
const first = args[0]
if (first === null || first === undefined) return {}
if (typeof first === "object") return first
throw new InterpreterRuntimeError(
`Object(${typeof first}) wrapper objects are not supported in CodeMode; use the primitive value directly.`,
node,
)
}
private constructDate(args: Array<unknown>): CodeModeDate {
if (args.length === 0) return new CodeModeDate(Date.now())
if (args.length === 1) {
@@ -1041,7 +1070,7 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError(
`RegExp flags must be a string of flag characters (e.g. "g", "gi"), not ${flagsArg === null ? "null" : typeof flagsArg}.`,
node,
)
).as("SyntaxError")
}
const flags = flagsArg ?? (first instanceof CodeModeRegExp ? first.regex.flags : "")
try {
@@ -1401,7 +1430,19 @@ export class Interpreter<R> {
if ((callable === null || callable === undefined) && node.optional === true) return OptionalShortCircuit
const args = yield* self.evaluateCallArguments(argNodes)
return yield* self.invokeCallable(callable, args, node, callee)
})
}
// The single dispatch for every invocation: call expressions and callbacks share it.
private invokeCallable(
callable: unknown,
args: Array<unknown>,
node: AstNode,
callee: AstNode = node,
): Effect.Effect<unknown, unknown, R> {
const self = this
return Effect.gen(function* () {
if (callable instanceof ToolReference) {
if (callable.path.length === 0) throw new InterpreterRuntimeError("The tools root is not callable.", callee)
return yield* self.createToolCallPromise(callable.path, args)
@@ -1426,7 +1467,10 @@ export class Interpreter<R> {
if (callable.namespace === "Object" && objectMethodsPreservingIdentity.has(callable.name)) {
return invokeGlobalMethod(callable, args, node)
}
if (callable.namespace === "Array" && (callable.name === "from" || callable.name === "of")) {
if (callable.namespace === "Array" && callable.name === "from") {
return yield* invokeArrayFrom(self.runner, args, node)
}
if (callable.namespace === "Array" && callable.name === "of") {
return invokeGlobalMethod(callable, args, node)
}
return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`)
@@ -1443,6 +1487,22 @@ export class Interpreter<R> {
if (callable instanceof ErrorConstructorReference) {
return constructErrorValue(callable.name, args, node)
}
if (callable instanceof GlobalNamespace) {
// Real JS permits calling Array, Object, Date, and RegExp without new.
if (callable.name === "Array") return self.constructArray(args, node)
if (callable.name === "Object") return self.constructObject(args, node)
// ISO instead of the host's locale string: CodeMode date strings are
// deterministic and must not leak the host timezone.
if (callable.name === "Date") return new Date().toISOString()
if (callable.name === "RegExp") return self.constructRegExp(args, node)
if (typeofValue(callable) === "function") {
throw new InterpreterRuntimeError(`Constructor ${callable.name} requires 'new'.`, node).as("TypeError")
}
throw new InterpreterRuntimeError(`${callable.name} is not a function.`, node).as("TypeError")
}
if (callable instanceof PromiseNamespace) {
throw new InterpreterRuntimeError("Constructor Promise requires 'new'.", node).as("TypeError")
}
if (callable instanceof PromiseCapabilityFunction) {
callable.settle(args[0])
return undefined
@@ -1604,7 +1664,8 @@ export class Interpreter<R> {
return Effect.gen(function* () {
for (const elementValue of elements) {
if (elementValue === null) {
values.push(undefined)
// A literal elision is a real hole, like JS: extend length without an own index.
values.length += 1
continue
}
const element = asNode(elementValue, "elements")
+11 -7
View File
@@ -1,16 +1,12 @@
import {
type AstNode,
CodeModeFunction,
InterpreterRuntimeError,
supportedSyntaxMessage,
} from "../interpreter/model.js"
import { type AstNode, InterpreterRuntimeError, supportedSyntaxMessage } from "../interpreter/model.js"
import { typeofValue } from "../interpreter/references.js"
import { copyIn, copyOut } from "../tool-runtime.js"
export const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
switch (name) {
case "stringify": {
const replacer = args[1]
if (Array.isArray(replacer) || replacer instanceof CodeModeFunction) {
if (Array.isArray(replacer) || typeofValue(replacer) === "function") {
throw new InterpreterRuntimeError(
"JSON.stringify replacers are not supported in CodeMode.",
node,
@@ -25,6 +21,14 @@ export const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNo
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) {
+19 -9
View File
@@ -42,16 +42,26 @@ export const mathMethods = new Set([
export const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNode): number => {
if (!mathMethods.has(name)) throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node)
if (name === "random") return Math.random()
const nums = args.map((arg) => {
// Validate only the arguments the method consumes; like JS, extras are ignored
// (so built-ins work as callbacks receiving (element, index, array)).
const num = (index: number): number => {
if (index >= args.length) return Number.NaN
const arg = args[index]
if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node)
return arg
})
const [a = Number.NaN, b = Number.NaN] = nums
}
const nums = () =>
args.map((arg) => {
if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node)
return arg
})
const a = num(0)
const b = () => num(1)
switch (name) {
case "max":
return Math.max(...nums)
return Math.max(...nums())
case "min":
return Math.min(...nums)
return Math.min(...nums())
case "abs":
return Math.abs(a)
case "acos":
@@ -65,7 +75,7 @@ export const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNo
case "atan":
return Math.atan(a)
case "atan2":
return Math.atan2(a, b)
return Math.atan2(a, b())
case "atanh":
return Math.atanh(a)
case "floor":
@@ -83,9 +93,9 @@ export const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNo
case "cbrt":
return Math.cbrt(a)
case "pow":
return Math.pow(a, b)
return Math.pow(a, b())
case "hypot":
return Math.hypot(...nums)
return Math.hypot(...nums())
case "cos":
return Math.cos(a)
case "cosh":
@@ -117,7 +127,7 @@ export const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNo
case "clz32":
return Math.clz32(a)
case "imul":
return Math.imul(a, b)
return Math.imul(a, b())
}
throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node)
}
+11
View File
@@ -41,6 +41,15 @@ export const coerceToString = (value: unknown): string => {
if (value instanceof CodeModeSet) return "[object Set]"
if (value instanceof CodeModeURL) return value.url.href
if (value instanceof CodeModeURLSearchParams) return value.params.toString()
if (errorBrandName(value) !== undefined) {
// Match Error.prototype.toString: "name: message", or just one when the other is empty.
const error = value as { name?: unknown; message?: unknown }
const name = typeof error.name === "string" ? error.name : "Error"
const message = typeof error.message === "string" ? error.message : ""
if (message === "") return name
if (name === "") return message
return `${name}: ${message}`
}
if (typeof value === "object") {
return Array.isArray(value)
? value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",")
@@ -57,6 +66,8 @@ export const coerceToNumber = (value: unknown): number => {
export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node: AstNode): unknown => {
const raw = args[0]
// Error values are plain SafeObjects; the boundedData path below would strip their brand.
if (ref.name === "String" && errorBrandName(raw) !== undefined) return coerceToString(raw)
if (isCodeModeValue(raw)) {
if (ref.name === "Boolean") return true
if (ref.name === "Number") return coerceToNumber(raw)
+2 -1
View File
@@ -270,7 +270,8 @@ export const copyOut = (value: unknown, undefinedAsNull = false): unknown => {
return null
}
if (Array.isArray(value)) {
return value.map((item) => copyOut(item, undefinedAsNull))
// Array.from densifies holes so sparse arrays normalize at the boundary like JSON does.
return Array.from(value, (item) => copyOut(item, undefinedAsNull))
}
if (value !== null && typeof value === "object" && !(value instanceof ToolReference)) {
+258
View File
@@ -0,0 +1,258 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
// Callback acceptance is one gate shared by array methods, sort, string replacers,
// Array.from mappers, Map/Set/URLSearchParams forEach, and promise reactions:
// interpreter functions, coercion/URI builtins, resolver capabilities, and built-in
// method references are callable; tools and other opaque callables get a wrap hint.
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const value = async (code: string) => {
const result = await run(code)
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
const error = async (code: string) => {
const result = await run(code)
if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
return result.error
}
const logsOf = async (code: string) => {
const result = await run(code)
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.logs ?? []
}
const echo = Tool.make({
description: "Echo the input",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.Number,
run: (input: { id: number }) => Effect.succeed(input.id),
})
const withTool = (code: string) => Effect.runPromise(CodeMode.make({ tools: { host: { echo } } }).execute(code))
const toolError = async (code: string) => {
const result = await withTool(code)
if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
return result.error
}
describe("built-in method references as callbacks", () => {
test("map accepts Math methods", async () => {
expect(await value(`return [-1, 2, -3].map(Math.abs)`)).toEqual([1, 2, 3])
expect(await value(`return [1.5, 2.7].map(Math.floor)`)).toEqual([1, 2])
})
test("map(JSON.stringify) matches JS: the index replacer and array space are ignored", async () => {
expect(await value(`return [{ a: 1 }, [2]].map(JSON.stringify)`)).toEqual(['{"a":1}', "[2]"])
})
test("map(Number.parseInt) reproduces the JS radix footgun", async () => {
// parseInt("2", 1) is NaN in real JS; NaN serializes to null at the result boundary.
expect(await value(`return ["1", "2"].map(Number.parseInt)`)).toEqual([1, null])
})
test("filter and find accept built-in predicates", async () => {
expect(await value(`return [0, 1, NaN, 2].filter(Number.isInteger)`)).toEqual([0, 1, 2])
expect(await value(`return [1.5, 3, 2.5].find(Number.isInteger)`)).toBe(3)
})
test("forEach(console.log) captures one log line per element", async () => {
const logs = await logsOf(`["a", "b"].forEach(console.log); return null`)
expect(logs).toHaveLength(2)
expect(logs[0]).toContain("a")
expect(logs[1]).toContain("b")
})
test("intrinsic method references keep their receiver, unlike detached JS methods", async () => {
expect(await value(`return ["a", "z"].filter("abc".includes)`)).toEqual(["a"])
})
test("promise reactions accept built-in references", async () => {
expect(await value(`return await Promise.resolve(-5).then(Math.abs)`)).toBe(5)
const logs = await logsOf(`await Promise.resolve("done").then(console.log); return null`)
expect(logs).toHaveLength(1)
expect(logs[0]).toContain("done")
})
})
describe("constructors callable without new, like JS", () => {
test("Error constructors work as callbacks and direct calls", async () => {
expect(await value(`return ["boom"].map(Error)[0].message`)).toBe("boom")
expect(await value(`return TypeError("bad").name`)).toBe("TypeError")
})
test("error values stringify like JS Error.prototype.toString", async () => {
expect(await value(`return String(TypeError("bad"))`)).toBe("TypeError: bad")
expect(await value(`return String(Error(""))`)).toBe("Error")
expect(await value(`return "x" + RangeError("oops")`)).toBe("xRangeError: oops")
expect(await value(`return "a1b2".replace(/\\d/, Error)`)).toBe("aError: 1b2")
})
test("literal elisions are real holes, like JS", async () => {
expect(await value(`return (0 in [, 1])`)).toBe(false)
expect(await value(`return Object.keys([, 1, ,])`)).toEqual(["1"])
expect(await value(`return [, 1, ,].filter(() => true).length`)).toBe(1)
expect(await value(`return [, ,].every((x) => false)`)).toBe(true)
})
test("Array constructs from arguments or a length", async () => {
expect(await value(`return Array(1, 2, 3)`)).toEqual([1, 2, 3])
expect(await value(`return Array("3")`)).toEqual(["3"])
expect(await value(`return Array(3).length`)).toBe(3)
expect(await value(`return new Array(2).length`)).toBe(2)
// Holes stay holes, like JS: map skips them (length preserved, normalized to
// null at the host boundary), spread materializes undefined.
expect(await value(`return Array(3).map((x) => 1)`)).toEqual([null, null, null])
expect(await value(`return Array(3).map((x) => 1).length`)).toBe(3)
expect(await value(`return [...Array(3)].map((_, i) => i)`)).toEqual([0, 1, 2])
expect((await error(`return Array(-1)`)).message).toContain("Invalid array length")
expect((await error(`return Array(1.5)`)).message).toContain("Invalid array length")
})
test("Object returns objects unchanged and rejects primitive wrappers", async () => {
expect(await value(`return Object()`)).toEqual({})
expect(await value(`const o = { a: 1 }; return Object(o) === o`)).toBe(true)
expect((await error(`return Object(1)`)).message).toContain("wrapper objects are not supported")
})
test("Date() without new returns a deterministic ISO string and ignores arguments", async () => {
expect(await value(`return /^\\d{4}-\\d{2}-\\d{2}T.*Z$/.test(Date(1000))`)).toBe(true)
expect(await value(`return "abc".replace(RegExp("b"), "x")`)).toBe("axc")
})
test("map(Array) matches the JS 3-argument call", async () => {
expect(await value(`return [7].map(Array)`)).toEqual([[7, 0, [7]]])
})
test("array length boundaries match JS", async () => {
expect(await value(`return Array(4294967295).length`)).toBe(4294967295)
const diagnostic = await error(`return Array(4294967296)`)
expect(diagnostic.message).toContain("Invalid array length")
expect((await error(`try { Array(-1) } catch (e) { throw Error(e.name) }`)).message).toContain("RangeError")
})
test("sort densifies trailing holes into undefined (documented divergence)", async () => {
expect(await value(`return Array(2).sort().map(() => 1)`)).toEqual([1, 1])
})
test("returned sparse arrays normalize holes to null at the host boundary", async () => {
expect(await value(`return Array(3)`)).toEqual([null, null, null])
})
test("RegExp with non-string flags throws a SyntaxError, like JS", async () => {
expect((await error(`try { RegExp("a", 0) } catch (e) { throw Error(e.name) }`)).message).toContain("SyntaxError")
})
test("new-requiring constructors throw a TypeError when called", async () => {
expect((await error(`return Map()`)).message).toContain("Constructor Map requires 'new'")
expect((await error(`return [1].map(Set)`)).message).toContain("Constructor Set requires 'new'")
expect((await error(`return Promise(() => 1)`)).message).toContain("Constructor Promise requires 'new'")
// As a reaction handler the TypeError rejects the derived promise catchably, like JS.
expect(await value(`return await Promise.resolve(1).then(Map).catch((e) => e.name)`)).toBe("TypeError")
})
})
describe("sort accepts the unified callback set", () => {
test("sort and toSorted take built-in comparators", async () => {
expect(await value(`return [0, 1, 0].sort(Boolean)`)).toEqual([0, 0, 1])
expect(await value(`return [0, 1, 0].toSorted(Boolean)`)).toEqual([0, 0, 1])
})
test("a non-callable comparator is rejected", async () => {
expect((await error(`return [2, 1].sort(42)`)).message).toContain("Array.sort expects a function callback")
expect((await error(`return [2, 1].toSorted(42)`)).message).toContain("Array.toSorted expects a function callback")
})
})
describe("Array.from mapper", () => {
test("maps with (value, index) over arrays, strings, and Sets", async () => {
expect(await value(`return Array.from([1, 2, 3], (x) => x * 2)`)).toEqual([2, 4, 6])
expect(await value(`return Array.from("ab", (c, i) => c + i)`)).toEqual(["a0", "b1"])
expect(await value(`return Array.from(new Set([1, 2]), (x) => x * 10)`)).toEqual([10, 20])
})
test("accepts coercion builtins and an explicit undefined mapper", async () => {
expect(await value(`return Array.from(["5", "7"], Number)`)).toEqual([5, 7])
expect(await value(`return Array.from([1, 2], undefined)`)).toEqual([1, 2])
})
test("rejects a non-callable mapper", async () => {
expect((await error(`return Array.from([1], 42)`)).message).toContain("Array.from expects a function callback")
})
})
describe("thisArg is accepted and ignored, like JS arrows", () => {
// CodeMode functions have no `this`, so a thisArg can never change behavior —
// exactly like passing one alongside an arrow function in real JS.
test("iteration methods and Array.from ignore a thisArg", async () => {
expect(await value(`return [1, 2].map((x) => x * 2, {})`)).toEqual([2, 4])
expect(await value(`return [1, 2].map((x) => x, undefined)`)).toEqual([1, 2])
expect(await value(`return Array.from([1], (x) => x + 1, {})`)).toEqual([2])
})
test("Map, Set, and URLSearchParams forEach ignore a thisArg", async () => {
expect(await value(`const o = []; new Map([["a", 1]]).forEach((v, k) => o.push(k), {}); return o`)).toEqual(["a"])
expect(await value(`const o = []; new Set([1]).forEach((v) => o.push(v), "self"); return o`)).toEqual([1])
expect(await value(`const o = []; new URLSearchParams("a=1").forEach((v) => o.push(v), 0); return o`)).toEqual([
"1",
])
})
})
describe("still-rejected callables get the wrap hint", () => {
test("tool references as callbacks suggest an arrow wrapper", async () => {
const diagnostic = await toolError(`return [1, 2].map(tools.host.echo)`)
expect(diagnostic.message).toContain("wrap it in an arrow function")
expect(await withTool(`return await Promise.all([1, 2].map((id) => tools.host.echo({ id })))`)).toMatchObject({
ok: true,
value: [1, 2],
})
})
test("detached Promise statics as callbacks suggest an arrow wrapper", async () => {
expect((await error(`return [1].map(Promise.resolve)`)).message).toContain("wrap it in an arrow function")
})
test("string replacers reject opaque callables with the wrap hint, not a type error", async () => {
const diagnostic = await toolError(`return "abc".replace(/b/, tools.host.echo)`)
expect(diagnostic.message).toContain("wrap it in an arrow function")
expect(diagnostic.message).not.toContain("argument 2")
})
test("built-in references work as replacers", async () => {
// Like real JS: JSON.stringify(match, offset, string) quotes the match.
expect(await value(`return "abc".replace(/b/, JSON.stringify)`)).toBe('a"b"c')
// Math methods stay strict about consumed arguments: a match string is not coerced.
expect((await error(`return "3.7".replace(/\\d\\.\\d/, Math.floor)`)).message).toContain(
"Math.floor expects number arguments",
)
})
test("non-callables still get the plain callback error", async () => {
expect((await error(`return [1].map(42)`)).message).toContain("Array.map expects a function callback")
})
test("promise handlers reject opaque callables with the wrap hint", async () => {
const diagnostic = await toolError(`return await Promise.resolve(1).then(tools.host.echo)`)
expect(diagnostic.message).toContain("Promise.prototype.then cannot use this callable as a handler")
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",
)
expect((await toolError(`return JSON.stringify({ a: 1 }, tools.host.echo)`)).message).toContain(
"JSON.stringify replacers are not supported",
)
})
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",
)
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 })
})
})
@@ -1406,9 +1406,8 @@ describe("Test262 Promise constructor", () => {
).toEqual(["before", "executor", "after"])
})
test.failing("calling Promise without new throws TypeError", async () => {
test("calling Promise without new throws TypeError", async () => {
// Source: test/built-ins/Promise/undefined-newtarget.js
// CodeMode currently reports a generic Error ("Only tools are callable in CodeMode.").
expect(
await value(`
try {
+3 -2
View File
@@ -1083,9 +1083,10 @@ describe("promise chaining", () => {
expect(result.warnings).toBeUndefined()
})
test("non-plain-function handlers fail loudly instead of being ignored", async () => {
test("unsupported callable handlers fail loudly with a wrap hint", async () => {
const diagnostic = await error(`return await tools.host.echo({ id: 1 }).then(tools.host.completed)`)
expect(diagnostic.message).toContain("Promise.prototype.then handlers must be plain functions")
expect(diagnostic.message).toContain("Promise.prototype.then cannot use this callable as a handler")
expect(diagnostic.message).toContain("wrap it in an arrow function")
})
test("chaining methods are opaque references until called", async () => {