ソースを参照

feat(codemode): add groupBy parity (#37992)

Aiden Cline 3 週間 前
コミット
0c18d06c9f

+ 2 - 2
packages/codemode/interpreter-support.md

@@ -177,7 +177,7 @@ ultimate source of truth.
 - [ ] Legal own data fields named `__proto__`, `constructor`, or `prototype` are rejected at JSON/tool boundaries and
       cannot be created, read, or written in CodeMode; tool path segments with those names remain supported.
 - [x] `Object.is` for supported data values.
-- [ ] `Object.groupBy`.
+- [x] `Object.groupBy` over supported collection iterables, with string-key coercion and null-prototype results.
 
 ## Arrays
 
@@ -287,7 +287,7 @@ ultimate source of truth.
 
 ## Map and Set
 
-- [ ] Static `Map.groupBy`.
+- [x] Static `Map.groupBy` over supported collection iterables, preserving key identity.
 - [x] `new Map()` from entry arrays or another Map.
 - [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, and `forEach`.
 - [x] `new Set()` from arrays, strings, or another Set.

+ 85 - 2
packages/codemode/src/interpreter/methods.ts

@@ -12,7 +12,7 @@ import {
   PromiseNamespace,
   UriFunction,
 } from "./model.js"
-import { containsOpaqueReference, rejectCircularInsertion, typeofValue } from "./references.js"
+import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, typeofValue } from "./references.js"
 import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
 import {
   CodeModeDate,
@@ -413,6 +413,89 @@ export const invokeArrayFrom = <R>(
   })
 }
 
+export const invokeGroupBy = <R>(
+  runner: CallbackRunner<R>,
+  namespace: "Map" | "Object",
+  args: Array<unknown>,
+  node: AstNode,
+): Effect.Effect<unknown, unknown, R> => {
+  const source = args[0]
+  if (source === null || source === undefined) {
+    throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError")
+  }
+  const apply = applyCollectionCallback(runner, args[1], `${namespace}.groupBy`, node)
+  const items = groupByItems(source)
+  if (items === undefined) {
+    throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError")
+  }
+  return Effect.gen(function* () {
+    if (namespace === "Map") {
+      const result = new CodeModeMap()
+      let index = 0
+      for (const item of items) {
+        const key = yield* apply([item, index])
+        const group = result.map.get(key)
+        if (group === undefined) result.map.set(key, [item])
+        else (group as Array<unknown>).push(item)
+        index += 1
+      }
+      return result
+    }
+
+    const result: SafeObject = Object.create(null) as SafeObject
+    let index = 0
+    for (const item of items) {
+      const key = yield* coerceGroupByPropertyKey(runner, yield* apply([item, index]), node)
+      if (isBlockedMember(key)) {
+        throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, node)
+      }
+      const group = result[key]
+      if (group === undefined) result[key] = [item]
+      else (group as Array<unknown>).push(item)
+      index += 1
+    }
+    return result
+  })
+}
+
+const groupByItems = (source: unknown): Iterable<unknown> | undefined => {
+  if (Array.isArray(source) || typeof source === "string") return source
+  if (source instanceof CodeModeMap) return source.map.entries()
+  if (source instanceof CodeModeSet) return source.set.values()
+  if (source instanceof CodeModeURLSearchParams) return source.params.entries()
+}
+
+const coerceGroupByPropertyKey = <R>(
+  runner: CallbackRunner<R>,
+  value: unknown,
+  node: AstNode,
+): Effect.Effect<string, unknown, R> => {
+  if (value === null || typeof value !== "object" || Array.isArray(value) || isCodeModeValue(value)) {
+    return Effect.succeed(coerceToString(value))
+  }
+  if (value instanceof CodeModePromise) return Effect.succeed("[object Promise]")
+  if (isRuntimeReference(value)) {
+    throw new InterpreterRuntimeError("Object.groupBy callback must return a data value.", node, "InvalidDataValue")
+  }
+  const object = value as Record<string, unknown>
+  if (!Object.hasOwn(object, "toString")) return Effect.succeed(coerceToString(value))
+  return Effect.gen(function* () {
+    if (typeofValue(object.toString) === "function") {
+      const result = yield* runner.invokeCallable(object.toString, [], node)
+      if (result === null || (typeof result !== "object" && typeof result !== "function")) {
+        return coerceToString(result)
+      }
+    }
+    if (Object.hasOwn(object, "valueOf") && typeofValue(object.valueOf) === "function") {
+      const result = yield* runner.invokeCallable(object.valueOf, [], node)
+      if (result === null || (typeof result !== "object" && typeof result !== "function")) {
+        return coerceToString(result)
+      }
+    }
+    throw new InterpreterRuntimeError("Cannot convert object to primitive value.", node).as("TypeError")
+  })
+}
+
 const invokeStringReplacer = <R>(
   runner: CallbackRunner<R>,
   value: string,
@@ -491,7 +574,7 @@ export const applyCollectionCallback = <R>(
         node,
       )
     }
