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

This commit is contained in:
Aiden Cline 2026-07-16 12:21:28 -05:00 committed by GitHub
commit ad8e6b1fb6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 93 additions and 1 deletions

View file

@ -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

View file

@ -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,

View file

@ -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)