diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 14e2489e97de..0bfef64ef3b7 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -176,9 +176,9 @@ ultimate source of truth. ## Strings - [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`. -- [x] Trimming: `trim`, `trimStart`, `trimEnd`, `trimLeft`, and `trimRight`. +- [x] Trimming: `trim`, `trimStart`, and `trimEnd`. - [x] Searching/tests: `includes`, `startsWith`, `endsWith`, `indexOf`, `lastIndexOf`, and `search`. -- [x] Slicing/access: `slice`, `substring`, `substr`, `at`, `charAt`, `charCodeAt`, and `codePointAt`. +- [x] Slicing/access: `slice`, `substring`, `at`, `charAt`, `charCodeAt`, and `codePointAt`. - [x] Construction/transformation: `split`, `concat`, `repeat`, `padStart`, `padEnd`, `replace`, and `replaceAll`. - [x] Regular-expression integration: `match`, materialized `matchAll`, `replace`, `replaceAll`, `split`, and `search`. - [x] `localeCompare`; locale and options arguments are currently ignored. diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts index 1f13f357ff28..c53c7b40abfd 100644 --- a/packages/codemode/src/codemode.ts +++ b/packages/codemode/src/codemode.ts @@ -1,5 +1,5 @@ import { Effect, Schema } from "effect" -import { executeWithLimits } from "./interpreter/runtime.js" +import { executeWithLimits } from "./interpreter/execute.js" import { type HostTools, type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js" import type { Definition } from "./tool.js" @@ -9,15 +9,15 @@ export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescr /** Resource budgets enforced independently during each CodeMode program execution. */ export type ExecutionLimits = { /** - * Wall-clock milliseconds before execution is interrupted; result delivery additionally - * waits for tool interruption cleanup. No default: absent means no timeout. + * Wall-clock milliseconds before interruption. Result delivery waits for tool cleanup. + * No default: absent means no timeout. */ readonly timeoutMs?: number /** Maximum number of tool calls admitted by the runtime. No default: absent means unlimited. */ readonly maxToolCalls?: number /** - * Maximum UTF-8 bytes retained from the result value and logs; warnings have a separate - * budget of the same size. Fixed truncation notices and host formatting are additional. + * Maximum UTF-8 bytes retained from the result and logs. Warnings have a separate equal budget; + * truncation notices and host formatting are additional. */ readonly maxOutputBytes?: number } @@ -94,7 +94,6 @@ const ToolCallSchema = Schema.Struct({ name: Schema.String }) export const Success = Schema.Struct({ ok: Schema.Literal(true), value: Schema.Json, - // Runtime-authored non-fatal diagnostics; program console output stays in `logs`. warnings: Schema.optionalKey(Schema.Array(Diagnostic)), logs: Schema.optionalKey(Schema.Array(Schema.String)), truncated: Schema.optionalKey(Schema.Boolean), diff --git a/packages/codemode/src/interpreter/errors.ts b/packages/codemode/src/interpreter/errors.ts new file mode 100644 index 000000000000..58ddb8febdd5 --- /dev/null +++ b/packages/codemode/src/interpreter/errors.ts @@ -0,0 +1,93 @@ +import type { Diagnostic } from "../codemode.js" +import { ToolError } from "../tool-error.js" +import { copyOut, ToolRuntimeError, type SafeObject } from "../tool-runtime.js" +import { type AstNode, formatLocation, InterpreterRuntimeError, ProgramThrow, sourceLocation } from "./model.js" +import { containsRuntimeReference } from "./references.js" +import { spreadItems } from "../stdlib/collections.js" +import { coerceToString, createAggregateErrorValue, createErrorValue, errorConstructors } from "../stdlib/value.js" + +export const normalizeError = (error: unknown): Diagnostic => { + if (error instanceof InterpreterRuntimeError) { + return { + kind: error.kind, + message: `${error.message}${formatLocation(error.node)}`, + ...(error.node?.loc ? { location: sourceLocation(error.node) } : {}), + ...(error.suggestions ? { suggestions: error.suggestions } : {}), + } + } + + if (error instanceof ToolRuntimeError) { + return { + kind: error.kind, + message: error.message, + ...(error.suggestions.length > 0 ? { suggestions: error.suggestions } : {}), + } + } + + if (error instanceof ToolError) { + return { kind: "ToolFailure", message: error.message } + } + + if (error instanceof ProgramThrow) { + const value = error.value + let message: string + if (containsRuntimeReference(value)) { + // Never expose runtime reference internals through thrown values. + message = "a non-data value" + } else if (typeof value === "string") { + message = value + } else if ( + value !== null && + typeof value === "object" && + typeof (value as { message?: unknown }).message === "string" + ) { + message = (value as { message: string }).message + } else { + try { + message = JSON.stringify(copyOut(value)) ?? String(value) + } catch { + message = String(value) + } + } + return { kind: "ExecutionFailure", message: `Uncaught: ${message}` } + } + + if (error instanceof RangeError && /call stack|recursion/i.test(error.message)) { + return { + kind: "ExecutionFailure", + message: "Execution exceeded the maximum nesting depth.", + } + } + + if (error instanceof Error) { + return { + kind: error.name === "SyntaxError" ? "ParseError" : "ExecutionFailure", + message: error.message, + } + } + + return { + kind: "ExecutionFailure", + message: String(error), + } +} + +export const caughtErrorValue = (thrown: unknown): unknown => { + if (thrown instanceof ProgramThrow) return thrown.value + if (thrown instanceof InterpreterRuntimeError) return createErrorValue(thrown.errorName, thrown.message) + const name = thrown instanceof Error && errorConstructors.has(thrown.name) ? thrown.name : "Error" + return createErrorValue(name, normalizeError(thrown).message) +} + +export const constructErrorValue = (name: string, args: Array, node: AstNode): SafeObject => { + if (name !== "AggregateError") return createErrorValue(name, args[0] === undefined ? "" : coerceToString(args[0])) + const errors = spreadItems(args[0]) + if (errors === undefined) { + throw new InterpreterRuntimeError( + "new AggregateError(...) expects an array of errors (e.g. new AggregateError(errors, message?)).", + node, + ).as("TypeError") + } + // Error values must not alias caller-owned arrays. + return createAggregateErrorValue([...errors], args[1] === undefined ? "" : coerceToString(args[1])) +} diff --git a/packages/codemode/src/interpreter/execute.ts b/packages/codemode/src/interpreter/execute.ts new file mode 100644 index 000000000000..1e81a0bfca2d --- /dev/null +++ b/packages/codemode/src/interpreter/execute.ts @@ -0,0 +1,224 @@ +import { parse } from "acorn" +import { Cause, Effect, Scope } from "effect" +import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript" +import type { DataValue, Diagnostic, ExecuteOptions, ResolvedExecutionLimits, Result } from "../codemode.js" +import { copyIn, copyOut, ToolRuntime, type HostTools, type Services } from "../tool-runtime.js" +import { normalizeError } from "./errors.js" +import { InterpreterRuntimeError, isRecord, type ProgramNode } from "./model.js" +import { PromiseRuntime } from "./promises.js" +import { Interpreter } from "./runtime.js" + +export const executeWithLimits = >( + options: ExecuteOptions, + limits: ResolvedExecutionLimits, + searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"], +): Effect.Effect> => { + if (options.code.trim().length === 0) { + return Effect.succeed({ + ok: false, + error: { kind: "ParseError", message: "Code cannot be empty." }, + toolCalls: [], + }) + } + + // Allocate execution state inside suspension so reused Effects never share it. + return Effect.suspend(() => { + const tools = ToolRuntime.make( + (options.tools ?? {}) as HostTools>, + limits.maxToolCalls, + searchIndex, + { + onToolCallStart: options.onToolCallStart, + onToolCallEnd: options.onToolCallEnd, + }, + ) + const logs: Array = [] + const logged = () => (logs.length > 0 ? { logs: [...logs] } : {}) + // Set only after copy-out so timeouts cannot report invalid values as completed. + let returned: { value: DataValue; promises: PromiseRuntime> } | undefined + + const base = Effect.acquireUseRelease( + Scope.make("parallel"), + (scope) => + Effect.gen(function* () { + const program = parseProgram(options.code) + const promises = new PromiseRuntime>(scope) + const interpreter = new Interpreter>(tools.invoke, tools.search, tools.keys, promises, logs) + const value = yield* interpreter.run(program) + const result = copyOut(copyIn(value, "Execution result"), true) as DataValue + returned = { value: result, promises } + const warnings = yield* promises.interrupt() + return { + ok: true, + value: result, + ...(warnings.length > 0 ? { warnings } : {}), + ...logged(), + toolCalls: tools.calls, + } satisfies Result + }), + (scope, exit) => Scope.close(scope, exit), + ) + const timeoutMs = limits.timeoutMs + const operation = + timeoutMs === undefined + ? base + : base.pipe( + Effect.timeoutOrElse({ + duration: timeoutMs, + orElse: () => + Effect.sync(() => { + if (returned === undefined) { + return { + ok: false, + error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` }, + ...logged(), + toolCalls: tools.calls, + } satisfies Result + } + // Keep the timeout warning first so truncation preserves it. + return { + ok: true, + value: returned.value, + warnings: [ + { + kind: "TimeoutExceeded", + message: `The program returned, but background work was still running at the ${timeoutMs}ms timeout and was interrupted. Await all started promises.`, + }, + ...returned.promises.diagnostics(), + ], + ...logged(), + toolCalls: tools.calls, + } satisfies Result + }), + }), + ) + + return operation.pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.succeed({ + ok: false, + error: normalizeError(Cause.squash(cause)), + ...logged(), + toolCalls: tools.calls, + } satisfies Result), + ), + Effect.map((result) => + limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes), + ), + ) + }) +} + +const parseProgram = (code: string): ProgramNode => { + const transpiled = transpileModule(`async function __codemode__() {\n${code}\n}`, { + reportDiagnostics: true, + compilerOptions: { + target: ScriptTarget.ESNext, + module: ModuleKind.ESNext, + }, + }) + const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error) + + if (diagnostic) { + throw new InterpreterRuntimeError( + `Failed to parse TypeScript: ${flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`, + undefined, + "ParseError", + ) + } + + const bodyStart = transpiled.outputText.indexOf("{") + 1 + const bodyEnd = transpiled.outputText.lastIndexOf("}") + const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd) + const parsed = parse(executableCode, { + ecmaVersion: "latest", + sourceType: "script", + allowReturnOutsideFunction: true, + allowAwaitOutsideFunction: true, + locations: true, + }) as unknown + + if (!isRecord(parsed) || parsed.type !== "Program" || !Array.isArray(parsed.body)) { + throw new InterpreterRuntimeError("Failed to parse script as a Program node.") + } + + return parsed as ProgramNode +} + +const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength + +// Drop a replacement character produced by truncating inside a UTF-8 sequence. +const utf8Truncate = (value: string, maxBytes: number): string => { + const bytes = new TextEncoder().encode(value) + if (bytes.byteLength <= maxBytes) return value + const text = new TextDecoder("utf-8").decode(bytes.slice(0, Math.max(0, maxBytes))) + return text.endsWith("\uFFFD") ? text.slice(0, -1) : text +} + +// Warnings have a separate budget so result data cannot starve diagnostics. +const boundOutput = (result: Result, maxOutputBytes: number): Result => { + let truncated = false + + let value: DataValue = null + let valueBytes = 0 + if (result.ok) { + const serialized = JSON.stringify(result.value) ?? "null" + const bytes = utf8ByteLength(serialized) + if (bytes > maxOutputBytes) { + truncated = true + value = `${utf8Truncate(serialized, maxOutputBytes)} [result truncated: ${bytes} bytes exceeds the ${maxOutputBytes}-byte output limit; return a smaller value]` + valueBytes = maxOutputBytes + } else { + value = result.value + valueBytes = bytes + } + } + + const warnings = result.ok ? (result.warnings ?? []) : [] + const keptWarnings: Array = [] + let warningBytes = 0 + for (const warning of warnings) { + const bytes = utf8ByteLength(JSON.stringify(warning)) + 1 + if (warningBytes + bytes > maxOutputBytes) break + warningBytes += bytes + keptWarnings.push(warning) + } + if (keptWarnings.length < warnings.length) { + truncated = true + keptWarnings.push({ + kind: "Truncated", + message: `${warnings.length - keptWarnings.length} additional warnings omitted by the output limit.`, + }) + } + + const logs = result.logs ?? [] + const kept: Array = [] + const logBudget = Math.max(0, maxOutputBytes - valueBytes) + let logBytes = 0 + for (const line of logs) { + const lineBytes = utf8ByteLength(line) + 1 + if (logBytes + lineBytes > logBudget) break + logBytes += lineBytes + kept.push(line) + } + if (kept.length < logs.length) { + truncated = true + kept.push(`[logs truncated: showing ${kept.length} of ${logs.length} lines]`) + } + + if (!truncated) return result + const warningsPart = keptWarnings.length > 0 ? { warnings: keptWarnings } : {} + const logsPart = kept.length > 0 ? { logs: kept } : {} + return result.ok + ? { + ok: true, + value, + ...warningsPart, + ...logsPart, + truncated: true, + toolCalls: result.toolCalls, + } + : { ok: false, error: result.error, ...logsPart, truncated: true, toolCalls: result.toolCalls } +} diff --git a/packages/codemode/src/interpreter/methods.ts b/packages/codemode/src/interpreter/methods.ts new file mode 100644 index 000000000000..365a0778376c --- /dev/null +++ b/packages/codemode/src/interpreter/methods.ts @@ -0,0 +1,819 @@ +import { Effect } from "effect" +import { + type AstNode, + CodeModeFunction, + CoercionFunction, + GlobalMethodReference, + IntrinsicReference, + InterpreterRuntimeError, + PromiseCapabilityFunction, + supportedSyntaxMessage, + UriFunction, +} from "./model.js" +import { rejectCircularInsertion } from "./references.js" +import { isBlockedMember, type SafeObject } from "../tool-runtime.js" +import { + SandboxDate, + SandboxMap, + SandboxPromise, + SandboxRegExp, + SandboxSet, + SandboxURL, + SandboxURLSearchParams, +} from "../values.js" +import { invokeDateMethod, invokeDateStatic } from "../stdlib/date.js" +import { invokeJsonMethod } from "../stdlib/json.js" +import { invokeMathMethod } from "../stdlib/math.js" +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" + +export type CallbackRunner = { + readonly invokeFunction: (fn: CodeModeFunction, args: Array) => Effect.Effect + readonly settlePromise: (promise: SandboxPromise) => Effect.Effect +} + +export const invokeIntrinsic = ( + runner: CallbackRunner, + ref: IntrinsicReference, + args: Array, + 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) + } + return Effect.succeed(invokeStringMethod(ref.receiver, ref.name, args, node)) + } + if (typeof ref.receiver === "number") { + return Effect.succeed(invokeNumberMethod(ref.receiver, ref.name, args, node)) + } + if (Array.isArray(ref.receiver)) { + return invokeArrayMethod(runner, ref.receiver, ref.name, args, node) + } + if (ref.receiver instanceof SandboxDate) { + return Effect.succeed(invokeDateMethod(ref.receiver, ref.name, node)) + } + if (ref.receiver instanceof SandboxRegExp) { + return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node)) + } + if (ref.receiver instanceof SandboxMap) { + return invokeMapMethod(runner, ref.receiver, ref.name, args, node) + } + if (ref.receiver instanceof SandboxSet) { + return invokeSetMethod(runner, ref.receiver, ref.name, args, node) + } + if (ref.receiver instanceof SandboxURL) { + return Effect.succeed(invokeURLMethod(ref.receiver, ref.name, node)) + } + if (ref.receiver instanceof SandboxURLSearchParams) { + return invokeURLSearchParamsMethod(runner, ref.receiver, ref.name, args, node) + } + throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node) +} + +export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array, node: AstNode): unknown => { + if (ref.namespace === "console") + throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node) + if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node) + if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node) + if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node) + if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node) + if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node) + if (ref.namespace === "URL") return invokeURLStatic(ref.name, args, node) + if (ref.namespace === "Date") return invokeDateStatic(ref.name, args, node) + if ( + ref.namespace === "RegExp" || + ref.namespace === "Map" || + ref.namespace === "Set" || + ref.namespace === "URLSearchParams" + ) { + throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node) + } + return invokeJsonMethod(ref.name, args, node) +} + +const invokeStringMethod = (value: string, name: string, args: Array, node: AstNode): unknown => { + const str = (index: number): string => { + const arg = args[index] + if (typeof arg !== "string") + throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node) + return arg + } + const num = (index: number): number => { + const arg = args[index] + if (typeof arg !== "number") + throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node) + return arg + } + const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index)) + const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index)) + + let result: unknown + switch (name) { + case "toLowerCase": + result = value.toLowerCase() + break + case "toUpperCase": + result = value.toUpperCase() + break + case "trim": + result = value.trim() + break + case "trimStart": + result = value.trimStart() + break + case "trimEnd": + result = value.trimEnd() + break + // Locale/options are deliberately unsupported; comparison uses the host default locale. + case "localeCompare": + result = value.localeCompare(str(0)) + break + case "normalize": { + const form = optStr(0) + try { + result = value.normalize(form) + } catch { + throw new InterpreterRuntimeError( + `String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`, + node, + ).as("RangeError") + } + break + } + case "split": { + if (args.length === 0) { + result = [value] + break + } + if (args[0] instanceof SandboxRegExp) { + result = value.split(args[0].regex, optNum(1)) + break + } + const requestedLimit = optNum(1) + result = value.split(str(0), requestedLimit === undefined ? undefined : requestedLimit >>> 0) + break + } + case "slice": + result = value.slice(optNum(0), optNum(1)) + break + case "includes": + result = value.includes(str(0), optNum(1)) + break + case "startsWith": + result = value.startsWith(str(0), optNum(1)) + break + case "endsWith": + result = value.endsWith(str(0), optNum(1)) + break + case "indexOf": + result = value.indexOf(str(0), optNum(1)) + break + case "lastIndexOf": + result = value.lastIndexOf(str(0), optNum(1)) + break + case "replace": + case "replaceAll": { + if (args[0] instanceof SandboxRegExp) { + const pattern = args[0].regex + const replacement = str(1) + if (name === "replaceAll" && !pattern.global) { + throw new InterpreterRuntimeError( + `String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.replace to replace only the first match.`, + node, + ) + } + result = name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement) + break + } + if (name === "replace") { + result = value.replace(str(0), str(1)) + break + } + result = value.replaceAll(str(0), str(1)) + break + } + case "match": { + const pattern = toHostRegex(args[0], name, node) + const matched = value.match(pattern) + if (matched === null) return null + // Preserve the own `index` and `groups` properties on non-global matches. + if (pattern.global) return boundedData(matched, "String.match result") + return matchToValue(matched) + } + case "matchAll": { + const pattern = toHostRegex(args[0], name, node, "g") + if (!pattern.global) { + throw new InterpreterRuntimeError( + `String.matchAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.match for a single match.`, + node, + ) + } + return Array.from(value.matchAll(pattern), matchToValue) + } + case "search": { + result = value.search(toHostRegex(args[0], name, node)) + break + } + case "repeat": { + const count = num(0) + if (!Number.isFinite(count) || count < 0) + throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node) + result = value.repeat(count) + break + } + case "padStart": + result = value.padStart(num(0), optStr(1)) + break + case "padEnd": + result = value.padEnd(num(0), optStr(1)) + break + case "charAt": + result = value.charAt(optNum(0) ?? 0) + break + case "at": + result = value.at(optNum(0) ?? 0) + break + case "substring": + result = value.substring(optNum(0) ?? 0, optNum(1)) + break + case "charCodeAt": + result = value.charCodeAt(optNum(0) ?? 0) + break + case "codePointAt": + result = value.codePointAt(optNum(0) ?? 0) + break + case "toString": + result = value + break + case "concat": { + result = value.concat(...args.map((_, index) => str(index))) + break + } + default: + throw new InterpreterRuntimeError(`String method '${name}' is not available in CodeMode.`, node) + } + return boundedData(result, `String.${name} result`) +} + +const invokeArrayStatic = (name: string, args: Array, node: AstNode): unknown => { + switch (name) { + case "isArray": + 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 SandboxMap) return Array.from(args[0].map.entries(), ([key, item]) => [key, item]) + if (args[0] instanceof SandboxSet) return Array.from(args[0].set.values()) + if (args[0] instanceof SandboxURLSearchParams) { + return Array.from(args[0].params.entries(), ([key, value]) => [key, value]) + } + const source = args[0] + if (source instanceof SandboxPromise) { + 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", + ) + } + default: + throw new InterpreterRuntimeError(`Array.${name} is not available in CodeMode.`, node) + } +} + +const invokeStringReplacer = ( + runner: CallbackRunner, + value: string, + name: "replace" | "replaceAll", + args: Array, + node: AstNode, +): Effect.Effect => { + const apply = applyCollectionCallback(runner, args[1], `String.${name}`, node) + const matches: Array<{ readonly match: string; readonly offset: number; readonly args: Array }> = [] + const collect = (...callbackArgs: Array): string => { + const match = callbackArgs[0] + const groups = callbackArgs[callbackArgs.length - 1] + const hasGroups = groups !== null && typeof groups === "object" + const offset = callbackArgs[callbackArgs.length - (hasGroups ? 3 : 2)] + if (typeof match !== "string" || typeof offset !== "number") { + throw new InterpreterRuntimeError(`String.${name} produced an invalid replacement match.`, node) + } + if (hasGroups) { + const safeGroups: SafeObject = Object.create(null) as SafeObject + for (const [key, group] of Object.entries(groups)) { + if (!isBlockedMember(key)) safeGroups[key] = group + } + callbackArgs[callbackArgs.length - 1] = safeGroups + } + matches.push({ match, offset, args: callbackArgs }) + return match + } + + const pattern = args[0] + if (pattern instanceof SandboxRegExp) { + if (name === "replaceAll" && !pattern.regex.global) { + throw new InterpreterRuntimeError( + `String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.regex.source}/${pattern.regex.flags}g, or use String.replace to replace only the first match.`, + node, + ) + } + if (name === "replace") value.replace(pattern.regex, collect) + else value.replaceAll(pattern.regex, collect) + } else { + if (typeof pattern !== "string") { + throw new InterpreterRuntimeError(`String.${name} expects argument 1 to be a string.`, node) + } + if (name === "replace") value.replace(pattern, collect) + else value.replaceAll(pattern, collect) + } + + return Effect.gen(function* () { + const output: Array = [] + let end = 0 + for (const match of matches) { + const replacement = yield* apply(match.args) + const resolved = + args[1] instanceof CodeModeFunction && args[1].async && replacement instanceof SandboxPromise + ? yield* runner.settlePromise(replacement) + : replacement + output.push( + value.slice(end, match.offset), + coerceToString(boundedData(resolved, `String.${name} replacer result`)), + ) + end = match.offset + match.match.length + } + output.push(value.slice(end)) + return boundedData(output.join(""), `String.${name} result`) + }) +} + +export const applyCollectionCallback = ( + runner: CallbackRunner, + callback: unknown, + name: string, + node: AstNode, +): ((args: Array) => Effect.Effect) => { + if ( + !(callback instanceof CodeModeFunction) && + !(callback instanceof CoercionFunction) && + !(callback instanceof UriFunction) && + !(callback instanceof PromiseCapabilityFunction) + ) { + 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) +} + +const invokeMapMethod = ( + runner: CallbackRunner, + target: SandboxMap, + name: string, + args: Array, + node: AstNode, +): Effect.Effect => { + switch (name) { + case "get": + return Effect.succeed(target.map.get(args[0])) + case "has": + return Effect.succeed(target.map.has(args[0])) + case "set": + return Effect.sync(() => { + target.map.set(args[0], args[1]) + return target + }) + case "delete": + return Effect.sync(() => target.map.delete(args[0])) + case "clear": + return Effect.sync(() => { + target.map.clear() + return undefined + }) + case "keys": + return Effect.sync(() => Array.from(target.map.keys())) + case "values": + return Effect.sync(() => Array.from(target.map.values())) + case "entries": + return Effect.sync(() => Array.from(target.map.entries(), ([key, item]): Array => [key, item])) + case "forEach": { + 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]) + return undefined + }) + } + default: + throw new InterpreterRuntimeError(`Map method '${name}' is not available in CodeMode.`, node) + } +} + +const invokeSetMethod = ( + runner: CallbackRunner, + target: SandboxSet, + name: string, + args: Array, + node: AstNode, +): Effect.Effect => { + switch (name) { + case "has": + return Effect.succeed(target.set.has(args[0])) + case "add": + return Effect.sync(() => { + target.set.add(args[0]) + return target + }) + case "delete": + return Effect.sync(() => target.set.delete(args[0])) + case "clear": + return Effect.sync(() => { + target.set.clear() + return undefined + }) + case "keys": + case "values": + return Effect.sync(() => Array.from(target.set.values())) + case "entries": + return Effect.sync(() => Array.from(target.set.values(), (item): Array => [item, item])) + case "forEach": { + 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]) + return undefined + }) + } + default: + throw new InterpreterRuntimeError(`Set method '${name}' is not available in CodeMode.`, node) + } +} + +const invokeURLSearchParamsMethod = ( + runner: CallbackRunner, + target: SandboxURLSearchParams, + name: string, + args: Array, + node: AstNode, +): Effect.Effect => { + const arg = (index: number): string => uriArgument(args[index], `URLSearchParams.${name} argument ${index + 1}`) + const requireArgs = (count: number): void => { + if (args.length < count) { + throw new InterpreterRuntimeError( + `URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`, + node, + ).as("TypeError") + } + } + switch (name) { + case "append": { + requireArgs(2) + return Effect.sync(() => { + target.params.append(arg(0), arg(1)) + return undefined + }) + } + case "delete": { + requireArgs(1) + return Effect.sync(() => { + if (args[1] !== undefined) target.params.delete(arg(0), arg(1)) + else target.params.delete(arg(0)) + return undefined + }) + } + case "get": + requireArgs(1) + return Effect.sync(() => target.params.get(arg(0))) + case "getAll": + requireArgs(1) + return Effect.sync(() => target.params.getAll(arg(0))) + case "has": + requireArgs(1) + return Effect.sync(() => (args[1] !== undefined ? target.params.has(arg(0), arg(1)) : target.params.has(arg(0)))) + case "set": { + requireArgs(2) + return Effect.sync(() => { + target.params.set(arg(0), arg(1)) + return undefined + }) + } + case "sort": + return Effect.sync(() => { + target.params.sort() + return undefined + }) + case "keys": + return Effect.sync(() => Array.from(target.params.keys())) + case "values": + return Effect.sync(() => Array.from(target.params.values())) + case "entries": + return Effect.sync(() => Array.from(target.params.entries(), ([key, value]): Array => [key, value])) + case "toString": + return Effect.sync(() => target.params.toString()) + case "forEach": { + requireArgs(1) + 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]) + return undefined + }) + } + default: + throw new InterpreterRuntimeError(`URLSearchParams method '${name}' is not available in CodeMode.`, node) + } +} + +const invokeArrayMethod = ( + runner: CallbackRunner, + target: Array, + name: string, + args: Array, + node: AstNode, +): Effect.Effect => { + const optNumber = (value: unknown, label: string): number | undefined => { + if (value === undefined) return undefined + if (typeof value !== "number") + throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node) + return value + } + switch (name) { + case "join": { + if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) { + throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node) + } + const input = boundedData(target, "Array.join input") as Array + return Effect.succeed( + input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string)), + ) + } + case "includes": + if (args.length === 0 || args.length > 2) + throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node) + return Effect.succeed(target.includes(args[0], optNumber(args[1], "start index"))) + case "indexOf": + return Effect.succeed(target.indexOf(args[0], optNumber(args[1], "start index"))) + case "lastIndexOf": + return Effect.succeed( + args[1] === undefined + ? target.lastIndexOf(args[0]) + : target.lastIndexOf(args[0], optNumber(args[1], "start index")), + ) + case "at": + return Effect.succeed(target.at(optNumber(args[0], "index") ?? 0)) + case "slice": + return Effect.succeed(target.slice(optNumber(args[0], "start"), optNumber(args[1], "end"))) + case "concat": + return Effect.succeed(target.concat(...args)) + case "flat": + return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1)) + case "reverse": + return Effect.succeed(target.reverse()) + case "sort": + return Effect.map(sortArray(runner, target, args[0], node), (sorted) => { + target.splice(0, target.length, ...sorted) + return target + }) + case "toSorted": + return sortArray(runner, target, args[0], node) + case "toReversed": + return Effect.succeed([...target].reverse()) + case "with": { + const index = optNumber(args[0], "index") ?? 0 + const resolved = index < 0 ? target.length + index : index + if (resolved < 0 || resolved >= target.length) { + throw new InterpreterRuntimeError("Array.with index is out of range.", node) + } + const copied = [...target] + copied[resolved] = args[1] + return Effect.succeed(copied) + } + case "push": { + // Validate all insertions before mutating to avoid partial cyclic updates. + for (const item of args) rejectCircularInsertion(target, item, "Array.push result", node) + target.push(...args) + return Effect.succeed(target.length) + } + case "unshift": { + for (const item of args) rejectCircularInsertion(target, item, "Array.unshift result", node) + target.unshift(...args) + return Effect.succeed(target.length) + } + case "pop": + return Effect.succeed(target.pop()) + case "shift": + return Effect.succeed(target.shift()) + case "splice": { + if (args.length === 0) return Effect.succeed(target.splice(0, 0)) + const start = optNumber(args[0], "start") ?? 0 + if (args.length === 1) return Effect.succeed(target.splice(start)) + const deleteCount = optNumber(args[1], "delete count") ?? 0 + const inserted = args.slice(2) + for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result", node) + return Effect.succeed(target.splice(start, deleteCount, ...inserted)) + } + case "fill": { + rejectCircularInsertion(target, args[0], "Array.fill result", node) + return Effect.succeed(target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end"))) + } + case "copyWithin": + return Effect.succeed( + target.copyWithin( + optNumber(args[0], "target index") ?? 0, + optNumber(args[1], "start") ?? 0, + optNumber(args[2], "end"), + ), + ) + case "keys": + return Effect.succeed(Array.from(target.keys())) + case "values": + return Effect.succeed([...target]) + case "entries": + return Effect.succeed(Array.from(target.entries(), ([index, item]): Array => [index, item])) + } + + const apply = applyCollectionCallback(runner, args[0], `Array.${name}`, node) + return Effect.gen(function* () { + // Fix iteration length while reading existing elements live. + const length = target.length + switch (name) { + case "map": { + const values: Array = [] + values.length = length + for (let index = 0; index < length; index += 1) { + if (!(index in target)) continue + values[index] = yield* apply([target[index], index, target]) + } + return values + } + case "flatMap": { + const values: Array = [] + for (let index = 0; index < length; index += 1) { + if (!(index in target)) continue + const mapped = yield* apply([target[index], index, target]) + if (Array.isArray(mapped)) values.push(...mapped) + else values.push(mapped) + } + return values + } + case "filter": { + const values: Array = [] + for (let index = 0; index < length; index += 1) { + if (!(index in target)) continue + const item = target[index] + if (yield* apply([item, index, target])) values.push(item) + } + return values + } + case "find": + for (let index = 0; index < length; index += 1) { + const item = target[index] + if (yield* apply([item, index, target])) return item + } + return undefined + case "findIndex": + for (let index = 0; index < length; index += 1) { + if (yield* apply([target[index], index, target])) return index + } + return -1 + case "some": + for (let index = 0; index < length; index += 1) { + if (!(index in target)) continue + if (yield* apply([target[index], index, target])) return true + } + return false + case "every": + for (let index = 0; index < length; index += 1) { + if (!(index in target)) continue + if (!(yield* apply([target[index], index, target]))) return false + } + return true + case "forEach": + for (let index = 0; index < length; index += 1) { + if (index in target) yield* apply([target[index], index, target]) + } + return undefined + case "reduce": { + let accumulator: unknown + let start: number + if (args.length >= 2) { + accumulator = args[1] + start = 0 + } else { + if (length === 0) + throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node) + accumulator = target[0] + start = 1 + } + for (let index = start; index < length; index += 1) { + if (!(index in target)) continue + accumulator = yield* apply([accumulator, target[index], index, target]) + } + return accumulator + } + case "reduceRight": { + let accumulator: unknown + let start: number + if (args.length >= 2) { + accumulator = args[1] + start = length - 1 + } else { + if (length === 0) + throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node) + accumulator = target[length - 1] + start = length - 2 + } + for (let index = start; index >= 0; index -= 1) { + if (!(index in target)) continue + accumulator = yield* apply([accumulator, target[index], index, target]) + } + return accumulator + } + case "findLast": + for (let index = length - 1; index >= 0; index -= 1) { + if (yield* apply([target[index], index, target])) return target[index] + } + return undefined + case "findLastIndex": + for (let index = length - 1; index >= 0; index -= 1) { + if (yield* apply([target[index], index, target])) return index + } + return -1 + } + throw new InterpreterRuntimeError(`Array method '${name}' is not available in CodeMode.`, node) + }) +} + +const sortArray = ( + runner: CallbackRunner, + target: Array, + comparator: unknown, + 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)) { + return Effect.sync(() => + [...target].sort((a, b) => { + const left = coerceToString(a) + const right = coerceToString(b) + return left < right ? -1 : left > right ? 1 : 0 + }), + ) + } + const mergeSort = (items: Array): Effect.Effect, unknown, R> => { + if (items.length <= 1) return Effect.succeed(items) + const midpoint = Math.floor(items.length / 2) + return Effect.gen(function* () { + const left = yield* mergeSort(items.slice(0, midpoint)) + const right = yield* mergeSort(items.slice(midpoint)) + const merged: Array = [] + let leftIndex = 0 + 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]])) + if (Number.isNaN(order) || order <= 0) merged.push(left[leftIndex++]) + else merged.push(right[rightIndex++]) + } + return [...merged, ...left.slice(leftIndex), ...right.slice(rightIndex)] + }) + } + const defined = target.filter((item) => item !== undefined) + const undefinedCount = target.length - defined.length + return Effect.map(mergeSort(defined), (items) => [...items, ...Array(undefinedCount).fill(undefined)]) +} diff --git a/packages/codemode/src/interpreter/model.ts b/packages/codemode/src/interpreter/model.ts index 0f1641e8be9e..b6e6ad64f8b3 100644 --- a/packages/codemode/src/interpreter/model.ts +++ b/packages/codemode/src/interpreter/model.ts @@ -76,8 +76,6 @@ export class PromiseInstanceMethodReference { ) {} } -// The resolve/reject callables handed to a `new Promise(executor)` executor. `settle` closes -// over the promise's deferred and is first-settlement-wins; later calls are no-ops, as in JS. export class PromiseCapabilityFunction { constructor(readonly settle: (value: unknown) => void) {} } @@ -114,8 +112,6 @@ export class UriFunction { constructor(readonly name: "encodeURI" | "encodeURIComponent" | "decodeURI" | "decodeURIComponent") {} } -// The global `search` built-in: synchronous tool discovery that shares the tool admission -// pipeline (budget, audit, hooks) without living in the `tools` tree. export class SearchFunction {} export class ProgramThrow { diff --git a/packages/codemode/src/interpreter/promises.ts b/packages/codemode/src/interpreter/promises.ts new file mode 100644 index 000000000000..95f9b7741ff9 --- /dev/null +++ b/packages/codemode/src/interpreter/promises.ts @@ -0,0 +1,336 @@ +import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect" +import type { Diagnostic } from "../codemode.js" +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 { typeofValue } from "./references.js" +import { spreadItems } from "../stdlib/collections.js" +import { createAggregateErrorValue } from "../stdlib/value.js" +import { SandboxPromise } from "../values.js" + +// Observation only controls rejection reporting; program completion interrupts all promise work. +export class PromiseRuntime { + private readonly active = new Set() + private readonly ids = new WeakMap() + private readonly observed = new WeakSet() + private readonly failures = new Map() + private nextID = 0 + + constructor(private readonly scope: Scope.Scope) {} + + create(effect: Effect.Effect): Effect.Effect { + return Effect.suspend(() => { + // Allocate before forking so reruns get distinct IDs and diagnostics retain creation order. + const id = this.nextID++ + return Effect.map(Effect.forkIn(effect, this.scope, { startImmediately: true }), (fiber) => { + const promise = new SandboxPromise(fiber) + this.active.add(promise) + this.ids.set(promise, id) + fiber.addObserver((exit) => { + this.active.delete(promise) + if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause) || this.observed.has(promise)) { + this.ids.delete(promise) + return + } + const failure = normalizeError(Cause.squash(exit.cause)) + this.failures.set(id, { + ...failure, + message: `Unhandled rejection from an un-awaited promise: ${failure.message}`, + }) + }) + return promise + }) + }) + } + + // Observation must be recorded when responsibility transfers, before the consumer fiber runs. + markObserved(promise: SandboxPromise): void { + this.observed.add(promise) + const id = this.ids.get(promise) + this.ids.delete(promise) + if (id !== undefined) this.failures.delete(id) + } + + await(promise: SandboxPromise): Effect.Effect> { + return Fiber.await(promise.fiber) + } + + diagnostics(): Array { + return [...this.failures].sort(([left], [right]) => left - right).map(([, failure]) => failure) + } + + // Re-check because a straggler can create promises before its interruption lands. + interrupt(): Effect.Effect> { + const self = this + return Effect.gen(function* () { + while (self.active.size > 0) { + yield* Fiber.interruptAll([...self.active].map((promise) => promise.fiber)) + } + return self.diagnostics() + }) + } +} + +export const selfResolutionError = (node?: AstNode): InterpreterRuntimeError => + new InterpreterRuntimeError("Chaining cycle detected: a promise cannot resolve with itself.", node).as("TypeError") + +export const invokePromiseMethod = ( + runner: CallbackRunner, + promises: PromiseRuntime, + ref: PromiseMethodReference, + args: Array, + node: AstNode, +): Effect.Effect => { + if (ref.name === "resolve") { + const value = args[0] + return value instanceof SandboxPromise ? Effect.succeed(value) : promises.create(Effect.succeed(value)) + } + if (ref.name === "reject") { + return promises.create(Effect.fail(new ProgramThrow(args[0]))) + } + + const spread = spreadItems(args[0]) + if (spread === undefined) { + return promises.create( + Effect.fail( + new InterpreterRuntimeError( + `Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`, + node, + ).as("TypeError"), + ), + ) + } + const items = Array.from(spread) + + for (const item of items) { + if (item instanceof SandboxPromise) promises.markObserved(item) + } + + switch (ref.name) { + case "all": { + const observations = items.map((item) => + item instanceof SandboxPromise ? Effect.flatten(promises.await(item)) : Effect.succeed(item), + ) + return promises.create(settleAfterTurn(Effect.all(observations, { concurrency: "unbounded" }))) + } + case "allSettled": { + const observations = items.map((item) => + item instanceof SandboxPromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)), + ) + return promises.create( + settleAfterTurn( + Effect.gen(function* () { + const outcomes: Array = [] + for (const observation of observations) { + const exit = yield* observation + if (Exit.isSuccess(exit)) { + outcomes.push( + Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }), + ) + continue + } + if (Cause.hasInterruptsOnly(exit.cause)) { + // Teardown interruption is not a program-level rejection. + return yield* Effect.failCause(exit.cause) + } + outcomes.push( + Object.assign(Object.create(null) as SafeObject, { + status: "rejected", + reason: caughtErrorValue(Cause.squash(exit.cause)), + }), + ) + } + return outcomes + }), + ), + ) + } + case "race": { + if (items.length === 0) { + return promises.create( + Effect.fail( + new InterpreterRuntimeError( + "Promise.race([]) would never settle; provide at least one promise or value.", + node, + ), + ), + ) + } + const observations = items.map((item) => + item instanceof SandboxPromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)), + ) + return promises.create(settleAfterTurn(Effect.flatten(Effect.raceAll(observations)))) + } + case "any": { + const flipped = items.map((item) => + item instanceof SandboxPromise + ? Effect.flatMap(promises.await(item), (exit) => { + if (Exit.isSuccess(exit)) return Effect.fail(new PromiseAnyFulfilled(exit.value)) + if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause) + return Effect.succeed(caughtErrorValue(Cause.squash(exit.cause))) + }) + : Effect.fail(new PromiseAnyFulfilled(item)), + ) + const body = Effect.all(flipped, { concurrency: "unbounded" }).pipe( + Effect.flatMap((reasons) => + Effect.fail(new ProgramThrow(createAggregateErrorValue(reasons, "All promises were rejected"))), + ), + Effect.catch((error) => + error instanceof PromiseAnyFulfilled ? Effect.succeed(error.value) : Effect.fail(error), + ), + ) + return promises.create(settleAfterTurn(body)) + } + } +} + +export const invokePromiseInstanceMethod = ( + runner: CallbackRunner, + promises: PromiseRuntime, + ref: PromiseInstanceMethodReference, + args: Array, + node: AstNode, +): Effect.Effect => { + const method = `Promise.prototype.${ref.name}` + promises.markObserved(ref.promise) + if (ref.name === "finally") { + return chainFinally(runner, promises, ref.promise, reactionHandler(args[0], method, node), method, node) + } + const onFulfilled = ref.name === "then" ? reactionHandler(args[0], method, node) : undefined + const onRejected = reactionHandler(ref.name === "then" ? args[1] : args[0], method, node) + return chainReaction(runner, promises, ref.promise, onFulfilled, onRejected, method, node) +} + +export const constructPromise = ( + runner: CallbackRunner, + promises: PromiseRuntime, + executor: unknown, + node: AstNode, +): Effect.Effect => { + if (!(executor instanceof CodeModeFunction)) { + throw new InterpreterRuntimeError( + "new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).", + node, + ).as("TypeError") + } + return Effect.gen(function* () { + const deferred = Deferred.makeUnsafe() + const box: { own?: SandboxPromise } = {} + const promise = yield* promises.create( + Effect.flatMap(Deferred.await(deferred), (value) => { + if (!(value instanceof SandboxPromise)) return Effect.succeed(value) + if (value === box.own) return Effect.fail(selfResolutionError(node)) + return runner.settlePromise(value) + }), + ) + box.own = promise + const resolve = new PromiseCapabilityFunction((value) => { + Deferred.doneUnsafe(deferred, Exit.succeed(value)) + }) + const reject = new PromiseCapabilityFunction((value) => { + Deferred.doneUnsafe(deferred, Exit.fail(new ProgramThrow(value))) + }) + const executed = yield* Effect.exit(runner.invokeFunction(executor, [resolve, reject])) + if (!Exit.isSuccess(executed)) { + if (Cause.hasInterruptsOnly(executed.cause)) return yield* Effect.failCause(executed.cause) + Deferred.doneUnsafe(deferred, Exit.fail(Cause.squash(executed.cause))) + } + return promise + }) +} + +// Settle one reaction turn after the deciding member, after its existing reactions. +const settleAfterTurn = (body: Effect.Effect): Effect.Effect => + Effect.flatMap(Effect.exit(body), (exit) => Effect.andThen(Effect.yieldNow, exit)) + +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 + } + 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).`, + node, + ) + } + return undefined +} + +// Teardown bypasses handlers; settled reactions yield once so handlers never run inline. +const reactionExit = ( + promises: PromiseRuntime, + source: SandboxPromise, +): Effect.Effect, unknown, R> => + Effect.gen(function* () { + const exit = yield* promises.await(source) + if (!Exit.isSuccess(exit) && Cause.hasInterruptsOnly(exit.cause)) return yield* Effect.failCause(exit.cause) + yield* Effect.yieldNow + return exit + }) + +const chainReaction = ( + runner: CallbackRunner, + promises: PromiseRuntime, + source: SandboxPromise, + onFulfilled: ReactionHandler | undefined, + onRejected: ReactionHandler | undefined, + method: string, + node: AstNode, +): Effect.Effect => { + const box: { derived?: SandboxPromise } = {} + const body = Effect.gen(function* () { + const exit = yield* reactionExit(promises, source) + const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected + if (handler === undefined) return yield* exit + const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause)) + const result = yield* applyCollectionCallback(runner, handler, method, node)([input]) + if (result === box.derived) return yield* Effect.fail(selfResolutionError(node)) + if (result instanceof SandboxPromise) return yield* runner.settlePromise(result) + return result + }) + return Effect.map(promises.create(body), (derived) => { + box.derived = derived + return derived + }) +} + +const chainFinally = ( + runner: CallbackRunner, + promises: PromiseRuntime, + source: SandboxPromise, + cleanup: ReactionHandler | undefined, + method: string, + node: AstNode, +): Effect.Effect => + promises.create( + Effect.gen(function* () { + const exit = yield* reactionExit(promises, source) + if (cleanup !== undefined) { + const result = yield* applyCollectionCallback(runner, cleanup, method, node)([]) + if (result instanceof SandboxPromise) yield* runner.settlePromise(result) + } + return yield* exit + }), + ) diff --git a/packages/codemode/src/interpreter/references.ts b/packages/codemode/src/interpreter/references.ts new file mode 100644 index 000000000000..afbbdbf9e966 --- /dev/null +++ b/packages/codemode/src/interpreter/references.ts @@ -0,0 +1,99 @@ +import { + type AstNode, + CodeModeFunction, + CoercionFunction, + ErrorConstructorReference, + GlobalMethodReference, + GlobalNamespace, + InterpreterRuntimeError, + IntrinsicReference, + PromiseCapabilityFunction, + PromiseInstanceMethodReference, + PromiseMethodReference, + PromiseNamespace, + SearchFunction, + UriFunction, +} from "./model.js" +import { ToolReference } from "../tool-runtime.js" +import { isSandboxValue, SandboxPromise } from "../values.js" + +export const isRuntimeReference = (value: unknown): boolean => + value instanceof CodeModeFunction || + value instanceof ToolReference || + value instanceof IntrinsicReference || + value instanceof GlobalNamespace || + value instanceof GlobalMethodReference || + value instanceof PromiseNamespace || + value instanceof PromiseMethodReference || + value instanceof PromiseInstanceMethodReference || + value instanceof SandboxPromise || + value instanceof CoercionFunction || + value instanceof UriFunction || + value instanceof SearchFunction || + value instanceof PromiseCapabilityFunction || + value instanceof ErrorConstructorReference || + isSandboxValue(value) + +export const containsRuntimeReference = (value: unknown, seen = new Set()): boolean => { + if (isRuntimeReference(value)) return true + if (value === null || typeof value !== "object") return false + if (seen.has(value)) return false + seen.add(value) + const contains = Array.isArray(value) + ? value.some((item) => containsRuntimeReference(item, seen)) + : Object.values(value).some((item) => containsRuntimeReference(item, seen)) + seen.delete(value) + return contains +} + +// Sandbox values are data here, not opaque interpreter references. +export const containsOpaqueReference = (value: unknown, seen = new Set()): boolean => { + if (isSandboxValue(value)) return false + if (isRuntimeReference(value)) return true + if (value === null || typeof value !== "object") return false + if (seen.has(value)) return false + seen.add(value) + const contains = Array.isArray(value) + ? value.some((item) => containsOpaqueReference(item, seen)) + : Object.values(value).some((item) => containsOpaqueReference(item, seen)) + seen.delete(value) + return contains +} + +// Reject cycles before mutation so later boundary walks remain safe. +export const rejectCircularInsertion = ( + container: object, + value: unknown, + label: string, + node: AstNode, + seen = new Set(), +): void => { + if (value === container) + throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue") + if (value === null || typeof value !== "object" || isRuntimeReference(value) || seen.has(value)) return + seen.add(value) + const items = Array.isArray(value) ? value : Object.values(value) + for (const item of items) rejectCircularInsertion(container, item, label, node, seen) + seen.delete(value) +} + +export const typeofValue = (value: unknown): string => { + if ( + value instanceof CodeModeFunction || + value instanceof CoercionFunction || + value instanceof IntrinsicReference || + value instanceof GlobalMethodReference || + value instanceof PromiseMethodReference || + value instanceof PromiseInstanceMethodReference || + value instanceof PromiseNamespace || + value instanceof PromiseCapabilityFunction || + value instanceof ErrorConstructorReference + ) + return "function" + if (value instanceof UriFunction || value instanceof SearchFunction) return "function" + if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object" + if (value instanceof GlobalNamespace) { + return value.name === "Math" || value.name === "JSON" || value.name === "console" ? "object" : "function" + } + return typeof value +} diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 03f071604e5e..1770c84e946e 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -1,26 +1,5 @@ -import { parse } from "acorn" -import { Cause, Deferred, Effect, Exit, Fiber, Scope, Semaphore } from "effect" -import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript" -import { - copyIn, - copyOut, - isBlockedMember, - ToolReference, - ToolRuntime, - ToolRuntimeError, - type HostTools, - type SafeObject, - type Services, -} from "../tool-runtime.js" -import { ToolError } from "../tool-error.js" -import type { - DataValue, - Diagnostic, - DiagnosticKind, - ExecuteOptions, - ResolvedExecutionLimits, - Result, -} from "../codemode.js" +import { Cause, Effect, Semaphore } from "effect" +import { isBlockedMember, ToolReference, ToolRuntimeError, type SafeObject } from "../tool-runtime.js" import { type AstNode, asNode, @@ -31,7 +10,6 @@ import { ErrorConstructorReference, GlobalMethodReference, GlobalNamespace, - formatLocation, getArray, getBoolean, getNode, @@ -51,43 +29,36 @@ import { type ProgramNode, SearchFunction, type StatementResult, - sourceLocation, supportedSyntaxMessage, unsupportedSyntax, UriFunction, } from "./model.js" -import { arrayMethods, mapMethods, setMethods, spreadItems } from "../stdlib/collections.js" -import { consoleMethods, MAX_CONSOLE_DEPTH } from "../stdlib/console.js" -import { dateMethods, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js" -import { invokeJsonMethod } from "../stdlib/json.js" -import { invokeMathMethod, mathConstants } from "../stdlib/math.js" +import { caughtErrorValue, constructErrorValue } from "./errors.js" +import { type CallbackRunner, invokeGlobalMethod, invokeIntrinsic } from "./methods.js" import { - invokeNumberMethod, - invokeNumberStatic, - numberConstants, - numberMethods, - numberStatics, -} from "../stdlib/number.js" -import { invokeObjectMethod, objectMethodsPreservingIdentity } from "../stdlib/object.js" + constructPromise, + invokePromiseInstanceMethod, + invokePromiseMethod, + PromiseRuntime, + selfResolutionError, +} from "./promises.js" +import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, typeofValue } from "./references.js" +import { ScopeStack } from "./scope.js" +import { arrayMethods, mapMethods, setMethods, spreadItems } from "../stdlib/collections.js" +import { consoleMethods, formatConsoleMessage } from "../stdlib/console.js" +import { dateMethods } from "../stdlib/date.js" +import { mathConstants } from "../stdlib/math.js" +import { numberConstants, numberMethods, numberStatics } from "../stdlib/number.js" +import { objectMethodsPreservingIdentity } from "../stdlib/object.js" import { promiseStatics, TOOL_CALL_CONCURRENCY } from "../stdlib/promise.js" -import { - escapeRegexHint, - invokeRegExpMethod, - matchToValue, - regexpMethods, - regexpProperties, - regexFailureReason, - toHostRegex, -} from "../stdlib/regexp.js" -import { invokeStringStatic, stringMethods, stringStatics } from "../stdlib/string.js" +import { escapeRegexHint, regexpMethods, regexpProperties, regexFailureReason } from "../stdlib/regexp.js" +import { stringMethods, stringStatics } from "../stdlib/string.js" import { urlMethods, urlProperties, urlSearchParamsMethods, urlWritableProperties, invokeUriFunction, - invokeURLMethod, - invokeURLStatic, uriArgument, urlArgument, } from "../stdlib/url.js" @@ -96,8 +67,6 @@ import { coerceToNumber, coerceToString, compoundOperators, - createAggregateErrorValue, - createErrorValue, errorBrandName, errorConstructors, invokeCoercion, @@ -114,244 +83,6 @@ import { SandboxURLSearchParams, } from "../values.js" -const parseProgram = (code: string): ProgramNode => { - const transpiled = transpileModule(`async function __codemode__() {\n${code}\n}`, { - reportDiagnostics: true, - compilerOptions: { - target: ScriptTarget.ESNext, - module: ModuleKind.ESNext, - }, - }) - const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error) - - if (diagnostic) { - throw new InterpreterRuntimeError( - `Failed to parse TypeScript: ${flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`, - undefined, - "ParseError", - ) - } - - const bodyStart = transpiled.outputText.indexOf("{") + 1 - const bodyEnd = transpiled.outputText.lastIndexOf("}") - const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd) - const parsed = parse(executableCode, { - ecmaVersion: "latest", - sourceType: "script", - allowReturnOutsideFunction: true, - allowAwaitOutsideFunction: true, - locations: true, - }) as unknown - - if (!isRecord(parsed) || parsed.type !== "Program" || !Array.isArray(parsed.body)) { - throw new InterpreterRuntimeError("Failed to parse script as a Program node.") - } - - return parsed as ProgramNode -} - -const publicErrorMessage = (message: string): string => - message.replace(/\/(?:Users|home|private|tmp|var\/folders)\/[^\s"'`]+/g, "") - -const normalizeError = (error: unknown): Diagnostic => { - if (error instanceof InterpreterRuntimeError) { - return { - kind: error.kind, - message: `${error.message}${formatLocation(error.node)}`, - ...(error.node?.loc ? { location: sourceLocation(error.node) } : {}), - ...(error.suggestions ? { suggestions: error.suggestions } : {}), - } - } - - if (error instanceof ToolRuntimeError) { - return { - kind: error.kind, - message: error.message, - ...(error.suggestions.length > 0 ? { suggestions: error.suggestions } : {}), - } - } - - if (error instanceof ToolError) { - return { kind: "ToolFailure", message: publicErrorMessage(error.message) } - } - - if (error instanceof ProgramThrow) { - const value = error.value - let message: string - if (containsRuntimeReference(value)) { - // A thrown tool/function reference must not leak its internal structure. - message = "a non-data value" - } else if (typeof value === "string") { - message = value - } else if ( - value !== null && - typeof value === "object" && - typeof (value as { message?: unknown }).message === "string" - ) { - message = (value as { message: string }).message - } else { - try { - message = JSON.stringify(copyOut(value)) ?? String(value) - } catch { - message = String(value) - } - } - return { kind: "ExecutionFailure", message: `Uncaught: ${message}` } - } - - if (error instanceof RangeError && /call stack|recursion/i.test(error.message)) { - return { - kind: "ExecutionFailure", - message: "Execution exceeded the maximum nesting depth.", - } - } - - if (error instanceof Error) { - return { - kind: error.name === "SyntaxError" ? "ParseError" : "ExecutionFailure", - message: publicErrorMessage(error.message), - } - } - - // A non-Error thrown by a host tool (raw string / number / Symbol) still routes through - // path redaction so filesystem paths can never leak through the catch-all branch. - return { - kind: "ExecutionFailure", - message: publicErrorMessage(String(error)), - } -} - -// V8 parity: a combinator settles one reaction turn after the deciding member, never -// before reactions already attached to it. -const settleAfterTurn = (body: Effect.Effect): Effect.Effect => - Effect.flatMap(Effect.exit(body), (exit) => Effect.andThen(Effect.yieldNow, exit)) - -const selfResolutionError = (node?: AstNode): InterpreterRuntimeError => - new InterpreterRuntimeError("Chaining cycle detected: a promise cannot resolve with itself.", node).as("TypeError") - -// Short-circuit marker for Promise.any: the first fulfillment travels the error channel of -// the flipped members so fail-fast Effect.all stops observing on it. -class PromiseAnyFulfilled { - constructor(readonly value: unknown) {} -} - -type ReactionHandler = CodeModeFunction | CoercionFunction | UriFunction | PromiseCapabilityFunction - -// Non-callables are ignored as in JS: `.then(undefined, f)` relies on the passthrough. -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 - } - 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).`, - node, - ) - } - return undefined -} - -// Shared by catch bindings and Promise.allSettled rejection reasons. -const caughtErrorValue = (thrown: unknown): unknown => { - if (thrown instanceof ProgramThrow) return thrown.value - if (thrown instanceof InterpreterRuntimeError) return createErrorValue(thrown.errorName, thrown.message) - const name = thrown instanceof Error && errorConstructors.has(thrown.name) ? thrown.name : "Error" - return createErrorValue(name, normalizeError(thrown).message) -} - -// `new Error("msg")` and the no-new call form share this; AggregateError alone takes -// (errors, message?) with a required errors collection, as in JS. -const constructErrorValue = (name: string, args: Array, node: AstNode): SafeObject => { - if (name !== "AggregateError") return createErrorValue(name, args[0] === undefined ? "" : coerceToString(args[0])) - const errors = spreadItems(args[0]) - if (errors === undefined) { - throw new InterpreterRuntimeError( - "new AggregateError(...) expects an array of errors (e.g. new AggregateError(errors, message?)).", - node, - ).as("TypeError") - } - // Copy: spreadItems returns array input itself, and the error value must not alias caller data. - return createAggregateErrorValue([...errors], args[1] === undefined ? "" : coerceToString(args[1])) -} - -const isRuntimeReference = (value: unknown): boolean => - value instanceof CodeModeFunction || - value instanceof ToolReference || - value instanceof IntrinsicReference || - value instanceof GlobalNamespace || - value instanceof GlobalMethodReference || - value instanceof PromiseNamespace || - value instanceof PromiseMethodReference || - value instanceof PromiseInstanceMethodReference || - value instanceof SandboxPromise || - value instanceof CoercionFunction || - value instanceof UriFunction || - value instanceof SearchFunction || - value instanceof PromiseCapabilityFunction || - value instanceof ErrorConstructorReference || - isSandboxValue(value) - -const containsRuntimeReference = (value: unknown, seen = new Set()): boolean => { - if (isRuntimeReference(value)) return true - if (value === null || typeof value !== "object") return false - if (seen.has(value)) return false - seen.add(value) - const contains = Array.isArray(value) - ? value.some((item) => containsRuntimeReference(item, seen)) - : Object.values(value).some((item) => containsRuntimeReference(item, seen)) - seen.delete(value) - return contains -} - -// Like containsRuntimeReference, but sandbox standard-library values count as data: -// operators and switch treat them as ordinary object operands (identity equality, ToPrimitive -// coercion) rather than rejecting them as opaque interpreter machinery. -const containsOpaqueReference = (value: unknown, seen = new Set()): boolean => { - if (isSandboxValue(value)) return false - if (isRuntimeReference(value)) return true - if (value === null || typeof value !== "object") return false - if (seen.has(value)) return false - seen.add(value) - const contains = Array.isArray(value) - ? value.some((item) => containsOpaqueReference(item, seen)) - : Object.values(value).some((item) => containsOpaqueReference(item, seen)) - seen.delete(value) - return contains -} - -// `typeof` never throws in JS; map every interpreter value to its JS-visible category. -// A SandboxPromise falls through to the final `typeof value` and reports "object", exactly -// like a real JS promise. -const typeofValue = (value: unknown): string => { - if ( - value instanceof CodeModeFunction || - value instanceof CoercionFunction || - value instanceof IntrinsicReference || - value instanceof GlobalMethodReference || - value instanceof PromiseMethodReference || - value instanceof PromiseInstanceMethodReference || - value instanceof PromiseNamespace || - value instanceof PromiseCapabilityFunction || - value instanceof ErrorConstructorReference - ) - return "function" - if (value instanceof UriFunction || value instanceof SearchFunction) return "function" - if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object" - if (value instanceof GlobalNamespace) { - return value.name === "Math" || value.name === "JSON" || value.name === "console" ? "object" : "function" - } - return typeof value -} - -// `x instanceof C` against the constructors CodeMode knows. Like `typeof`, it observes any -// left-hand value (opaque references included) without coercing it. Error checks use the -// error brand: `instanceof Error` accepts every branded error; a specific error type matches -// its own brand only (as in JS, where TypeError instances are also Error instances). const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => { if (rhs instanceof ErrorConstructorReference) { const brand = errorBrandName(lhs) @@ -378,8 +109,6 @@ const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => } } if (rhs instanceof PromiseNamespace) return lhs instanceof SandboxPromise - // Number/String/Boolean wrap primitives in JS; no boxed values exist in CodeMode, so - // `x instanceof Number` is always false - exactly what it is for primitives in JS. if (rhs instanceof CoercionFunction && (rhs.name === "Number" || rhs.name === "String" || rhs.name === "Boolean")) { return false } @@ -389,254 +118,6 @@ const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => ) } -const invokeStringMethod = (value: string, name: string, args: Array, node: AstNode): unknown => { - const str = (index: number): string => { - const arg = args[index] - if (typeof arg !== "string") - throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node) - return arg - } - const num = (index: number): number => { - const arg = args[index] - if (typeof arg !== "number") - throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node) - return arg - } - const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index)) - const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index)) - - let result: unknown - switch (name) { - case "toLowerCase": - result = value.toLowerCase() - break - case "toUpperCase": - result = value.toUpperCase() - break - case "trim": - result = value.trim() - break - // trimLeft/trimRight are the legacy aliases of trimStart/trimEnd, kept because models write them. - case "trimStart": - case "trimLeft": - result = value.trimStart() - break - case "trimEnd": - case "trimRight": - result = value.trimEnd() - break - // Locale/options arguments are ignored: comparison runs with the host default locale, and - // the common use is a sort comparator where any consistent order works. - case "localeCompare": - result = value.localeCompare(str(0)) - break - case "normalize": { - const form = optStr(0) - try { - result = value.normalize(form) - } catch { - throw new InterpreterRuntimeError( - `String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`, - node, - ).as("RangeError") - } - break - } - case "split": { - if (args.length === 0) { - result = [value] - break - } - if (args[0] instanceof SandboxRegExp) { - result = value.split(args[0].regex, optNum(1)) - break - } - const requestedLimit = optNum(1) - result = value.split(str(0), requestedLimit === undefined ? undefined : requestedLimit >>> 0) - break - } - case "slice": - result = value.slice(optNum(0), optNum(1)) - break - case "includes": - result = value.includes(str(0), optNum(1)) - break - case "startsWith": - result = value.startsWith(str(0), optNum(1)) - break - case "endsWith": - result = value.endsWith(str(0), optNum(1)) - break - case "indexOf": - result = value.indexOf(str(0), optNum(1)) - break - case "lastIndexOf": - result = value.lastIndexOf(str(0), optNum(1)) - break - case "replace": - case "replaceAll": { - if (args[0] instanceof SandboxRegExp) { - const pattern = args[0].regex - const replacement = str(1) - if (name === "replaceAll" && !pattern.global) { - throw new InterpreterRuntimeError( - `String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.replace to replace only the first match.`, - node, - ) - } - result = name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement) - break - } - if (name === "replace") { - result = value.replace(str(0), str(1)) - break - } - result = value.replaceAll(str(0), str(1)) - break - } - case "match": { - const pattern = toHostRegex(args[0], name, node) - const matched = value.match(pattern) - if (matched === null) return null - // A global match is a plain array of matched strings; a non-global match carries - // index/groups own properties, so bypass the copying data checkpoint to keep them. - if (pattern.global) return boundedData(matched, "String.match result") - return matchToValue(matched) - } - case "matchAll": { - const pattern = toHostRegex(args[0], name, node, "g") - if (!pattern.global) { - throw new InterpreterRuntimeError( - `String.matchAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.match for a single match.`, - node, - ) - } - // Materialized as an array (not an iterator); each entry is a match array with - // index/groups own properties. Match count is bounded by the subject length. - return Array.from(value.matchAll(pattern), matchToValue) - } - case "search": { - result = value.search(toHostRegex(args[0], name, node)) - break - } - case "repeat": { - const count = num(0) - if (!Number.isFinite(count) || count < 0) - throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node) - result = value.repeat(count) - break - } - case "padStart": - result = value.padStart(num(0), optStr(1)) - break - case "padEnd": - result = value.padEnd(num(0), optStr(1)) - break - case "charAt": - result = value.charAt(optNum(0) ?? 0) - break - case "at": - result = value.at(optNum(0) ?? 0) - break - case "substring": - result = value.substring(optNum(0) ?? 0, optNum(1)) - break - case "substr": - result = value.substr(optNum(0) ?? 0, optNum(1)) - break - // JS charCodeAt returns NaN out of range; NaN flows as an ordinary in-sandbox value - // (normalized to null only at the data boundary - see copyOut), so return it as-is. - case "charCodeAt": - result = value.charCodeAt(optNum(0) ?? 0) - break - case "codePointAt": - result = value.codePointAt(optNum(0) ?? 0) - break - case "toString": - result = value - break - case "concat": { - result = value.concat(...args.map((_, index) => str(index))) - break - } - default: - throw new InterpreterRuntimeError(`String method '${name}' is not available in CodeMode.`, node) - } - return boundedData(result, `String.${name} result`) -} - -const invokeArrayStatic = (name: string, args: Array, node: AstNode): unknown => { - switch (name) { - case "isArray": - 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], - ) - } - // Map/Set materialize directly (the data checkpoint would serialize them to {}). - if (args[0] instanceof SandboxMap) return Array.from(args[0].map.entries(), ([key, item]) => [key, item]) - if (args[0] instanceof SandboxSet) return Array.from(args[0].set.values()) - if (args[0] instanceof SandboxURLSearchParams) { - return Array.from(args[0].params.entries(), ([key, value]) => [key, value]) - } - const source = args[0] - if (source instanceof SandboxPromise) { - 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", - ) - } - default: - throw new InterpreterRuntimeError(`Array.${name} is not available in CodeMode.`, node) - } -} - -const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array, node: AstNode): unknown => { - if (ref.namespace === "console") - throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node) - if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node) - if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node) - if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node) - if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node) - if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node) - if (ref.namespace === "URL") return invokeURLStatic(ref.name, args, node) - if (ref.namespace === "Date") return invokeDateStatic(ref.name, args, node) - if ( - ref.namespace === "RegExp" || - ref.namespace === "Map" || - ref.namespace === "Set" || - ref.namespace === "URLSearchParams" - ) { - throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node) - } - return invokeJsonMethod(ref.name, args, node) -} - -// Every identifier a parameter pattern binds, used to seed TDZ slots before defaults run. const collectPatternNames = (pattern: AstNode, out: Array = []): Array => { switch (pattern.type) { case "Identifier": @@ -663,92 +144,18 @@ const collectPatternNames = (pattern: AstNode, out: Array = []): Array { - private readonly active = new Set() - private readonly ids = new WeakMap() - private readonly observed = new WeakSet() - private readonly failures = new Map() - private nextID = 0 - - constructor(private readonly scope: Scope.Scope) {} - - create(effect: Effect.Effect): Effect.Effect { - return Effect.suspend(() => { - // Allocated at execution time (not construction) so re-run effects cannot share an id, - // and before the fork so diagnostics order by creation: a forked body that immediately - // creates promises of its own must sequence after its creator. - const id = this.nextID++ - return Effect.map(Effect.forkIn(effect, this.scope, { startImmediately: true }), (fiber) => { - const promise = new SandboxPromise(fiber) - this.active.add(promise) - this.ids.set(promise, id) - fiber.addObserver((exit) => { - this.active.delete(promise) - if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause) || this.observed.has(promise)) { - this.ids.delete(promise) - return - } - const failure = normalizeError(Cause.squash(exit.cause)) - this.failures.set(id, { - ...failure, - message: `Unhandled rejection from an un-awaited promise: ${failure.message}`, - }) - }) - return promise - }) - }) - } - - // Synchronous on purpose: JS makes a promise "handled" the moment a construct takes - // responsibility for it (await, or membership in a combinator call), not when the - // consuming fiber later runs. Call sites must invoke this at that moment. - markObserved(promise: SandboxPromise): void { - this.observed.add(promise) - const id = this.ids.get(promise) - this.ids.delete(promise) - if (id !== undefined) this.failures.delete(id) - } - - // Pure settlement subscription: never re-runs work and never affects rejection reporting. - await(promise: SandboxPromise): Effect.Effect> { - return Fiber.await(promise.fiber) - } - - // Unobserved rejections that already settled, in creation order. - diagnostics(): Array { - return [...this.failures].sort(([left], [right]) => left - right).map(([, failure]) => failure) - } - - // Normal-completion lifecycle: interrupts everything still running and reports the - // rejections that already settled un-awaited. interruptAll signals every fiber - // synchronously before awaiting termination, so no straggler can spawn new work between - // interrupts; the loop re-checks as a backstop because a straggler can create promises - // before its interrupt lands. - interrupt(): Effect.Effect> { - const self = this - return Effect.gen(function* () { - while (self.active.size > 0) { - yield* Fiber.interruptAll([...self.active].map((promise) => promise.fiber)) - } - return self.diagnostics() - }) - } -} - -class Interpreter { - private scopes: Array> +export class Interpreter { + private scopes: ScopeStack private readonly invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect - // The built-in `search` global, threaded from ToolRuntime.make like invokeTool. private readonly invokeSearch: (args: Array) => Effect.Effect - // Enumerable namespace/tool names at a node of the host tool tree, threaded from - // ToolRuntime.make like invokeTool: the interpreter never holds the tree itself. private readonly toolKeys: (path: ReadonlyArray) => ReadonlyArray private readonly logs: Array - // Caps how many eagerly forked tool calls run at once (the parallel-call concurrency cap). private readonly callPermits: Semaphore.Semaphore private readonly promises: PromiseRuntime + private readonly runner: CallbackRunner = { + invokeFunction: (fn, args) => this.invokeFunction(fn, args), + settlePromise: (promise) => this.settlePromise(promise), + } constructor( invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect, @@ -759,7 +166,7 @@ class Interpreter { callPermits: Semaphore.Semaphore = Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY), ) { const globalScope = new Map() - this.scopes = [globalScope] + this.scopes = new ScopeStack([globalScope]) this.invokeTool = invokeTool this.invokeSearch = invokeSearch this.toolKeys = toolKeys @@ -790,23 +197,17 @@ class Interpreter { globalScope.set("encodeURIComponent", { mutable: false, value: new UriFunction("encodeURIComponent") }) globalScope.set("decodeURI", { mutable: false, value: new UriFunction("decodeURI") }) globalScope.set("decodeURIComponent", { mutable: false, value: new UriFunction("decodeURIComponent") }) - // Error constructors are real values, so `x instanceof Error` works and `Error("msg")` - // (with or without `new`) constructs a branded { name, message } error object. for (const name of errorConstructors) { globalScope.set(name, { mutable: false, value: new ErrorConstructorReference(name) }) } - // NaN/Infinity flow as ordinary in-sandbox values (normalized to null only at the data - // boundary - see copyOut), so their global bindings must exist too, e.g. `reduce(max, -Infinity)`. globalScope.set("NaN", { mutable: false, value: NaN }) globalScope.set("Infinity", { mutable: false, value: Infinity }) } run(program: ProgramNode): Effect.Effect { const self = this - // Run the program body in its own module scope on top of the builtin global scope, so - // top-level declarations (`let undefined = 5`, `const Object = ...`) shadow builtins like - // JS module scope, instead of colliding with the seeded globals. - this.pushScope() + // Keep top-level declarations separate so they can shadow builtins. + this.scopes.push() return Effect.gen(function* () { self.hoistFunctions(program.body) let value: unknown = undefined @@ -827,18 +228,13 @@ class Interpreter { } } - // The program body runs inside an implicit async function, so a returned promise - // resolves before crossing the data boundary - `return tools.ns.tool(...)` works - // without an explicit await, exactly as in JS. + // The implicit async body adopts returned promises before copy-out. if (value instanceof SandboxPromise) value = yield* self.settlePromise(value) return value - }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + }).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop()))) } - // Eagerly starts a tool call in the execution's promise scope (so timeout and teardown - // interrupt it) gated by the concurrency semaphore, and wraps the fiber in a - // first-class promise value. `startImmediately` makes the runtime admit the call - charging - // the tool-call budget and firing onToolCallStart - at the call site, before any await. + // Fork at the call site so admission and hooks occur when the call is made. private createToolCallPromise( path: ReadonlyArray, args: Array, @@ -850,9 +246,7 @@ class Interpreter { return this.promises.create(effect) } - // Settlement is idempotent (fiber exits replay), so awaiting the same promise repeatedly - // never re-runs the call. The post-settlement yield defers the continuation one reaction - // turn, as in JS: awaiters never resume inline, so they interleave in attach order. + // Fiber exits make settlement idempotent; yielding prevents inline continuation. private settlePromise(promise: SandboxPromise): Effect.Effect { const promises = this.promises return Effect.suspend(() => { @@ -900,14 +294,14 @@ class Interpreter { case "EmptyStatement": return Effect.succeed({ kind: "none" }) case "FunctionDeclaration": - return Effect.succeed({ kind: "none" }) // bound ahead of time by hoistFunctions + return Effect.succeed({ kind: "none" }) default: throw unsupportedSyntax(node.type, node) } } private evaluateBlock(node: AstNode): Effect.Effect { - this.pushScope() + this.scopes.push() const self = this return Effect.gen(function* () { const body = getArray(node, "body") @@ -923,7 +317,7 @@ class Interpreter { } return { kind: "none" } satisfies StatementResult - }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + }).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop()))) } private createFunction(node: AstNode): CodeModeFunction { @@ -938,18 +332,16 @@ class Interpreter { return new CodeModeFunction( getArray(node, "params").map((parameter, index) => asNode(parameter, `params[${index}]`)), getNode(node, "body"), - this.scopes.slice(), + this.scopes.capture(), node.async === true, ) } - // Function declarations are hoisted: bound in their scope before the body runs, so a - // program can call a helper defined further down (matching JavaScript). private hoistFunctions(statements: Array): void { for (const statementValue of statements) { if (!isRecord(statementValue) || statementValue.type !== "FunctionDeclaration") continue const node = statementValue as AstNode - this.declare(getString(getNode(node, "id"), "name"), this.createFunction(node), true, node) + this.scopes.declare(getString(getNode(node, "id"), "name"), this.createFunction(node), true, node) } } @@ -969,7 +361,7 @@ class Interpreter { private evaluateSwitchStatement(node: AstNode): Effect.Effect { const self = this - this.pushScope() + this.scopes.push() return Effect.gen(function* () { const discriminant = yield* self.evaluateExpression(getNode(node, "discriminant")) if (containsOpaqueReference(discriminant)) { @@ -1011,7 +403,7 @@ class Interpreter { } } return { kind: "none" } satisfies StatementResult - }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + }).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop()))) } private evaluateWhileStatement(node: AstNode): Effect.Effect { @@ -1067,7 +459,7 @@ class Interpreter { } private evaluateForStatement(node: AstNode): Effect.Effect { - this.pushScope() + this.scopes.push() const self = this return Effect.gen(function* () { const initNode = getOptionalNode(node, "init") @@ -1085,21 +477,21 @@ class Interpreter { const perIterationBindings = initNode?.type === "VariableDeclaration" && getString(initNode, "kind") !== "var" - ? Array.from(self.currentScope().keys()) + ? Array.from(self.scopes.current().keys()) : [] while (testNode ? yield* self.evaluateExpression(testNode) : true) { const iterationScope = perIterationBindings.length > 0 ? new Map( - perIterationBindings.map((name): [string, Binding] => [name, { ...self.currentScope().get(name)! }]), + perIterationBindings.map((name): [string, Binding] => [name, { ...self.scopes.current().get(name)! }]), ) : undefined if (iterationScope) self.scopes.push(iterationScope) const result = yield* self.evaluateStatement(bodyNode).pipe( Effect.ensuring( Effect.sync(() => { - if (iterationScope) self.popScope() + if (iterationScope) self.scopes.pop() }), ), ) @@ -1113,7 +505,7 @@ class Interpreter { } if (iterationScope) { - const loopScope = self.currentScope() + const loopScope = self.scopes.current() for (const name of perIterationBindings) { loopScope.set(name, { ...iterationScope.get(name)! }) } @@ -1129,7 +521,7 @@ class Interpreter { } return { kind: "none" } satisfies StatementResult - }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + }).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop()))) } private evaluateForOfStatement(node: AstNode): Effect.Effect { @@ -1143,8 +535,6 @@ class Interpreter { const right = yield* self.evaluateExpression(getNode(node, "right")) const body = getNode(node, "body") - // Arrays iterate in place; strings iterate code points; Maps iterate [key, value] - // pairs and Sets iterate values over a snapshot (mutation during iteration is safe). const iterable = spreadItems(right) if (iterable === undefined) { throw new InterpreterRuntimeError("for...of requires an array, string, Map, or Set value in CodeMode.", node) @@ -1174,7 +564,7 @@ class Interpreter { for (const value of iterable) { if (declaration) { - self.pushScope() + self.scopes.push() yield* self.declarePattern(declaration.pattern, value, declaration.mutable, left) } else if (assignment) { yield* self.assignPattern(assignment, value, left) @@ -1183,7 +573,7 @@ class Interpreter { const result = yield* self.evaluateStatement(body).pipe( Effect.ensuring( Effect.sync(() => { - if (declaration) self.popScope() + if (declaration) self.scopes.pop() }), ), ) @@ -1205,11 +595,6 @@ class Interpreter { }) } - // Own enumerable string keys of a value, shared by `for...in` and `Object.keys` over tool - // references: plain data objects enumerate their own keys, arrays their index strings (plus - // any own non-index properties, e.g. match results' index/groups - exactly Object.keys in - // JS), and a tool reference the namespace/tool names at its path in the host tool tree. - // Returns undefined for everything else so callers can raise a contextual error. private enumerableKeys(value: unknown): Array | undefined { if (value instanceof ToolReference) { return [...this.toolKeys(value.path)] @@ -1230,12 +615,6 @@ class Interpreter { const right = yield* self.evaluateExpression(getNode(node, "right")) const body = getNode(node, "body") - // Keys are snapshotted up front (mutation during iteration is safe): plain objects - // enumerate their own keys, arrays their index strings, and tool references the - // namespace/tool names at that node - the same enumeration Object.keys performs. - // Anything else (strings, Maps, Sets, numbers, null, ...) is a deliberate error rather - // than real JS's surprising behavior (indices for strings, zero iterations for - // Maps/Sets/null): the hint points at the constructs that do what the program means. const keys = self.enumerableKeys(right) if (keys === undefined) { throw new InterpreterRuntimeError( @@ -1263,16 +642,16 @@ class Interpreter { for (const key of keys) { if (declaration) { - self.pushScope() + self.scopes.push() yield* self.declarePattern(declaration.pattern, key, declaration.mutable, left) } else if (assignmentName) { - self.setIdentifierValue(assignmentName, key, left) + self.scopes.set(assignmentName, key, left) } const result = yield* self.evaluateStatement(body).pipe( Effect.ensuring( Effect.sync(() => { - if (declaration) self.popScope() + if (declaration) self.scopes.pop() }), ), ) @@ -1331,15 +710,13 @@ class Interpreter { return Effect.failCause(cause) } - // The program sees a plain { message } error (or the thrown value itself) - see - // caughtErrorValue, shared with Promise.allSettled rejection reasons. const caught = caughtErrorValue(Cause.squash(cause)) const parameter = getOptionalNode(handler, "param") - self.pushScope() + self.scopes.push() return Effect.gen(function* () { if (parameter) yield* self.declarePattern(parameter, caught, true, handler) return yield* self.evaluateStatement(getNode(handler, "body")) - }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + }).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop()))) }, onSuccess: Effect.succeed, }) @@ -1391,11 +768,10 @@ class Interpreter { const self = this return Effect.gen(function* () { if (pattern.type === "Identifier") { - self.declare(getString(pattern, "name"), value, mutable, node) + self.scopes.declare(getString(pattern, "name"), value, mutable, node) return } - // Default values: `x = expr` / `{ a = 1 }` - the default is evaluated only when the value is undefined. if (pattern.type === "AssignmentPattern") { const resolved = value === undefined ? yield* self.evaluateExpression(getNode(pattern, "right")) : value yield* self.declarePattern(getNode(pattern, "left"), resolved, mutable, node) @@ -1415,7 +791,6 @@ class Interpreter { for (const propertyValue of getArray(pattern, "properties")) { const property = asNode(propertyValue, "properties") - // Object rest: `{ a, ...others }` - gather the not-yet-consumed own keys. if (property.type === "RestElement") { const rest: SafeObject = Object.create(null) as SafeObject for (const [key, item] of Object.entries(value as SafeObject)) { @@ -1452,7 +827,6 @@ class Interpreter { for (const [index, item] of getArray(pattern, "elements").entries()) { if (item === null) continue const element = asNode(item, `elements[${index}]`) - // Array rest: `[head, ...tail]` - binds the remaining elements (must be last). if (element.type === "RestElement") { yield* self.declarePattern(getNode(element, "argument"), value.slice(index), mutable, element) break @@ -1470,7 +844,7 @@ class Interpreter { const self = this return Effect.gen(function* () { if (pattern.type === "Identifier") { - self.setIdentifierValue(getString(pattern, "name"), value, pattern) + self.scopes.set(getString(pattern, "name"), value, pattern) return } @@ -1547,8 +921,6 @@ class Interpreter { private evaluateExpression(node: AstNode): Effect.Effect { switch (node.type) { case "Literal": { - // A regex literal parses as a Literal node carrying { pattern, flags }; construct the - // sandbox regex from those (the host `value` instance is never exposed). const regex = node.regex if (isRecord(regex) && typeof regex.pattern === "string") { return Effect.sync(() => @@ -1558,7 +930,7 @@ class Interpreter { return Effect.sync(() => boundedData(node.value, "Literal")) } case "Identifier": - return Effect.sync(() => this.getIdentifierValue(getString(node, "name"), node)) + return Effect.sync(() => this.scopes.get(getString(node, "name"), node)) case "BinaryExpression": return this.evaluateBinaryExpression(node) case "LogicalExpression": @@ -1599,7 +971,7 @@ class Interpreter { case "UpdateExpression": return this.evaluateUpdateExpression(node) case "AwaitExpression": { - // In JS every await suspends, even on a non-promise. + // Await always suspends, including for plain values. const self = this return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) => value instanceof SandboxPromise ? self.settlePromise(value) : Effect.as(Effect.yieldNow, value), @@ -1621,7 +993,9 @@ class Interpreter { const argNodes = getArray(node, "arguments") const self = this if (name === "Promise") { - return Effect.flatMap(this.evaluateCallArguments(argNodes), (args) => self.constructPromise(args[0], node)) + return Effect.flatMap(this.evaluateCallArguments(argNodes), (args) => + constructPromise(self.runner, self.promises, args[0], node), + ) } if (errorConstructors.has(name)) { return Effect.map(this.evaluateCallArguments(argNodes), (args) => constructErrorValue(name, args, node)) @@ -1657,7 +1031,6 @@ class Interpreter { if (typeof arg === "string") return new SandboxDate(Date.parse(arg)) return new SandboxDate(Number.NaN) } - // new Date(year, month, day?, hours?, ...) - local-time component form. const parts = args.map((arg) => coerceToNumber(arg)) return new SandboxDate(new Date(...(parts as [number, number])).getTime()) } @@ -1677,9 +1050,6 @@ class Interpreter { try { return new SandboxRegExp(pattern, flags) } catch (error) { - // Say which part was rejected and how to fix it, instead of passing the engine - // message through bare. A flags failure names the flags; a pattern failure gets the - // escaping hint (the usual cause is an unescaped metacharacter in a built-up string). const reason = regexFailureReason(error) throw new InterpreterRuntimeError( /flag/i.test(reason) @@ -1797,29 +1167,17 @@ class Interpreter { return Effect.gen(function* () { const lhs = yield* self.evaluateExpression(getNode(node, "left")) const rhs = yield* self.evaluateExpression(getNode(node, "right")) - // Like `typeof`, `instanceof` observes any value without coercing it (a promise or - // function operand is a legitimate question, not an error), so it is handled before - // the data-only operand check. if (operator === "instanceof") return instanceofValue(lhs, rhs, node) return boundedData(self.applyBinaryOperator(operator, lhs, rhs, node), "Binary expression result") }) } - /** - * Applies a binary operator to two already-evaluated operands with CodeMode's coercion - * semantics. Shared by binary expressions and compound assignment (`x op= y` must behave - * exactly like `x = x op y`, coercion included). - */ private applyBinaryOperator(operator: string, lhs: unknown, rhs: unknown, node: AstNode): unknown { if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) { throw new InterpreterRuntimeError("Binary operators require data values in CodeMode.", node, "InvalidDataValue") } - // Data objects/arrays are null-prototype, so JS's ToPrimitive throws an opaque host - // "No default value" TypeError when an operator coerces them. Coerce to their JS string - // form first (as String(x) / template literals do) so operators behave like JavaScript. - // A Date follows its ToPrimitive hints: string for `+` (concatenation), its time value - // for arithmetic and ordering - so `end - start` and `a < b` work as in JS. - // Identity (=== / !==) and the right operand of `in` keep their raw object value. + // Null-prototype data needs explicit primitive coercion; identity and `in` retain raw objects. + // Dates use string coercion for `+` and epoch time elsewhere. const coerceOperand = (operand: unknown): unknown => { if (operand instanceof SandboxDate) return operator === "+" ? coerceToString(operand) : operand.time return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand @@ -1840,7 +1198,6 @@ class Interpreter { return (l as number) % (r as number) case "**": return (l as number) ** (r as number) - // Two objects compare by identity in JS (no ToPrimitive); only object-vs-primitive coerces. case "==": return bothObjects ? lhs === rhs : l == r case "===": @@ -1873,7 +1230,7 @@ class Interpreter { if (rhs === null || typeof rhs !== "object") { throw new InterpreterRuntimeError("The 'in' operator requires a data object on the right-hand side.", node) } - // Own properties only, so arrays don't leak the host Array.prototype (map/constructor/...). + // Never expose properties inherited from host prototypes. return Object.hasOwn(rhs as object, coerceOperand(lhs) as PropertyKey) default: throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node) @@ -1896,23 +1253,16 @@ class Interpreter { private evaluateUnaryExpression(node: AstNode): Effect.Effect { const operator = getString(node, "operator") const argument = getNode(node, "argument") - // `typeof undeclaredIdentifier` is `"undefined"` in JS (never a ReferenceError), so - // feature-detection guards like `typeof x !== "undefined"` don't crash. Short-circuit before - // evaluating the argument; a declared-but-TDZ binding still falls through to the normal throw. - if (operator === "typeof" && argument.type === "Identifier" && !this.resolveBinding(getString(argument, "name"))) { + // Undeclared names short-circuit, but declared TDZ bindings must still throw. + if (operator === "typeof" && argument.type === "Identifier" && !this.scopes.resolve(getString(argument, "name"))) { return Effect.succeed("undefined") } return Effect.map(this.evaluateExpression(argument), (value) => { - // `typeof` and `!` never throw in JS - they observe any value (functions and runtime - // references included) without coercing it, so feature detection and negation work. if (operator === "typeof") return typeofValue(value) if (operator === "!") return !value if (containsOpaqueReference(value)) { throw new InterpreterRuntimeError("Unary operators require data values in CodeMode.", node, "InvalidDataValue") } - // Numeric/bitwise unary operators ToPrimitive their operand; a Date yields its time value - // (`+date` is the epoch-ms idiom), other null-prototype data objects/arrays coerce to - // their JS string form first (see evaluateBinaryExpression). const operand = value instanceof SandboxDate ? value.time @@ -1953,16 +1303,16 @@ class Interpreter { if (left.type === "Identifier") { const name = getString(left, "name") if (operator !== "=") { - const current = self.getIdentifierValue(name, left) + const current = self.scopes.get(name, left) const rightValue = yield* self.evaluateExpression(getNode(node, "right")) const next = boundedData( self.applyCompoundAssignment(operator, current, rightValue, node), "Assignment result", ) - return self.setIdentifierValue(name, next, left) + return self.scopes.set(name, next, left) } const rightValue = yield* self.evaluateExpression(getNode(node, "right")) - return self.setIdentifierValue(name, rightValue, left) + return self.scopes.set(name, rightValue, left) } if (left.type === "MemberExpression") { return yield* self.modifyMember(left, (current) => @@ -1991,14 +1341,13 @@ class Interpreter { if (left.type === "Identifier") { const name = getString(left, "name") return Effect.gen(function* () { - const current = self.getIdentifierValue(name, left) + const current = self.scopes.get(name, left) if (!shouldAssign(current)) return current const rightValue = yield* self.evaluateExpression(getNode(node, "right")) - return self.setIdentifierValue(name, rightValue, left) + return self.scopes.set(name, rightValue, left) }) } if (left.type === "MemberExpression") { - // Resolve the member exactly once; evaluate the RHS only if we actually assign. return self.modifyMember(left, (current) => shouldAssign(current) ? Effect.map(self.evaluateExpression(getNode(node, "right")), (rightValue) => ({ @@ -2026,9 +1375,9 @@ class Interpreter { if (argument.type === "Identifier") { return Effect.sync(() => { const name = getString(argument, "name") - const current = Number(this.getIdentifierValue(name, argument)) + const current = Number(this.scopes.get(name, argument)) const next = current + increment - this.setIdentifierValue(name, next, argument) + this.scopes.set(name, next, argument) return prefix ? next : current }) } @@ -2058,20 +1407,19 @@ class Interpreter { if (callable instanceof ToolReference) { if (callable.path.length === 0) throw new InterpreterRuntimeError("The tools root is not callable.", callee) - // An un-awaited tool call is a first-class promise value; the call itself starts now. return yield* self.createToolCallPromise(callable.path, args) } if (callable instanceof PromiseMethodReference) { - return yield* self.invokePromiseMethod(callable, args, node) + return yield* invokePromiseMethod(self.runner, self.promises, callable, args, node) } if (callable instanceof PromiseInstanceMethodReference) { - return yield* self.invokePromiseInstanceMethod(callable, args, node) + return yield* invokePromiseInstanceMethod(self.runner, self.promises, callable, args, node) } if (callable instanceof CodeModeFunction) { return yield* self.invokeFunction(callable, args) } if (callable instanceof IntrinsicReference) { - return yield* self.invokeIntrinsic(callable, args, node) + return yield* invokeIntrinsic(self.runner, callable, args, node) } if (callable instanceof GlobalMethodReference) { if (callable.namespace === "console") return self.invokeConsole(callable.name, args, node) @@ -2093,11 +1441,8 @@ class Interpreter { return invokeUriFunction(callable, args, node) } if (callable instanceof SearchFunction) { - // The built-in search is synchronous in-memory matching: the call returns its - // result directly (await still works, as with any plain value). return yield* self.invokeSearch(args) } - // `Error("msg")` without `new` constructs an error exactly like `new Error("msg")`, as in JS. if (callable instanceof ErrorConstructorReference) { return constructErrorValue(callable.name, args, node) } @@ -2109,10 +1454,6 @@ class Interpreter { }) } - // Object.* over a tool reference: `Object.keys(tools)` / `Object.keys(tools.ns)` enumerate - // namespace/tool names from the host tool tree - the discovery idiom a model reaches for - // first. Other Object helpers fail with a pointer at the working idioms instead of a generic - // plain-data message. private invokeObjectMethodOnTools(name: string, ref: ToolReference, node: AstNode): unknown { if (name === "keys") { return boundedData(this.enumerableKeys(ref)!, "Object.keys result") @@ -2127,126 +1468,10 @@ class Interpreter { private invokeConsole(name: string, args: Array, node: AstNode): undefined { if (!consoleMethods.has(name)) throw new InterpreterRuntimeError(`console.${name} is not available in CodeMode.`, node) - this.logs.push(publicErrorMessage(this.formatConsoleMessage(name, args, node))) + this.logs.push(formatConsoleMessage(name, args)) return undefined } - private formatConsoleMessage(name: string, args: Array, node: AstNode): string { - if (name === "dir") return args.length === 0 ? "undefined" : this.formatConsoleArgument(args[0]) - if (name === "table") return this.formatConsoleTable(args[0], args[1], node) - const prefix = name === "warn" ? "[warn] " : name === "error" ? "[error] " : name === "debug" ? "[debug] " : "" - return `${prefix}${args.map((arg) => this.formatConsoleArgument(arg)).join(" ")}` - } - - // Console arguments format deeply and totally: values render as a debugger would show them - // rather than as boundary JSON - numbers keep NaN/Infinity (JSON would say null), sandbox - // values keep their friendly forms at ANY depth (ISO date, /regex/flags, Map(n) [...], - // Set(n) [...]), opaque runtime references become "[CodeMode reference]" markers in place, - // and plain objects/arrays render JSON-style. Formatting never fails the program: cycles - // render "[Circular]" and extreme depth degrades to "...". - private formatConsoleArgument(value: unknown): string { - if (value === undefined) return "undefined" - // A top-level string prints bare; nested strings are JSON-quoted (see formatConsoleValue). - if (typeof value === "string") return value - return this.formatConsoleValue(value, new Set(), 0) - } - - private formatConsoleValue(value: unknown, seen: Set, depth: number): string { - // Nested undefined renders as null, matching what JSON boundary output would show. - if (value === null || value === undefined) return "null" - if (typeof value === "string") return JSON.stringify(value) - // String(value) keeps NaN/Infinity/-Infinity readable; finite numbers match their JSON form. - if (typeof value === "number" || typeof value === "boolean") return String(value) - if (typeof value !== "object") return String(value) - if (value instanceof SandboxPromise) return "[Promise (await it to get its value)]" - if (value instanceof SandboxDate) return coerceToString(value) - if (value instanceof SandboxRegExp) return coerceToString(value) - if (value instanceof SandboxURL) return coerceToString(value) - if (value instanceof SandboxURLSearchParams) return coerceToString(value) - if (depth > MAX_CONSOLE_DEPTH) return "..." - if (seen.has(value)) return "[Circular]" - if (value instanceof SandboxMap) { - seen.add(value) - try { - const entries = Array.from(value.map.entries(), ([key, item]): Array => [key, item]) - return `Map(${value.map.size}) ${this.formatConsoleValue(entries, seen, depth + 1)}` - } finally { - seen.delete(value) - } - } - if (value instanceof SandboxSet) { - seen.add(value) - try { - return `Set(${value.set.size}) ${this.formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}` - } finally { - seen.delete(value) - } - } - if (isRuntimeReference(value)) return "[CodeMode reference]" - seen.add(value) - try { - if (Array.isArray(value)) { - return `[${value.map((item) => this.formatConsoleValue(item, seen, depth + 1)).join(",")}]` - } - return `{${Object.entries(value) - .map(([key, item]) => `${JSON.stringify(key)}:${this.formatConsoleValue(item, seen, depth + 1)}`) - .join(",")}}` - } finally { - seen.delete(value) - } - } - - private formatConsoleTable(value: unknown, columnsArgument: unknown, node: AstNode): string { - if (value === undefined) return "undefined" - // Sandbox values are legitimate table data (cells render their friendly forms); only - // truly opaque references (functions, tools, promises) collapse to the marker. - if (containsOpaqueReference(value)) return "[CodeMode reference]" - const data = boundedData(value, "console.table argument") - const columns = this.consoleTableColumns(columnsArgument, node) - const rows = this.consoleTableRows(data, columns) - const keys = columns ?? Array.from(new Set(rows.flatMap((row) => Object.keys(row.values)))) - const header = ["(index)", ...keys].join("\t") - return [ - header, - ...rows.map((row) => [row.index, ...keys.map((key) => this.formatConsoleTableCell(row.values[key]))].join("\t")), - ].join("\n") - } - - private consoleTableColumns(value: unknown, node: AstNode): ReadonlyArray | undefined { - if (value === undefined) return undefined - if (containsRuntimeReference(value)) return undefined - const columns = copyOut(copyIn(value, "console.table columns"), true) - return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined - } - - private consoleTableRows( - data: unknown, - columns: ReadonlyArray | undefined, - ): Array<{ readonly index: string; readonly values: Record }> { - if (Array.isArray(data)) { - return data.map((item, index) => ({ index: String(index), values: this.consoleTableValues(item, columns) })) - } - if (data !== null && typeof data === "object" && !isSandboxValue(data)) { - return Object.entries(data).map(([index, item]) => ({ index, values: this.consoleTableValues(item, columns) })) - } - return [{ index: "0", values: { Value: data } }] - } - - private consoleTableValues(value: unknown, columns: ReadonlyArray | undefined): Record { - if (value !== null && typeof value === "object" && !Array.isArray(value) && !isSandboxValue(value)) { - const source = value as Record - if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]])) - return Object.fromEntries(Object.entries(source)) - } - return { Value: value } - } - - private formatConsoleTableCell(value: unknown): string { - if (value === undefined) return "" - if (typeof value === "string") return value - return this.formatConsoleValue(value, new Set(), 0) - } - private evaluateCallArguments(argNodes: Array): Effect.Effect, unknown, R> { const self = this return Effect.gen(function* () { @@ -2270,240 +1495,6 @@ class Interpreter { }) } - // Promise.* over ordinary runtime values. Combinators accept ANY array (or spreadable - // collection) mixing promise values and plain data - built inline, beforehand, via spread, - // whatever - because tool calls already run eagerly on their own fibers. Each combinator - // returns a real promise whose join runs on its own scope-owned fiber, observing member - // settlements; the concurrency cap stays where the work is: the fork semaphore. - private invokePromiseMethod( - ref: PromiseMethodReference, - args: Array, - node: AstNode, - ): Effect.Effect { - if (ref.name === "resolve") { - // Promise.resolve of a promise is that promise (JS flattens); anything else is a - // promise already fulfilled with the value. Pre-settled values still fork a scope-owned - // fiber so every promise shares one lifecycle (an abandoned reject is reported, teardown - // is uniform). - const value = args[0] - return value instanceof SandboxPromise ? Effect.succeed(value) : this.createPromise(Effect.succeed(value)) - } - if (ref.name === "reject") { - return this.createPromise(Effect.fail(new ProgramThrow(args[0]))) - } - - const spread = spreadItems(args[0]) - if (spread === undefined) { - return this.createPromise( - Effect.fail( - new InterpreterRuntimeError( - `Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`, - node, - ).as("TypeError"), - ), - ) - } - // Densify: JS combinator iteration reads sparse holes as undefined members; .map would skip them. - const items = Array.from(spread) - - // JS makes combinator members "handled" synchronously at the call - their rejections - // belong to the aggregate from this moment, even ones settling before it runs. - for (const item of items) { - if (item instanceof SandboxPromise) this.promises.markObserved(item) - } - - switch (ref.name) { - case "all": { - // Rejects on the first failure; sibling fibers stay execution-owned and keep running, as in JS. - const observations = items.map((item) => - item instanceof SandboxPromise ? Effect.flatten(this.promises.await(item)) : Effect.succeed(item), - ) - return this.createPromise(settleAfterTurn(Effect.all(observations, { concurrency: "unbounded" }))) - } - case "allSettled": { - const observations = items.map((item) => - item instanceof SandboxPromise ? this.promises.await(item) : Effect.succeed(Exit.succeed(item)), - ) - return this.createPromise( - settleAfterTurn( - Effect.gen(function* () { - const outcomes: Array = [] - for (const observation of observations) { - const exit = yield* observation - if (Exit.isSuccess(exit)) { - outcomes.push( - Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }), - ) - continue - } - if (Cause.hasInterruptsOnly(exit.cause)) { - // Execution teardown (timeout/host interruption), not a program-level rejection. - return yield* Effect.failCause(exit.cause) - } - outcomes.push( - Object.assign(Object.create(null) as SafeObject, { - status: "rejected", - reason: caughtErrorValue(Cause.squash(exit.cause)), - }), - ) - } - return outcomes - }), - ), - ) - } - case "race": { - if (items.length === 0) { - return this.createPromise( - Effect.fail( - new InterpreterRuntimeError( - "Promise.race([]) would never settle; provide at least one promise or value.", - node, - ), - ), - ) - } - const observations = items.map((item) => - item instanceof SandboxPromise ? this.promises.await(item) : Effect.succeed(Exit.succeed(item)), - ) - // First settlement (fulfilled OR rejected) wins; losing work stays execution-owned - // and is interrupted at normal completion (already observed) or by teardown. - return this.createPromise(settleAfterTurn(Effect.flatten(Effect.raceAll(observations)))) - } - case "any": { - // De Morgan dual of Promise.all: members are flipped so the first fulfillment - // short-circuits fail-fast Effect.all, and all-rejected completes with the reasons - // in input order for the AggregateError. - const flipped = items.map((item) => - item instanceof SandboxPromise - ? Effect.flatMap(this.promises.await(item), (exit) => { - if (Exit.isSuccess(exit)) return Effect.fail(new PromiseAnyFulfilled(exit.value)) - if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause) - return Effect.succeed(caughtErrorValue(Cause.squash(exit.cause))) - }) - : Effect.fail(new PromiseAnyFulfilled(item)), - ) - const body = Effect.all(flipped, { concurrency: "unbounded" }).pipe( - Effect.flatMap((reasons) => - Effect.fail(new ProgramThrow(createAggregateErrorValue(reasons, "All promises were rejected"))), - ), - Effect.catch((error) => - error instanceof PromiseAnyFulfilled ? Effect.succeed(error.value) : Effect.fail(error), - ), - ) - return this.createPromise(settleAfterTurn(body)) - } - } - } - - // Teardown interruption propagates without running handlers; a real settlement defers - // one reaction turn so handlers never run inline. - private reactionExit(source: SandboxPromise): Effect.Effect, unknown, R> { - const promises = this.promises - return Effect.gen(function* () { - const exit = yield* promises.await(source) - if (!Exit.isSuccess(exit) && Cause.hasInterruptsOnly(exit.cause)) return yield* Effect.failCause(exit.cause) - yield* Effect.yieldNow - return exit - }) - } - - private invokePromiseInstanceMethod( - ref: PromiseInstanceMethodReference, - args: Array, - node: AstNode, - ): Effect.Effect { - const method = `Promise.prototype.${ref.name}` - this.promises.markObserved(ref.promise) - if (ref.name === "finally") { - return this.chainFinally(ref.promise, reactionHandler(args[0], method, node), method, node) - } - const onFulfilled = ref.name === "then" ? reactionHandler(args[0], method, node) : undefined - const onRejected = reactionHandler(ref.name === "then" ? args[1] : args[0], method, node) - return this.chainReaction(ref.promise, onFulfilled, onRejected, method, node) - } - - private chainReaction( - source: SandboxPromise, - onFulfilled: ReactionHandler | undefined, - onRejected: ReactionHandler | undefined, - method: string, - node: AstNode, - ): Effect.Effect { - const self = this - const box: { derived?: SandboxPromise } = {} - const body = Effect.gen(function* () { - const exit = yield* self.reactionExit(source) - const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected - if (handler === undefined) return yield* exit - const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause)) - const result = yield* self.applyCollectionCallback(handler, method, node)([input]) - if (result === box.derived) return yield* Effect.fail(selfResolutionError(node)) - if (result instanceof SandboxPromise) return yield* self.settlePromise(result) - return result - }) - return Effect.map(this.createPromise(body), (derived) => { - box.derived = derived - return derived - }) - } - - private chainFinally( - source: SandboxPromise, - cleanup: ReactionHandler | undefined, - method: string, - node: AstNode, - ): Effect.Effect { - const self = this - return this.createPromise( - Effect.gen(function* () { - const exit = yield* self.reactionExit(source) - if (cleanup !== undefined) { - const result = yield* self.applyCollectionCallback(cleanup, method, node)([]) - if (result instanceof SandboxPromise) yield* self.settlePromise(result) - } - return yield* exit - }), - ) - } - - // new Promise(executor): the promise's fiber awaits a Deferred that resolve/reject settle - // exactly once. The executor runs synchronously; its throw rejects the promise unless it - // already settled (JS swallows post-settlement executor throws). - private constructPromise(executor: unknown, node: AstNode): Effect.Effect { - if (!(executor instanceof CodeModeFunction)) { - throw new InterpreterRuntimeError( - "new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).", - node, - ).as("TypeError") - } - const self = this - return Effect.gen(function* () { - const deferred = Deferred.makeUnsafe() - const box: { own?: SandboxPromise } = {} - const promise = yield* self.createPromise( - Effect.flatMap(Deferred.await(deferred), (value) => { - if (!(value instanceof SandboxPromise)) return Effect.succeed(value) - if (value === box.own) return Effect.fail(selfResolutionError(node)) - return self.settlePromise(value) - }), - ) - box.own = promise - const resolve = new PromiseCapabilityFunction((value) => { - Deferred.doneUnsafe(deferred, Exit.succeed(value)) - }) - const reject = new PromiseCapabilityFunction((value) => { - Deferred.doneUnsafe(deferred, Exit.fail(new ProgramThrow(value))) - }) - const executed = yield* Effect.exit(self.invokeFunction(executor, [resolve, reject])) - if (!Exit.isSuccess(executed)) { - if (Cause.hasInterruptsOnly(executed.cause)) return yield* Effect.failCause(executed.cause) - Deferred.doneUnsafe(deferred, Exit.fail(Cause.squash(executed.cause))) - } - return promise - }) - } - private invokeFunction(fn: CodeModeFunction, args: Array): Effect.Effect { const invocation = new Interpreter( this.invokeTool, @@ -2513,12 +1504,10 @@ class Interpreter { this.logs, this.callPermits, ) - invocation.scopes = [...fn.capturedScopes, new Map()] + invocation.scopes = new ScopeStack([...fn.capturedScopes, new Map()]) const run = Effect.gen(function* () { - // Seed every parameter name into the scope as a TDZ slot first, so a default that - // references another parameter resolves to that (uninitialized) param rather than - // silently falling through to an outer binding of the same name - matching JS. - const paramScope = invocation.currentScope() + // Seed all parameters first so defaults cannot fall through to same-named outer bindings. + const paramScope = invocation.scopes.current() for (const parameter of fn.parameters) { for (const name of collectPatternNames(parameter)) { paramScope.set(name, { mutable: true, value: undefined, initialized: false }) @@ -2540,7 +1529,7 @@ class Interpreter { return yield* invocation.evaluateExpression(fn.body) }) if (!fn.async) return run - // Every await yields, so `box.own` is assigned before the body can return a reference to it. + // The initial yield assigns `box.own` before the body can self-resolve. const box: { own?: SandboxPromise } = {} return Effect.map( this.createPromise( @@ -2557,561 +1546,6 @@ class Interpreter { ) } - private invokeIntrinsic( - ref: IntrinsicReference, - args: Array, - 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 this.invokeStringReplacer(ref.receiver, ref.name, args, node) - } - return Effect.succeed(invokeStringMethod(ref.receiver, ref.name, args, node)) - } - if (typeof ref.receiver === "number") { - return Effect.succeed(invokeNumberMethod(ref.receiver, ref.name, args, node)) - } - if (Array.isArray(ref.receiver)) { - return this.invokeArrayMethod(ref.receiver, ref.name, args, node) - } - if (ref.receiver instanceof SandboxDate) { - return Effect.succeed(invokeDateMethod(ref.receiver, ref.name, node)) - } - if (ref.receiver instanceof SandboxRegExp) { - return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node)) - } - if (ref.receiver instanceof SandboxMap) { - return this.invokeMapMethod(ref.receiver, ref.name, args, node) - } - if (ref.receiver instanceof SandboxSet) { - return this.invokeSetMethod(ref.receiver, ref.name, args, node) - } - if (ref.receiver instanceof SandboxURL) { - return Effect.succeed(invokeURLMethod(ref.receiver, ref.name, node)) - } - if (ref.receiver instanceof SandboxURLSearchParams) { - return this.invokeURLSearchParamsMethod(ref.receiver, ref.name, args, node) - } - throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node) - } - - private invokeStringReplacer( - value: string, - name: "replace" | "replaceAll", - args: Array, - node: AstNode, - ): Effect.Effect { - const apply = this.applyCollectionCallback(args[1], `String.${name}`, node) - const matches: Array<{ readonly match: string; readonly offset: number; readonly args: Array }> = [] - const collect = (...callbackArgs: Array): string => { - const match = callbackArgs[0] - const groups = callbackArgs[callbackArgs.length - 1] - const hasGroups = groups !== null && typeof groups === "object" - const offset = callbackArgs[callbackArgs.length - (hasGroups ? 3 : 2)] - if (typeof match !== "string" || typeof offset !== "number") { - throw new InterpreterRuntimeError(`String.${name} produced an invalid replacement match.`, node) - } - if (hasGroups) { - const safeGroups: SafeObject = Object.create(null) as SafeObject - for (const [key, group] of Object.entries(groups)) { - if (!isBlockedMember(key)) safeGroups[key] = group - } - callbackArgs[callbackArgs.length - 1] = safeGroups - } - matches.push({ match, offset, args: callbackArgs }) - return match - } - - const pattern = args[0] - if (pattern instanceof SandboxRegExp) { - if (name === "replaceAll" && !pattern.regex.global) { - throw new InterpreterRuntimeError( - `String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.regex.source}/${pattern.regex.flags}g, or use String.replace to replace only the first match.`, - node, - ) - } - if (name === "replace") value.replace(pattern.regex, collect) - else value.replaceAll(pattern.regex, collect) - } else { - if (typeof pattern !== "string") { - throw new InterpreterRuntimeError(`String.${name} expects argument 1 to be a string.`, node) - } - if (name === "replace") value.replace(pattern, collect) - else value.replaceAll(pattern, collect) - } - - const self = this - return Effect.gen(function* () { - const output: Array = [] - let end = 0 - for (const match of matches) { - const replacement = yield* apply(match.args) - const resolved = - args[1] instanceof CodeModeFunction && args[1].async && replacement instanceof SandboxPromise - ? yield* self.settlePromise(replacement) - : replacement - output.push( - value.slice(end, match.offset), - coerceToString(boundedData(resolved, `String.${name} replacer result`)), - ) - end = match.offset + match.match.length - } - output.push(value.slice(end)) - return boundedData(output.join(""), `String.${name} result`) - }) - } - - // Accepts a user function or supported builtin callable, so idioms such as - // `filter(Boolean)`, `map(String)`, and `map(encodeURIComponent)` work as in JS. - private applyCollectionCallback( - callback: unknown, - name: string, - node: AstNode, - ): (args: Array) => Effect.Effect { - if ( - !(callback instanceof CodeModeFunction) && - !(callback instanceof CoercionFunction) && - !(callback instanceof UriFunction) && - !(callback instanceof PromiseCapabilityFunction) - ) { - 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])) - : this.invokeFunction(callback, callbackArgs) - } - - private invokeMapMethod( - target: SandboxMap, - name: string, - args: Array, - node: AstNode, - ): Effect.Effect { - switch (name) { - case "get": - return Effect.succeed(target.map.get(args[0])) - case "has": - return Effect.succeed(target.map.has(args[0])) - case "set": - return Effect.sync(() => { - target.map.set(args[0], args[1]) - return target - }) - case "delete": - return Effect.sync(() => target.map.delete(args[0])) - case "clear": - return Effect.sync(() => { - target.map.clear() - return undefined - }) - case "keys": - return Effect.sync(() => Array.from(target.map.keys())) - case "values": - return Effect.sync(() => Array.from(target.map.values())) - case "entries": - return Effect.sync(() => Array.from(target.map.entries(), ([key, item]): Array => [key, item])) - case "forEach": { - const apply = this.applyCollectionCallback(args[0], "Map.forEach", node) - return Effect.gen(function* () { - // Snapshot iteration, matching the array-method callback contract. - for (const [key, item] of Array.from(target.map.entries())) yield* apply([item, key, target]) - return undefined - }) - } - default: - throw new InterpreterRuntimeError(`Map method '${name}' is not available in CodeMode.`, node) - } - } - - private invokeSetMethod( - target: SandboxSet, - name: string, - args: Array, - node: AstNode, - ): Effect.Effect { - switch (name) { - case "has": - return Effect.succeed(target.set.has(args[0])) - case "add": - return Effect.sync(() => { - target.set.add(args[0]) - return target - }) - case "delete": - return Effect.sync(() => target.set.delete(args[0])) - case "clear": - return Effect.sync(() => { - target.set.clear() - return undefined - }) - case "keys": - case "values": - return Effect.sync(() => Array.from(target.set.values())) - case "entries": - return Effect.sync(() => Array.from(target.set.values(), (item): Array => [item, item])) - case "forEach": { - const apply = this.applyCollectionCallback(args[0], "Set.forEach", node) - return Effect.gen(function* () { - for (const item of Array.from(target.set.values())) yield* apply([item, item, target]) - return undefined - }) - } - default: - throw new InterpreterRuntimeError(`Set method '${name}' is not available in CodeMode.`, node) - } - } - - private invokeURLSearchParamsMethod( - target: SandboxURLSearchParams, - name: string, - args: Array, - node: AstNode, - ): Effect.Effect { - const arg = (index: number): string => uriArgument(args[index], `URLSearchParams.${name} argument ${index + 1}`) - const requireArgs = (count: number): void => { - if (args.length < count) { - throw new InterpreterRuntimeError( - `URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`, - node, - ).as("TypeError") - } - } - switch (name) { - case "append": { - requireArgs(2) - return Effect.sync(() => { - target.params.append(arg(0), arg(1)) - return undefined - }) - } - case "delete": { - requireArgs(1) - return Effect.sync(() => { - if (args[1] !== undefined) target.params.delete(arg(0), arg(1)) - else target.params.delete(arg(0)) - return undefined - }) - } - case "get": - requireArgs(1) - return Effect.sync(() => target.params.get(arg(0))) - case "getAll": - requireArgs(1) - return Effect.sync(() => target.params.getAll(arg(0))) - case "has": - requireArgs(1) - return Effect.sync(() => - args[1] !== undefined ? target.params.has(arg(0), arg(1)) : target.params.has(arg(0)), - ) - case "set": { - requireArgs(2) - return Effect.sync(() => { - target.params.set(arg(0), arg(1)) - return undefined - }) - } - case "sort": - return Effect.sync(() => { - target.params.sort() - return undefined - }) - case "keys": - return Effect.sync(() => Array.from(target.params.keys())) - case "values": - return Effect.sync(() => Array.from(target.params.values())) - case "entries": - return Effect.sync(() => Array.from(target.params.entries(), ([key, value]): Array => [key, value])) - case "toString": - return Effect.sync(() => target.params.toString()) - case "forEach": { - requireArgs(1) - const apply = this.applyCollectionCallback(args[0], "URLSearchParams.forEach", node) - return Effect.gen(function* () { - for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target]) - return undefined - }) - } - default: - throw new InterpreterRuntimeError(`URLSearchParams method '${name}' is not available in CodeMode.`, node) - } - } - - private invokeArrayMethod( - target: Array, - name: string, - args: Array, - node: AstNode, - ): Effect.Effect { - const optNumber = (value: unknown, label: string): number | undefined => { - if (value === undefined) return undefined - if (typeof value !== "number") - throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node) - return value - } - switch (name) { - case "join": { - if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) { - throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node) - } - const input = boundedData(target, "Array.join input") as Array - return Effect.succeed( - input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string)), - ) - } - case "includes": - if (args.length === 0 || args.length > 2) - throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node) - return Effect.succeed(target.includes(args[0], optNumber(args[1], "start index"))) - case "indexOf": - return Effect.succeed(target.indexOf(args[0], optNumber(args[1], "start index"))) - case "lastIndexOf": - return Effect.succeed( - args[1] === undefined - ? target.lastIndexOf(args[0]) - : target.lastIndexOf(args[0], optNumber(args[1], "start index")), - ) - case "at": - return Effect.succeed(target.at(optNumber(args[0], "index") ?? 0)) - case "slice": - return Effect.succeed(target.slice(optNumber(args[0], "start"), optNumber(args[1], "end"))) - case "concat": - return Effect.succeed(target.concat(...args)) - case "flat": - return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1)) - case "reverse": - return Effect.succeed(target.reverse()) - case "sort": - return Effect.map(this.sortArray(target, args[0], node), (sorted) => { - target.splice(0, target.length, ...sorted) - return target - }) - case "toSorted": - return this.sortArray(target, args[0], node) - case "toReversed": - return Effect.succeed([...target].reverse()) - case "with": { - const index = optNumber(args[0], "index") ?? 0 - const resolved = index < 0 ? target.length + index : index - if (resolved < 0 || resolved >= target.length) { - throw new InterpreterRuntimeError("Array.with index is out of range.", node) - } - const copied = [...target] - copied[resolved] = args[1] - return Effect.succeed(copied) - } - case "push": { - // Validate before mutating (so no rollback is needed): inserting a container into - // itself would create a cycle no later walk could survive. - for (const item of args) this.rejectCircularInsertion(target, item, "Array.push result", node) - target.push(...args) - return Effect.succeed(target.length) - } - case "unshift": { - for (const item of args) this.rejectCircularInsertion(target, item, "Array.unshift result", node) - target.unshift(...args) - return Effect.succeed(target.length) - } - case "pop": - return Effect.succeed(target.pop()) - case "shift": - return Effect.succeed(target.shift()) - case "splice": { - // Mutates in place and returns the removed elements, exactly like JS: one argument - // removes to the end, an undefined delete count removes nothing. - if (args.length === 0) return Effect.succeed(target.splice(0, 0)) - const start = optNumber(args[0], "start") ?? 0 - if (args.length === 1) return Effect.succeed(target.splice(start)) - const deleteCount = optNumber(args[1], "delete count") ?? 0 - const inserted = args.slice(2) - for (const item of inserted) this.rejectCircularInsertion(target, item, "Array.splice result", node) - return Effect.succeed(target.splice(start, deleteCount, ...inserted)) - } - case "fill": { - this.rejectCircularInsertion(target, args[0], "Array.fill result", node) - return Effect.succeed(target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end"))) - } - case "copyWithin": - return Effect.succeed( - target.copyWithin( - optNumber(args[0], "target index") ?? 0, - optNumber(args[1], "start") ?? 0, - optNumber(args[2], "end"), - ), - ) - // keys/values/entries return arrays (not iterators), matching the Map/Set convention; - // they work with for...of and spread either way. - case "keys": - return Effect.succeed(Array.from(target.keys())) - case "values": - return Effect.succeed([...target]) - case "entries": - return Effect.succeed(Array.from(target.entries(), ([index, item]): Array => [index, item])) - } - - const apply = this.applyCollectionCallback(args[0], `Array.${name}`, node) - return Effect.gen(function* () { - // Capture the initial length, but read the receiver live so callbacks observe mutations - // without visiting elements appended after iteration begins. - const length = target.length - switch (name) { - case "map": { - const values: Array = [] - values.length = length - for (let index = 0; index < length; index += 1) { - if (!(index in target)) continue - values[index] = yield* apply([target[index], index, target]) - } - return values - } - case "flatMap": { - const values: Array = [] - for (let index = 0; index < length; index += 1) { - if (!(index in target)) continue - const mapped = yield* apply([target[index], index, target]) - if (Array.isArray(mapped)) values.push(...mapped) - else values.push(mapped) - } - return values - } - case "filter": { - const values: Array = [] - for (let index = 0; index < length; index += 1) { - if (!(index in target)) continue - const item = target[index] - if (yield* apply([item, index, target])) values.push(item) - } - return values - } - case "find": - for (let index = 0; index < length; index += 1) { - const item = target[index] - if (yield* apply([item, index, target])) return item - } - return undefined - case "findIndex": - for (let index = 0; index < length; index += 1) { - if (yield* apply([target[index], index, target])) return index - } - return -1 - case "some": - for (let index = 0; index < length; index += 1) { - if (!(index in target)) continue - if (yield* apply([target[index], index, target])) return true - } - return false - case "every": - for (let index = 0; index < length; index += 1) { - if (!(index in target)) continue - if (!(yield* apply([target[index], index, target]))) return false - } - return true - case "forEach": - for (let index = 0; index < length; index += 1) { - if (index in target) yield* apply([target[index], index, target]) - } - return undefined - case "reduce": { - let accumulator: unknown - let start: number - if (args.length >= 2) { - accumulator = args[1] - start = 0 - } else { - if (length === 0) - throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node) - accumulator = target[0] - start = 1 - } - for (let index = start; index < length; index += 1) { - if (!(index in target)) continue - accumulator = yield* apply([accumulator, target[index], index, target]) - } - return accumulator - } - case "reduceRight": { - let accumulator: unknown - let start: number - if (args.length >= 2) { - accumulator = args[1] - start = length - 1 - } else { - if (length === 0) - throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node) - accumulator = target[length - 1] - start = length - 2 - } - for (let index = start; index >= 0; index -= 1) { - if (!(index in target)) continue - accumulator = yield* apply([accumulator, target[index], index, target]) - } - return accumulator - } - case "findLast": - for (let index = length - 1; index >= 0; index -= 1) { - if (yield* apply([target[index], index, target])) return target[index] - } - return undefined - case "findLastIndex": - for (let index = length - 1; index >= 0; index -= 1) { - if (yield* apply([target[index], index, target])) return index - } - return -1 - } - throw new InterpreterRuntimeError(`Array method '${name}' is not available in CodeMode.`, node) - }) - } - - private sortArray( - target: Array, - comparator: unknown, - 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)) { - return Effect.sync(() => - [...target].sort((a, b) => { - const left = coerceToString(a) - const right = coerceToString(b) - return left < right ? -1 : left > right ? 1 : 0 - }), - ) - } - const self = this - const mergeSort = (items: Array): Effect.Effect, unknown, R> => { - if (items.length <= 1) return Effect.succeed(items) - const midpoint = Math.floor(items.length / 2) - return Effect.gen(function* () { - const left = yield* mergeSort(items.slice(0, midpoint)) - const right = yield* mergeSort(items.slice(midpoint)) - const merged: Array = [] - let leftIndex = 0 - let rightIndex = 0 - while (leftIndex < left.length && rightIndex < right.length) { - // Coerce the comparator's result like JS ToNumber (data objects -> NaN, never a host - // crash) and treat NaN as 0 - the spec's "no consistent order" -> keep the left element. - const order = coerceToNumber(yield* self.invokeFunction(comparator, [left[leftIndex], right[rightIndex]])) - if (Number.isNaN(order) || order <= 0) merged.push(left[leftIndex++]) - else merged.push(right[rightIndex++]) - } - return [...merged, ...left.slice(leftIndex), ...right.slice(rightIndex)] - }) - } - // Per spec, undefined elements sort to the end and the comparator is never called on them. - const defined = target.filter((item) => item !== undefined) - const undefinedCount = target.length - defined.length - return Effect.map(mergeSort(defined), (items) => [...items, ...Array(undefinedCount).fill(undefined)]) - } - private evaluateObjectExpression(node: AstNode): Effect.Effect, unknown, R> { const objectValue: Record = Object.create(null) as Record const properties = getArray(node, "properties") @@ -3122,9 +1556,6 @@ class Interpreter { if (property.type === "SpreadElement") { const spread = yield* self.evaluateExpression(getNode(property, "argument")) - // JS treats `{ ...null }` / `{ ...undefined }` as a no-op, so the common - // `{ ...maybeOpts, override }` merge works when the operand is absent. Sandbox values - // have no own enumerable properties in JS, so they are no-ops too. if (spread === null || spread === undefined || isSandboxValue(spread)) continue if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) { throw new InterpreterRuntimeError( @@ -3224,8 +1655,6 @@ class Interpreter { if (index < expressions.length) { const raw = yield* self.evaluateExpression(asNode(expressions[index], "expressions")) - // The preserving checkpoint keeps sandbox values intact, so coerceToString renders - // them directly (ISO date, /regex/ literal form) instead of a JSON-serialized husk. output += coerceToString(boundedData(raw, "Template interpolation")) } } @@ -3241,10 +1670,6 @@ class Interpreter { } private applyCompoundAssignment(operator: string, current: unknown, incoming: unknown, node: AstNode): unknown { - // `x op= y` is `x = x op y`: dispatch through the shared binary operator implementation - // so compound assignment inherits the same coercion semantics (Dates, data objects, ...). - // Only the arithmetic/bitwise operators are compoundable; logical assignments (&&=/||=/??=) - // short-circuit and are handled by evaluateLogicalAssignment before reaching here. if (!compoundOperators.has(operator)) { throw new InterpreterRuntimeError(`Unsupported assignment operator '${operator}'.`, node) } @@ -3317,20 +1742,14 @@ class Interpreter { if (typeof key === "number") return new ComputedValue(objectValue[key]) if (typeof key === "string" && /^\d+$/.test(key)) return new ComputedValue(objectValue[Number(key)]) if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key) - // Unknown property on a string reads as `undefined`, matching JS (`"x".foo === undefined`), - // instead of throwing - so defensive access like `result?.login ?? result` on a JSON-string - // tool result doesn't crash. (Optional chaining only guards null/undefined receivers, so a - // real string still reaches here.) Only the method allowlist above yields callables. return new ComputedValue(undefined) } if (typeof objectValue === "number") { if (typeof key === "string" && numberMethods.has(key)) return new IntrinsicReference(objectValue, key) - // Unknown property on a number reads as `undefined`, matching JS, rather than throwing. return new ComputedValue(undefined) } - // Number / String expose a small allowlist of statics; everything else stays opaque. if (objectValue instanceof CoercionFunction && typeof key === "string" && !isBlockedMember(key)) { if (objectValue.name === "Number" && numberConstants.has(key)) { return new ComputedValue((Number as unknown as Record)[key]) @@ -3339,8 +1758,6 @@ class Interpreter { if (objectValue.name === "String" && stringStatics.has(key)) return new GlobalMethodReference("String", key) } - // Sandbox value types expose their method/property allowlists; any other key reads as - // `undefined`, consistent with unknown-property reads on strings/numbers/arrays. if (objectValue instanceof SandboxDate) { if (typeof key === "string" && dateMethods.has(key)) return new IntrinsicReference(objectValue, key) return new ComputedValue(undefined) @@ -3378,7 +1795,7 @@ class Interpreter { return new ComputedValue(undefined) } - // Error instead of `undefined` so a missing await never hides. + // Reject unknown promise properties so a missing await cannot hide. if (objectValue instanceof SandboxPromise) { if (key === "then" || key === "catch" || key === "finally") { return new PromiseInstanceMethodReference(objectValue, key) @@ -3413,13 +1830,9 @@ class Interpreter { typeof key !== "number" && !/^\d+$/.test(key) ) { - // Own non-index properties read through (match results carry index/groups); like JS, - // they are readable in place and dropped by JSON at data boundaries. if (typeof key === "string" && Object.hasOwn(objectValue, key)) { return new ComputedValue((objectValue as Record & Array)[key]) } - // Unknown property on an array reads as `undefined`, matching JS (`[1,2].foo === undefined`), - // instead of throwing - so defensive access under optional chaining behaves as expected. return new ComputedValue(undefined) } return { target: objectValue, key } @@ -3459,9 +1872,7 @@ class Interpreter { return this.modifyMember(node, () => Effect.succeed({ write: true, next: value, result: value })) } - // Resolves the member reference EXACTLY ONCE (so a side-effecting object/key expression - // runs once), then lets `compute` decide whether to write - enabling compound assignment, - // updates, plain writes, and short-circuiting logical assignment to share one safe path. + // Resolve side-effecting object and key expressions exactly once. private modifyMember( node: AstNode, compute: (current: unknown) => Effect.Effect<{ write: boolean; next: unknown; result: unknown }, unknown, R>, @@ -3499,24 +1910,6 @@ class Interpreter { }) } - // Rejects inserting a value that (transitively) contains the container it is being inserted - // into - the mutation that would create a circular structure no later walk could survive. - private rejectCircularInsertion( - container: object, - value: unknown, - label: string, - node: AstNode, - seen = new Set(), - ): void { - if (value === container) - throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue") - if (value === null || typeof value !== "object" || isRuntimeReference(value) || seen.has(value)) return - seen.add(value) - const items = Array.isArray(value) ? value : Object.values(value) - for (const item of items) this.rejectCircularInsertion(container, item, label, node, seen) - seen.delete(value) - } - private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void { if (Array.isArray(reference.target)) { const target = reference.target @@ -3528,7 +1921,7 @@ class Interpreter { "InvalidDataValue", ) } - this.rejectCircularInsertion(target, next, "Array assignment result", node) + rejectCircularInsertion(target, next, "Array assignment result", node) target[index] = next return } @@ -3548,7 +1941,7 @@ class Interpreter { } const target = reference.target as SafeObject const objectKey = key as string - this.rejectCircularInsertion(target, next, "Object assignment result", node) + rejectCircularInsertion(target, next, "Object assignment result", node) target[objectKey] = next } @@ -3559,282 +1952,4 @@ class Interpreter { throw new InterpreterRuntimeError("Property key must be a string or number.", node) } - - private declare(name: string, value: unknown, mutable: boolean, node: AstNode): void { - const scope = this.currentScope() - - // A pre-seeded parameter slot (initialized === false) is being bound for the first time; - // anything else already present is a genuine duplicate declaration. - const existing = scope.get(name) - if (existing && existing.initialized !== false) { - throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node) - } - - scope.set(name, { mutable, value, initialized: true }) - } - - private getIdentifierValue(name: string, node: AstNode): unknown { - const binding = this.resolveBinding(name) - - if (!binding) { - throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError") - } - - // A parameter default that forward-references a later (not-yet-bound) parameter - JS TDZ. - if (binding.initialized === false) { - throw new InterpreterRuntimeError(`Cannot access '${name}' before initialization.`, node).as("ReferenceError") - } - - return binding.value - } - - private setIdentifierValue(name: string, value: unknown, node: AstNode): unknown { - const binding = this.resolveBinding(name) - - if (!binding) { - throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError") - } - - if (!binding.mutable) { - throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node).as("TypeError") - } - - binding.value = value - return value - } - - private resolveBinding(name: string): Binding | undefined { - for (let index = this.scopes.length - 1; index >= 0; index -= 1) { - const scope = this.scopes[index] - const binding = scope?.get(name) - - if (binding) { - return binding - } - } - - return undefined - } - - private currentScope(): Map { - const scope = this.scopes[this.scopes.length - 1] - - if (!scope) { - throw new InterpreterRuntimeError("Interpreter scope stack is empty.") - } - - return scope - } - - private pushScope(): void { - this.scopes.push(new Map()) - } - - private popScope(): void { - this.scopes.pop() - } -} - -/** - * Executes one Effect-native CodeMode program without constructing a reusable runtime. - * - * @example - * ```ts - * const result = yield* CodeMode.execute({ - * tools: { lookup }, - * code: `return await tools.lookup({ id: "order_42" })`, - * }) - * ``` - */ -export const executeWithLimits = >( - options: ExecuteOptions, - limits: ResolvedExecutionLimits, - searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"], -): Effect.Effect> => { - if (options.code.trim().length === 0) { - return Effect.succeed({ - ok: false, - error: { kind: "ParseError", message: "Code cannot be empty." }, - toolCalls: [], - }) - } - - // Suspended so all per-execution state - tool-call admission budget and audit list, logs, - // and the timeout path's completed value - binds at run time: a reused Effect must start - // from a clean slate instead of observing a previous run's state. - return Effect.suspend(() => { - const tools = ToolRuntime.make( - (options.tools ?? {}) as HostTools>, - limits.maxToolCalls, - searchIndex, - { - onToolCallStart: options.onToolCallStart, - onToolCallEnd: options.onToolCallEnd, - }, - ) - const logs: Array = [] - const logged = () => (logs.length > 0 ? { logs: [...logs] } : {}) - // Set once the program body returned and its value crossed the data boundary, so a timeout - // firing during leftover interruption reports "completed with interrupted background work" - // instead of discarding the computed value as a plain timeout. - let returned: { value: DataValue; promises: PromiseRuntime> } | undefined - - const base = Effect.acquireUseRelease( - Scope.make("parallel"), - (scope) => - Effect.gen(function* () { - const program = parseProgram(options.code) - const promises = new PromiseRuntime>(scope) - const interpreter = new Interpreter>(tools.invoke, tools.search, tools.keys, promises, logs) - const value = yield* interpreter.run(program) - // Validate the result first so an invalid value is a fatal completion that closes - // the promise scope directly instead of taking the normal-completion path. - const result = copyOut(copyIn(value, "Execution result"), true) as DataValue - returned = { value: result, promises } - const warnings = yield* promises.interrupt() - return { - ok: true, - value: result, - ...(warnings.length > 0 ? { warnings } : {}), - ...logged(), - toolCalls: tools.calls, - } satisfies Result - }), - (scope, exit) => Scope.close(scope, exit), - ) - const timeoutMs = limits.timeoutMs - const operation = - timeoutMs === undefined - ? base - : base.pipe( - Effect.timeoutOrElse({ - duration: timeoutMs, - orElse: () => - Effect.sync(() => { - if (returned === undefined) { - return { - ok: false, - error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` }, - ...logged(), - toolCalls: tools.calls, - } satisfies Result - } - // The timeout warning leads so byte-budget truncation cuts it last. - return { - ok: true, - value: returned.value, - warnings: [ - { - kind: "TimeoutExceeded", - message: `The program returned, but background work was still running at the ${timeoutMs}ms timeout and was interrupted. Await all started promises.`, - }, - ...returned.promises.diagnostics(), - ], - ...logged(), - toolCalls: tools.calls, - } satisfies Result - }), - }), - ) - - return operation.pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.interrupt - : Effect.succeed({ - ok: false, - error: normalizeError(Cause.squash(cause)), - ...logged(), - toolCalls: tools.calls, - } satisfies Result), - ), - Effect.map((result) => - limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes), - ), - ) - }) -} - -const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength - -// Truncates to a UTF-8 byte budget without splitting a code point (a split multi-byte -// sequence decodes to a replacement character, which is dropped). -const utf8Truncate = (value: string, maxBytes: number): string => { - const bytes = new TextEncoder().encode(value) - if (bytes.byteLength <= maxBytes) return value - const text = new TextDecoder("utf-8").decode(bytes.slice(0, Math.max(0, maxBytes))) - return text.endsWith("\uFFFD") ? text.slice(0, -1) : text -} - -/** - * Bounds retained program payload bytes (serialized result value and logs) to `maxOutputBytes`. - * Warning diagnostics are bounded by a separate budget of the same size so a large value can - * never starve runtime-authored diagnostics. Fixed truncation notices are added outside those - * budgets, as is any framing added when a host renders the structured result. Truncation never - * fails the execution; `truncated: true` marks affected results. Only runs when the host set - * `maxOutputBytes` - with the limit absent, output passes through unbounded. - */ -const boundOutput = (result: Result, maxOutputBytes: number): Result => { - let truncated = false - - let value: DataValue = null - let valueBytes = 0 - if (result.ok) { - const serialized = JSON.stringify(result.value) ?? "null" - const bytes = utf8ByteLength(serialized) - if (bytes > maxOutputBytes) { - truncated = true - value = `${utf8Truncate(serialized, maxOutputBytes)} [result truncated: ${bytes} bytes exceeds the ${maxOutputBytes}-byte output limit; return a smaller value]` - valueBytes = maxOutputBytes - } else { - value = result.value - valueBytes = bytes - } - } - - const warnings = result.ok ? (result.warnings ?? []) : [] - const keptWarnings: Array = [] - let warningBytes = 0 - for (const warning of warnings) { - const bytes = utf8ByteLength(JSON.stringify(warning)) + 1 - if (warningBytes + bytes > maxOutputBytes) break - warningBytes += bytes - keptWarnings.push(warning) - } - if (keptWarnings.length < warnings.length) { - truncated = true - keptWarnings.push({ - kind: "Truncated", - message: `${warnings.length - keptWarnings.length} additional warnings omitted by the output limit.`, - }) - } - - const logs = result.logs ?? [] - const kept: Array = [] - const logBudget = Math.max(0, maxOutputBytes - valueBytes) - let logBytes = 0 - for (const line of logs) { - const lineBytes = utf8ByteLength(line) + 1 - if (logBytes + lineBytes > logBudget) break - logBytes += lineBytes - kept.push(line) - } - if (kept.length < logs.length) { - truncated = true - kept.push(`[logs truncated: showing ${kept.length} of ${logs.length} lines]`) - } - - if (!truncated) return result - const warningsPart = keptWarnings.length > 0 ? { warnings: keptWarnings } : {} - const logsPart = kept.length > 0 ? { logs: kept } : {} - return result.ok - ? { - ok: true, - value, - ...warningsPart, - ...logsPart, - truncated: true, - toolCalls: result.toolCalls, - } - : { ok: false, error: result.error, ...logsPart, truncated: true, toolCalls: result.toolCalls } } diff --git a/packages/codemode/src/interpreter/scope.ts b/packages/codemode/src/interpreter/scope.ts new file mode 100644 index 000000000000..f5ee137cbc12 --- /dev/null +++ b/packages/codemode/src/interpreter/scope.ts @@ -0,0 +1,84 @@ +import { type AstNode, type Binding, InterpreterRuntimeError } from "./model.js" + +export class ScopeStack { + private readonly scopes: Array> + + constructor(scopes: Array>) { + this.scopes = scopes + } + + declare(name: string, value: unknown, mutable: boolean, node: AstNode): void { + const scope = this.current() + + const existing = scope.get(name) + if (existing && existing.initialized !== false) { + throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node) + } + + scope.set(name, { mutable, value, initialized: true }) + } + + get(name: string, node: AstNode): unknown { + const binding = this.resolve(name) + + if (!binding) { + throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError") + } + + if (binding.initialized === false) { + throw new InterpreterRuntimeError(`Cannot access '${name}' before initialization.`, node).as("ReferenceError") + } + + return binding.value + } + + set(name: string, value: unknown, node: AstNode): unknown { + const binding = this.resolve(name) + + if (!binding) { + throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError") + } + + if (!binding.mutable) { + throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node).as("TypeError") + } + + binding.value = value + return value + } + + resolve(name: string): Binding | undefined { + for (let index = this.scopes.length - 1; index >= 0; index -= 1) { + const scope = this.scopes[index] + const binding = scope?.get(name) + + if (binding) { + return binding + } + } + + return undefined + } + + current(): Map { + const scope = this.scopes[this.scopes.length - 1] + + if (!scope) { + throw new InterpreterRuntimeError("Interpreter scope stack is empty.") + } + + return scope + } + + push(scope: Map = new Map()): void { + this.scopes.push(scope) + } + + pop(): void { + this.scopes.pop() + } + + capture(): Array> { + return this.scopes.slice() + } +} diff --git a/packages/codemode/src/openapi/index.ts b/packages/codemode/src/openapi/index.ts index 7f1770ef3627..e1f2c64166f9 100644 --- a/packages/codemode/src/openapi/index.ts +++ b/packages/codemode/src/openapi/index.ts @@ -31,10 +31,8 @@ export type { } from "./types.js" /** - * Builds a CodeMode tool subtree from an OpenAPI 3.x document, one tool per - * operation. Auth is resolved host-side via `auth.resolve` and never - * model-visible. Tools require `HttpClient.HttpClient`; unrepresentable - * operations land in `skipped`. + * Builds one CodeMode tool per representable OpenAPI 3.x operation. Auth remains host-side, + * tools require `HttpClient.HttpClient`, and unrepresentable operations land in `skipped`. */ export const fromSpec = (options: Options): Result => { const document = options.spec diff --git a/packages/codemode/src/openapi/runtime.ts b/packages/codemode/src/openapi/runtime.ts index 2515621792b2..2b2dcd1e51fe 100644 --- a/packages/codemode/src/openapi/runtime.ts +++ b/packages/codemode/src/openapi/runtime.ts @@ -56,7 +56,7 @@ const buildRequest = ( input: Readonly>, ): Effect.Effect => Effect.gen(function* () { - // Validate every model-controlled value before auth resolution, which may refresh tokens. + // Validate model input before auth resolution can refresh credentials. const url = buildUrl(plan, input) if (url instanceof ToolError) return yield* Effect.fail(url) const missing = plan.fields.find( @@ -77,7 +77,6 @@ const buildRequest = ( request = serialized } - // Host headers first, then declared header parameters. request = HttpClientRequest.setHeaders(request, plan.headers) for (const field of plan.fields) { if (field.location !== "header") continue @@ -169,7 +168,7 @@ const applyCredentials = ( continue } if (credential.type === "basic") { - // Buffer instead of btoa: btoa throws on non-Latin-1 credentials. + // Basic auth credentials are UTF-8; btoa rejects non-Latin-1 input. const duplicate = add( "header", "authorization", @@ -183,7 +182,6 @@ const applyCredentials = ( if (duplicate !== undefined) return duplicate continue } - // apiKey: the carrier comes from the scheme declaration. if (definition.type !== "apiKey") { return toolError( `Security scheme '${name}' is not an apiKey scheme; resolve a bearer, basic, or header credential for it.`, @@ -212,8 +210,7 @@ const buildUrl = (plan: Plan, input: Readonly>): string ), ) if (fieldValue instanceof ToolError) return fieldValue - // '.'/'..' survive encoding and URL normalization collapses them, letting a - // model-supplied value retarget the request to a different endpoint. + // URL normalization collapses encoded `.` and `..`, which could retarget the request. if (fieldValue === "" || fieldValue === "." || fieldValue === "..") { return toolError(`Invalid path parameter '${field.inputName}'.`) } diff --git a/packages/codemode/src/openapi/spec.ts b/packages/codemode/src/openapi/spec.ts index bd4dc5aed63e..b74b3e69f209 100644 --- a/packages/codemode/src/openapi/spec.ts +++ b/packages/codemode/src/openapi/spec.ts @@ -23,8 +23,7 @@ const asArray = (value: unknown): ReadonlyArray => (Array.isArray(value export const nonEmptyString = (value: unknown): string | undefined => typeof value === "string" && value !== "" ? value : undefined -// Guards record lookups keyed by spec- or model-controlled names against -// prototype-inherited values (e.g. a parameter named `toString`). +// Spec- and model-controlled keys must not resolve inherited properties. export const own = (record: Readonly>, key: string): T | undefined => Object.hasOwn(record, key) ? record[key] : undefined @@ -106,7 +105,7 @@ const operationParameters = ( pathItem: Record, operation: Record, ): Parsed> => { - // Operation-level parameters override path-level ones sharing (location, name). + // OpenAPI operation parameters override path parameters with the same location and name. const declared = new Map< string, { readonly name: string; readonly location: string; readonly parameter: Record } diff --git a/packages/codemode/src/openapi/types.ts b/packages/codemode/src/openapi/types.ts index cab772e70148..6f3eb8628385 100644 --- a/packages/codemode/src/openapi/types.ts +++ b/packages/codemode/src/openapi/types.ts @@ -22,9 +22,8 @@ export type SecurityScheme = | { readonly type: "openIdConnect" } /** - * Credential material returned by a host auth resolver. The carrier for `apiKey` - * comes from the scheme definition, not the credential. `header` is the escape - * hatch for nonstandard schemes. + * Credential material returned by a host auth resolver. `apiKey` uses the scheme's carrier; + * `header` supports nonstandard schemes. */ export type Credential = | { readonly type: "bearer"; readonly token: string } @@ -33,9 +32,7 @@ export type Credential = | { readonly type: "header"; readonly name: string; readonly value: string } /** - * Resolves credential material for one named security scheme at call time. - * `undefined` means unavailable, try the next OR alternative; a failure aborts - * the call rather than falling through. + * Resolves credentials at call time. `undefined` tries the next OR alternative; failure aborts. */ export type AuthResolver = (context: { readonly name: string @@ -74,9 +71,7 @@ export type Parsed = { readonly ok: true; readonly value: T } | { readonly ok export type InputLocation = "path" | "query" | "header" | "body" export type InputField = { - /** Model-visible field name after cross-location collision handling. */ readonly inputName: string - /** Original parameter or body-property name used on the wire. */ readonly name: string readonly location: InputLocation readonly required: boolean @@ -92,7 +87,6 @@ export type OperationInput = { readonly body: Body | undefined } -/** One OR alternative: scheme name -> required scopes. Empty object = unauthenticated is acceptable. */ export type SecurityRequirement = Readonly>> export type Plan = { diff --git a/packages/codemode/src/stdlib/console.ts b/packages/codemode/src/stdlib/console.ts index 798563128e56..4663f62f1ae2 100644 --- a/packages/codemode/src/stdlib/console.ts +++ b/packages/codemode/src/stdlib/console.ts @@ -1,4 +1,122 @@ +import { containsOpaqueReference, containsRuntimeReference, isRuntimeReference } from "../interpreter/references.js" +import { copyIn, copyOut } from "../tool-runtime.js" +import { + isSandboxValue, + SandboxDate, + SandboxMap, + SandboxPromise, + SandboxRegExp, + SandboxSet, + SandboxURL, + SandboxURLSearchParams, +} from "../values.js" +import { boundedData, coerceToString } from "./value.js" + export const consoleMethods = new Set(["log", "info", "debug", "warn", "error", "dir", "table"]) -/** Console formatting recursion ceiling; deeper values render as "...". */ -export const MAX_CONSOLE_DEPTH = 32 +const MAX_CONSOLE_DEPTH = 32 + +export const formatConsoleMessage = (name: string, args: Array): string => { + if (name === "dir") return args.length === 0 ? "undefined" : formatConsoleArgument(args[0]) + if (name === "table") return formatConsoleTable(args[0], args[1]) + const prefix = name === "warn" ? "[warn] " : name === "error" ? "[error] " : name === "debug" ? "[debug] " : "" + return `${prefix}${args.map((arg) => formatConsoleArgument(arg)).join(" ")}` +} + +const formatConsoleArgument = (value: unknown): string => { + if (value === undefined) return "undefined" + if (typeof value === "string") return value + return formatConsoleValue(value, new Set(), 0) +} + +const formatConsoleValue = (value: unknown, seen: Set, depth: number): string => { + if (value === null || value === undefined) return "null" + if (typeof value === "string") return JSON.stringify(value) + if (typeof value === "number" || typeof value === "boolean") return String(value) + if (typeof value !== "object") return String(value) + if (value instanceof SandboxPromise) return "[Promise (await it to get its value)]" + if (value instanceof SandboxDate) return coerceToString(value) + if (value instanceof SandboxRegExp) return coerceToString(value) + if (value instanceof SandboxURL) return coerceToString(value) + if (value instanceof SandboxURLSearchParams) return coerceToString(value) + if (depth > MAX_CONSOLE_DEPTH) return "..." + if (seen.has(value)) return "[Circular]" + if (value instanceof SandboxMap) { + seen.add(value) + try { + const entries = Array.from(value.map.entries(), ([key, item]): Array => [key, item]) + return `Map(${value.map.size}) ${formatConsoleValue(entries, seen, depth + 1)}` + } finally { + seen.delete(value) + } + } + if (value instanceof SandboxSet) { + seen.add(value) + try { + return `Set(${value.set.size}) ${formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}` + } finally { + seen.delete(value) + } + } + if (isRuntimeReference(value)) return "[CodeMode reference]" + seen.add(value) + try { + if (Array.isArray(value)) { + return `[${value.map((item) => formatConsoleValue(item, seen, depth + 1)).join(",")}]` + } + return `{${Object.entries(value) + .map(([key, item]) => `${JSON.stringify(key)}:${formatConsoleValue(item, seen, depth + 1)}`) + .join(",")}}` + } finally { + seen.delete(value) + } +} + +const formatConsoleTable = (value: unknown, columnsArgument: unknown): string => { + if (value === undefined) return "undefined" + if (containsOpaqueReference(value)) return "[CodeMode reference]" + const data = boundedData(value, "console.table argument") + const columns = consoleTableColumns(columnsArgument) + const rows = consoleTableRows(data, columns) + const keys = columns ?? Array.from(new Set(rows.flatMap((row) => Object.keys(row.values)))) + const header = ["(index)", ...keys].join("\t") + return [ + header, + ...rows.map((row) => [row.index, ...keys.map((key) => formatConsoleTableCell(row.values[key]))].join("\t")), + ].join("\n") +} + +const consoleTableColumns = (value: unknown): ReadonlyArray | undefined => { + if (value === undefined) return undefined + if (containsRuntimeReference(value)) return undefined + const columns = copyOut(copyIn(value, "console.table columns"), true) + return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined +} + +const consoleTableRows = ( + data: unknown, + columns: ReadonlyArray | undefined, +): Array<{ readonly index: string; readonly values: Record }> => { + if (Array.isArray(data)) { + return data.map((item, index) => ({ index: String(index), values: consoleTableValues(item, columns) })) + } + if (data !== null && typeof data === "object" && !isSandboxValue(data)) { + return Object.entries(data).map(([index, item]) => ({ index, values: consoleTableValues(item, columns) })) + } + return [{ index: "0", values: { Value: data } }] +} + +const consoleTableValues = (value: unknown, columns: ReadonlyArray | undefined): Record => { + if (value !== null && typeof value === "object" && !Array.isArray(value) && !isSandboxValue(value)) { + const source = value as Record + if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]])) + return Object.fromEntries(Object.entries(source)) + } + return { Value: value } +} + +const formatConsoleTableCell = (value: unknown): string => { + if (value === undefined) return "" + if (typeof value === "string") return value + return formatConsoleValue(value, new Set(), 0) +} diff --git a/packages/codemode/src/stdlib/promise.ts b/packages/codemode/src/stdlib/promise.ts index 5a7cc5974b75..2bad2caf0cee 100644 --- a/packages/codemode/src/stdlib/promise.ts +++ b/packages/codemode/src/stdlib/promise.ts @@ -2,5 +2,4 @@ import type { PromiseMethodName } from "../interpreter/model.js" export const promiseStatics = new Set(["all", "allSettled", "race", "any", "resolve", "reject"]) -/** Maximum number of eagerly forked tool calls that may run concurrently. */ export const TOOL_CALL_CONCURRENCY = 8 diff --git a/packages/codemode/src/stdlib/string.ts b/packages/codemode/src/stdlib/string.ts index 3ac4372e363f..ffc33797dd9e 100644 --- a/packages/codemode/src/stdlib/string.ts +++ b/packages/codemode/src/stdlib/string.ts @@ -4,12 +4,9 @@ export const stringMethods = new Set([ "trim", "trimStart", "trimEnd", - "trimLeft", - "trimRight", "split", "slice", "substring", - "substr", "includes", "startsWith", "endsWith", diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index aab7f88a70ab..0bb8c00aec67 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -44,36 +44,30 @@ type ServicesOf> = Depth["length"] e : ServicesOf : never -/** Minimal audit record retained for each admitted tool call. */ export type ToolCall = { readonly name: string } -/** Decoded tool call observed immediately before tool execution. */ export type ToolCallStarted = { readonly index: number readonly name: string readonly input: unknown } -/** Completed tool call observed immediately after tool execution settles. */ export type ToolCallEnded = { readonly index: number readonly name: string readonly input: unknown readonly durationMs: number readonly outcome: "success" | "failure" - /** Model-safe failure message; present only when `outcome` is `"failure"`. */ readonly message?: string } -/** Non-throwing observation hooks fired around each admitted tool call. */ export type ToolCallHooks = { readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect) | undefined readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect) | undefined } -/** Model-visible description of one schema-backed tool. */ export type ToolDescription = { readonly path: string readonly description: string @@ -113,11 +107,6 @@ export class ToolReference { constructor(readonly path: ReadonlyArray) {} } -/** - * Maximum nesting depth for values crossing a data boundary. Fixed (not a configurable - * limit) purely because it produces a clearer diagnostic than a native stack-overflow - * RangeError would. - */ const MAX_VALUE_DEPTH = 32 export class ToolRuntimeError extends Error { @@ -152,21 +141,7 @@ const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"]) export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name) -/** - * Validates and copies a value against the plain-data contract (depth, circularity, plain - * objects only, blocked properties, data-only leaves). - * - * Two modes share the walk: - * - **Boundary** (`preserveSandboxValues` false, the default): the host<->sandbox boundary - - * final results, tool-call arguments, `JSON.stringify`. Sandbox value types serialize - * exactly as JSON.stringify would: Date/URL -> strings, the remaining value types -> {}. - * - **Intra-sandbox checkpoint** (`preserveSandboxValues` true; see `boundedData` in - * codemode.ts): standard-library value instances pass through untouched (treated as leaves, - * contents not walked), so values flowing through `Object.*` helpers, coercion inputs, and - * other in-sandbox checkpoints stay fully usable (`.getTime()`, `.has()`, ...). - * - * Both modes reject un-awaited promises with an await-hinting diagnostic. - */ +// Checkpoint mode preserves sandbox values; boundary mode JSON-normalizes them. export const copyIn = (value: unknown, label: string, preserveSandboxValues = false): unknown => copyBounded(value, label, 0, new Set(), preserveSandboxValues) @@ -185,10 +160,6 @@ const copyBounded = ( value === undefined || typeof value === "string" || typeof value === "boolean" || - // NaN/Infinity are allowed to exist as in-sandbox intermediates (matching real JS and a real - // engine) so defensive guards like `Number.isNaN(x)` / `parseInt(x) || 0` can run. They are - // normalized to `null` when the value leaves the sandbox - see copyOut - exactly as - // JSON.stringify already does at any tool boundary. typeof value === "number" ) { return value @@ -198,8 +169,6 @@ const copyBounded = ( throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`) } - // An un-awaited promise never crosses a data checkpoint as `{}`; the diagnostic tells the - // model exactly how to fix the program instead. if (value instanceof SandboxPromise) { throw new ToolRuntimeError( "InvalidDataValue", @@ -208,9 +177,6 @@ const copyBounded = ( } if (preserveSandboxValues) { - // Intra-sandbox checkpoints keep sandbox value instances alive as leaves; their contents - // are never walked here (Map/Set members are validated where mutation happens, and the - // real boundary still serializes them below). if ( value instanceof SandboxDate || value instanceof SandboxRegExp || @@ -221,8 +187,6 @@ const copyBounded = ( ) { return value } - // Host instances cannot normally reach an intra-sandbox checkpoint (tool results cross - // the boundary first), but wrap them defensively rather than degrading to JSON forms. if (value instanceof Date) return new SandboxDate(value.getTime()) if (value instanceof RegExp) return new SandboxRegExp(value.source, value.flags) if (value instanceof Map) { @@ -241,9 +205,6 @@ const copyBounded = ( if (value instanceof URLSearchParams) return new SandboxURLSearchParams(new URLSearchParams(value)) } - // Sandbox value types (and their host counterparts, which a host tool may legitimately - // return) serialize exactly as JSON.stringify would at the data boundary: Date/URL use - // toJSON(), while RegExp/Map/Set/URLSearchParams have no JSON form beyond {}. if (value instanceof SandboxDate) { return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null } @@ -274,7 +235,7 @@ const copyBounded = ( if (Array.isArray(value)) { const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveSandboxValues)) if (preserveSandboxValues) { - // Array metadata is not serialized, but intra-sandbox copies must retain it. + // Checkpoint copies retain array metadata that boundary copies omit. for (const [key, item] of Object.entries(value)) { if (Object.hasOwn(copied, key)) continue if (isBlockedMember(key)) { @@ -305,9 +266,6 @@ const copyBounded = ( export const copyOut = (value: unknown, undefinedAsNull = false): unknown => { if (value === undefined && undefinedAsNull) return null - // Normalize non-finite numbers to null as the value crosses out of the sandbox (final return - // and tool-call arguments both funnel through here), matching JSON semantics - NaN/Infinity - // have no JSON representation, so JSON.stringify would produce null anyway. if (typeof value === "number" && !Number.isFinite(value)) { return null } @@ -353,18 +311,10 @@ export type DiscoveryPlan = { export type SearchEntry = { readonly description: ToolDescription - /** Top-level namespace (first path segment), matched by the search `namespace` option. */ readonly namespace: string - /** Lowercased path + description + input property names/descriptions, for substring matching. */ readonly searchText: string } -/** - * Split a query into lowercased search terms. camelCase boundaries are split - * (`resolveLibrary` -> `resolve library`) and every non-alphanumeric character is a - * separator, so `resolve-library-id`, `resolveLibraryId`, and `resolve library id` all - * tokenize alike. Empties and the `*` wildcard are dropped. - */ const tokenize = (query: string): Array => query .replace(/([a-z0-9])([A-Z])/g, "$1 $2") @@ -372,13 +322,6 @@ const tokenize = (query: string): Array => .split(/[^a-z0-9]+/) .filter((term) => term.length > 0 && term !== "*") -/** - * A term plus its naive singular variants (trailing "s"/"es" stripped), so a plural - * query term ("issues") still matches indexed text that only carries the singular - * ("issue"). Matching is one-directional substring containment, so the variants are - * needed only on the query side; scoring weights are unchanged - each field check - * passes when ANY form matches. - */ const termForms = (term: string): Array => { const forms = [term] if (term.endsWith("es") && term.length > 3) forms.push(term.slice(0, -2)) @@ -400,8 +343,6 @@ const makeSearchTool = (searchIndex: ReadonlyArray): Definition => request.namespace === undefined ? searchIndex : searchIndex.filter((entry) => entry.namespace === request.namespace) - // A query that names one tool path exactly (canonical path or rendered JavaScript - // expression) is a lookup, not a search: return that tool alone. const trimmed = query.trim() const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed const exact = @@ -411,9 +352,6 @@ const makeSearchTool = (searchIndex: ReadonlyArray): Definition => (entry) => entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed, ) const terms = tokenize(query).map(termForms) - // Additive field-weighted scoring, summed across terms: exact path or path segment - // (20) > path substring (8) > description substring (4) > any searchable text, - // including input parameter names and descriptions (2). const ranked = exact !== undefined ? [exact] @@ -451,15 +389,12 @@ const makeSearchTool = (searchIndex: ReadonlyArray): Definition => }), }) -// The built-in `search` is a synchronous global function, not a tool-tree entry, so its -// advertised signature is rendered by hand instead of through `describeDefinition`. const searchSignature = (() => { const definition = makeSearchTool([]) return `search(input: ${inputTypeScript(definition, true)}): ${outputTypeScript(definition, true)}` })() const catalogLine = (tool: ToolDescription) => { - // Keep the tool description concise; the full schema documentation remains in the signature. const line = tool.description.split("\n", 1)[0]!.trim() const description = line.length > 120 ? line.slice(0, 119) + "..." : line return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}` @@ -479,21 +414,10 @@ const toSearchEntry = (path: string, definition: Definition, description: .toLowerCase(), }) -/** The runtime search index over every described tool. Search is always registered. */ export const searchIndex = (tools: HostTools): ReadonlyArray => visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description)) -/** - * Budgeted catalog: every namespace is always listed with its tool count; full call - * signatures are inlined against the `catalogBudget` (estimated tokens, - * chars/4) round-robin across namespaces - in each round (namespaces alphabetical), every - * namespace still holding un-inlined tools attempts to place its next-cheapest line, and - * a namespace whose next line does not fit is done while the others keep going - so every - * namespace gets some representation before any namespace gets everything. The section - * states exactly how comprehensive it is - overall (COMPLETE vs PARTIAL) and per - * namespace. Namespace stub lines are never budgeted: every namespace appears with its - * tool count even at budget 0. - */ +// Budget signatures round-robin so every namespace remains visible. export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBudget): DiscoveryPlan => { if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) { throw new RangeError("discovery.catalogBudget must be a non-negative safe integer") @@ -510,12 +434,6 @@ export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBu } const ordered = [...namespaces].sort(([left], [right]) => left.localeCompare(right)) - // Select which signatures fit the budget before emitting, so the list can state - // exactly how comprehensive it is. Round-robin fairness: in each round (namespaces - // alphabetical), every namespace still holding un-inlined tools tries to place its - // next-cheapest line against the shared budget; a namespace whose next line does not - // fit is done - the others keep going - so every namespace gets some representation - // before any namespace gets everything. const selections = ordered.map(([namespace, group]) => ({ namespace, picked: new Set(), @@ -547,10 +465,6 @@ export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBu const empty = described.length === 0 - // Section order is deliberate: workflow first (the top is the least likely part of a long - // description to be truncated or skimmed away), then rules, then syntax, with the budgeted - // catalog at the bottom. Example call forms use placeholders - never a real or fabricated - // tool name - and show both dot and bracket notation so non-identifier names are not normalized. const intro = [ empty ? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime." @@ -562,8 +476,6 @@ export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBu : ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]), ] - // The search step exists only when search is advertised (PARTIAL catalog); a COMPLETE - // catalog already shows every signature, so step 1 picks from the list instead. const workflow = empty ? [] : [ @@ -627,8 +539,6 @@ export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBu for (const [namespace, group] of ordered) { const picked = shown.get(namespace)! const count = `${group.length} tool${group.length === 1 ? "" : "s"}` - // Annotate only when a namespace is not fully shown, so a comprehensive - // namespace reads cleanly and a truncated one is unambiguous. const label = picked.size === group.length ? count @@ -651,13 +561,6 @@ export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBu } } -/** - * The enumerable names at one node of the callable tool tree - namespace names at the root, - * tool/namespace names below - powering `Object.keys(tools)` and `for...in` over tool - * references. A callable tool is a leaf and enumerates as `[]` (like `Object.keys` of a - * function in JS). An unknown path is an `UnknownTool` error pointing at the working - * discovery idioms, mirroring how calling an unknown tool fails. - */ const namespaceKeys = (tools: HostTools, path: ReadonlyArray): ReadonlyArray => { let value: HostTool | Definition | HostTools = tools for (const segment of path) { @@ -705,18 +608,12 @@ export type ToolRuntime = { readonly root: ToolReference readonly calls: Array readonly invoke: (path: ReadonlyArray, args: Array) => Effect.Effect - /** - * The built-in `search` global: a synchronous discovery call that shares the tool - * admission pipeline (budget, audit, hooks) without living in the `tools` tree. - */ readonly search: (args: Array) => Effect.Effect - /** Enumerable namespace/tool names at one node of the callable tool tree; see `namespaceKeys`. */ readonly keys: (path: ReadonlyArray) => ReadonlyArray } export const make = ( tools: HostTools, - /** Undefined means unlimited tool calls. */ maxToolCalls: number | undefined, searchIndex: ReadonlyArray, hooks?: ToolCallHooks, @@ -724,8 +621,7 @@ export const make = ( const calls: Array = [] const searchTool = makeSearchTool(searchIndex) - // Wraps the settling portion of a tool call so onToolCallEnd observes success and failure - // symmetrically. Interruption (e.g. the execution timeout) fires neither outcome. + // End hooks observe settled success or failure; interruption emits neither outcome. const observeEnd = (effect: Effect.Effect, call: ToolCallStarted): Effect.Effect => { const onEnd = hooks?.onToolCallEnd if (onEnd === undefined) return effect diff --git a/packages/codemode/src/tool-schema.ts b/packages/codemode/src/tool-schema.ts index 36fc7511b8ab..d8b48dc54850 100644 --- a/packages/codemode/src/tool-schema.ts +++ b/packages/codemode/src/tool-schema.ts @@ -5,13 +5,8 @@ const isEffectSchema = (schema: SchemaType): schema is Schema.Decoder & const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown" -/** - * Bare TypeScript identifier - usable unquoted as an object key (and, in the tool runtime, - * with dot access as a tool-path segment). Anything else must be quoted/bracketed. - */ export const identifierSegment = /^[A-Za-z_$][A-Za-z0-9_$]*$/ -/** Renders a property name as a valid TS object key: bare when an identifier, quoted otherwise. */ const renderKey = (name: string): string => (identifierSegment.test(name) ? name : JSON.stringify(name)) const effectNumberSentinel = (schema: JsonSchema) => @@ -27,16 +22,10 @@ const intersection = (members: ReadonlyArray): string => { return concrete.map((member) => (member.includes(" | ") ? `(${member})` : member)).join(" & ") } -/** - * Recursion ceiling for schema rendering. Object, array, and union recursion all increment - * depth, so this bounds every recursion path - pathological or structurally cyclic schemas - * degrade to `unknown` instead of overflowing the stack (rendering must never throw). - */ const MAX_RENDER_DEPTH = 8 type RenderContext = { readonly definitions: Readonly> - /** Indented, JSDoc-annotated multiline rendering (search results); compact single line otherwise. */ readonly pretty: boolean } @@ -64,10 +53,6 @@ const hasUnresolvedRef = ( ].some((item) => hasUnresolvedRef(item, definitions, seen, nextVisited)) } -/** - * Schema constraints a TypeScript type cannot express natively but a model benefits from, - * surfaced as JSDoc tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`). - */ const docTags = (schema: JsonSchema): Array => { const tags: Array = [] if (schema.deprecated === true) tags.push("@deprecated") @@ -75,9 +60,7 @@ const docTags = (schema: JsonSchema): Array => { try { const rendered = JSON.stringify(schema.default) if (rendered !== undefined) tags.push(`@default ${rendered}`) - } catch { - // unserializable default: skip rather than emit a broken tag - } + } catch {} } if (typeof schema.format === "string") tags.push(`@format ${schema.format}`) if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`) @@ -85,13 +68,7 @@ const docTags = (schema: JsonSchema): Array => { return tags } -/** - * Format a schema `description` plus `tags` as a JSDoc comment at the given indent, - * preserving multi-line text (a single line stays `/** ... *\/`; multiple lines become a - * `*`-prefixed block). `*\/` is neutralized so nothing can close the comment early, and - * blank leading/trailing lines are trimmed. Returns "" (else a trailing newline) so - * callers can prepend it directly to the field line. - */ +// Neutralize `*\/` so model-provided schema text cannot terminate generated documentation. const jsdoc = (description: string | undefined, tags: ReadonlyArray, pad: string): string => { const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) => line.replaceAll("*/", "* /").replace(/\s+$/, ""), @@ -128,17 +105,11 @@ const renderSchema = ( if (schema.enum) return schema.enum.map(renderLiteral).join(" | ") const alternatives = schema.anyOf ?? schema.oneOf if (alternatives) { - // Effect's number schema emits `anyOf: [{ type: "number" }, { const: "NaN" }, - // { const: "Infinity" }, { const: "-Infinity" }]`. Collapse only that artifact; - // real JSON Schema unions such as `string | number` or `number | null` must keep - // every branch. if ( alternatives.some((item) => item.type === "number") && alternatives.every((item) => item.type === "number" || effectNumberSentinel(item)) ) return "number" - // An empty Schema.Struct({}) emits `anyOf: [{ type: "object" }, { type: "array" }]` - // (no properties/items); render the bare shape as {} instead of `{} | Array`. if ( alternatives.length === 2 && alternatives[0]?.type === "object" && @@ -183,7 +154,6 @@ const renderSchema = ( return fields.length === 0 ? "{}" : `{ ${fields.join("; ")} }` } - // Pretty: an indented block, each described field preceded by its JSDoc comment. if (properties.length === 0 && indexType === undefined) return "{}" const pad = " ".repeat(depth + 1) const lines = properties.map( @@ -208,7 +178,6 @@ export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false } } -/** Renders a raw JSON Schema document as a TypeScript type string. */ export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): string => { try { return renderSchema(schema, { definitions: { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }, pretty }) @@ -217,20 +186,12 @@ export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): stri } } -/** One input property of a tool, extracted best-effort from its input schema. */ export type InputProperty = { readonly name: string readonly description: string | undefined readonly required: boolean } -/** - * The property names, descriptions, and required flags of a tool's input schema - the raw - * material for search text. Best-effort: Effect Schemas go through their - * JSON Schema document (the same emission signature rendering uses); JSON Schemas are read - * directly, resolving a trivial top-level `$ref` into `$defs`/`definitions` when present. - * Anything unresolvable yields `[]` (search falls back to path + description). - */ export const inputProperties = (definition: Definition): Array => { try { const document = isEffectSchema(definition.input) @@ -262,20 +223,11 @@ export const inputProperties = (definition: Definition): Array(definition: Definition, pretty = false): string => isEffectSchema(definition.input) ? toTypeScript(definition.input, false, pretty) : jsonSchemaToTypeScript(definition.input, pretty) -/** - * The model-visible TypeScript type of a tool's result; tools without an output schema - * return `unknown`. `pretty` renders the JSDoc-annotated multiline form, as for inputs. - */ export const outputTypeScript = (definition: Definition, pretty = false): string => definition.output === undefined ? "unknown" @@ -283,18 +235,9 @@ export const outputTypeScript = (definition: Definition, pretty = false): ? toTypeScript(definition.output, true, pretty) : jsonSchemaToTypeScript(definition.output, pretty) -/** - * Decodes tool input before `run` is invoked. Effect Schemas validate (throwing on failure); - * JSON-Schema-described inputs pass through unvalidated (render-only). - */ export const decodeInput = (definition: Definition, value: unknown): unknown => isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value -/** - * Decodes a tool result before it is exposed to the program. Effect Schemas validate and - * transform (throwing on failure); JSON Schema outputs and tools without an output schema pass - * the host value through unchanged. - */ export const decodeOutput = (definition: Definition, value: unknown): unknown => definition.output !== undefined && isEffectSchema(definition.output) ? Schema.decodeUnknownSync(definition.output)(value) diff --git a/packages/codemode/src/tool.ts b/packages/codemode/src/tool.ts index 6c6863f99dcf..0535cc9caac1 100644 --- a/packages/codemode/src/tool.ts +++ b/packages/codemode/src/tool.ts @@ -1,11 +1,8 @@ import { Effect, Schema } from "effect" /** - * JSON Schema subset accepted for render-only tool schemas. - * - * A JSON-Schema-described side of a tool is used to generate the model-visible TypeScript - * signature only - CodeMode performs no validation against it. This is the natural shape for - * adapter-provided tools (e.g. MCP definitions) whose schemas arrive as JSON Schema documents. + * JSON Schema subset for model-visible signatures. CodeMode does not validate values against + * these schemas. */ export type JsonSchema = { readonly type?: string | ReadonlyArray @@ -41,10 +38,8 @@ export type Definition = { readonly run: (input: unknown) => Effect.Effect } -/** The value `run` receives: the decoded type for Effect Schemas, `unknown` for JSON Schemas. */ type InputType = S extends Schema.Decoder ? S["Type"] : unknown -/** The value `run` returns: the encoded type for Effect Schemas, `unknown` otherwise. */ type ResultType = S extends Schema.Decoder ? S["Encoded"] : unknown /** Options for defining one CodeMode tool. */ @@ -61,29 +56,9 @@ export const isDefinition = (value: unknown): value is Definition /** * Defines one schema-described tool available to a CodeMode program through `tools.*`. * - * `input` and `output` each accept a validating Effect Schema or a render-only JSON Schema - * document. Effect Schema input is decoded before `run` is invoked, and `run` returns the - * encoded representation of an Effect Schema `output`, which CodeMode decodes before returning - * it to the program. JSON Schemas only shape the model-visible signature; values pass through - * unvalidated. `output` is optional - without it the signature advertises `unknown` and the - * host result is exposed as-is. The host tool remains responsible for authorization and - * durable side-effect handling. - * - * @example - * ```ts - * const lookup = Tool.make({ - * description: "Look up an order", - * input: Schema.Struct({ id: Schema.String }), - * output: Schema.Struct({ status: Schema.String }), - * run: ({ id }) => Effect.succeed({ status: "open" }), - * }) - * - * const fromJsonSchema = Tool.make({ - * description: "Call an adapter-described tool", - * input: { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, - * run: (input) => callHost(input), - * }) - * ``` + * Effect Schemas validate values; JSON Schemas only shape the model-visible signature. + * Without `output`, results are exposed as `unknown`. Hosts remain responsible for authorization + * and durable side effects. */ export const make = ( options: Options, diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 705723d06d40..a9218f0e7e90 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -26,6 +26,22 @@ describe("CodeMode host failure boundary", () => { }) }) + test("does not rewrite explicit safe tool failures", async () => { + const result = await run( + Tool.make({ + description: "Fail safely", + input: Schema.Struct({}), + output: Schema.String, + run: () => Effect.fail(toolError("File not found: /tmp/report.json")), + }), + ) + + expect(result.ok ? undefined : result.error).toStrictEqual({ + kind: "ToolFailure", + message: "File not found: /tmp/report.json", + }) + }) + test("sanitizes unknown host failures and defects", async () => { for (const failure of [ Effect.fail(new UnsafeHostError({ reason: "Authorization: Bearer typed-secret" })), diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index c8046a69671e..33c6e22361bc 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -302,9 +302,12 @@ describe("CodeMode-specific string behavior", () => { expect(await value(`try { "x".normalize("nope"); return "no" } catch (e) { return e.message }`)).toContain('"NFC"') }) - test("trimLeft/trimRight alias trimStart/trimEnd", async () => { - expect(await value(`return " x ".trimLeft()`)).toBe("x ") - expect(await value(`return " x ".trimRight()`)).toBe(" x") + test("does not expose obsolete string aliases", async () => { + expect(await value(`return [typeof "x".trimLeft, typeof "x".trimRight, typeof "x".substr]`)).toEqual([ + "undefined", + "undefined", + "undefined", + ]) }) }) diff --git a/packages/codemode/test/string-search-test262.test.ts b/packages/codemode/test/string-search-test262.test.ts index f332ea16715b..5dcd63c8ba78 100644 --- a/packages/codemode/test/string-search-test262.test.ts +++ b/packages/codemode/test/string-search-test262.test.ts @@ -49,12 +49,6 @@ * - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T8.js * - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T9.js * - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T10.js - * - test/annexB/built-ins/String/prototype/substr/start-negative.js - * - test/annexB/built-ins/String/prototype/substr/length-negative.js - * - test/annexB/built-ins/String/prototype/substr/length-positive.js - * - test/annexB/built-ins/String/prototype/substr/length-falsey.js - * - test/annexB/built-ins/String/prototype/substr/length-undef.js - * - test/annexB/built-ins/String/prototype/substr/surrogate-pairs.js * - test/built-ins/String/prototype/includes/String.prototype.includes_FailMissingLetter.js * - test/built-ins/String/prototype/includes/String.prototype.includes_SuccessNoLocation.js * - test/built-ins/String/prototype/includes/String.prototype.includes_FailBadLocation.js @@ -460,67 +454,6 @@ const cases = [ expected: ["this_is_"], labels: ['#1: __string.substring(0,8) === "this_is_"'], }, - { - path: "test/annexB/built-ins/String/prototype/substr/start-negative.js", - code: `return ["abc".substr(-1), "abc".substr(-2), "abc".substr(-3), "abc".substr(-4), "abc".substr(-1.1)]`, - expected: ["c", "bc", "abc", "abc", "c"], - labels: ["-1", "-2", "-3", "size + intStart < 0", "floating point rounding semantics"], - }, - { - path: "test/annexB/built-ins/String/prototype/substr/length-negative.js", - code: `return [ - "abc".substr(0, -1), "abc".substr(0, -2), "abc".substr(0, -3), "abc".substr(0, -4), - "abc".substr(1, -1), "abc".substr(1, -2), "abc".substr(1, -3), "abc".substr(1, -4), - "abc".substr(2, -1), "abc".substr(2, -2), "abc".substr(2, -3), "abc".substr(2, -4), - "abc".substr(3, -1), "abc".substr(3, -2), "abc".substr(3, -3), "abc".substr(3, -4), - ]`, - expected: ["", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""], - labels: [ - "0, -1", "0, -2", "0, -3", "0, -4", "1, -1", "1, -2", "1, -3", "1, -4", - "2, -1", "2, -2", "2, -3", "2, -4", "3, -1", "3, -2", "3, -3", "3, -4", - ], - }, - { - path: "test/annexB/built-ins/String/prototype/substr/length-positive.js", - code: `return [ - "abc".substr(0, 1), "abc".substr(0, 2), "abc".substr(0, 3), "abc".substr(0, 4), - "abc".substr(1, 1), "abc".substr(1, 2), "abc".substr(1, 3), "abc".substr(1, 4), - "abc".substr(2, 1), "abc".substr(2, 2), "abc".substr(2, 3), "abc".substr(2, 4), - "abc".substr(3, 1), "abc".substr(3, 2), "abc".substr(3, 3), "abc".substr(3, 4), - ]`, - expected: ["a", "ab", "abc", "abc", "b", "bc", "bc", "bc", "c", "c", "c", "c", "", "", "", ""], - labels: [ - "0, 1", "0, 1", "0, 1", "0, 1", "1, 1", "1, 1", "1, 1", "1, 1", - "2, 1", "2, 1", "2, 1", "2, 1", "3, 1", "3, 1", "3, 1", "3, 1", - ], - }, - { - path: "test/annexB/built-ins/String/prototype/substr/length-falsey.js", - code: `return ["abc".substr(0, NaN), "abc".substr(1, NaN), "abc".substr(2, NaN), "abc".substr(3, NaN)]`, - expected: ["", "", "", ""], - labels: ["start: 0, length: NaN", "start: 1, length: NaN", "start: 2, length: NaN", "start: 3, length: NaN"], - }, - { - path: "test/annexB/built-ins/String/prototype/substr/length-undef.js", - code: `return [ - "abc".substr(0), "abc".substr(1), "abc".substr(2), "abc".substr(3), - "abc".substr(0, undefined), "abc".substr(1, undefined), "abc".substr(2, undefined), "abc".substr(3, undefined), - ]`, - expected: ["abc", "bc", "c", "", "abc", "bc", "c", ""], - labels: [ - "start: 0, length: unspecified", "start: 1, length: unspecified", "start: 2, length: unspecified", "start: 3, length: unspecified", - "start: 0, length: undefined", "start: 1, length: undefined", "start: 2, length: undefined", "start: 3, length: undefined", - ], - }, - { - path: "test/annexB/built-ins/String/prototype/substr/surrogate-pairs.js", - code: `return [ - "\uD834\uDF06".substr(0), "\uD834\uDF06".substr(1), "\uD834\uDF06".substr(2), - "\uD834\uDF06".substr(0, 0), "\uD834\uDF06".substr(0, 1), "\uD834\uDF06".substr(0, 2), - ]`, - expected: ["\uD834\uDF06", "\uDF06", "", "", "\uD834", "\uD834\uDF06"], - labels: ["start: 0", "start: 1", "start: 2", "end: 0", "end: 1", "end: 2"], - }, { path: "test/built-ins/String/prototype/includes/String.prototype.includes_FailMissingLetter.js", code: `return ["word".includes("a", 0)]`, expected: [false], labels: ['"word".includes("a", 0)'], diff --git a/packages/codemode/tsconfig.json b/packages/codemode/tsconfig.json index fe5c4d217b2e..0cbc049d87a4 100644 --- a/packages/codemode/tsconfig.json +++ b/packages/codemode/tsconfig.json @@ -2,6 +2,7 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "@tsconfig/bun/tsconfig.json", "compilerOptions": { - "noUncheckedIndexedAccess": false + "noUncheckedIndexedAccess": false, + "noUnusedLocals": true } }