Просмотр исходного кода

feat(codemode): support custom async iterators (#38141)

Aiden Cline 3 недель назад
Родитель
Сommit
6f4b9504e5

+ 7 - 4
packages/codemode/interpreter-support.md

@@ -36,7 +36,8 @@ ultimate source of truth.
 - [x] Regular-expression literals.
 - [x] `NaN` and `Infinity` globals.
 - [ ] BigInt literals and in-interpreter BigInt arithmetic; BigInt remains invalid at JSON-like host boundaries.
-- [ ] Symbol primitive values and symbol-keyed properties.
+- [ ] Arbitrary Symbol primitive values and symbol-keyed properties. The confined `Symbol.iterator` and
+      `Symbol.asyncIterator` keys are available only for custom iterator protocols.
 - [ ] Tagged-template calls.
 - [ ] Getter and setter definitions in object literals.
 
@@ -70,9 +71,11 @@ ultimate source of truth.
 - [x] `try`, `catch`, optional catch bindings, and `finally`.
 - [x] `throw` with arbitrary values.
 - [x] Labeled statements, labeled `break`, and labeled `continue`.
-- [x] `for await...of` over the supported synchronous collections, awaiting each yielded CodeMode promise or plain
-      value before binding it. Custom sync/async iterator objects, `Symbol.asyncIterator`, and async generators remain
-      outside the supported subset.
+- [x] `for await...of` over the supported synchronous collections and custom iterator objects using
+      `Symbol.asyncIterator` or the `Symbol.iterator` fallback. Each iterator step is sequential, yielded promises and
+      plain values from synchronous collections and sync iterators are awaited before binding, and abrupt loop
+      completion invokes the iterator's optional `return()`. Custom async iterators control their yielded values, as in
+      JavaScript; only their `next()` results are awaited. Async generators remain outside the supported subset.
 
 ## Functions and callbacks
 

+ 7 - 1
packages/codemode/src/interpreter/model.ts

@@ -36,7 +36,7 @@ export type StatementResult =
 
 export type MemberReference = {
   target: SafeObject | Array<unknown> | CodeModeRegExp | CodeModeURL
-  key: string | number
+  key: PropertyKey
 }
 
 export class CodeModeFunction {
@@ -61,6 +61,12 @@ export class ComputedValue {
 
 export class PromiseNamespace {}
 
+export class SymbolNamespace {}
+
+export const AsyncIteratorSymbol: unique symbol = Symbol("codemode.async-iterator")
+export const IteratorSymbol: unique symbol = Symbol("codemode.iterator")
+export const IteratorSymbols = [AsyncIteratorSymbol, IteratorSymbol] as const
+
 export type PromiseMethodName = "all" | "allSettled" | "race" | "any" | "resolve" | "reject"
 
 export class PromiseMethodReference {

+ 4 - 1
packages/codemode/src/interpreter/references.ts

@@ -13,6 +13,7 @@ import {
   PromiseMethodReference,
   PromiseNamespace,
   SearchFunction,
+  SymbolNamespace,
   UriFunction,
 } from "./model.js"
 import { ToolReference } from "../tool-runtime.js"
@@ -34,6 +35,7 @@ export const isRuntimeReference = (value: unknown): boolean =>
   value instanceof SearchFunction ||
   value instanceof PromiseCapabilityFunction ||
   value instanceof ErrorConstructorReference ||
+  value instanceof SymbolNamespace ||
   isCodeModeValue(value)
 
 function* childValues(value: object): Generator<unknown> {
@@ -113,7 +115,8 @@ export const typeofValue = (value: unknown): string => {
     value instanceof PromiseInstanceMethodReference ||
     value instanceof PromiseNamespace ||
     value instanceof PromiseCapabilityFunction ||
-    value instanceof ErrorConstructorReference
+    value instanceof ErrorConstructorReference ||
+    value instanceof SymbolNamespace
   )
     return "function"
   if (value instanceof UriFunction || value instanceof SearchFunction) return "function"

+ 195 - 41
packages/codemode/src/interpreter/runtime.ts

@@ -1,7 +1,8 @@
-import { Cause, Effect } from "effect"
+import { Cause, Effect, Exit } from "effect"
 import { isBlockedMember, ToolReference, ToolRuntimeError, type SafeObject } from "../tool-runtime.js"
 import {
   type AstNode,
+  AsyncIteratorSymbol,
   asNode,
   type Binding,
   CodeModeFunction,
@@ -19,6 +20,8 @@ import {
   IntrinsicReference,
   InterpreterRuntimeError,
   isRecord,
+  IteratorSymbol,
+  IteratorSymbols,
   JsonMethodReference,
   type MemberReference,
   OptionalShortCircuit,
@@ -30,6 +33,7 @@ import {
   ProgramThrow,
   type ProgramNode,
   SearchFunction,
+  SymbolNamespace,
   type StatementResult,
   supportedSyntaxMessage,
   unsupportedSyntax,
@@ -211,6 +215,12 @@ const loopDeclaration = (left: AstNode, statement: "for...of" | "for...in") => {
   }
 }
 
+type CustomIterator = {
+  iterator: SafeObject
+  next: unknown
+  asynchronous: boolean
+}
+
 export class Interpreter<R> {
   private scopes: ScopeStack
   private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
@@ -241,6 +251,7 @@ export class Interpreter<R> {
     globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
     globalScope.set("search", { mutable: false, value: new SearchFunction() })
     globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() })
+    globalScope.set("Symbol", { mutable: false, value: new SymbolNamespace() })
     globalScope.set("undefined", { mutable: false, value: undefined })
     globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") })
     globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") })
@@ -636,9 +647,10 @@ export class Interpreter<R> {
       const body = getNode(node, "body")
 
       const iterable = spreadItems(right)
-      if (iterable === undefined) {
+      const iterator = iterable === undefined && awaiting ? yield* self.customIterator(right, node) : undefined
+      if (iterable === undefined && iterator === undefined) {
         throw new InterpreterRuntimeError(
-          `${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams value.`,
+          `${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams${awaiting ? ", or custom iterator" : ""} value.`,
           node,
         )
       }
@@ -657,19 +669,14 @@ export class Interpreter<R> {
         throw new InterpreterRuntimeError("Unsupported for...of binding.", left)
       }
 
-      for (const value of iterable) {
-        const resolved = awaiting
-          ? value instanceof CodeModePromise
-            ? yield* self.settlePromise(value)
-            : yield* Effect.as(Effect.yieldNow, value)
-          : value
-        const result = yield* Effect.gen(function* () {
+      const evaluateBody = (value: unknown) =>
+        Effect.gen(function* () {
           if (declared) {
             self.scopes.push()
             if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
-            yield* self.declarePattern(declared.pattern, resolved, declared.mutable, left, declared.lexical)
+            yield* self.declarePattern(declared.pattern, value, declared.mutable, left, declared.lexical)
           } else if (assignment) {
-            yield* self.assignPattern(assignment, resolved, left)
+            yield* self.assignPattern(assignment, value, left)
           }
           return yield* self.evaluateStatement(body)
         }).pipe(
@@ -680,22 +687,48 @@ export class Interpreter<R> {
           ),
         )
 
+      if (iterable !== undefined) {
+        for (const value of iterable) {
+          const result = yield* evaluateBody(awaiting ? yield* self.awaitValue(value) : value)
+
+          if (result.kind === "return") return result
+          if (result.kind === "break") {
+            if (result.label !== undefined && !labels?.has(result.label)) return result
+            return { kind: "none" } satisfies StatementResult
+          }
+          if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) return result
+        }
+        return { kind: "none" } satisfies StatementResult
+      }
+      if (iterator === undefined) throw new InterpreterRuntimeError("Custom iterator is unavailable.", node)
+
+      while (true) {
+        const step = yield* self.nextIteratorResult(iterator, node)
+        if (step.done) return { kind: "none" } satisfies StatementResult
+        const bodyExit = yield* Effect.exit(evaluateBody(step.value))
+        if (!Exit.isSuccess(bodyExit)) {
+          // Process interruption must remain prompt; user cleanup cannot extend a timeout.
+          if (!Cause.hasInterruptsOnly(bodyExit.cause)) yield* Effect.exit(self.closeIterator(iterator, node))
+          return yield* Effect.failCause(bodyExit.cause)
+        }
+        const result = bodyExit.value
+
         if (result.kind === "return") {
+          yield* self.closeIterator(iterator, node)
           return result
         }
 
         if (result.kind === "break") {
+          yield* self.closeIterator(iterator, node)
           if (result.label !== undefined && !labels?.has(result.label)) return result
           return { kind: "none" } satisfies StatementResult
         }
 
-        if (result.kind === "continue") {
-          if (result.label !== undefined && !labels?.has(result.label)) return result
-          continue
+        if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) {
+          yield* self.closeIterator(iterator, node)
+          return result
         }
       }
-
-      return { kind: "none" } satisfies StatementResult
     }).pipe(
       Effect.ensuring(
         Effect.sync(() => {
@@ -705,6 +738,101 @@ export class Interpreter<R> {
     )
   }
 
+  private awaitValue(value: unknown): Effect.Effect<unknown, unknown, R> {
+    return value instanceof CodeModePromise ? this.settlePromise(value) : Effect.as(Effect.yieldNow, value)
+  }
+
+  private customIterator(value: unknown, node: AstNode) {
+    if (!isRecord(value) || isRuntimeReference(value)) return Effect.succeed(undefined)
+    const asyncMethod = Reflect.get(value, AsyncIteratorSymbol)
+    const method = asyncMethod ?? Reflect.get(value, IteratorSymbol)
+    if (method === undefined || method === null) return Effect.succeed(undefined)
+    const self = this
+    return Effect.map(
+      this.invokeCallable(this.requireIteratorMethod(method, "Iterator method", node), [], node),
+      (iterator) => {
+        const object = self.requireIteratorObject(iterator, "Iterator method result", node)
+        return {
+          iterator: object,
+          next: self.requireIteratorMethod(object.next, "Iterator next", node),
+          asynchronous: asyncMethod !== undefined && asyncMethod !== null,
+        }
+      },
+    )
+  }
+
+  private nextIteratorResult(iterator: CustomIterator, node: AstNode) {
+    const self = this
+    return Effect.gen(function* () {
+      if (iterator.asynchronous) {
+        const object = self.requireIteratorObject(
+          yield* self.awaitValue(yield* self.invokeCallable(iterator.next, [], node)),
+          "Iterator next() result",
+          node,
+        )
+        return { done: Boolean(object.done), value: object.value }
+      }
+
+      const called = yield* Effect.exit(self.invokeCallable(iterator.next, [], node))
+      if (!Exit.isSuccess(called)) {
+        yield* Effect.yieldNow
+        return yield* Effect.failCause(called.cause)
+      }
+      const captured = yield* Effect.exit(
+        Effect.sync(() => {
+          const object = self.requireIteratorObject(called.value, "Iterator next() result", node)
+          return { done: Boolean(object.done), value: object.value }
+        }),
+      )
+      if (!Exit.isSuccess(captured)) {
+        yield* Effect.yieldNow
+        return yield* Effect.failCause(captured.cause)
+      }
+      return { done: captured.value.done, value: yield* self.awaitValue(captured.value.value) }
+    })
+  }
+
+  private closeIterator(iterator: CustomIterator, node: AstNode): Effect.Effect<void, unknown, R> {
+    const close = iterator.iterator.return
+    if (close === undefined || close === null) return iterator.asynchronous ? Effect.void : Effect.yieldNow
+    const self = this
+    return Effect.gen(function* () {
+      const method = self.requireIteratorMethod(close, "Iterator return", node)
+      if (iterator.asynchronous) {
+        self.requireIteratorObject(
+          yield* self.awaitValue(yield* self.invokeCallable(method, [], node)),
+          "Iterator return() result",
+          node,
+        )
+        return
+      }
+
+      const called = yield* Effect.exit(self.invokeCallable(method, [], node))
+      if (!Exit.isSuccess(called)) {
+        yield* Effect.yieldNow
+        return yield* Effect.failCause(called.cause)
+      }
+      const captured = yield* Effect.exit(
+        Effect.sync(() => self.requireIteratorObject(called.value, "Iterator return() result", node).value),
+      )
+      if (!Exit.isSuccess(captured)) {
+        yield* Effect.yieldNow
+        return yield* Effect.failCause(captured.cause)
+      }
+      yield* self.awaitValue(captured.value)
+    })
+  }
+
+  private requireIteratorObject(value: unknown, context: string, node: AstNode): SafeObject {
+    if (isRecord(value) && !isRuntimeReference(value)) return value
+    throw new InterpreterRuntimeError(`${context} must be an object.`, node).as("TypeError")
+  }
+
+  private requireIteratorMethod(value: unknown, context: string, node: AstNode): unknown {
+    if (typeofValue(value) === "function") return value
+    throw new InterpreterRuntimeError(`${context} must be a function.`, node).as("TypeError")
+  }
+
   private enumerableKeys(value: unknown): Array<string> | undefined {
     if (value instanceof ToolReference) {
       return [...this.toolKeys(value.path)]
@@ -922,7 +1050,7 @@ export class Interpreter<R> {
           )
         }
 
-        const consumed = new Set<string>()
+        const consumed = new Set<PropertyKey>()
         for (const propertyValue of getArray(pattern, "properties")) {
           const property = asNode(propertyValue, "properties")
 
@@ -931,6 +1059,10 @@ export class Interpreter<R> {
             for (const [key, item] of Object.entries(value as SafeObject)) {
               if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
             }
+            for (const symbol of IteratorSymbols) {
+              if (!consumed.has(symbol) && Object.hasOwn(value, symbol))
+                Reflect.set(rest, symbol, Reflect.get(value, symbol))
+            }
             yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property, initialize)
             continue
           }
@@ -939,7 +1071,7 @@ export class Interpreter<R> {
           if (isBlockedMember(String(key))) {
             throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property)
           }
-          consumed.add(String(key))
+          consumed.add(typeof key === "symbol" ? key : String(key))
           yield* self.declarePattern(
             getNode(property, "value"),
             self.destructuringPropertyValue(value as SafeObject | Array<unknown>, key),
@@ -1002,7 +1134,7 @@ export class Interpreter<R> {
         }
 
         const source = value as SafeObject | Array<unknown>
-        const consumed = new Set<string>()
+        const consumed = new Set<PropertyKey>()
         for (const propertyValue of getArray(pattern, "properties")) {
           const property = asNode(propertyValue, "properties")
           if (property.type === "RestElement") {
@@ -1010,6 +1142,10 @@ export class Interpreter<R> {
             for (const [key, item] of Object.entries(source)) {
               if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
             }
+            for (const symbol of IteratorSymbols) {
+              if (!consumed.has(symbol) && Object.hasOwn(source, symbol))
+                Reflect.set(rest, symbol, Reflect.get(source, symbol))
+            }
             yield* self.assignPattern(getNode(property, "argument"), rest, property)
             continue
           }
@@ -1017,7 +1153,7 @@ export class Interpreter<R> {
           if (isBlockedMember(String(key))) {
             throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property)
           }
-          consumed.add(String(key))
+          consumed.add(typeof key === "symbol" ? key : String(key))
           yield* self.assignPattern(getNode(property, "value"), self.destructuringPropertyValue(source, key), property)
         }
         return
@@ -1044,7 +1180,7 @@ export class Interpreter<R> {
     })
   }
 
-  private destructuringPropertyKey(property: AstNode): Effect.Effect<string | number, unknown, R> {
+  private destructuringPropertyKey(property: AstNode): Effect.Effect<PropertyKey, unknown, R> {
     if (property.type !== "Property" || getString(property, "kind") !== "init") {
       throw new InterpreterRuntimeError("Unsupported object destructuring property.", property)
     }
@@ -1055,12 +1191,12 @@ export class Interpreter<R> {
     return Effect.succeed(keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value))
   }
 
-  private destructuringPropertyValue(source: SafeObject | Array<unknown>, key: string | number): unknown {
-    if (!Array.isArray(source)) return source[String(key)]
+  private destructuringPropertyValue(source: SafeObject | Array<unknown>, key: PropertyKey): unknown {
+    if (!Array.isArray(source)) return Reflect.get(source, key)
     if (key === "length") return source.length
     if (typeof key === "number") return source[key]
-    if (Object.hasOwn(source, key)) return (source as Record<string, unknown> & Array<unknown>)[key]
-    if (arrayMethods.has(key)) return new IntrinsicReference(source, key)
+    if (Object.hasOwn(source, key)) return Reflect.get(source, key)
+    if (typeof key === "string" && arrayMethods.has(key)) return new IntrinsicReference(source, key)
     return undefined
   }
 
@@ -1690,6 +1826,12 @@ export class Interpreter<R> {
       if (callable instanceof PromiseNamespace) {
         throw new InterpreterRuntimeError("Constructor Promise requires 'new'.", node).as("TypeError")
       }
+      if (callable instanceof SymbolNamespace) {
+        throw new InterpreterRuntimeError(
+          "Symbol is not callable; only Symbol.asyncIterator and Symbol.iterator are available.",
+          node,
+        ).as("TypeError")
+      }
       if (callable instanceof PromiseCapabilityFunction) {
         callable.settle(args[0])
         return undefined
@@ -1800,6 +1942,9 @@ export class Interpreter<R> {
             if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, property)
             objectValue[key] = value
           }
+          for (const symbol of IteratorSymbols) {
+            if (Object.hasOwn(spread, symbol)) Reflect.set(objectValue, symbol, Reflect.get(spread, symbol))
+          }
           continue
         }
 
@@ -1830,7 +1975,7 @@ export class Interpreter<R> {
         if (isBlockedMember(String(key))) {
           throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, keyNode)
         }
-        objectValue[String(key)] = yield* self.evaluateExpression(valueNode)
+        Reflect.set(objectValue, key, yield* self.evaluateExpression(valueNode))
       }
 
       return objectValue
@@ -1955,6 +2100,12 @@ export class Interpreter<R> {
         )
       }
 
+      if (objectValue instanceof SymbolNamespace) {
+        if (key === "asyncIterator") return new ComputedValue(AsyncIteratorSymbol)
+        if (key === "iterator") return new ComputedValue(IteratorSymbol)
+        return new ComputedValue(undefined)
+      }
+
       if (objectValue instanceof GlobalNamespace) {
         if (typeof key === "string" && isBlockedMember(key)) {
           throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available.`, propertyNode)
@@ -1976,7 +2127,7 @@ export class Interpreter<R> {
 
       if (typeof objectValue === "string") {
         if (key === "length") return new ComputedValue(objectValue.length)
-        const index = parseArrayIndex(key)
+        const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
         if (index !== undefined) return new ComputedValue(objectValue[index])
         if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key)
         return new ComputedValue(undefined)
@@ -2072,7 +2223,7 @@ export class Interpreter<R> {
 
       if (Array.isArray(objectValue)) {
         if (operation === "delete") return { target: objectValue, key }
-        const index = parseArrayIndex(key)
+        const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
         if (key !== "length" && !(typeof key === "string" && arrayMethods.has(key)) && index === undefined) {
           if (typeof key === "string" && Object.hasOwn(objectValue, key)) {
             return new ComputedValue((objectValue as Record<string, unknown> & Array<unknown>)[key])
@@ -2103,13 +2254,13 @@ export class Interpreter<R> {
       if (Array.isArray(reference.target)) {
         if (reference.key === "length") return reference.target.length
         if (typeof reference.key === "string") return new IntrinsicReference(reference.target, reference.key)
-        return reference.target[reference.key]
+        return Reflect.get(reference.target, reference.key)
       }
       if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
       if (reference.target instanceof CodeModeURL) {
-        return (reference.target.url as unknown as Record<string, unknown>)[String(reference.key)]
+        return Reflect.get(reference.target.url, reference.key)
       }
-      return reference.target[String(reference.key)]
+      return Reflect.get(reference.target, reference.key)
     })
   }
 
@@ -2171,22 +2322,22 @@ export class Interpreter<R> {
           throw new InterpreterRuntimeError("Array methods cannot be assigned.", node)
         }
       }
-      const key = Array.isArray(reference.target) ? reference.key : String(reference.key)
+      const key = reference.key
       const { write, next, result } = yield* compute(self.readReferenceValue(reference, key))
       if (write) self.assignToReference(reference, key, next, node)
       return result
     })
   }
 
-  private readReferenceValue(reference: MemberReference, key: number | string): unknown {
+  private readReferenceValue(reference: MemberReference, key: PropertyKey): unknown {
     if (reference.target instanceof CodeModeURL) {
-      return (reference.target.url as unknown as Record<string, unknown>)[key]
+      return Reflect.get(reference.target.url, key)
     }
     if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
-    return (reference.target as Record<PropertyKey, unknown>)[key]
+    return Reflect.get(reference.target, key)
   }
 
-  private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void {
+  private assignToReference(reference: MemberReference, key: PropertyKey, next: unknown, node: AstNode): void {
     if (Array.isArray(reference.target)) {
       const target = reference.target
       if (typeof key !== "number" || parseArrayIndex(key) === undefined) {
@@ -2219,16 +2370,19 @@ export class Interpreter<R> {
       return
     }
     const target = reference.target as SafeObject
-    const objectKey = key as string
     rejectCircularInsertion(target, next, "Object assignment result", node)
-    target[objectKey] = next
+    Reflect.set(target, key, next)
   }
 
-  private toPropertyKey(value: unknown, node: AstNode): string | number {
+  private toPropertyKey(value: unknown, node: AstNode): PropertyKey {
     if (typeof value === "string" || typeof value === "number") {
       return value
     }
+    if (value === AsyncIteratorSymbol || value === IteratorSymbol) return value
 
-    throw new InterpreterRuntimeError("Property key must be a string or number.", node)
+    throw new InterpreterRuntimeError(
+      "Property key must be a string or number, or Symbol.asyncIterator/Symbol.iterator.",
+      node,
+    )
   }
 }

+ 14 - 2
packages/codemode/src/stdlib/object.ts

@@ -1,4 +1,10 @@
-import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
+import {
+  type AstNode,
+  AsyncIteratorSymbol,
+  InterpreterRuntimeError,
+  IteratorSymbol,
+  IteratorSymbols,
+} from "../interpreter/model.js"
 import { containsOpaqueReference } from "../interpreter/references.js"
 import { isBlockedMember } from "../tool-runtime.js"
 import { isCodeModeValue, CodeModeMap, CodeModePromise, CodeModeSet, CodeModeURLSearchParams } from "../values.js"
@@ -46,7 +52,10 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
     case "entries":
       return Object.entries(requireObject()).map(([key, item]) => [key, item])
     case "hasOwn":
-      return Object.hasOwn(requireObject(), String(args[1]))
+      return Object.hasOwn(
+        requireObject(),
+        args[1] === AsyncIteratorSymbol || args[1] === IteratorSymbol ? args[1] : String(args[1]),
+      )
     case "is":
       if (containsOpaqueReference(args[0]) || containsOpaqueReference(args[1])) {
         throw new InterpreterRuntimeError("Object.is requires data values.", node, "InvalidDataValue")
@@ -64,6 +73,9 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
           throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
         }
         for (const [key, item] of Object.entries(source)) guardedSet(out, key, item)
+        for (const symbol of IteratorSymbols) {
+          if (Object.hasOwn(source, symbol)) Reflect.set(out, symbol, Reflect.get(source, symbol))
+        }
       }
       return out
     }

+ 397 - 2
packages/codemode/test/for-await-test262.test.ts

@@ -1,10 +1,15 @@
 /*
  * Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
  * - test/language/statements/for-await-of/ticks-with-sync-iter-resolved-promise-and-constructor-lookup.js
+ * - test/language/statements/for-await-of/ticks-with-async-iter-resolved-promise-and-constructor-lookup.js
  * - test/language/statements/for-await-of/async-func-dstr-let-ary-ptrn-elem-id-iter-val.js
  * - test/language/statements/for-await-of/async-func-decl-dstr-array-rest-after-element.js
+ * - test/language/statements/for-await-of/iterator-close-non-throw-get-method-is-null.js
+ * - test/language/statements/for-await-of/iterator-close-non-throw-get-method-non-callable.js
+ * - test/language/statements/for-await-of/iterator-close-throw-get-method-non-callable.js
  *
  * Copyright (C) 2019 André Bargull. All rights reserved.
+ * Copyright (C) 2020 Alexey Shvayka. All rights reserved.
  * Test262 portions are governed by the BSD license in LICENSE.test262.
  */
 import { describe, expect, test } from "bun:test"
@@ -130,10 +135,400 @@ describe("Test262 for-await-of adaptations", () => {
     ).toEqual([1, 3])
   })
 
-  test("keeps custom iterator objects outside the supported subset", async () => {
+  test("drives a custom async iterator sequentially", async () => {
+    expect(
+      await value(`
+        let index = 0
+        const iterator = {
+          [Symbol.asyncIterator]: () => iterator,
+          async next() {
+            index += 1
+            if (index > 3) return { done: true }
+            return { done: false, value: index }
+          },
+        }
+        const values = []
+        for await (const item of iterator) values.push(item)
+        return values
+      `),
+    ).toEqual([1, 2, 3])
+  })
+
+  test("leaves async iterator values under the iterator's control", async () => {
+    expect(
+      await value(`
+        let done = false
+        const iterator = {
+          [Symbol.asyncIterator]: () => iterator,
+          next: async () => done ? { done: true } : (done = true, { done: false, value: Promise.resolve(1) }),
+        }
+        for await (const item of iterator) return [item instanceof Promise, await item]
+      `),
+    ).toEqual([true, 1])
+  })
+
+  test("awaits synchronous results from an async iterator before the body", async () => {
+    expect(
+      await value(`
+        const events = ["pre"]
+        const ticks = Promise.resolve()
+          .then(() => events.push("tick 1"))
+          .then(() => events.push("tick 2"))
+        let done = false
+        const iterator = {
+          [Symbol.asyncIterator]: () => iterator,
+          next: () => done ? { done: true } : (done = true, { done: false, value: Promise.resolve(1) }),
+        }
+        for await (const item of iterator) events.push(item instanceof Promise ? "loop" : "adopted")
+        events.push("post")
+        await ticks
+        return events
+      `),
+    ).toEqual(["pre", "tick 1", "loop", "tick 2", "post"])
+  })
+
+  test("falls back to a custom synchronous iterator", async () => {
+    expect(
+      await value(`
+        let index = 0
+        const iterator = {
+          [Symbol.iterator]: () => iterator,
+          next() {
+            index += 1
+            return index > 2 ? { done: true } : { done: false, value: Promise.resolve(index) }
+          },
+        }
+        const values = []
+        for await (const item of iterator) values.push(item)
+        return values
+      `),
+    ).toEqual([1, 2])
+  })
+
+  test("adopts terminal and close values from synchronous iterators", async () => {
+    expect(
+      await value(`
+        const terminal = {
+          [Symbol.iterator]: () => terminal,
+          next: () => ({ done: true, value: Promise.reject("terminal") }),
+        }
+        let terminalError
+        try {
+          for await (const item of terminal) {}
+        } catch (error) {
+          terminalError = error
+        }
+
+        const closing = {
+          [Symbol.iterator]: () => closing,
+          next: () => ({ done: false, value: 1 }),
+          return: () => ({ done: true, value: Promise.reject("close") }),
+        }
+        let closeError
+        try {
+          for await (const item of closing) break
+        } catch (error) {
+          closeError = error
+        }
+        return [terminalError, closeError]
+      `),
+    ).toEqual(["terminal", "close"])
+  })
+
+  test("captures the next method when acquiring the iterator", async () => {
+    expect(
+      await value(`
+        let count = 0
+        const next = () => {
+          count += 1
+          if (count === 1) iterator.next = () => ({ done: true })
+          return count > 2 ? { done: true } : { done: false, value: count }
+        }
+        const iterator = { [Symbol.iterator]: () => iterator, next }
+        const values = []
+        for await (const item of iterator) values.push(item)
+        return values
+      `),
+    ).toEqual([1, 2])
+  })
+
+  test("captures synchronous iterator result fields before suspending", async () => {
+    expect(
+      await value(`
+        let count = 0
+        const result = { done: false, value: 1 }
+        const iterator = {
+          [Symbol.iterator]: () => iterator,
+          next() {
+            count += 1
+            if (count > 1) return { done: true }
+            Promise.resolve().then(() => {
+              result.done = true
+              result.value = 2
+            })
+            return result
+          },
+        }
+        const values = []
+        for await (const item of iterator) values.push(item)
+        return values
+      `),
+    ).toEqual([1])
+  })
+
+  test("preserves the async close turn for a sync iterator without return", async () => {
+    expect(
+      await value(`
+        const events = []
+        const iterator = {
+          [Symbol.iterator]: () => iterator,
+          next: () => ({ done: false, value: 1 }),
+        }
+        for await (const item of iterator) {
+          Promise.resolve().then(() => events.push("reaction"))
+          break
+        }
+        events.push("after")
+        return events
+      `),
+    ).toEqual(["reaction", "after"])
+  })
+
+  test("defers synchronous iterator protocol errors", async () => {
+    expect(
+      await value(`
+        const events = []
+        const throwing = {
+          [Symbol.iterator]: () => throwing,
+          next() {
+            Promise.resolve().then(() => events.push("next reaction"))
+            throw "next"
+          },
+        }
+        try {
+          for await (const item of throwing) {}
+        } catch (error) {
+          events.push("next catch")
+        }
+
+        const malformed = {
+          [Symbol.iterator]: () => malformed,
+          next() {
+            Promise.resolve().then(() => events.push("result reaction"))
+            return 1
+          },
+        }
+        try {
+          for await (const item of malformed) {}
+        } catch (error) {
+          events.push("result catch")
+        }
+
+        const closing = {
+          [Symbol.iterator]: () => closing,
+          next: () => ({ done: false, value: 1 }),
+          return() {
+            Promise.resolve().then(() => events.push("return reaction"))
+            throw "return"
+          },
+        }
+        try {
+          for await (const item of closing) break
+        } catch (error) {
+          events.push("return catch")
+        }
+        return events
+      `),
+    ).toEqual(["next reaction", "next catch", "result reaction", "result catch", "return reaction", "return catch"])
+  })
+
+  test("prefers Symbol.asyncIterator over Symbol.iterator", async () => {
+    expect(
+      await value(`
+        let done = false
+        const asyncIterator = {
+          next: async () => done ? { done: true } : (done = true, { done: false, value: "async" }),
+        }
+        const syncIterator = { next: () => ({ done: true }) }
+        const iterable = {
+          [Symbol.asyncIterator]: () => asyncIterator,
+          [Symbol.iterator]: () => syncIterator,
+        }
+        const values = []
+        for await (const item of iterable) values.push(item)
+        return values
+      `),
+    ).toEqual(["async"])
+  })
+
+  test("closes a custom iterator on abrupt loop completion", async () => {
+    expect(
+      await value(`
+        let closed = 0
+        const iterator = {
+          [Symbol.asyncIterator]: () => iterator,
+          next: async () => ({ done: false, value: 1 }),
+          return: async () => (closed += 1, { done: true }),
+        }
+        for await (const item of iterator) break
+        try {
+          for await (const item of iterator) throw "stop"
+        } catch (error) {}
+        return closed
+      `),
+    ).toBe(2)
+  })
+
+  test("treats a null iterator return method as absent", async () => {
+    expect(
+      await value(`
+        let count = 0
+        const iterator = {
+          [Symbol.asyncIterator]: () => iterator,
+          next: async () => ({ done: false, value: 1 }),
+          return: null,
+        }
+        for await (const item of iterator) {
+          count += 1
+          break
+        }
+        return count
+      `),
+    ).toBe(1)
+  })
+
+  test("a non-callable return method replaces break with TypeError", async () => {
+    expect(
+      await value(`
+        const iterator = {
+          [Symbol.asyncIterator]: () => iterator,
+          next: async () => ({ done: false, value: 1 }),
+          return: true,
+        }
+        try {
+          for await (const item of iterator) break
+        } catch (error) {
+          return error.name
+        }
+        return "missed"
+      `),
+    ).toBe("TypeError")
+  })
+
+  test("a body throw wins over a non-callable return method", async () => {
+    expect(
+      await value(`
+        const iterator = {
+          [Symbol.asyncIterator]: () => iterator,
+          next: async () => ({ done: false, value: 1 }),
+          return: true,
+        }
+        try {
+          for await (const item of iterator) throw "body"
+        } catch (error) {
+          return error
+        }
+        return "missed"
+      `),
+    ).toBe("body")
+  })
+
+  test("rejects a primitive iterator return result", async () => {
+    expect(
+      await value(`
+        const iterator = {
+          [Symbol.asyncIterator]: () => iterator,
+          next: async () => ({ done: false, value: 1 }),
+          return: async () => null,
+        }
+        try {
+          for await (const item of iterator) break
+        } catch (error) {
+          return error.name
+        }
+        return "missed"
+      `),
+    ).toBe("TypeError")
+  })
+
+  test("propagates iterator acquisition failures", async () => {
+    expect(
+      await value(`
+        const iterator = {
+          [Symbol.asyncIterator]: () => { throw "acquire" },
+        }
+        try {
+          for await (const item of iterator) {}
+        } catch (error) {
+          return error
+        }
+        return "missed"
+      `),
+    ).toBe("acquire")
+  })
+
+  test("rejects malformed iterator acquisition methods and results", async () => {
+    expect(
+      await value(`
+        const names = []
+        const invalid = [
+          { [Symbol.asyncIterator]: true },
+          { [Symbol.asyncIterator]: () => 1 },
+          { [Symbol.asyncIterator]: () => ({ next: true }) },
+        ]
+        for (const iterator of invalid) {
+          try {
+            for await (const item of iterator) {}
+          } catch (error) {
+            names.push(error.name)
+          }
+        }
+        return names
+      `),
+    ).toEqual(["TypeError", "TypeError", "TypeError"])
+  })
+
+  test("preserves iterator protocol keys through object copies", async () => {
+    expect(
+      await value(`
+        let done = false
+        const iterable = {
+          plain: true,
+          [Symbol.asyncIterator]: () => iterable,
+          next: async () => done ? { done: true } : (done = true, { done: false, value: 1 }),
+        }
+        const spread = { ...iterable }
+        const { plain, ...rest } = iterable
+        const assigned = Object.assign({}, iterable)
+        const values = []
+        for await (const item of spread) values.push(item)
+        return [
+          values,
+          Object.hasOwn(spread, Symbol.asyncIterator),
+          Object.hasOwn(rest, Symbol.asyncIterator),
+          Object.hasOwn(assigned, Symbol.asyncIterator),
+        ]
+      `),
+    ).toEqual([[1], true, true, true])
+  })
+
+  test("rejects malformed iterator protocol results", async () => {
+    const result = await execute(`
+      const iterator = {
+        [Symbol.asyncIterator]: () => iterator,
+        next: async () => 1,
+      }
+      for await (const item of iterator) {}
+    `)
+    expect(result.ok).toBe(false)
+    if (result.ok) return
+    expect(result.error.message).toContain("Iterator next() result must be an object")
+  })
+
+  test("rejects objects without an iterator protocol method", async () => {
     const result = await execute(`for await (const item of { values: [1, 2] }) {}`)
     expect(result.ok).toBe(false)
     if (result.ok) return
-    expect(result.error.message).toContain("for await...of requires an array, string, Map, Set, or URLSearchParams")
+    expect(result.error.message).toContain("or custom iterator value")
   })
 })

+ 12 - 0
packages/codemode/test/parity.test.ts

@@ -717,6 +717,18 @@ describe("destructuring assignment", () => {
     ).toEqual({ first: 1, rest: [2, 3], entry: "a4" })
   })
 
+  test("excludes computed numeric keys from object rest", async () => {
+    expect(
+      await value(`
+        const { [0]: declared, ...declarationRest } = { 0: "a", 1: "b" }
+        let assigned
+        let assignmentRest
+        ;({ [0]: assigned, ...assignmentRest } = { 0: "c", 1: "d" })
+        return { declared, declarationRest, assigned, assignmentRest }
+      `),
+    ).toEqual({ declared: "a", declarationRest: { 1: "b" }, assigned: "c", assignmentRest: { 1: "d" } })
+  })
+
   test("rejects computed keys that are not confined property keys", async () => {
     const err = await error(`const key = {}; const { [key]: value } = {}`)
     expect(err.message).toContain("Property key must be a string or number")