From ad8e6b1fb6ab32a19bdc00e6580a3144b0be7362 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:21:28 -0500 Subject: [PATCH] feat(codemode): support property deletion (#37335) --- packages/codemode/interpreter-support.md | 3 +- packages/codemode/src/interpreter/runtime.ts | 26 ++++++++ packages/codemode/test/parity.test.ts | 65 ++++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 072d08dd04..2164e54569 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -117,7 +117,8 @@ ultimate source of truth. - [x] Unary `+`, unary `-`, `void`, `typeof`, `instanceof`, and own-property-only `in`. - [x] Prefix and postfix `++` and `--`. - [x] Plain, arithmetic, bitwise, and logical assignment operators. -- [ ] Property deletion, including computed forms such as `delete object[key]`. +- [x] Property deletion on plain data objects and arrays, including computed and optional forms; deleting an array index + creates a hole without changing its length. ## Promises and tools diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index a333744ee0..e21fa37f9d 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -1279,6 +1279,7 @@ export class Interpreter { private evaluateUnaryExpression(node: AstNode): Effect.Effect { const operator = getString(node, "operator") const argument = getNode(node, "argument") + if (operator === "delete") return this.evaluateDeleteExpression(argument) // Undeclared names short-circuit, but declared TDZ bindings must still throw. if (operator === "typeof" && argument.type === "Identifier" && !this.scopes.resolve(getString(argument, "name"))) { return Effect.succeed("undefined") @@ -1730,6 +1731,7 @@ export class Interpreter { private getMemberReference( node: AstNode, + operation: "read" | "delete" = "read", ): Effect.Effect< | MemberReference | ToolReference @@ -1876,6 +1878,7 @@ export class Interpreter { } if (Array.isArray(objectValue)) { + if (operation === "delete") return { target: objectValue, key } if ( key !== "length" && !(typeof key === "string" && arrayMethods.has(key)) && @@ -1924,6 +1927,29 @@ export class Interpreter { return this.modifyMember(node, () => Effect.succeed({ write: true, next: value, result: value })) } + private evaluateDeleteExpression(argument: AstNode): Effect.Effect { + const target = argument.type === "ChainExpression" ? getNode(argument, "expression") : argument + if (target.type !== "MemberExpression") { + throw new InterpreterRuntimeError("Only data fields may be deleted in CodeMode.", argument) + } + return Effect.map(this.getMemberReference(target, "delete"), (reference) => { + if (reference === OptionalShortCircuit) return true + if ( + reference instanceof ComputedValue || + reference === undefined || + reference instanceof ToolReference || + reference instanceof PromiseMethodReference || + reference instanceof PromiseInstanceMethodReference || + reference instanceof IntrinsicReference || + reference instanceof GlobalMethodReference || + reference.target instanceof CodeModeURL + ) { + throw new InterpreterRuntimeError("Only data fields may be deleted in CodeMode.", target, "InvalidDataValue") + } + return Reflect.deleteProperty(reference.target, reference.key) + }) + } + // Resolve side-effecting object and key expressions exactly once. private modifyMember( node: AstNode, diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index b30123db17..ac19dcc3f7 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -112,6 +112,71 @@ describe("unary void", () => { }) }) +describe("property deletion", () => { + test("deletes plain object fields and reports missing fields as successful", async () => { + expect( + await value(` + const object = { keep: 1, remove: 2 } + return [delete object.remove, delete object.missing, object] + `), + ).toEqual([true, true, { keep: 1 }]) + }) + + test("evaluates computed object and key expressions once", async () => { + expect( + await value(` + const object = { remove: true } + let objectReads = 0 + let keyReads = 0 + function getObject() { objectReads++; return object } + function getKey() { keyReads++; return "remove" } + const removed = delete getObject()[getKey()] + return [removed, objectReads, keyReads, Object.hasOwn(object, "remove")] + `), + ).toEqual([true, 1, 1, false]) + }) + + test("deleting an array index creates a hole without changing its length", async () => { + expect(await value(`const values = [1, 2, 3]; const removed = delete values[1]; return [removed, values.length, 1 in values, values]`)).toEqual([ + true, + 3, + false, + [1, null, 3], + ]) + }) + + test("array length is not configurable", async () => { + expect(await value(`const values = [1, 2]; return [delete values.length, values.length]`)).toEqual([false, 2]) + }) + + test("does not broaden unsupported array property assignment", async () => { + expect( + await value(` + const values = [] + let rightHandSideRuns = 0 + function next() { rightHandSideRuns++; return 1 } + try { values.field = next() } catch {} + return rightHandSideRuns + `), + ).toBe(0) + }) + + test("optional deletion short-circuits without evaluating the key", async () => { + expect( + await value(`let keyReads = 0; const object = null; return [delete object?.[keyReads++], keyReads]`), + ).toEqual([true, 0]) + }) + + test("rejects deletion from opaque runtime references", async () => { + expect((await error(`return delete tools.example`)).kind).toBe("InvalidDataValue") + }) + + test("keeps blocked property names unavailable", async () => { + expect((await error(`const object = {}; return delete object.__proto__`)).kind).toBe("ExecutionFailure") + expect((await error(`const values = []; return delete values["constructor"]`)).kind).toBe("ExecutionFailure") + }) +}) + describe("H1: NaN/Infinity flow as intermediates and normalize to null at the boundary", () => { test("guards run instead of the program crashing on a transient NaN", async () => { expect(await value(`return parseInt("abc") || 0`)).toBe(0)