diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 692e5d03e2..9178043817 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -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. diff --git a/packages/codemode/src/interpreter/methods.ts b/packages/codemode/src/interpreter/methods.ts index 4757dd55df..56a441939d 100644 --- a/packages/codemode/src/interpreter/methods.ts +++ b/packages/codemode/src/interpreter/methods.ts @@ -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 = { readonly invokeFunction: (fn: CodeModeFunction, args: Array) => Effect.Effect + readonly invokeCallable: ( + callable: unknown, + args: Array, + node: AstNode, + ) => Effect.Effect readonly settlePromise: (promise: CodeModePromise) => Effect.Effect } +// 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 = ( runner: CallbackRunner, ref: IntrinsicReference, @@ -43,11 +57,14 @@ export const invokeIntrinsic = ( node: AstNode, ): Effect.Effect => { 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, 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) - } - 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 => { + 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) + } + throw new InterpreterRuntimeError( + "Array.from expects an array, string, Map, Set, or array-like value.", + node, + "InvalidDataValue", + ) +} + +export const invokeArrayFrom = ( + runner: CallbackRunner, + args: Array, + node: AstNode, +): Effect.Effect => { + 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 = [] + for (let index = 0; index < items.length; index += 1) { + values.push(yield* apply([items[index], index])) + } + return values + }) +} + +const rejectThisArg = (name: string, args: Array, 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 = ( runner: CallbackRunner, value: string, @@ -384,22 +425,16 @@ export const applyCollectionCallback = ( name: string, node: AstNode, ): ((args: Array) => Effect.Effect) => { - 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 = ( @@ -433,6 +468,7 @@ const invokeMapMethod = ( case "entries": return Effect.sync(() => Array.from(target.map.entries(), ([key, item]): Array => [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 = ( case "entries": return Effect.sync(() => Array.from(target.set.values(), (item): Array => [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 = ( 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 = ( } } +// 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 = ( runner: CallbackRunner, target: Array, @@ -603,12 +655,12 @@ const invokeArrayMethod = ( 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 = ( return Effect.succeed(Array.from(target.entries(), ([index, item]): Array => [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 = ( runner: CallbackRunner, target: Array, comparator: unknown, + name: string, node: AstNode, ): Effect.Effect, 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 = ( }), ) } + const apply = applyCollectionCallback(runner, comparator, name, node) const mergeSort = (items: Array): Effect.Effect, unknown, R> => { if (items.length <= 1) return Effect.succeed(items) const midpoint = Math.floor(items.length / 2) @@ -807,7 +859,7 @@ const sortArray = ( 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++]) } diff --git a/packages/codemode/src/interpreter/promises.ts b/packages/codemode/src/interpreter/promises.ts index 2bf1b1ea30..1d783cf44e 100644 --- a/packages/codemode/src/interpreter/promises.ts +++ b/packages/codemode/src/interpreter/promises.ts @@ -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 } diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 3dfc9c5cb9..bf18bdeb4d 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -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 { private readonly promises: PromiseRuntime private readonly runner: CallbackRunner = { 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 { 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, + node: AstNode, + callee: AstNode = node, + ): Effect.Effect { + 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 { 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`) diff --git a/packages/codemode/src/stdlib/math.ts b/packages/codemode/src/stdlib/math.ts index 7720f69775..54fa3be91b 100644 --- a/packages/codemode/src/stdlib/math.ts +++ b/packages/codemode/src/stdlib/math.ts @@ -42,16 +42,26 @@ export const mathMethods = new Set([ export const invokeMathMethod = (name: string, args: Array, 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, 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, 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, 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) } diff --git a/packages/codemode/test/callbacks.test.ts b/packages/codemode/test/callbacks.test.ts new file mode 100644 index 0000000000..fe843526d9 --- /dev/null +++ b/packages/codemode/test/callbacks.test.ts @@ -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") + }) +})