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