Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65f4ddd339 | |||
| 105df935e2 |
@@ -77,16 +77,23 @@ 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] Tool references, `Error` constructors, and `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.
|
||||
- [x] A non-`undefined` `thisArg` for iteration methods is rejected explicitly: CodeMode functions have no `this`.
|
||||
- [ ] `this`, `super`, 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,8 +144,9 @@ 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.
|
||||
|
||||
@@ -157,7 +165,8 @@ ultimate source of truth.
|
||||
|
||||
## Arrays
|
||||
|
||||
- [x] Static methods: `Array.isArray`, `Array.of`, and `Array.from`.
|
||||
- [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 +176,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`.
|
||||
- [ ] The `thisArg` form of `Array.from` (rejected explicitly; CodeMode functions have no `this`).
|
||||
- [ ] `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.
|
||||
@@ -295,8 +304,12 @@ 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 callback argument explicitly rather than silently ignoring it (`thisArg` now fails
|
||||
loudly).
|
||||
- [ ] 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.
|
||||
|
||||
@@ -7,10 +7,9 @@ import {
|
||||
IntrinsicReference,
|
||||
InterpreterRuntimeError,
|
||||
PromiseCapabilityFunction,
|
||||
supportedSyntaxMessage,
|
||||
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 +27,29 @@ 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 } 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.
|
||||
export const isSupportedCallback = (value: unknown): boolean =>
|
||||
value instanceof CodeModeFunction ||
|
||||
value instanceof CoercionFunction ||
|
||||
value instanceof UriFunction ||
|
||||
value instanceof PromiseCapabilityFunction ||
|
||||
value instanceof GlobalMethodReference ||
|
||||
value instanceof IntrinsicReference
|
||||
|
||||
export const invokeIntrinsic = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
ref: IntrinsicReference,
|
||||
@@ -43,11 +57,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 +286,73 @@ 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> => {
|
||||
rejectThisArg("Array.from", args, 2, node)
|
||||
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 rejectThisArg = (name: string, args: Array<unknown>, start: number, node: AstNode): void => {
|
||||
for (let index = start; index < args.length; index += 1) {
|
||||
if (args[index] !== undefined) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`${name} does not support a thisArg: CodeMode functions have no 'this'. Use an arrow function that closes over the values it needs.`,
|
||||
node,
|
||||
"UnsupportedSyntax",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const invokeStringReplacer = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
value: string,
|
||||
@@ -384,22 +425,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>(
|
||||
@@ -433,6 +468,7 @@ const invokeMapMethod = <R>(
|
||||
case "entries":
|
||||
return Effect.sync(() => Array.from(target.map.entries(), ([key, item]): Array<unknown> => [key, item]))
|
||||
case "forEach": {
|
||||
rejectThisArg("Map.forEach", args, 1, node)
|
||||
const apply = applyCollectionCallback(runner, args[0], "Map.forEach", node)
|
||||
return Effect.gen(function* () {
|
||||
for (const [key, item] of Array.from(target.map.entries())) yield* apply([item, key, target])
|
||||
@@ -472,6 +508,7 @@ const invokeSetMethod = <R>(
|
||||
case "entries":
|
||||
return Effect.sync(() => Array.from(target.set.values(), (item): Array<unknown> => [item, item]))
|
||||
case "forEach": {
|
||||
rejectThisArg("Set.forEach", args, 1, node)
|
||||
const apply = applyCollectionCallback(runner, args[0], "Set.forEach", node)
|
||||
return Effect.gen(function* () {
|
||||
for (const item of Array.from(target.set.values())) yield* apply([item, item, target])
|
||||
@@ -546,6 +583,7 @@ const invokeURLSearchParamsMethod = <R>(
|
||||
return Effect.sync(() => target.params.toString())
|
||||
case "forEach": {
|
||||
requireArgs(1)
|
||||
rejectThisArg("URLSearchParams.forEach", args, 1, node)
|
||||
const apply = applyCollectionCallback(runner, args[0], "URLSearchParams.forEach", node)
|
||||
return Effect.gen(function* () {
|
||||
for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target])
|
||||
@@ -557,6 +595,20 @@ const invokeURLSearchParamsMethod = <R>(
|
||||
}
|
||||
}
|
||||
|
||||
// Methods whose spec signature is (callback, thisArg); reduce/reduceRight use args[1] as the initial value.
|
||||
const thisArgMethods = new Set([
|
||||
"map",
|
||||
"flatMap",
|
||||
"filter",
|
||||
"find",
|
||||
"findIndex",
|
||||
"some",
|
||||
"every",
|
||||
"forEach",
|
||||
"findLast",
|
||||
"findLastIndex",
|
||||
])
|
||||
|
||||
const invokeArrayMethod = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
target: Array<unknown>,
|
||||
@@ -603,12 +655,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": {
|
||||
@@ -665,6 +717,7 @@ const invokeArrayMethod = <R>(
|
||||
return Effect.succeed(Array.from(target.entries(), ([index, item]): Array<unknown> => [index, item]))
|
||||
}
|
||||
|
||||
if (thisArgMethods.has(name)) rejectThisArg(`Array.${name}`, args, 1, node)
|
||||
const apply = applyCollectionCallback(runner, args[0], `Array.${name}`, node)
|
||||
return Effect.gen(function* () {
|
||||
// Fix iteration length while reading existing elements live.
|
||||
@@ -782,12 +835,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 +847,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 +859,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++])
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
type AstNode,
|
||||
CodeModeFunction,
|
||||
CoercionFunction,
|
||||
GlobalMethodReference,
|
||||
InterpreterRuntimeError,
|
||||
IntrinsicReference,
|
||||
ProgramThrow,
|
||||
PromiseCapabilityFunction,
|
||||
PromiseInstanceMethodReference,
|
||||
@@ -258,14 +260,22 @@ class PromiseAnyFulfilled {
|
||||
constructor(readonly value: unknown) {}
|
||||
}
|
||||
|
||||
type ReactionHandler = CodeModeFunction | CoercionFunction | UriFunction | PromiseCapabilityFunction
|
||||
type ReactionHandler =
|
||||
| CodeModeFunction
|
||||
| CoercionFunction
|
||||
| UriFunction
|
||||
| PromiseCapabilityFunction
|
||||
| GlobalMethodReference
|
||||
| IntrinsicReference
|
||||
|
||||
const reactionHandler = (value: unknown, method: string, node: AstNode): ReactionHandler | undefined => {
|
||||
if (
|
||||
value instanceof CodeModeFunction ||
|
||||
value instanceof CoercionFunction ||
|
||||
value instanceof UriFunction ||
|
||||
value instanceof PromiseCapabilityFunction
|
||||
value instanceof PromiseCapabilityFunction ||
|
||||
value instanceof GlobalMethodReference ||
|
||||
value instanceof IntrinsicReference
|
||||
) {
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -1401,7 +1402,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 +1439,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`)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
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("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 and a thisArg", async () => {
|
||||
expect((await error(`return Array.from([1], 42)`)).message).toContain("Array.from expects a function callback")
|
||||
const diagnostic = await error(`return Array.from([1], (x) => x, {})`)
|
||||
expect(diagnostic.kind).toBe("UnsupportedSyntax")
|
||||
expect(diagnostic.message).toContain("thisArg")
|
||||
})
|
||||
})
|
||||
|
||||
describe("thisArg is rejected loudly", () => {
|
||||
test("array iteration methods reject a thisArg and allow explicit undefined", async () => {
|
||||
const diagnostic = await error(`return [1, 2].map((x) => x, {})`)
|
||||
expect(diagnostic.kind).toBe("UnsupportedSyntax")
|
||||
expect(diagnostic.message).toContain("Array.map does not support a thisArg")
|
||||
expect((await error(`return [1].forEach((x) => x, "self")`)).message).toContain("thisArg")
|
||||
expect(await value(`return [1, 2].map((x) => x, undefined)`)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
test("Map, Set, and URLSearchParams forEach reject a thisArg", async () => {
|
||||
expect((await error(`new Map([["a", 1]]).forEach(() => {}, {}); return null`)).message).toContain("Map.forEach")
|
||||
expect((await error(`new Set([1]).forEach(() => {}, {}); return null`)).message).toContain("Set.forEach")
|
||||
expect((await error(`new URLSearchParams("a=1").forEach(() => {}, {}); return null`)).message).toContain(
|
||||
"URLSearchParams.forEach",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
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("Error constructors and Promise statics as callbacks suggest an arrow wrapper", async () => {
|
||||
expect((await error(`return [1].map(Error)`)).message).toContain("wrap it in an arrow function")
|
||||
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")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user