Răsfoiți Sursa

feat(codemode): expand standard library parity (#37943)

Aiden Cline 4 săptămâni în urmă
părinte
comite
3da0dea8e7

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

@@ -196,7 +196,7 @@ ultimate source of truth.
 - [x] Materialized iteration helpers: `keys`, `values`, and `entries` return arrays rather than iterators.
 - [x] `length`, numeric indexing, index assignment, spread, and `for...of`.
 - [x] The `thisArg` argument of `Array.from` is accepted and ignored, like JS arrows.
-- [ ] `Array.prototype.toSpliced`.
+- [x] `Array.prototype.toSpliced`.
 - [x] Canonical array/string index parsing: keys such as `"01"` remain non-index properties rather than aliasing index
       `1`; arbitrary array-property assignment remains unsupported.
 - [x] `Array.prototype.sort` preserves trailing holes, while `toSorted` densifies holes into `undefined` elements,
@@ -266,7 +266,7 @@ ultimate source of truth.
 - [x] `getTimezoneOffset`, arithmetic, relational comparison, and `instanceof Date`.
 - [x] Date values serialize to ISO strings; invalid dates serialize to `null`.
 - [ ] Date setters.
-- [ ] `Date.prototype.toUTCString` and its `toGMTString` alias.
+- [x] `Date.prototype.toUTCString` and its `toGMTString` alias.
 - [x] Native one-argument Date coercion for supported values, including booleans, null, arrays, and plain objects.
 - [ ] Native Date loose-equality and default primitive-coercion semantics.
 - [x] Native `RangeError` branding for invalid `toISOString()` calls.
@@ -278,7 +278,7 @@ ultimate source of truth.
 - [x] Readable `source`, `flags`, `lastIndex`, `global`, `ignoreCase`, `multiline`, `sticky`, `unicode`, and `dotAll`.
 - [x] Captures, safe named groups (blocked member names are omitted), match `.index`, and stateful global matching.
 - [x] Integration with supported String methods, including function replacers.
-- [ ] Writable `lastIndex`.
+- [x] Writable `lastIndex`.
 - [ ] `hasIndices`, match `indices`, and `unicodeSets` metadata for the `d` and `v` flags.
 - [ ] `RegExp.escape`.
 

+ 13 - 0
packages/codemode/src/interpreter/methods.ts

@@ -714,6 +714,19 @@ const invokeArrayMethod = <R>(
       for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result", node)
       return Effect.succeed(target.splice(start, deleteCount, ...inserted))
     }
+    case "toSpliced": {
+      if (args.length === 0) return Effect.succeed([...target])
+      const start = optNumber(args[0], "start") ?? 0
+      if (args.length === 1) {
+        const copied = [...target]
+        copied.splice(start)
+        return Effect.succeed(copied)
+      }
+      const deleteCount = optNumber(args[1], "delete count") ?? 0
+      const copied = [...target]
+      copied.splice(start, deleteCount, ...args.slice(2))
+      return Effect.succeed(copied)
+    }
     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")))

+ 2 - 2
packages/codemode/src/interpreter/model.ts

@@ -1,5 +1,5 @@
 import type { SafeObject } from "../tool-runtime.js"
-import type { CodeModePromise, CodeModeURL } from "../values.js"
+import type { CodeModePromise, CodeModeRegExp, CodeModeURL } from "../values.js"
 
 export type SourcePosition = {
   line: number
@@ -35,7 +35,7 @@ export type StatementResult =
   | { kind: "continue" }
 
 export type MemberReference = {
-  target: SafeObject | Array<unknown> | CodeModeURL
+  target: SafeObject | Array<unknown> | CodeModeRegExp | CodeModeURL
   key: string | number
 }
 

+ 18 - 5
packages/codemode/src/interpreter/runtime.ts

@@ -1958,6 +1958,7 @@ export class Interpreter<R> {
         return new ComputedValue(undefined)
       }
       if (objectValue instanceof CodeModeRegExp) {
+        if (key === "lastIndex") return { target: objectValue, key }
         if (typeof key === "string" && regexpProperties.has(key)) {
           return new ComputedValue((objectValue.regex as unknown as Record<string, unknown>)[key])
         }
@@ -2052,6 +2053,7 @@ export class Interpreter<R> {
         if (typeof reference.key === "string") return new IntrinsicReference(reference.target, reference.key)
         return reference.target[reference.key]
       }
+      if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
       if (reference.target instanceof CodeModeURL) {
         return (reference.target.url as unknown as Record<string, unknown>)[String(reference.key)]
       }
@@ -2082,6 +2084,9 @@ export class Interpreter<R> {
       ) {
         throw new InterpreterRuntimeError("Only data fields may be deleted in CodeMode.", target, "InvalidDataValue")
       }
+      if (reference.target instanceof CodeModeRegExp) {
+        return Reflect.deleteProperty(reference.target.regex, reference.key)
+      }
       return Reflect.deleteProperty(reference.target, reference.key)
     })
   }
