Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton f55a0c904f fix(codemode): align mutable JavaScript semantics 2026-07-06 15:44:27 -04:00
4 changed files with 138 additions and 47 deletions
+71 -44
View File
@@ -1804,25 +1804,31 @@ class Interpreter<R> {
if (operator === "??=" || operator === "||=" || operator === "&&=") { if (operator === "??=" || operator === "||=" || operator === "&&=") {
return yield* self.evaluateLogicalAssignment(node, left, operator) return yield* self.evaluateLogicalAssignment(node, left, operator)
} }
const rightValue = yield* self.evaluateExpression(getNode(node, "right"))
if (left.type === "Identifier") { if (left.type === "Identifier") {
const name = getString(left, "name") const name = getString(left, "name")
if (operator === "=") return self.setIdentifierValue(name, rightValue, left) if (operator !== "=") {
const next = boundedData( const current = self.getIdentifierValue(name, left)
self.applyCompoundAssignment(operator, self.getIdentifierValue(name, left), rightValue, node), const rightValue = yield* self.evaluateExpression(getNode(node, "right"))
"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) => {
const next = boundedData( const next = boundedData(
self.applyCompoundAssignment(operator, current, rightValue, node), self.applyCompoundAssignment(operator, current, rightValue, node),
"Assignment result", "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) throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left)
}) })
@@ -1923,6 +1929,9 @@ class Interpreter<R> {
if (callable.namespace === "Object" && args[0] instanceof ToolReference) { if (callable.namespace === "Object" && args[0] instanceof ToolReference) {
return self.invokeObjectMethodOnTools(callable.name, args[0] as ToolReference, node) 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`) return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`)
} }
if (callable instanceof CoercionFunction) { if (callable instanceof CoercionFunction) {
@@ -2575,8 +2584,12 @@ class Interpreter<R> {
case "flat": case "flat":
return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1)) return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1))
case "reverse": case "reverse":
return Effect.succeed([...target].reverse()) return Effect.succeed(target.reverse())
case "sort": case "sort":
return Effect.map(this.sortArray(target, args[0], node), (sorted) => {
target.splice(0, target.length, ...sorted)
return target
})
case "toSorted": case "toSorted":
return this.sortArray(target, args[0], node) return this.sortArray(target, args[0], node)
case "toReversed": case "toReversed":
@@ -2659,19 +2672,24 @@ class Interpreter<R> {
? Effect.succeed(invokeUriFunction(callback, callbackArgs, node)) ? Effect.succeed(invokeUriFunction(callback, callbackArgs, node))
: self.invokeFunction(callback, callbackArgs) : self.invokeFunction(callback, callbackArgs)
return Effect.gen(function* () { return Effect.gen(function* () {
// Iterate a snapshot taken at call time so a callback that mutates the array can't // Capture the initial length, but read the receiver live so callbacks observe mutations
// self-extend the loop - matching JS, where elements appended during iteration are not visited. // without visiting elements appended after iteration begins.
const items = target.slice() const length = target.length
switch (name) { switch (name) {
case "map": { case "map": {
const values: Array<unknown> = [] const values: Array<unknown> = []
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 return values
} }
case "flatMap": { case "flatMap": {
const values: Array<unknown> = [] const values: Array<unknown> = []
for (const [index, item] of items.entries()) { for (let index = 0; index < length; index += 1) {
const mapped = yield* apply([item, index, items]) if (!(index in target)) continue
const mapped = yield* apply([target[index], index, target])
if (Array.isArray(mapped)) values.push(...mapped) if (Array.isArray(mapped)) values.push(...mapped)
else values.push(mapped) else values.push(mapped)
} }
@@ -2679,33 +2697,40 @@ class Interpreter<R> {
} }
case "filter": { case "filter": {
const values: Array<unknown> = [] const values: Array<unknown> = []
for (const [index, item] of items.entries()) { for (let index = 0; index < length; index += 1) {
if (yield* apply([item, index, items])) values.push(item) if (!(index in target)) continue
const item = target[index]
if (yield* apply([item, index, target])) values.push(item)
} }
return values return values
} }
case "find": case "find":
for (const [index, item] of items.entries()) { for (let index = 0; index < length; index += 1) {
if (yield* apply([item, index, items])) return item const item = target[index]
if (yield* apply([item, index, target])) return item
} }
return undefined return undefined
case "findIndex": case "findIndex":
for (const [index, item] of items.entries()) { for (let index = 0; index < length; index += 1) {
if (yield* apply([item, index, items])) return index if (yield* apply([target[index], index, target])) return index
} }
return -1 return -1
case "some": case "some":
for (const [index, item] of items.entries()) { for (let index = 0; index < length; index += 1) {
if (yield* apply([item, index, items])) return true if (!(index in target)) continue
if (yield* apply([target[index], index, target])) return true
} }
return false return false
case "every": case "every":
for (const [index, item] of items.entries()) { for (let index = 0; index < length; index += 1) {
if (!(yield* apply([item, index, items]))) return false if (!(index in target)) continue
if (!(yield* apply([target[index], index, target]))) return false
} }
return true return true
case "forEach": 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 return undefined
case "reduce": { case "reduce": {
let accumulator: unknown let accumulator: unknown
@@ -2714,13 +2739,14 @@ class Interpreter<R> {
accumulator = args[1] accumulator = args[1]
start = 0 start = 0
} else { } else {
if (items.length === 0) if (length === 0)
throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node) throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node)
accumulator = items[0] accumulator = target[0]
start = 1 start = 1
} }
for (let index = start; index < items.length; index += 1) { for (let index = start; index < length; 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 return accumulator
} }
@@ -2729,26 +2755,27 @@ class Interpreter<R> {
let start: number let start: number
if (args.length >= 2) { if (args.length >= 2) {
accumulator = args[1] accumulator = args[1]
start = items.length - 1 start = length - 1
} else { } else {
if (items.length === 0) if (length === 0)
throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node) throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node)
accumulator = items[items.length - 1] accumulator = target[length - 1]
start = items.length - 2 start = length - 2
} }
for (let index = start; index >= 0; index -= 1) { 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 return accumulator
} }
case "findLast": case "findLast":
for (let index = items.length - 1; index >= 0; index -= 1) { for (let index = length - 1; index >= 0; index -= 1) {
if (yield* apply([items[index], index, items])) return items[index] if (yield* apply([target[index], index, target])) return target[index]
} }
return undefined return undefined
case "findLastIndex": case "findLastIndex":
for (let index = items.length - 1; index >= 0; index -= 1) { for (let index = length - 1; index >= 0; index -= 1) {
if (yield* apply([items[index], index, items])) return index if (yield* apply([target[index], index, target])) return index
} }
return -1 return -1
} }
+7 -3
View File
@@ -36,10 +36,14 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
case "hasOwn": case "hasOwn":
return Object.hasOwn(requireObject(), String(args[1])) return Object.hasOwn(requireObject(), String(args[1]))
case "assign": { case "assign": {
const out: Record<string, unknown> = Object.create(null) const target = args[0]
for (const source of args) { 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<string, unknown>
for (const source of args.slice(1)) {
if (source === null || source === undefined) continue if (source === null || source === undefined) continue
const value = boundedData(source, "Object.assign input") const value = source
if (isSandboxValue(value)) continue if (isSandboxValue(value)) continue
if (value === null || typeof value !== "object" || Array.isArray(value)) { if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new InterpreterRuntimeError("Object.assign expects data objects.", node) throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
+40
View File
@@ -264,6 +264,46 @@ describe("Error values and instanceof", () => {
}) })
describe("array methods: splice, fill, copyWithin, keys/values/entries", () => { 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 () => { 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({ expect(await value(`const a = [1,2,3,4]; const removed = a.splice(1, 2); return { removed, a }`)).toEqual({
removed: [2, 3], removed: [2, 3],
+20
View File
@@ -586,6 +586,26 @@ describe("Set", () => {
}) })
describe("stdlib integration", () => { 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 () => { test("typeof reports constructors as functions and never throws", async () => {
expect(await value(`return typeof Map`)).toBe("function") expect(await value(`return typeof Map`)).toBe("function")
expect(await value(`return typeof ((x) => x)`)).toBe("function") expect(await value(`return typeof ((x) => x)`)).toBe("function")