From ba24037e6419c02e432e3b06405004c66321fc29 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:47:17 -0500 Subject: [PATCH] feat(codemode): math parity (#35609) --- packages/codemode/codemode.md | 6 +- packages/codemode/src/interpreter/runtime.ts | 10 +-- packages/codemode/src/stdlib/math.ts | 57 ++++++++++++++ packages/codemode/src/stdlib/object.ts | 28 ++++--- packages/codemode/src/tool-runtime.ts | 10 +++ packages/codemode/test/stdlib.test.ts | 79 ++++++++++++++++++++ 6 files changed, 172 insertions(+), 18 deletions(-) diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index 9a307f2200..f7f798f2e1 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -155,12 +155,10 @@ current omissions to implement, not intentional product boundaries. collection values, then extend it to bounded host streams when a stream boundary exists. - [ ] Support callback-bearing standard-library variants that models commonly generate: the mapper argument to `Array.from(...)` and replacers for `JSON.stringify(...)`, including Effect-aware callbacks where needed. -- [ ] Close basic `Object` parity gaps: let `Object.values`/`Object.entries` accept arrays, make `Object.assign` validate - and mutate its target, add `Object.is`, and let `Object.fromEntries` consume every supported iterable. +- [ ] Add `Object.is` after runtime method and tool references have stable identity semantics. - [ ] Add deterministic modern collection conveniences where they improve orchestration: `Object.groupBy`, Set composition methods, and `Array.prototype.toSpliced`. -- [ ] Complete the deterministic `Math` surface beyond the current arithmetic, rounding, root, power, and logarithm - helpers. Decide separately whether nondeterministic `Math.random` belongs in the runtime. +- [ ] Decide whether nondeterministic `Math.random` and iterable `Math.sumPrecise` belong in the runtime. - [ ] Refine diagnostics so user throws, expected tool failures, unexpected host/tool defects, and genuine interpreter defects are distinguishable without leaking private causes. diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 49c2deec66..84f4d7c53f 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -66,7 +66,7 @@ import { numberMethods, numberStatics, } from "../stdlib/number.js" -import { invokeObjectMethod } from "../stdlib/object.js" +import { invokeObjectMethod, objectMethodsPreservingIdentity } from "../stdlib/object.js" import { promiseStatics, TOOL_CALL_CONCURRENCY } from "../stdlib/promise.js" import { escapeRegexHint, @@ -2013,9 +2013,9 @@ class Interpreter { if (callable instanceof GlobalMethodReference) { if (callable.namespace === "console") return self.invokeConsole(callable.name, args, node) if (callable.namespace === "Object" && args[0] instanceof ToolReference) { - return self.invokeObjectMethodOnTools(callable.name, args[0] as ToolReference, node) + return self.invokeObjectMethodOnTools(callable.name, args[0], node) } - if (callable.namespace === "Object" && callable.name === "assign") { + if (callable.namespace === "Object" && objectMethodsPreservingIdentity.has(callable.name)) { return invokeGlobalMethod(callable, args, node) } return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`) @@ -2036,8 +2036,8 @@ 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. Every other Object helper cannot produce data from a tool reference, so it fails - // with a pointer at the working idioms instead of the generic plain-objects-only message. + // 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") diff --git a/packages/codemode/src/stdlib/math.ts b/packages/codemode/src/stdlib/math.ts index cc8dd0670e..edb7e3d94b 100644 --- a/packages/codemode/src/stdlib/math.ts +++ b/packages/codemode/src/stdlib/math.ts @@ -4,6 +4,13 @@ export const mathMethods = new Set([ "max", "min", "abs", + "acos", + "acosh", + "asin", + "asinh", + "atan", + "atan2", + "atanh", "floor", "ceil", "round", @@ -13,10 +20,22 @@ export const mathMethods = new Set([ "cbrt", "pow", "hypot", + "cos", + "cosh", + "sin", + "sinh", + "tan", + "tanh", "log", "log2", "log10", + "log1p", "exp", + "expm1", + "f16round", + "fround", + "clz32", + "imul", ]) export const invokeMathMethod = (name: string, args: Array, node: AstNode): number => { @@ -33,6 +52,20 @@ export const invokeMathMethod = (name: string, args: Array, node: AstNo return Math.min(...nums) case "abs": return Math.abs(a) + case "acos": + return Math.acos(a) + case "acosh": + return Math.acosh(a) + case "asin": + return Math.asin(a) + case "asinh": + return Math.asinh(a) + case "atan": + return Math.atan(a) + case "atan2": + return Math.atan2(a, b) + case "atanh": + return Math.atanh(a) case "floor": return Math.floor(a) case "ceil": @@ -51,14 +84,38 @@ export const invokeMathMethod = (name: string, args: Array, node: AstNo return Math.pow(a, b) case "hypot": return Math.hypot(...nums) + case "cos": + return Math.cos(a) + case "cosh": + return Math.cosh(a) + case "sin": + return Math.sin(a) + case "sinh": + return Math.sinh(a) + case "tan": + return Math.tan(a) + case "tanh": + return Math.tanh(a) case "log": return Math.log(a) case "log2": return Math.log2(a) case "log10": return Math.log10(a) + case "log1p": + return Math.log1p(a) case "exp": return Math.exp(a) + case "expm1": + return Math.expm1(a) + case "f16round": + return Math.f16round(a) + case "fround": + return Math.fround(a) + case "clz32": + return Math.clz32(a) + case "imul": + return Math.imul(a, b) } throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node) } diff --git a/packages/codemode/src/stdlib/object.ts b/packages/codemode/src/stdlib/object.ts index 4d454cbda2..73b548d433 100644 --- a/packages/codemode/src/stdlib/object.ts +++ b/packages/codemode/src/stdlib/object.ts @@ -1,17 +1,20 @@ import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" import { isBlockedMember } from "../tool-runtime.js" -import { isSandboxValue, SandboxMap, SandboxURLSearchParams } from "../values.js" +import { isSandboxValue, SandboxMap, SandboxSet, SandboxURLSearchParams } from "../values.js" import { boundedData, coerceToString } from "./value.js" export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "assign", "fromEntries"]) +export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"]) export const invokeObjectMethod = (name: string, args: Array, node: AstNode): unknown => { if (!objectStatics.has(name)) throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node) const requireObject = (): Record => { + const input = args[0] const value = boundedData(args[0], `Object.${name} input`) + if (Array.isArray(input)) return input as unknown as Record if (isSandboxValue(value)) return {} - if (value === null || typeof value !== "object" || Array.isArray(value)) { - throw new InterpreterRuntimeError(`Object.${name} expects a data object.`, node) + if (value === null || typeof value !== "object") { + throw new InterpreterRuntimeError(`Object.${name} expects a data object or array.`, node) } return value as Record } @@ -19,6 +22,11 @@ export const invokeObjectMethod = (name: string, args: Array, node: Ast if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, node) out[key] = item } + const addEntry = (out: Record, key: unknown, item: unknown): void => { + boundedData(key, "Object.fromEntries key") + boundedData(item, "Object.fromEntries value") + guardedSet(out, coerceToString(key), item) + } switch (name) { case "keys": { const value = boundedData(args[0], "Object.keys input") @@ -55,7 +63,7 @@ export const invokeObjectMethod = (name: string, args: Array, node: Ast case "fromEntries": { if (args[0] instanceof SandboxMap) { const out: Record = Object.create(null) - for (const [key, item] of args[0].map.entries()) guardedSet(out, coerceToString(key), item) + for (const [key, item] of args[0].map.entries()) addEntry(out, key, item) return out } if (args[0] instanceof SandboxURLSearchParams) { @@ -63,16 +71,18 @@ export const invokeObjectMethod = (name: string, args: Array, node: Ast for (const [key, value] of args[0].params.entries()) guardedSet(out, key, value) return out } - const pairs = boundedData(args[0], "Object.fromEntries input") + const pairs = args[0] instanceof SandboxSet ? Array.from(args[0].set.values()) : args[0] if (!Array.isArray(pairs)) { + boundedData(args[0], "Object.fromEntries input") throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node) } const out: Record = Object.create(null) for (const pair of pairs) { - if (!Array.isArray(pair)) { - throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] pairs.", node) - } - guardedSet(out, String(pair[0]), pair[1]) + const validated = boundedData(pair, "Object.fromEntries entry") + if (validated === null || typeof validated !== "object" || isSandboxValue(validated)) + throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node) + const entry = pair as Record + addEntry(out, entry[0], entry[1]) } return out } diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index f4ccc61d4c..7d67ba6c79 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -274,6 +274,16 @@ 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. + for (const [key, item] of Object.entries(value)) { + if (Object.hasOwn(copied, key)) continue + if (isBlockedMember(key)) { + throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`) + } + Reflect.set(copied, key, copyBounded(item, label, depth + 1, seen, true)) + } + } seen.delete(value) return copied } diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index 489de451a7..59262f3197 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -586,6 +586,85 @@ describe("Set", () => { }) describe("stdlib integration", () => { + test("Object values and entries accept arrays", async () => { + expect(await value(`return [Object.values(["a", "b"]), Object.entries(["a", "b"])]`)).toEqual([ + ["a", "b"], + [ + ["0", "a"], + ["1", "b"], + ], + ]) + expect(await value(`const match = /a/.exec("ba"); return [Object.values(match), Object.entries(match)]`)).toEqual([ + ["a", 1], + [ + ["0", "a"], + ["index", 1], + ], + ]) + expect(await value(`return Object.keys(Object.values({ match: /a/.exec("ba") })[0])`)).toEqual(["0", "index"]) + }) + + test("Object.fromEntries accepts every supported entry collection", async () => { + expect( + await value(` + return [ + Object.fromEntries([["a", 1]]), + Object.fromEntries(new Map([["b", 2]])), + Object.fromEntries(new Set([["c", 3]])), + Object.fromEntries(new URLSearchParams("d=4")), + Object.fromEntries([{ 0: "e", 1: 5 }]), + Object.fromEntries(new Set([[{}, 6], [new Date(0), 7], [null, 8], [undefined, 9]])), + ] + `), + ).toEqual([ + { a: 1 }, + { b: 2 }, + { c: 3 }, + { d: "4" }, + { e: 5 }, + { "[object Object]": 6, "1970-01-01T00:00:00.000Z": 7, null: 8, undefined: 9 }, + ]) + expect(await value(`try { Object.fromEntries(new Set([Math.max])); return false } catch { return true }`)).toBe( + true, + ) + expect( + await value( + `try { Object.fromEntries(new Map([["fn", Math.max]])); return false } catch { return true }`, + ), + ).toBe(true) + }) + + test("deterministic Math methods match the host runtime", async () => { + const result = await value(` + return [ + Math.acos(0.5), Math.acosh(2), Math.asin(0.5), Math.asinh(2), Math.atan(1), Math.atan2(1, 2), Math.atanh(0.5), + Math.cos(0.5), Math.cosh(0.5), Math.sin(0.5), Math.sinh(0.5), Math.tan(0.5), Math.tanh(0.5), + Math.log1p(0.5), Math.expm1(0.5), Math.f16round(1.337), Math.fround(1.337), Math.clz32(1), Math.imul(2, 3), + ] + `) + expect(result).toEqual([ + Math.acos(0.5), + Math.acosh(2), + Math.asin(0.5), + Math.asinh(2), + Math.atan(1), + Math.atan2(1, 2), + Math.atanh(0.5), + Math.cos(0.5), + Math.cosh(0.5), + Math.sin(0.5), + Math.sinh(0.5), + Math.tan(0.5), + Math.tanh(0.5), + Math.log1p(0.5), + Math.expm1(0.5), + Math.f16round(1.337), + Math.fround(1.337), + Math.clz32(1), + Math.imul(2, 3), + ]) + }) + test("Object.assign mutates and returns its target", async () => { expect( await value(`