Prechádzať zdrojové kódy

feat(codemode): math parity (#35609)

Aiden Cline 1 mesiac pred
rodič
commit
ba24037e64

+ 2 - 4
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.
 

+ 5 - 5
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<R> {
       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<R> {
 
   // 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")

+ 57 - 0
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<unknown>, node: AstNode): number => {
@@ -33,6 +52,20 @@ export const invokeMathMethod = (name: string, args: Array<unknown>, 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<unknown>, 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)
 }

+ 19 - 9
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<unknown>, node: AstNode): unknown => {
   if (!objectStatics.has(name)) throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node)
   const requireObject = (): Record<string, unknown> => {
+    const input = args[0]
     const value = boundedData(args[0], `Object.${name} input`)
+    if (Array.isArray(input)) return input as unknown as Record<string, unknown>
     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<string, unknown>
   }
@@ -19,6 +22,11 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
     if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, node)
     out[key] = item
   }
+  const addEntry = (out: Record<string, unknown>, 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<unknown>, node: Ast
     case "fromEntries": {
       if (args[0] instanceof SandboxMap) {
         const out: Record<string, unknown> = 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<unknown>, 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<string, unknown> = 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<string, unknown>
+        addEntry(out, entry[0], entry[1])
       }
       return out
     }

+ 10 - 0
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
   }

+ 79 - 0
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(`