-    throw new InterpreterRuntimeError(`${name} expects a function callback.`, node)
+    throw new InterpreterRuntimeError(`${name} expects a function callback.`, node).as("TypeError")
   }
   return (callbackArgs) => runner.invokeCallable(callback, callbackArgs, node)
 }

+ 13 - 2
packages/codemode/src/interpreter/runtime.ts

@@ -35,7 +35,14 @@ import {
   UriFunction,
 } from "./model.js"
 import { caughtErrorValue, constructErrorValue } from "./errors.js"
-import { arrayStatics, type CallbackRunner, invokeArrayFrom, invokeGlobalMethod, invokeIntrinsic } from "./methods.js"
+import {
+  arrayStatics,
+  type CallbackRunner,
+  invokeArrayFrom,
+  invokeGlobalMethod,
+  invokeGroupBy,
+  invokeIntrinsic,
+} from "./methods.js"
 import {
   constructPromise,
   invokePromiseInstanceMethod,
@@ -45,7 +52,7 @@ import {
 } 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 { arrayMethods, mapMethods, mapStatics, setMethods, spreadItems } from "../stdlib/collections.js"
 import { consoleMethods, formatConsoleMessage } from "../stdlib/console.js"
 import { dateMethods, dateStatics } from "../stdlib/date.js"
 import { jsonStatics } from "../stdlib/json.js"
@@ -100,6 +107,7 @@ const globalStaticMembers: Partial<Record<GlobalNamespaceName, Set<string>>> = {
   console: consoleMethods,
   Date: dateStatics,
   RegExp: regexpStatics,
+  Map: mapStatics,
   URL: urlStatics,
 }
 
@@ -1608,6 +1616,9 @@ export class Interpreter<R> {
         if (callable.namespace === "Array" && callable.name === "from") {
           return yield* invokeArrayFrom(self.runner, args, node)
         }
+        if ((callable.namespace === "Object" || callable.namespace === "Map") && callable.name === "groupBy") {
+          return yield* invokeGroupBy(self.runner, callable.namespace, args, node)
+        }
         if (callable.namespace === "Array" && callable.name === "of") {
           return invokeGlobalMethod(callable, args, node)
         }

+ 2 - 0
packages/codemode/src/stdlib/collections.ts

@@ -39,6 +39,8 @@ export const arrayMethods = new Set([
 
 export const mapMethods = new Set(["get", "set", "has", "delete", "clear", "forEach", "keys", "values", "entries"])
 
+export const mapStatics = new Set(["groupBy"])
+
 export const setMethods = new Set(["add", "has", "delete", "clear", "forEach", "keys", "values", "entries"])
 
 export const spreadItems = (value: unknown): Array<unknown> | undefined => {

+ 1 - 1
packages/codemode/src/stdlib/object.ts

@@ -6,7 +6,7 @@ import { boundedData, coerceToString } from "./value.js"
 
 export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"])
 
-export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "is", "assign", "fromEntries"])
+export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "is", "assign", "fromEntries", "groupBy"])
 
 export const invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
   const requireObject = (): Record<string, unknown> => {

+ 184 - 0
packages/codemode/test/groupby-test262.test.ts

@@ -0,0 +1,184 @@
+/*
+ * Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
+ * - test/built-ins/Object/groupBy/evenOdd.js
+ * - test/built-ins/Object/groupBy/groupLength.js
+ * - test/built-ins/Object/groupBy/callback-arg.js
+ * - test/built-ins/Object/groupBy/string.js
+ * - test/built-ins/Object/groupBy/emptyList.js
+ * - test/built-ins/Object/groupBy/toPropertyKey.js
+ * - test/built-ins/Object/groupBy/invalid-property-key.js
+ * - test/built-ins/Object/groupBy/invalid-iterable.js
+ * - test/built-ins/Object/groupBy/invalid-callback.js
+ * - test/built-ins/Object/groupBy/callback-throws.js
+ * - test/built-ins/Object/groupBy/null-prototype.js
+ * - test/built-ins/Map/groupBy/evenOdd.js
+ * - test/built-ins/Map/groupBy/groupLength.js
+ * - test/built-ins/Map/groupBy/callback-arg.js
+ * - test/built-ins/Map/groupBy/string.js
+ * - test/built-ins/Map/groupBy/emptyList.js
+ * - test/built-ins/Map/groupBy/toPropertyKey.js
+ * - test/built-ins/Map/groupBy/negativeZero.js
+ * - test/built-ins/Map/groupBy/invalid-iterable.js
+ * - test/built-ins/Map/groupBy/invalid-callback.js
+ * - test/built-ins/Map/groupBy/callback-throws.js
+ * - test/built-ins/Map/groupBy/map-instance.js
+ *
+ * Copyright (c) 2023 Ecma International.  All rights reserved.
+ * Test262 portions are governed by the BSD license in LICENSE.test262.
+ * Invalid-iterable cases use a plain object, equivalent to the upstream object's absent
+ * Symbol.iterator; CodeMode does not support symbol-keyed properties.
+ */
+import { describe, expect, test } from "bun:test"
+import { Effect } from "effect"
+import { CodeMode } from "../src/index.js"
+
+const execute = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
+
+const value = async (code: string) => {
+  const result = await execute(code)
+  if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
+  return result.value
+}
+
+describe("Object.groupBy Test262 parity", () => {
+  test("groups values by coerced property keys", async () => {
+    expect(
+      await value(`
+        const values = [1, 2, 3]
+        const grouped = Object.groupBy(values, (value) => value % 2 === 0 ? "even" : "odd")
+        const stringable = { toString: () => 1 }
+        const coerced = Object.groupBy([1, "1", stringable], (value) => value)
+        const lengths = Object.groupBy(["hello", "test", "world"], (value) => value.length)
+        return [
+          Object.keys(grouped), grouped.even, grouped.odd, grouped.toString === undefined,
+          Object.keys(coerced), coerced[1].length,
+          Object.keys(lengths), lengths[5], lengths[4],
+        ]
+      `),
+    ).toEqual([["odd", "even"], [2], [1, 3], true, ["1"], 3, ["4", "5"], ["hello", "world"], ["test"]])
+  })
+
+  test("passes each value and index to the callback", async () => {
+    expect(
+      await value(`
+        const values = [-0, 0, 1, 2, 3]
+        const seen = []
+        Object.groupBy(values, (value, index, ...extra) => {
+          seen.push([value === values[index], index, extra.length])
+          return null
+        })
+        return seen
+      `),
+    ).toEqual([
+      [true, 0, 0],
+      [true, 1, 0],
+      [true, 2, 0],
+      [true, 3, 0],
+      [true, 4, 0],
+    ])
+  })
+
+  test("supports strings and empty collections", async () => {
+    expect(
+      await value(`
+        const grouped = Object.groupBy("🥰💩🙏😈", (char) => char < "🙏" ? "before" : "after")
+        const empty = Object.groupBy([], () => { throw new Error("not called") })
+        return [Object.keys(grouped), grouped.before, grouped.after, Object.keys(empty)]
+      `),
+    ).toEqual([["after", "before"], ["💩", "😈"], ["🥰", "🙏"], []])
+  })
+
+  test("rejects invalid inputs and propagates callback and key-coercion failures", async () => {
+    expect(
+      await value(`
+        const messages = []
+        try { Object.groupBy({}, () => { throw new Error("not called") }) } catch (error) { messages.push(error.name) }
+        for (const callback of [null, undefined, {}]) {
+          try { Object.groupBy([], callback) } catch (error) { messages.push(error.name) }
+        }
+        try { Object.groupBy([1], () => { throw new Error("callback") }) } catch (error) { messages.push(error.message) }
+        try {
+          Object.groupBy([1], () => ({
+            toString: () => { throw new Error("property key") },
+          }))
+        } catch (error) { messages.push(error.message) }
+        return messages
+      `),
+    ).toEqual(["TypeError", "TypeError", "TypeError", "TypeError", "callback", "property key"])
+  })
+})
+
+describe("Map.groupBy Test262 parity", () => {
+  test("returns a Map with identity-preserving groups", async () => {
+    expect(
+      await value(`
+        const stringable = { toString: () => 1 }
+        const grouped = Map.groupBy([1, "1", stringable], (value) => value)
+        const parity = Map.groupBy([1, 2, 3], (value) => value % 2 === 0 ? "even" : "odd")
+        const lengths = Map.groupBy(["hello", "test", "world"], (value) => value.length)
+        return [
+          grouped instanceof Map,
+          grouped.size,
+          grouped.get(1),
+          grouped.get("1"),
+          grouped.has(stringable),
+          grouped.keys().length,
+          parity.get("even"),
+          parity.get("odd"),
+          lengths.keys(),
+          lengths.get(5),
+          lengths.get(4),
+        ]
+      `),
+    ).toEqual([true, 3, [1], ["1"], true, 3, [2], [1, 3], [5, 4], ["hello", "world"], ["test"]])
+  })
+
+  test("normalizes negative zero and passes each value and index", async () => {
+    expect(
+      await value(`
+        const values = [-0, 0, 1, 2, 3]
+        const seen = []
+        const grouped = Map.groupBy(values, (value, index, ...extra) => {
+          seen.push([value === values[index], index, extra.length])
+          return value
+        })
+        return [grouped.size, grouped.get(0), seen]
+      `),
+    ).toEqual([
+      4,
+      [-0, 0],
+      [
+        [true, 0, 0],
+        [true, 1, 0],
+        [true, 2, 0],
+        [true, 3, 0],
+        [true, 4, 0],
+      ],
+    ])
+  })
+
+  test("supports strings and empty collections", async () => {
+    expect(
+      await value(`
+        const grouped = Map.groupBy("🥰💩🙏😈", (char) => char < "🙏" ? "before" : "after")
+        const empty = Map.groupBy([], () => { throw new Error("not called") })
+        return [grouped.keys(), grouped.get("before"), grouped.get("after"), empty.size]
+      `),
+    ).toEqual([["after", "before"], ["💩", "😈"], ["🥰", "🙏"], 0])
+  })
+
+  test("rejects invalid inputs and propagates callback failures", async () => {
+    expect(
+      await value(`
+        const results = []
+        try { Map.groupBy({}, () => { throw new Error("not called") }) } catch (error) { results.push(error.name) }
+        for (const callback of [null, undefined, {}]) {
+          try { Map.groupBy([], callback) } catch (error) { results.push(error.name) }
+        }
+        try { Map.groupBy([1], () => { throw new Error("callback") }) }
+        catch (error) { results.push(error.message) }
+        return results
+      `),
+    ).toEqual(["TypeError", "TypeError", "TypeError", "TypeError", "callback"])
+  })
+})

+ 2 - 2
packages/codemode/test/parity.test.ts

@@ -857,7 +857,6 @@ describe("coercion parity: ++ and -- use CodeMode numeric coercion", () => {
 describe("coercion parity: unknown static members read as undefined", () => {
   test("feature detection on missing statics works like native JS", async () => {
     expect(await value(`return typeof Math.sum`)).toBe("undefined")
-    expect(await value(`return Object.groupBy === undefined`)).toBe(true)
     expect(await value(`return RegExp.quote === undefined`)).toBe(true)
     expect(await value(`return Number.range === undefined`)).toBe(true)
     expect(await value(`return String.raw === undefined`)).toBe(true)
@@ -866,7 +865,6 @@ describe("coercion parity: unknown static members read as undefined", () => {
     expect(await value(`return Date.moment === undefined`)).toBe(true)
     expect(await value(`return JSON.rawJSON === undefined`)).toBe(true)
     expect(await value(`return URL.createObjectURL === undefined`)).toBe(true)
-    expect(await value(`return Map.groupBy === undefined`)).toBe(true)
     expect(await value(`return Math.sum?.([1]) ?? "fallback"`)).toBe("fallback")
   })
 
@@ -876,6 +874,8 @@ describe("coercion parity: unknown static members read as undefined", () => {
     expect(await value(`return typeof Date.now`)).toBe("function")
     expect(await value(`return typeof Math.sumPrecise`)).toBe("function")
     expect(await value(`return typeof RegExp.escape`)).toBe("function")
+    expect(await value(`return typeof Object.groupBy`)).toBe("function")
+    expect(await value(`return typeof Map.groupBy`)).toBe("function")
     expect(await value(`return Math.max(1, 2)`)).toBe(2)
     expect(await value(`return Math.sumPrecise([1, 2])`)).toBe(3)
     expect(await value(`return RegExp.escape("a.b")`)).toBe("\\x61\\.b")