mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-24 20:25:36 -04:00
feat(codemode): support property deletion
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -1823,6 +1823,13 @@ 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") {
|
||||
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<R> {
|
||||
|
||||
private getMemberReference(
|
||||
node: AstNode,
|
||||
options?: { readonly allowUnknownArrayProperty: boolean },
|
||||
): Effect.Effect<
|
||||
| MemberReference
|
||||
| ToolReference
|
||||
@@ -3233,6 +3241,7 @@ class Interpreter<R> {
|
||||
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<R> {
|
||||
return this.modifyMember(node, () => Effect.succeed({ write: true, next: value, result: value }))
|
||||
}
|
||||
|
||||
private deleteMember(node: AstNode): Effect.Effect<boolean, unknown, R> {
|
||||
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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user