From f55a0c904fe47e5c1869fe54acb2a73d8fc5214b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 6 Jul 2026 15:44:27 -0400 Subject: [PATCH 1/2] fix(codemode): align mutable JavaScript semantics --- packages/codemode/src/interpreter/runtime.ts | 115 ++++++++++++------- packages/codemode/src/stdlib/object.ts | 10 +- packages/codemode/test/parity.test.ts | 40 +++++++ packages/codemode/test/stdlib.test.ts | 20 ++++ 4 files changed, 138 insertions(+), 47 deletions(-) diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 093f577765..403edd34f5 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -1804,25 +1804,31 @@ class Interpreter { if (operator === "??=" || operator === "||=" || operator === "&&=") { return yield* self.evaluateLogicalAssignment(node, left, operator) } - const rightValue = yield* self.evaluateExpression(getNode(node, "right")) if (left.type === "Identifier") { const name = getString(left, "name") - if (operator === "=") return self.setIdentifierValue(name, rightValue, left) - const next = boundedData( - self.applyCompoundAssignment(operator, self.getIdentifierValue(name, left), rightValue, node), - "Assignment result", - ) - return self.setIdentifierValue(name, next, left) - } - if (left.type === "MemberExpression") { - if (operator === "=") return yield* self.writeMember(left, rightValue) - return yield* self.modifyMember(left, (current) => { + if (operator !== "=") { + const current = self.getIdentifierValue(name, left) + const rightValue = yield* self.evaluateExpression(getNode(node, "right")) const next = boundedData( self.applyCompoundAssignment(operator, current, rightValue, node), "Assignment result", ) - return Effect.succeed({ write: true, next, result: next }) - }) + return self.setIdentifierValue(name, next, left) + } + const rightValue = yield* self.evaluateExpression(getNode(node, "right")) + if (operator === "=") return self.setIdentifierValue(name, rightValue, left) + } + if (left.type === "MemberExpression") { + return yield* self.modifyMember(left, (current) => + Effect.map(self.evaluateExpression(getNode(node, "right")), (rightValue) => { + if (operator === "=") return { write: true, next: rightValue, result: rightValue } + const next = boundedData( + self.applyCompoundAssignment(operator, current, rightValue, node), + "Assignment result", + ) + return { write: true, next, result: next } + }), + ) } throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left) }) @@ -1923,6 +1929,9 @@ class Interpreter { if (callable.namespace === "Object" && args[0] instanceof ToolReference) { return self.invokeObjectMethodOnTools(callable.name, args[0] as ToolReference, node) } + if (callable.namespace === "Object" && callable.name === "assign") { + return invokeGlobalMethod(callable, args, node) + } return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`) } if (callable instanceof CoercionFunction) { @@ -2575,8 +2584,12 @@ class Interpreter { case "flat": return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1)) case "reverse": - return Effect.succeed([...target].reverse()) + return Effect.succeed(target.reverse()) case "sort": + return Effect.map(this.sortArray(target, args[0], node), (sorted) => { + target.splice(0, target.length, ...sorted) + return target + }) case "toSorted": return this.sortArray(target, args[0], node) case "toReversed": @@ -2659,19 +2672,24 @@ class Interpreter { ? Effect.succeed(invokeUriFunction(callback, callbackArgs, node)) : self.invokeFunction(callback, callbackArgs) return Effect.gen(function* () { - // Iterate a snapshot taken at call time so a callback that mutates the array can't - // self-extend the loop - matching JS, where elements appended during iteration are not visited. - const items = target.slice() + // Capture the initial length, but read the receiver live so callbacks observe mutations + // without visiting elements appended after iteration begins. + const length = target.length switch (name) { case "map": { const values: Array = [] - for (const [index, item] of items.entries()) values.push(yield* apply([item, index, items])) + values.length = length + for (let index = 0; index < length; index += 1) { + if (!(index in target)) continue + values[index] = yield* apply([target[index], index, target]) + } return values } case "flatMap": { const values: Array = [] - for (const [index, item] of items.entries()) { - const mapped = yield* apply([item, index, items]) + for (let index = 0; index < length; index += 1) { + if (!(index in target)) continue + const mapped = yield* apply([target[index], index, target]) if (Array.isArray(mapped)) values.push(...mapped) else values.push(mapped) } @@ -2679,33 +2697,40 @@ class Interpreter { } case "filter": { const values: Array = [] - for (const [index, item] of items.entries()) { - if (yield* apply([item, index, items])) values.push(item) + for (let index = 0; index < length; index += 1) { + if (!(index in target)) continue + const item = target[index] + if (yield* apply([item, index, target])) values.push(item) } return values } case "find": - for (const [index, item] of items.entries()) { - if (yield* apply([item, index, items])) return item + for (let index = 0; index < length; index += 1) { + const item = target[index] + if (yield* apply([item, index, target])) return item } return undefined case "findIndex": - for (const [index, item] of items.entries()) { - if (yield* apply([item, index, items])) return index + for (let index = 0; index < length; index += 1) { + if (yield* apply([target[index], index, target])) return index } return -1 case "some": - for (const [index, item] of items.entries()) { - if (yield* apply([item, index, items])) return true + for (let index = 0; index < length; index += 1) { + if (!(index in target)) continue + if (yield* apply([target[index], index, target])) return true } return false case "every": - for (const [index, item] of items.entries()) { - if (!(yield* apply([item, index, items]))) return false + for (let index = 0; index < length; index += 1) { + if (!(index in target)) continue + if (!(yield* apply([target[index], index, target]))) return false } return true case "forEach": - for (const [index, item] of items.entries()) yield* apply([item, index, items]) + for (let index = 0; index < length; index += 1) { + if (index in target) yield* apply([target[index], index, target]) + } return undefined case "reduce": { let accumulator: unknown @@ -2714,13 +2739,14 @@ class Interpreter { accumulator = args[1] start = 0 } else { - if (items.length === 0) + if (length === 0) throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node) - accumulator = items[0] + accumulator = target[0] start = 1 } - for (let index = start; index < items.length; index += 1) { - accumulator = yield* apply([accumulator, items[index], index, items]) + for (let index = start; index < length; index += 1) { + if (!(index in target)) continue + accumulator = yield* apply([accumulator, target[index], index, target]) } return accumulator } @@ -2729,26 +2755,27 @@ class Interpreter { let start: number if (args.length >= 2) { accumulator = args[1] - start = items.length - 1 + start = length - 1 } else { - if (items.length === 0) + if (length === 0) throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node) - accumulator = items[items.length - 1] - start = items.length - 2 + accumulator = target[length - 1] + start = length - 2 } for (let index = start; index >= 0; index -= 1) { - accumulator = yield* apply([accumulator, items[index], index, items]) + if (!(index in target)) continue + accumulator = yield* apply([accumulator, target[index], index, target]) } return accumulator } case "findLast": - for (let index = items.length - 1; index >= 0; index -= 1) { - if (yield* apply([items[index], index, items])) return items[index] + for (let index = length - 1; index >= 0; index -= 1) { + if (yield* apply([target[index], index, target])) return target[index] } return undefined case "findLastIndex": - for (let index = items.length - 1; index >= 0; index -= 1) { - if (yield* apply([items[index], index, items])) return index + for (let index = length - 1; index >= 0; index -= 1) { + if (yield* apply([target[index], index, target])) return index } return -1 } diff --git a/packages/codemode/src/stdlib/object.ts b/packages/codemode/src/stdlib/object.ts index 2beda0bf5c..4d454cbda2 100644 --- a/packages/codemode/src/stdlib/object.ts +++ b/packages/codemode/src/stdlib/object.ts @@ -36,10 +36,14 @@ export const invokeObjectMethod = (name: string, args: Array, node: Ast case "hasOwn": return Object.hasOwn(requireObject(), String(args[1])) case "assign": { - const out: Record = Object.create(null) - for (const source of args) { + const target = args[0] + if (target === null || typeof target !== "object" || Array.isArray(target) || isSandboxValue(target)) { + throw new InterpreterRuntimeError("Object.assign expects a data object target.", node) + } + const out = target as Record + for (const source of args.slice(1)) { if (source === null || source === undefined) continue - const value = boundedData(source, "Object.assign input") + const value = source if (isSandboxValue(value)) continue if (value === null || typeof value !== "object" || Array.isArray(value)) { throw new InterpreterRuntimeError("Object.assign expects data objects.", node) diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index dfa8583183..39f2ba02af 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -264,6 +264,46 @@ describe("Error values and instanceof", () => { }) describe("array methods: splice, fill, copyWithin, keys/values/entries", () => { + test("sort and reverse mutate and return the receiver", async () => { + expect( + await value(` + const sorted = [3, 1, 2] + const sortResult = sorted.sort((a, b) => a - b) + const reversed = [1, 2, 3] + const reverseResult = reversed.reverse() + return { sorted, sameSort: sorted === sortResult, reversed, sameReverse: reversed === reverseResult } + `), + ).toEqual({ sorted: [1, 2, 3], sameSort: true, reversed: [3, 2, 1], sameReverse: true }) + }) + + test("array callbacks receive the receiver and observe later mutations", async () => { + expect( + await value(` + const values = [1, 2, 3] + const seen = values.map((value, index, receiver) => { + if (index === 0) values[1] = 9 + return [value, receiver === values] + }) + return seen + `), + ).toEqual([ + [1, true], + [9, true], + [3, true], + ]) + expect( + await value(` + const values = [1, 2, 3] + const seen = [] + values.forEach((value, index) => { + seen.push(value) + if (index === 0) values.pop() + }) + return seen + `), + ).toEqual([1, 2]) + }) + test("splice removes in place and returns the removed elements", async () => { expect(await value(`const a = [1,2,3,4]; const removed = a.splice(1, 2); return { removed, a }`)).toEqual({ removed: [2, 3], diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index f7831a0603..489de451a7 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -586,6 +586,26 @@ describe("Set", () => { }) describe("stdlib integration", () => { + test("Object.assign mutates and returns its target", async () => { + expect( + await value(` + const target = { a: 1 } + const result = Object.assign(target, { b: 2 }) + return { target, result, same: target === result } + `), + ).toEqual({ target: { a: 1, b: 2 }, result: { a: 1, b: 2 }, same: true }) + expect(await value(`try { Object.assign(null, { a: 1 }); return false } catch { return true }`)).toBe(true) + }) + + test("assignment resolves and reads its left side before evaluating the right side", async () => { + expect(await value(`let x = 1; x += (x = 5); return x`)).toBe(6) + expect(await value(`let i = 0; const values = [9]; values[i++] = i; return [values, i]`)).toEqual([[1], 1]) + expect(await value(`let i = 0; const values = [10, 20]; values[i++] += i; return [values, i]`)).toEqual([ + [11, 20], + 1, + ]) + }) + test("typeof reports constructors as functions and never throws", async () => { expect(await value(`return typeof Map`)).toBe("function") expect(await value(`return typeof ((x) => x)`)).toBe("function") From 7711a4b543fa1690d7a32c94547fbbdf6d70ac1a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 6 Jul 2026 15:47:01 -0400 Subject: [PATCH 2/2] feat(codemode): support destructuring assignment --- packages/codemode/src/interpreter/runtime.ts | 83 ++++++++++++++++++++ packages/codemode/test/parity.test.ts | 35 +++++++++ 2 files changed, 118 insertions(+) diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 403edd34f5..f34e3949e1 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -1403,6 +1403,84 @@ class Interpreter { }) } + private assignPattern(pattern: AstNode, value: unknown, node: AstNode): Effect.Effect { + const self = this + return Effect.gen(function* () { + if (pattern.type === "Identifier") { + self.setIdentifierValue(getString(pattern, "name"), value, pattern) + return + } + + if (pattern.type === "MemberExpression") { + yield* self.writeMember(pattern, value) + return + } + + if (pattern.type === "AssignmentPattern") { + const resolved = value === undefined ? yield* self.evaluateExpression(getNode(pattern, "right")) : value + yield* self.assignPattern(getNode(pattern, "left"), resolved, node) + return + } + + if (pattern.type === "ObjectPattern") { + if (value === null || typeof value !== "object" || Array.isArray(value) || isRuntimeReference(value)) { + throw new InterpreterRuntimeError( + "Object destructuring requires a data object value.", + pattern, + "InvalidDataValue", + ) + } + + const source = value as SafeObject + const consumed = new Set() + for (const propertyValue of getArray(pattern, "properties")) { + const property = asNode(propertyValue, "properties") + if (property.type === "RestElement") { + const rest: SafeObject = Object.create(null) as SafeObject + for (const [key, item] of Object.entries(source)) { + if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item + } + yield* self.assignPattern(getNode(property, "argument"), rest, property) + continue + } + if ( + property.type !== "Property" || + getBoolean(property, "computed") || + getString(property, "kind") !== "init" + ) { + throw new InterpreterRuntimeError("Only named object destructuring properties are supported.", property) + } + const keyNode = getNode(property, "key") + const key = keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value) + if (isBlockedMember(key)) { + throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, keyNode) + } + consumed.add(key) + yield* self.assignPattern(getNode(property, "value"), source[key], property) + } + return + } + + if (pattern.type === "ArrayPattern") { + if (!Array.isArray(value)) { + throw new InterpreterRuntimeError("Array destructuring requires an array value.", pattern) + } + for (const [index, item] of getArray(pattern, "elements").entries()) { + if (item === null) continue + const element = asNode(item, `elements[${index}]`) + if (element.type === "RestElement") { + yield* self.assignPattern(getNode(element, "argument"), value.slice(index), element) + break + } + yield* self.assignPattern(element, value[index], pattern) + } + return + } + + throw new InterpreterRuntimeError(`Unsupported assignment pattern '${pattern.type}'.`, node) + }) + } + private evaluateExpression(node: AstNode): Effect.Effect { switch (node.type) { case "Literal": { @@ -1804,6 +1882,11 @@ class Interpreter { if (operator === "??=" || operator === "||=" || operator === "&&=") { return yield* self.evaluateLogicalAssignment(node, left, operator) } + if (operator === "=" && (left.type === "ObjectPattern" || left.type === "ArrayPattern")) { + const rightValue = yield* self.evaluateExpression(getNode(node, "right")) + yield* self.assignPattern(left, rightValue, node) + return rightValue + } if (left.type === "Identifier") { const name = getString(left, "name") if (operator !== "=") { diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index 39f2ba02af..1831a7222d 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -463,3 +463,38 @@ describe("H5: builtin coercion functions work as array callbacks", () => { expect(err.message).toContain("callback") }) }) + +describe("destructuring assignment", () => { + test("assigns object and array patterns to existing bindings", async () => { + expect( + await value(` + let a = 0 + let b = 0 + ;({ a } = { a: 2 }) + ;[a, b] = [3, 4] + return [a, b] + `), + ).toEqual([3, 4]) + }) + + test("supports defaults, nesting, rest, and member targets", async () => { + expect( + await value(` + let first = 0 + let fallback = 0 + let rest = {} + const target = {} + ;[first, fallback = 2, ...target.tail] = [1] + ;({ nested: { value: target.value }, kept: target.kept = 3, ...rest } = { + nested: { value: 4 }, + extra: 5, + }) + return { first, fallback, target, rest } + `), + ).toEqual({ first: 1, fallback: 2, target: { tail: [], value: 4, kept: 3 }, rest: { extra: 5 } }) + }) + + test("returns the assigned value", async () => { + expect(await value(`let a = 0; const result = ([a] = [7]); return [a, result]`)).toEqual([7, [7]]) + }) +})