@@ -2114,16 +2119,20 @@ export class Interpreter<R> {
         }
       }
       const key = Array.isArray(reference.target) ? reference.key : String(reference.key)
-      const current =
-        reference.target instanceof CodeModeURL
-          ? (reference.target.url as unknown as Record<string, unknown>)[key]
-          : (reference.target as Record<PropertyKey, unknown>)[key]
-      const { write, next, result } = yield* compute(current)
+      const { write, next, result } = yield* compute(self.readReferenceValue(reference, key))
       if (write) self.assignToReference(reference, key, next, node)
       return result
     })
   }
 
+  private readReferenceValue(reference: MemberReference, key: number | string): unknown {
+    if (reference.target instanceof CodeModeURL) {
+      return (reference.target.url as unknown as Record<string, unknown>)[key]
+    }
+    if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
+    return (reference.target as Record<PropertyKey, unknown>)[key]
+  }
+
   private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void {
     if (Array.isArray(reference.target)) {
       const target = reference.target
@@ -2152,6 +2161,10 @@ export class Interpreter<R> {
         throw new InterpreterRuntimeError(`URL.${property} received an invalid value.`, node).as("TypeError")
       }
     }
+    if (reference.target instanceof CodeModeRegExp) {
+      reference.target.lastIndex = next
+      return
+    }
     const target = reference.target as SafeObject
     const objectKey = key as string
     rejectCircularInsertion(target, next, "Object assignment result", node)

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

@@ -29,6 +29,7 @@ export const arrayMethods = new Set([
   "shift",
   "unshift",
   "splice",
+  "toSpliced",
   "fill",
   "copyWithin",
   "keys",

+ 5 - 0
packages/codemode/src/stdlib/date.ts

@@ -4,6 +4,8 @@ export const dateMethods = new Set([
   "toISOString",
   "toJSON",
   "toString",
+  "toUTCString",
+  "toGMTString",
   "getFullYear",
   "getMonth",
   "getDate",
@@ -51,6 +53,9 @@ export const invokeDateMethod = (value: CodeModeDate, name: string, node: AstNod
       return Number.isFinite(value.time) ? hosted.toISOString() : null
     case "toString":
       return coerceToString(value)
+    case "toUTCString":
+    case "toGMTString":
+      return hosted.toUTCString()
     case "getFullYear":
       return hosted.getFullYear()
     case "getMonth":

+ 18 - 3
packages/codemode/src/stdlib/regexp.ts

@@ -59,9 +59,18 @@ export const invokeRegExpMethod = (
 ): unknown => {
   switch (name) {
     case "test":
-      return value.regex.test(coerceToString(args[0]))
     case "exec": {
-      const matched = value.regex.exec(coerceToString(args[0]))
+      const input = coerceToString(args[0])
+      const lastIndex = value.lastIndex
+      const stateful = value.regex.global || value.regex.sticky
+      value.regex.lastIndex = toLength(lastIndex)
+      if (name === "test") {
+        const matched = value.regex.test(input)
+        if (!stateful) value.lastIndex = lastIndex
+        return matched
+      }
+      const matched = value.regex.exec(input)
+      if (!stateful) value.lastIndex = lastIndex
       return matched === null ? null : matchToValue(matched)
     }
     case "toString":
@@ -70,7 +79,13 @@ export const invokeRegExpMethod = (
       throw new InterpreterRuntimeError(`RegExp method '${name}' is not available in CodeMode.`, node)
   }
 }
+
+const toLength = (value: unknown): number => {
+  const number = coerceToNumber(value)
+  if (Number.isNaN(number) || number <= 0) return 0
+  return Math.min(Math.floor(number), Number.MAX_SAFE_INTEGER)
+}
 import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
 import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
 import { CodeModeRegExp } from "../values.js"
-import { coerceToString } from "./value.js"
+import { coerceToNumber, coerceToString } from "./value.js"

+ 8 - 0
packages/codemode/src/values.ts

@@ -13,6 +13,14 @@ export class CodeModeRegExp {
   constructor(pattern: string, flags: string) {
     this.regex = new RegExp(pattern, flags)
   }
+
+  get lastIndex(): unknown {
+    return Reflect.get(this.regex, "lastIndex")
+  }
+
+  set lastIndex(value: unknown) {
+    Reflect.set(this.regex, "lastIndex", value)
+  }
 }
 
 export class CodeModeMap {

+ 32 - 0
packages/codemode/test/array-core-test262.test.ts

@@ -29,6 +29,11 @@
  * - test/built-ins/Array/prototype/splice/S15.4.4.12_A1.1_T1.js
  * - test/built-ins/Array/prototype/splice/S15.4.4.12_A1.2_T1.js
  * - test/built-ins/Array/prototype/splice/called_with_one_argument.js
+ * - test/built-ins/Array/prototype/toSpliced/holes-not-preserved.js
+ * - test/built-ins/Array/prototype/toSpliced/immutable.js
+ * - test/built-ins/Array/prototype/toSpliced/start-and-deleteCount-undefineds.js
+ * - test/built-ins/Array/prototype/toSpliced/start-and-deleteCount-missing.js
+ * - test/built-ins/Array/prototype/toSpliced/start-undefined-and-deleteCount-missing.js
  * - test/built-ins/Array/prototype/fill/fill-values.js
  * - test/built-ins/Array/prototype/fill/fill-values-custom-start-and-end.js
  * - test/built-ins/Array/prototype/fill/return-this.js
@@ -59,6 +64,8 @@
  * Copyright 2015 Microsoft Corporation. All rights reserved.
  * Copyright 2016 The V8 project authors. All rights reserved.
  * Test262 portions are governed by the BSD license in LICENSE.test262.
+ * The toSpliced hole case omits the source test's inherited Array.prototype element because CodeMode does not expose
+ * prototype mutation; it retains the source test's hole-densification assertions.
  */
 import { describe, expect, test } from "bun:test"
 import { Effect } from "effect"
@@ -227,6 +234,31 @@ const cases = [
     code: `const input = ["first", "second", "third"]; const removed = input.splice(1); return [input, removed]`,
     expected: [["first"], ["second", "third"]],
   },
+  {
+    path: "test/built-ins/Array/prototype/toSpliced/immutable.js",
+    code: `const input = [2, 0, 1]; const inserted = input.toSpliced(0, 0, -1); const replaced = input.toSpliced(0, 1, -1); return [input, inserted, replaced, inserted !== input, replaced !== input]`,
+    expected: [[2, 0, 1], [-1, 2, 0, 1], [-1, 0, 1], true, true],
+  },
+  {
+    path: "test/built-ins/Array/prototype/toSpliced/start-and-deleteCount-missing.js",
+    code: `const input = ["first", "second", "third"]; const result = input.toSpliced(); return [result, result !== input]`,
+    expected: [["first", "second", "third"], true],
+  },
+  {
+    path: "test/built-ins/Array/prototype/toSpliced/start-undefined-and-deleteCount-missing.js",
+    code: `return ["first", "second", "third"].toSpliced(undefined)`,
+    expected: [],
+  },
+  {
+    path: "test/built-ins/Array/prototype/toSpliced/start-and-deleteCount-undefineds.js",
+    code: `const input = ["first", "second", "third"]; const result = input.toSpliced(undefined, undefined); return [result, result !== input]`,
+    expected: [["first", "second", "third"], true],
+  },
+  {
+    path: "test/built-ins/Array/prototype/toSpliced/holes-not-preserved.js",
+    code: `const input = [0, , 2, , 4]; const result = input.toSpliced(0, 0, -1); return [result, 2 in result, 4 in result]`,
+    expected: [[-1, 0, null, 2, null, 4], true, true],
+  },
   {
     path: "test/built-ins/Array/prototype/fill/fill-values-custom-start-and-end.js",
     code: `const input = [0, 0, 0, 0, 0]; input.fill(8, -3, 4); const sparse = []; sparse[4] = 0; sparse.fill(8, 1, 3); return [[0, 0, 0].fill(8, 1, 2), input, [0, 0, 0, 0, 0].fill(8, -2, -1), [0, 0, 0, 0, 0].fill(8, -1, -3), [0 in sparse, sparse[1], sparse[2], 3 in sparse, sparse[4]]]`,

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

@@ -64,7 +64,7 @@ describe("H3: array property access reads as undefined (not a throw)", () => {
   })
 
   test("unknown property reads stay undefined for methods CodeMode does not implement", async () => {
-    expect(await value(`return [1,2,3].toSpliced === undefined`)).toBe(true)
+    expect(await value(`return [1,2,3].unknownMethod === undefined`)).toBe(true)
   })
 
   test("array indexing still works", async () => {

+ 70 - 1
packages/codemode/test/stdlib.test.ts

@@ -2,11 +2,16 @@
  * Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
  * - test/built-ins/Date/value-to-primitive-result-non-string-prim.js
  * - test/built-ins/Date/value-to-primitive-result-string.js
+ * - test/built-ins/Date/prototype/toUTCString/format.js
+ * - test/built-ins/Date/prototype/toUTCString/invalid-date.js
+ * - test/built-ins/RegExp/prototype/exec/S15.10.6.2_A4_T8.js
  *
- * CodeMode does not support Symbol.toPrimitive, so these cases exercise the same Date-constructor primitive-result
+ * CodeMode does not support Symbol.toPrimitive, so the Date-constructor cases exercise the same primitive-result
  * handling through supported own valueOf and toString functions.
  *
  * Copyright (C) 2016 the V8 project authors. All rights reserved.
+ * Copyright (C) 2017 the V8 project authors. All rights reserved.
+ * Copyright 2009 the Sputnik authors. All rights reserved.
  * Test262 portions are governed by the BSD license in LICENSE.test262.
  */
 import { describe, expect, test } from "bun:test"
@@ -175,6 +180,25 @@ describe("Date", () => {
     expect(await value(`return typeof new Date(0)`)).toBe("object")
     expect(await value(`return new Date(0).nope === undefined`)).toBe(true)
   })
+
+  test("toUTCString and toGMTString use the native UTC format", async () => {
+    expect(
+      await value(`
+        const date = new Date(0)
+        return [
+          date.toUTCString(),
+          date.toGMTString(),
+          new Date(NaN).toUTCString(),
+          new Date("0020-01-01T00:00:00Z").toUTCString(),
+        ]
+      `),
+    ).toEqual([
+      "Thu, 01 Jan 1970 00:00:00 GMT",
+      "Thu, 01 Jan 1970 00:00:00 GMT",
+      "Invalid Date",
+      "Wed, 01 Jan 0020 00:00:00 GMT",
+    ])
+  })
 })
 
 describe("RegExp", () => {
@@ -211,6 +235,51 @@ describe("RegExp", () => {
     ).toEqual(["1", "22"])
   })
 
+  test("lastIndex is writable and exec coerces its stored value", async () => {
+    expect(
+      await value(`
+        const pattern = /(?:ab|cd)\\d?/g
+        pattern.lastIndex = "12"
+        const stored = [pattern.lastIndex, typeof pattern.lastIndex]
+        const match = pattern.exec("aacd2233ab12nm444ab42")
+        pattern.lastIndex = 0
+        return [stored, match[0], match.index, pattern.lastIndex, delete pattern.lastIndex]
+      `),
+    ).toEqual([["12", "string"], "ab4", 17, 0, false])
+  })
+
+  test("exec coerces CodeMode data objects assigned to lastIndex", async () => {
+    expect(
+      await value(`
+        const pattern = /a/g
+        pattern.lastIndex = {}
+        const stored = pattern.lastIndex
+        const match = pattern.exec("ba")
+        pattern.lastIndex = 10
+        const missed = pattern.exec("a")
+        return [stored, match.index, pattern.lastIndex, missed]
+      `),
+    ).toEqual([{}, 1, 0, null])
+  })
+
+  test("non-global exec and test coerce and preserve lastIndex", async () => {
+    expect(
+      await value(`
+        const execPattern = /a/
+        const execIndex = {}
+        execPattern.lastIndex = execIndex
+        const match = execPattern.exec("ba")
+
+        const testPattern = /a/
+        const testIndex = {}
+        testPattern.lastIndex = testIndex
+        const matched = testPattern.test("ba")
+
+        return [match.index, execPattern.lastIndex === execIndex, matched, testPattern.lastIndex === testIndex]
+      `),
+    ).toEqual([1, true, true, true])
+  })
+
   test("an unmatched string pattern returns null", async () => {
     expect(await value(`return "abc".match(/\\d/)`)).toBeNull()
   })