diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 8ae03199a9a..46fc6c844d6 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -197,8 +197,8 @@ ultimate source of truth. - [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`. -- [ ] Canonical array/string index parsing: a key such as `"01"` must remain an ordinary property key rather than - aliasing index `1`. +- [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, like JavaScript. diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 7f92cb93bff..ff02cee3874 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -96,6 +96,15 @@ const globalStaticMembers: Partial>> = { URL: urlStatics, } +const MAX_ARRAY_LENGTH = 4_294_967_295 + +const parseArrayIndex = (key: string | number): number | undefined => { + const property = String(key) + if (!/^(0|[1-9]\d*)$/.test(property)) return undefined + const index = Number(property) + return index < MAX_ARRAY_LENGTH ? index : undefined +} + const calleeDescription = (callee: AstNode): string => { if (callee.type === "Identifier") return getString(callee, "name") if (callee.type === "MemberExpression") { @@ -1916,8 +1925,8 @@ export class Interpreter { if (typeof objectValue === "string") { if (key === "length") return new ComputedValue(objectValue.length) - if (typeof key === "number") return new ComputedValue(objectValue[key]) - if (typeof key === "string" && /^\d+$/.test(key)) return new ComputedValue(objectValue[Number(key)]) + const index = parseArrayIndex(key) + if (index !== undefined) return new ComputedValue(objectValue[index]) if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key) return new ComputedValue(undefined) } @@ -2011,18 +2020,14 @@ export class Interpreter { if (Array.isArray(objectValue)) { if (operation === "delete") return { target: objectValue, key } - if ( - key !== "length" && - !(typeof key === "string" && arrayMethods.has(key)) && - typeof key !== "number" && - !/^\d+$/.test(key) - ) { + const index = parseArrayIndex(key) + if (key !== "length" && !(typeof key === "string" && arrayMethods.has(key)) && index === undefined) { if (typeof key === "string" && Object.hasOwn(objectValue, key)) { return new ComputedValue((objectValue as Record & Array)[key]) } return new ComputedValue(undefined) } - return { target: objectValue, key } + return { target: objectValue, key: index ?? key } } return { target: objectValue as SafeObject, key } @@ -2043,10 +2048,9 @@ export class Interpreter { ) return reference if (Array.isArray(reference.target)) { - if (typeof reference.key === "string" && arrayMethods.has(reference.key)) { - return new IntrinsicReference(reference.target, reference.key) - } - return reference.key === "length" ? reference.target.length : reference.target[Number(reference.key)] + if (reference.key === "length") return reference.target.length + if (typeof reference.key === "string") return new IntrinsicReference(reference.target, reference.key) + return reference.target[reference.key] } if (reference.target instanceof CodeModeURL) { return (reference.target.url as unknown as Record)[String(reference.key)] @@ -2109,7 +2113,7 @@ export class Interpreter { throw new InterpreterRuntimeError("Array methods cannot be assigned in CodeMode.", node) } } - const key = Array.isArray(reference.target) ? Number(reference.key) : String(reference.key) + 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] @@ -2123,16 +2127,15 @@ export class Interpreter { private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void { if (Array.isArray(reference.target)) { const target = reference.target - const index = key as number - if (!Number.isInteger(index) || index < 0) { + if (typeof key !== "number" || parseArrayIndex(key) === undefined) { throw new InterpreterRuntimeError( - "Array assignment index must be a non-negative integer.", + "Array assignment index must be a valid array index.", node, "InvalidDataValue", ) } rejectCircularInsertion(target, next, "Array assignment result", node) - target[index] = next + target[key] = next return } if (reference.target instanceof CodeModeURL) { diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index a5c003a62c6..e1b0ce2e2a6 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -42,6 +42,15 @@ describe("H2: string property access reads as undefined (not a throw)", () => { test("unknown property on a number is undefined", async () => { expect(await value(`return (5).foo ?? "n"`)).toBe("n") }) + + test("only canonical string index keys access characters", async () => { + expect( + await value(` + const text = "abc" + return [text[1], text["1"], text["01"], text["1.0"], text[-0], text["-0"]] + `), + ).toEqual(["b", "b", null, null, "a", null]) + }) }) describe("H3: array property access reads as undefined (not a throw)", () => { @@ -62,6 +71,38 @@ describe("H3: array property access reads as undefined (not a throw)", () => { expect(await value(`return [1,2,3][9] === undefined`)).toBe(true) expect(await value(`return [1,2,3][9]`)).toBeNull() }) + + test("only canonical array index keys access elements", async () => { + expect( + await value(` + const values = ["a", "b"] + return [values[1], values["1"], values["01"], values["1.0"], values[-0], values["-0"]] + `), + ).toEqual(["b", "b", null, null, "a", null]) + }) + + test("noncanonical keys cannot mutate or delete an aliased element", async () => { + expect( + await value(` + const values = ["a", "b"] + let writes = 0 + try { values["01"] = ++writes } catch {} + const removed = delete values["01"] + return [writes, removed, values] + `), + ).toEqual([0, true, ["a", "b"]]) + }) + + test("the maximum array length is not accepted as an array index", async () => { + expect( + await value(` + const values = [] + let writes = 0 + try { values["4294967295"] = ++writes } catch {} + return [writes, values.length] + `), + ).toEqual([0, 0]) + }) }) describe("H6: object spread of null/undefined is a no-op", () => {