Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton 3302589ef3 feat(codemode): support property deletion 2026-07-07 17:40:57 +00:00
3 changed files with 96 additions and 1 deletions
+1 -1
View File
@@ -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.
+63
View File
@@ -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")