diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 771d672943f..b81722609b8 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -237,7 +237,7 @@ A host cannot define its own `$codemode` top-level namespace. CodeMode executes a deliberately bounded JavaScript subset. It supports: -- Plain data literals, property access, assignment, destructuring, and sequence expressions (the comma operator, evaluated left to right with the final value returned). +- Plain data literals, property access, assignment, property deletion, destructuring, and sequence expressions (the comma operator, evaluated left to right with the final value returned). - `if`, conditional expressions, `switch`, `for`, `for...of` (arrays, strings, Maps, Sets, including assignment-form destructuring such as `for ([key, value] of entries)`), `for...in` (own keys of plain objects, index strings of arrays, and namespace/tool names of `tools` references - anything else is an error suggesting `for...of` or `Object.keys`, rather than real JS's surprising behavior of indices for strings and zero iterations for Maps/Sets), `while`, and `do...while`. - Arrow functions and function declarations with closures, defaults, rest parameters, and destructuring. - Optional chaining, nullish coalescing, templates, spread (arrays, strings, Maps, Sets), and `try`/`catch`. diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index bdee13b2a8f..46b84c61e70 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -1823,6 +1823,13 @@ class Interpreter { private evaluateUnaryExpression(node: AstNode): Effect.Effect { const operator = getString(node, "operator") const argument = getNode(node, "argument") + if (operator === "delete") { + const target = argument.type === "ChainExpression" ? getNode(argument, "expression") : argument + if (target.type !== "MemberExpression") { + throw new InterpreterRuntimeError("Delete target must be a data property in CodeMode.", argument) + } + return this.deleteMember(target) + } // `typeof undeclaredIdentifier` is `"undefined"` in JS (never a ReferenceError), so // feature-detection guards like `typeof x !== "undefined"` don't crash. Short-circuit before // evaluating the argument; a declared-but-TDZ binding still falls through to the normal throw. @@ -3067,6 +3074,7 @@ class Interpreter { private getMemberReference( node: AstNode, + options?: { readonly allowUnknownArrayProperty: boolean }, ): Effect.Effect< | MemberReference | ToolReference @@ -3233,6 +3241,7 @@ class Interpreter { typeof key !== "number" && !/^\d+$/.test(key) ) { + if (options?.allowUnknownArrayProperty === true) return { target: objectValue, key } // Own non-index properties read through (match results carry index/groups); like JS, // they are readable in place and dropped by JSON at data boundaries. if (typeof key === "string" && Object.hasOwn(objectValue, key)) { @@ -3278,6 +3287,29 @@ class Interpreter { return this.modifyMember(node, () => Effect.succeed({ write: true, next: value, result: value })) } + private deleteMember(node: AstNode): Effect.Effect { + return Effect.map(this.getMemberReference(node, { allowUnknownArrayProperty: true }), (reference) => { + if (reference === OptionalShortCircuit) return true + if ( + reference instanceof ComputedValue || + reference === undefined || + reference instanceof ToolReference || + reference instanceof PromiseMethodReference || + reference instanceof IntrinsicReference || + reference instanceof GlobalMethodReference || + reference.target instanceof SandboxURL + ) { + throw new InterpreterRuntimeError("Only data properties may be deleted in CodeMode.", node) + } + if (Array.isArray(reference.target)) { + if (reference.key === "length" || (typeof reference.key === "string" && arrayMethods.has(reference.key))) { + throw new InterpreterRuntimeError("Array length and methods cannot be deleted in CodeMode.", node) + } + } + return Reflect.deleteProperty(reference.target, reference.key) + }) + } + // Resolves the member reference EXACTLY ONCE (so a side-effecting object/key expression // runs once), then lets `compute` decide whether to write - enabling compound assignment, // updates, plain writes, and short-circuiting logical assignment to share one safe path. diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index 5593831127a..0f7e5922c1e 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -89,6 +89,69 @@ describe("H6: object spread of null/undefined is a no-op", () => { }) }) +describe("delete data properties", () => { + test("removes an object property and reports success", async () => { + expect(await value(`const item = { keep: 1, remove: 2 }; const removed = delete item.remove; return { item, removed }`)).toEqual({ + item: { keep: 1 }, + removed: true, + }) + }) + + test("evaluates a computed deletion key once", async () => { + expect( + await value(` + const item = { a: 1, b: 2 } + let calls = 0 + const key = () => { calls += 1; return "b" } + const removed = delete item[key()] + return { item, calls, removed } + `), + ).toEqual({ item: { a: 1 }, calls: 1, removed: true }) + }) + + test("deleting an array element leaves a hole without changing length", async () => { + expect( + await value(` + const items = ["a", "b", "c"] + const removed = delete items[1] + return { removed, length: items.length, hasIndex: 1 in items, keys: Object.keys(items) } + `), + ).toEqual({ removed: true, length: 3, hasIndex: false, keys: ["0", "2"] }) + }) + + test("deleting an absent property succeeds", async () => { + expect(await value(`const item = { keep: 1 }; return delete item.missing`)).toBe(true) + }) + + test("optional deletion of a nullish receiver succeeds", async () => { + expect( + await value(`let calls = 0; const item = null; const removed = delete item?.[calls++]; return { calls, removed }`), + ).toEqual({ calls: 0, removed: true }) + }) + + test("deleting an absent or non-canonical array key does not remove an element", async () => { + expect( + await value(` + const items = ["a", "b"] + const absent = delete items.missing + const nonCanonical = delete items["01"] + return { absent, nonCanonical, items, hasIndex: 1 in items } + `), + ).toEqual({ absent: true, nonCanonical: true, items: ["a", "b"], hasIndex: true }) + }) + + test("rejects deletion of runtime and protected members", async () => { + for (const code of [ + `delete tools.missing`, + `delete new URL("https://example.test").pathname`, + `delete [1].length`, + `delete [1].map`, + ]) { + expect((await error(code)).message).toContain("deleted") + } + }) +}) + describe("H4: typeof on an undeclared identifier is 'undefined'", () => { test("feature-detection guard does not throw", async () => { expect(await value(`return typeof foo === "undefined" ? "safe" : "no"`)).toBe("safe")