Bläddra i källkod

feat(codemode): support property deletion (#37335)

Aiden Cline 1 månad sedan
förälder
incheckning
ad8e6b1fb6

+ 2 - 1
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
 

+ 26 - 0
packages/codemode/src/interpreter/runtime.ts

@@ -1279,6 +1279,7 @@ export class Interpreter<R> {
   private evaluateUnaryExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
     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<R> {
 
   private getMemberReference(
     node: AstNode,
+    operation: "read" | "delete" = "read",
   ): Effect.Effect<
     | MemberReference
     | ToolReference
@@ -1876,6 +1878,7 @@ export class Interpreter<R> {
       }
 
       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<R> {
     return this.modifyMember(node, () => Effect.succeed({ write: true, next: value, result: value }))
   }
 
+  private evaluateDeleteExpression(argument: AstNode): Effect.Effect<boolean, unknown, R> {
+    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,

+ 65 - 0
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)