From 3da0dea8e74bbea65d1c178e053d02346749b425 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:59:37 -0500 Subject: [PATCH] feat(codemode): expand standard library parity (#37943) --- packages/codemode/interpreter-support.md | 6 +- packages/codemode/src/interpreter/methods.ts | 13 ++++ packages/codemode/src/interpreter/model.ts | 4 +- packages/codemode/src/interpreter/runtime.ts | 23 ++++-- packages/codemode/src/stdlib/collections.ts | 1 + packages/codemode/src/stdlib/date.ts | 5 ++ packages/codemode/src/stdlib/regexp.ts | 21 +++++- packages/codemode/src/values.ts | 8 +++ .../codemode/test/array-core-test262.test.ts | 32 +++++++++ packages/codemode/test/parity.test.ts | 2 +- packages/codemode/test/stdlib.test.ts | 71 ++++++++++++++++++- 11 files changed, 171 insertions(+), 15 deletions(-) diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 46fc6c844d..70a9c1afff 100644 --- a/packages/codemode/interpreter-support.md +++ b/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`. diff --git a/packages/codemode/src/interpreter/methods.ts b/packages/codemode/src/interpreter/methods.ts index 44f80eb80e..67f95ac0ec 100644 --- a/packages/codemode/src/interpreter/methods.ts +++ b/packages/codemode/src/interpreter/methods.ts @@ -714,6 +714,19 @@ const invokeArrayMethod = ( 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"))) diff --git a/packages/codemode/src/interpreter/model.ts b/packages/codemode/src/interpreter/model.ts index a70bdbe0a9..1f04cb69e2 100644 --- a/packages/codemode/src/interpreter/model.ts +++ b/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 | CodeModeURL + target: SafeObject | Array | CodeModeRegExp | CodeModeURL key: string | number } diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index ff02cee387..6dba699548 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -1958,6 +1958,7 @@ export class Interpreter { 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)[key]) } @@ -2052,6 +2053,7 @@ export class Interpreter { 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(reference.key)] } @@ -2082,6 +2084,9 @@ export class Interpreter { ) { 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 { } } const key = Array.isArray(reference.target) ? reference.key : String(reference.key) - const current = - reference.target instanceof CodeModeURL - ? (reference.target.url as unknown as Record)[key] - : (reference.target as Record)[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)[key] + } + if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex + return (reference.target as Record)[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 { 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) diff --git a/packages/codemode/src/stdlib/collections.ts b/packages/codemode/src/stdlib/collections.ts index 3851339791..226a1c1e40 100644 --- a/packages/codemode/src/stdlib/collections.ts +++ b/packages/codemode/src/stdlib/collections.ts @@ -29,6 +29,7 @@ export const arrayMethods = new Set([ "shift", "unshift", "splice", + "toSpliced", "fill", "copyWithin", "keys", diff --git a/packages/codemode/src/stdlib/date.ts b/packages/codemode/src/stdlib/date.ts index a6a1a059f4..5796ba57b8 100644 --- a/packages/codemode/src/stdlib/date.ts +++ b/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": diff --git a/packages/codemode/src/stdlib/regexp.ts b/packages/codemode/src/stdlib/regexp.ts index 84c0d0425f..bcfedc15ab 100644 --- a/packages/codemode/src/stdlib/regexp.ts +++ b/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" diff --git a/packages/codemode/src/values.ts b/packages/codemode/src/values.ts index 539aba82e4..51208da5a3 100644 --- a/packages/codemode/src/values.ts +++ b/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 { diff --git a/packages/codemode/test/array-core-test262.test.ts b/packages/codemode/test/array-core-test262.test.ts index a04e1c2566..33618856b0 100644 --- a/packages/codemode/test/array-core-test262.test.ts +++ b/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]]]`, diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index e1b0ce2e2a..7167e6471e 100644 --- a/packages/codemode/test/parity.test.ts +++ b/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 () => { diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index 48c9fd6916..19989b75a5 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/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() })