From 765924dee405b4101e15355e3b99e19772390195 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Tue, 13 Oct 2026 03:38:08 +0300 Subject: [PATCH 1/3] Preserve intrinsic iterator symbol identity in call IR --- .../main/kotlin/org/jacodb/ets/dto/Convert.kt | 6 ++++ .../main/kotlin/org/jacodb/ets/dto/Values.kt | 1 + .../main/kotlin/org/jacodb/ets/model/Expr.kt | 4 +++ .../org/jacodb/ets/test/EtsTsSymbolTest.kt | 20 +++++++++++ jacodb-ets/ts-frontend/src/dto/values.ts | 2 ++ .../src/lowering/iteratorLowering.ts | 1 + .../ts-frontend/src/lowering/stmtLowering.ts | 1 + jacodb-ets/ts-frontend/src/validate.ts | 3 ++ jacodb-ets/ts-frontend/test/execute.ts | 2 +- .../test/iterator-symbol-provenance.spec.ts | 34 +++++++++++++++++++ 10 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 jacodb-ets/ts-frontend/test/iterator-symbol-provenance.spec.ts diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Convert.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Convert.kt index 74e2e4349..7466a7b9d 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Convert.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Convert.kt @@ -155,6 +155,7 @@ import org.jacodb.ets.model.EtsUnknownType import org.jacodb.ets.model.EtsUnsignedRightShiftExpr import org.jacodb.ets.model.EtsValue import org.jacodb.ets.model.EtsVoidType +import org.jacodb.ets.model.EtsWellKnownSymbol import org.jacodb.ets.model.EtsYieldExpr private val logger = KotlinLogging.logger {} @@ -516,6 +517,11 @@ class EtsMethodBuilder( callee = method.toEtsMethodSignature(), args = args.map { ensureLocal(it.toEtsEntity()) }, type = type.toEtsType(), + wellKnownSymbol = when (wellKnownSymbol) { + null -> null + "iterator" -> EtsWellKnownSymbol.ITERATOR + else -> error("Unknown intrinsic symbol key: $wellKnownSymbol") + }, ) is StaticCallExprDto -> EtsStaticCallExpr( diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Values.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Values.kt index e150674f6..4ef053bee 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Values.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Values.kt @@ -322,6 +322,7 @@ data class InstanceCallExprDto( val instance: ValueDto, // Local override val method: MethodSignatureDto, override val args: List, + val wellKnownSymbol: String? = null, ) : CallExprDto @Serializable diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Expr.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Expr.kt index 17c6de9fc..3d2c72f0d 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Expr.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Expr.kt @@ -851,11 +851,15 @@ interface EtsCallExpr : EtsExpr, CommonCallExpr { override val args: List } +/** Intrinsic symbol keys selected by language operations, independent of source bindings named Symbol. */ +enum class EtsWellKnownSymbol { ITERATOR } + data class EtsInstanceCallExpr( override val instance: EtsLocal, override val callee: EtsMethodSignature, override val args: List, override val type: EtsType, + val wellKnownSymbol: EtsWellKnownSymbol? = null, ) : EtsCallExpr, CommonInstanceCallExpr { override fun toString(): String { return "call ${instance}.${callee.name}(${args.joinToString()})" diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsSymbolTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsSymbolTest.kt index f79eae508..dd37897d1 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsSymbolTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsSymbolTest.kt @@ -33,6 +33,9 @@ import org.jacodb.ets.utils.generateEtsIR import org.junit.jupiter.api.Test import org.jacodb.ets.dto.SymbolTypeDto import org.jacodb.ets.model.EtsSymbolType +import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsInstanceCallExpr +import org.jacodb.ets.model.EtsWellKnownSymbol class EtsTsSymbolTest { companion object { @@ -54,6 +57,23 @@ class EtsTsSymbolTest { } } + @Test + fun `intrinsic iterator identity survives frontend JSON and model conversion`() { + val frontendDto = runFrontend( + source = "export function copy(input: number[], Symbol: any): number[] { return [...input]; }", + ) + val roundTripped = EtsFileDto.loadFromJson( + Json { serializersModule = dtoModule }.encodeToString(frontendDto), + ) + val method = roundTripped.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.name == "copy" } + val calls = method.cfg.stmts.filterIsInstance().mapNotNull { it.rhv as? EtsInstanceCallExpr } + val iteratorCall = calls.single { it.wellKnownSymbol == EtsWellKnownSymbol.ITERATOR } + + assertEquals("Symbol.iterator", iteratorCall.callee.name) + assertTrue(iteratorCall.args.isEmpty()) + } + @Test fun `symbol parameter and return types survive frontend JSON and model conversion`() { val frontendDto = runFrontend(source = "export function same(value: symbol): symbol { return value; }") diff --git a/jacodb-ets/ts-frontend/src/dto/values.ts b/jacodb-ets/ts-frontend/src/dto/values.ts index cb3ab33c8..098f6deb8 100644 --- a/jacodb-ets/ts-frontend/src/dto/values.ts +++ b/jacodb-ets/ts-frontend/src/dto/values.ts @@ -231,6 +231,8 @@ export interface InstanceCallExprDto { instance: LocalDto; // Kotlin Convert casts this to LocalDto — MUST be a Local method: MethodSignatureDto; args: ValueDto[]; + /** Intrinsic key identity, not a property whose string name happens to mention Symbol. */ + wellKnownSymbol?: "iterator"; } export interface StaticCallExprDto { diff --git a/jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts b/jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts index 4a7e67235..a05420009 100644 --- a/jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts @@ -40,6 +40,7 @@ export class IteratorLowerer { _: "InstanceCallExpr", instance: source, method: { declaringClass: UNKNOWN_CLASS_SIGNATURE, name: "Symbol.iterator", parameters: [], returnType: UNKNOWN_TYPE }, args: [], + wellKnownSymbol: "iterator", }, }); this.requireObject(this.iterator); diff --git a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts index a8f7b61b7..74df0f4f4 100644 --- a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts @@ -381,6 +381,7 @@ export class StmtLowerer { instance: iterable, method: unknownMethod("Symbol.iterator"), args: [], + wellKnownSymbol: "iterator", }); const headLabel = cfg.newLabel(); diff --git a/jacodb-ets/ts-frontend/src/validate.ts b/jacodb-ets/ts-frontend/src/validate.ts index 781a9761a..1b17a0bf0 100644 --- a/jacodb-ets/ts-frontend/src/validate.ts +++ b/jacodb-ets/ts-frontend/src/validate.ts @@ -359,6 +359,9 @@ function validateValue( if (value.instance._ !== "Local") { err(`${ctx}: InstanceCallExpr.instance has kind '${value.instance._}', must be Local`); } + if (value.wellKnownSymbol !== undefined && value.wellKnownSymbol !== "iterator") { + err(`${ctx}: unknown intrinsic instance-call symbol`); + } break; case "PropertyRef": case "InstanceFieldRef": diff --git a/jacodb-ets/ts-frontend/test/execute.ts b/jacodb-ets/ts-frontend/test/execute.ts index 50a991668..75bf484a9 100644 --- a/jacodb-ets/ts-frontend/test/execute.ts +++ b/jacodb-ets/ts-frontend/test/execute.ts @@ -108,7 +108,7 @@ export function executor(file: EtsFileDto, externals: Record = {}) if (typeof ctor !== "function") throw new Error(`Unsupported constructor ${instance.allocation}`); return Reflect.construct(ctor, callArgs); } - const key = v.method.name === "Symbol.iterator" ? Symbol.iterator : v.method.name; + const key = v.wellKnownSymbol === "iterator" ? Symbol.iterator : v.method.name; return Reflect.apply(instance[key], instance, callArgs); } default: throw new Error(`Unsupported value: ${v._}`); diff --git a/jacodb-ets/ts-frontend/test/iterator-symbol-provenance.spec.ts b/jacodb-ets/ts-frontend/test/iterator-symbol-provenance.spec.ts new file mode 100644 index 000000000..68f3aedbd --- /dev/null +++ b/jacodb-ets/ts-frontend/test/iterator-symbol-provenance.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { InstanceCallExprDto } from "../src/dto/values"; +import { serializeEtsFile } from "../src/serialize"; +import { executor } from "./execute"; +import { lower, methodByName } from "./util"; + +describe("intrinsic iterator symbol provenance", () => { + for (const body of [ + "return [...input];", + "const [first, ...rest] = input; return rest;", + "for (const value of input) { if (value > 0) return value; } return 0;", + ]) { + it(`preserves symbol identity through JSON: ${body}`, () => { + const { file, diagnostics } = lower(`function collect(input: number[], Symbol: any) { ${body} }`); + const roundTrip = JSON.parse(serializeEtsFile(file)); + const calls = methodByName(roundTrip, "collect").body!.cfg.blocks.flatMap((block) => block.stmts) + .flatMap((statement) => statement._ === "AssignStmt" && statement.right._ === "InstanceCallExpr" + ? [statement.right as InstanceCallExprDto] : []); + + expect(diagnostics.messages).toEqual([]); + expect(calls.filter((call) => call.wellKnownSymbol === "iterator")).toHaveLength(1); + }); + } + + it("ignores string-named lookalikes while still consulting an overridden real symbol", () => { + const { file } = lower("function collect(input: any, Symbol: any) { return [...input]; }"); + const run = executor(JSON.parse(serializeEtsFile(file))); + const input: any = [1, 2]; + Object.assign(input, { "Symbol.iterator": () => { throw new Error("string key must not run"); } }); + input[Symbol.iterator] = function* () { yield 7; }; + + expect(run.call("collect", input, { iterator: "Symbol.iterator" })).toEqual([7]); + }); +}); From 848dd95d0e7dfcee5110d07fe30a21d0953fcaef Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Tue, 13 Oct 2026 04:58:29 +0300 Subject: [PATCH 2/3] Use iterator semantics for array bindings without rest --- .../src/lowering/iteratorLowering.ts | 25 +++ .../ts-frontend/src/lowering/stmtLowering.ts | 68 +++---- .../array-binding-iterator-semantics.spec.ts | 178 ++++++++++++++++++ .../test/iterator-symbol-provenance.spec.ts | 1 + 4 files changed, 232 insertions(+), 40 deletions(-) create mode 100644 jacodb-ets/ts-frontend/test/array-binding-iterator-semantics.spec.ts diff --git a/jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts b/jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts index a05420009..f6ecd3470 100644 --- a/jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts @@ -90,6 +90,31 @@ export class IteratorLowerer { cfg.placeLabel(done); } + /** A binding pattern that stops before exhaustion performs normal IteratorClose. */ + close(): void { + const cfg = this.m.cfg; + const lookup = cfg.newLabel(); + const invoke = cfg.newLabel(); + const done = cfg.newLabel(); + cfg.branch(this.isExhausted(), done, lookup); + cfg.placeLabel(lookup); + cfg.emit({ _: "AssignStmt", left: this.exhausted, right: bool(true) }); + const method = this.field(this.iterator, "return", UNKNOWN_TYPE); + cfg.branch({ _: "ConditionExpr", op: "==", left: method, right: { _: "Constant", value: "null", type: { _: "NullType" } } }, done, invoke); + cfg.placeLabel(invoke); + const result = this.m.newTemp(UNKNOWN_TYPE); + cfg.emit({ + _: "AssignStmt", left: result, + right: { + _: "PtrCallExpr", ptr: method, receiver: this.iterator, + method: { declaringClass: UNKNOWN_CLASS_SIGNATURE, name: "return", parameters: [], returnType: UNKNOWN_TYPE }, args: [], + }, + }); + this.requireObject(result); + cfg.goto(done); + cfg.placeLabel(done); + } + /** Abrupt binding initialization closes the iterator; the original throw wins. */ closeOnException(action: () => void): void { const cfg = this.m.cfg; diff --git a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts index 74df0f4f4..4e3d223f2 100644 --- a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts @@ -994,7 +994,7 @@ export class StmtLowerer { } } - /** `{a, b: {c}, d = 1}` / `[x, , y]` unpacked from `source` via field/array refs. */ + /** Object bindings read properties; array bindings consume the iterator protocol. */ private lowerBindingPattern(pattern: ts.BindingPattern, source: LocalDto): void { if (ts.isObjectBindingPattern(pattern)) { const stableSource = this.expr.materialize({ @@ -1054,49 +1054,37 @@ export class StmtLowerer { } return; } - // Array patterns with rest consume the iterator, including omitted elements. - if (pattern.elements.some((element) => ts.isBindingElement(element) && element.dotDotDotToken !== undefined)) { - if (pattern.elements.some((element) => ts.isBindingElement(element) - && !ts.isIdentifier(element.name))) { - throw new LoweringError("nested bindings in array rest are not represented with iterator semantics"); - } - const iterator = new IteratorLowerer(this.m, source); - for (const element of pattern.elements) { - if (ts.isOmittedExpression(element)) { - iterator.take(UNKNOWN_TYPE, { readValue: false }); - continue; - } - if (element.dotDotDotToken === undefined) { - const value = iterator.take(this.bindingType(element.name)); - iterator.closeOnException(() => this.bindDestructured(element.name, value, element.initializer)); - continue; - } - const type = this.bindingType(element.name); - const elementType = type._ === "ArrayType" ? arrayElementType(type) : UNKNOWN_TYPE; - const rest = this.m.newTemp(type._ === "ArrayType" ? type : { _: "ArrayType", elementType, dimensions: 1 }); - this.m.cfg.emit({ _: "AssignStmt", left: rest, right: { _: "NewArrayExpr", elementType, size: constant("0", NUMBER_TYPE) } }); - const offset = this.m.newTemp(NUMBER_TYPE); - this.m.cfg.emit({ _: "AssignStmt", left: offset, right: constant("0", NUMBER_TYPE) }); - iterator.appendTo(rest, offset, elementType); - this.bindDestructured(element.name, rest, undefined); - } - return; + // Every array binding consumes the iterator, including patterns without rest and omitted elements. + const hasRest = pattern.elements.some((element) => ts.isBindingElement(element) + && element.dotDotDotToken !== undefined); + const hasNestedBinding = pattern.elements.some((element) => ts.isBindingElement(element) + && !ts.isIdentifier(element.name)); + if (hasRest && hasNestedBinding) { + throw new LoweringError("nested bindings in array rest are not represented with iterator semantics"); } - pattern.elements.forEach((element, index) => { + + const iterator = new IteratorLowerer(this.m, source); + for (const element of pattern.elements) { if (ts.isOmittedExpression(element)) { - return; + iterator.take(UNKNOWN_TYPE, { readValue: false }); + continue; } - if (element.dotDotDotToken !== undefined) { - throw new LoweringError("rest element in array destructuring"); + if (element.dotDotDotToken === undefined) { + const value = iterator.take(this.bindingType(element.name)); + iterator.closeOnException(() => this.bindDestructured(element.name, value, element.initializer)); + continue; } - const ref: ValueDto = { - _: "ArrayRef", - array: source, - index: constant(String(index), NUMBER_TYPE), - type: this.bindingType(element.name), - }; - this.bindDestructured(element.name, ref, element.initializer); - }); + + const type = this.bindingType(element.name); + const elementType = type._ === "ArrayType" ? arrayElementType(type) : UNKNOWN_TYPE; + const rest = this.m.newTemp(type._ === "ArrayType" ? type : { _: "ArrayType", elementType, dimensions: 1 }); + this.m.cfg.emit({ _: "AssignStmt", left: rest, right: { _: "NewArrayExpr", elementType, size: constant("0", NUMBER_TYPE) } }); + const offset = this.m.newTemp(NUMBER_TYPE); + this.m.cfg.emit({ _: "AssignStmt", left: offset, right: constant("0", NUMBER_TYPE) }); + iterator.appendTo(rest, offset, elementType); + this.bindDestructured(element.name, rest, undefined); + } + iterator.close(); } private bindingType(name: ts.BindingName): TypeDto { diff --git a/jacodb-ets/ts-frontend/test/array-binding-iterator-semantics.spec.ts b/jacodb-ets/ts-frontend/test/array-binding-iterator-semantics.spec.ts new file mode 100644 index 000000000..ead726a9c --- /dev/null +++ b/jacodb-ets/ts-frontend/test/array-binding-iterator-semantics.spec.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from "vitest"; +import { consumers } from "./array-test-consumers"; + +describe("array bindings without rest consume and close iterators", () => { + it("reads UTF16 code points and keeps defaults after exhaustion", () => { + const { ir, native } = consumers( + "function bind(input: any) { const [first, second = 'end', third] = input; return [first, second, third]; }", + "bind", + ); + + for (const input of ["", "\ud83d\ude00", "\ud800", "\udc00", "A\ud83d\ude00\ud800", "\udc00\ud800"]) { + expect(ir(input)).toEqual(native(input)); + } + expect(ir("\ud83d\ude00")).toEqual(["\ud83d\ude00", "end", undefined]); + }); + + it("uses undefined for sparse slots and applies binding defaults", () => { + const { ir, native } = consumers( + "function bind(input: any) { const [first = 7, second = 11, third] = input; return [first, second, third]; }", + "bind", + ); + const input = [, , 3]; + + expect(ir(input)).toEqual(native(input)); + expect(ir(input)).toEqual([7, 11, 3]); + }); + + it("closes early with the iterator as receiver and reads return once", () => { + const { ir, native } = consumers("function bind(input: any) { const [first] = input; return first; }", "bind"); + for (const consume of [native, ir]) { + const events: string[] = []; + const iterator = { + get next() { + events.push("next lookup"); + return function () { + expect(this).toBe(iterator); + events.push("next call"); + return { value: 7, done: false }; + }; + }, + get return() { + events.push("return lookup"); + const close = function () { + expect(this).toBe(iterator); + events.push("return call"); + return { get done() { throw new Error("close result done must not be read"); } }; + }; + close.call = () => { throw new Error("a user call property must not run"); }; + return close; + }, + }; + + expect(consume({ [Symbol.iterator]: () => iterator })).toBe(7); + expect(events).toEqual(["next lookup", "next call", "return lookup", "return call"]); + } + }); + + it("acquires and closes an empty binding without calling next", () => { + const { ir, native } = consumers("function bind(input: any) { const [] = input; return 7; }", "bind"); + for (const consume of [native, ir]) { + const events: string[] = []; + const iterator = { + next() { throw new Error("next must not run"); }, + return() { events.push("closed"); return {}; }, + }; + + expect(consume({ [Symbol.iterator]: () => iterator })).toBe(7); + expect(events).toEqual(["closed"]); + } + }); + + it("omitted elements do not read value and an exhausted iterator is not closed", () => { + const { ir, native } = consumers( + "function bind(input: any) { const [, second = 7, third] = input; return [second, third]; }", + "bind", + ); + for (const consume of [native, ir]) { + let steps = 0; + const iterator = { + next() { + steps++; + if (steps > 2) throw new Error("next after exhaustion"); + return { done: steps === 2, get value() { throw new Error("omitted or exhausted value"); } }; + }, + get return() { throw new Error("exhausted iterator must not close"); }, + }; + + expect(consume({ [Symbol.iterator]: () => iterator })).toEqual([7, undefined]); + expect(steps).toBe(2); + } + }); + + it("accepts absent return and rejects noncallable or primitive close results", () => { + const { ir, native } = consumers("function bind(input: any) { const [first] = input; return first; }", "bind"); + for (const consume of [native, ir]) { + for (const close of [null, undefined]) { + const iterator = { next: () => ({ value: 7, done: false }), return: close }; + expect(consume({ [Symbol.iterator]: () => iterator })).toBe(7); + } + for (const close of [1, () => 1]) { + const iterator = { next: () => ({ value: 7, done: false }), return: close }; + expect(() => consume({ [Symbol.iterator]: () => iterator })).toThrow(TypeError); + } + } + }); + + it("preserves a default initializer throw over a cleanup throw", () => { + const { ir, native } = consumers( + "function bind(input: any, fail: () => never) { const [first = fail()] = input; return first; }", + "bind", + ); + const original = new Error("default failed"); + const fail = () => { throw original; }; + for (const consume of [native, ir]) { + let closes = 0; + const iterator = { + next: () => ({ value: undefined, done: false }), + return() { closes++; throw new Error("cleanup failed"); }, + }; + + expect(() => consume({ [Symbol.iterator]: () => iterator }, fail)).toThrow(original); + expect(closes).toBe(1); + } + }); + + it("propagates a normal return getter failure", () => { + const { ir, native } = consumers("function bind(input: any) { const [first] = input; return first; }", "bind"); + const failure = new Error("return lookup failed"); + for (const consume of [native, ir]) { + let lookups = 0; + const iterator = { + next: () => ({ value: 7, done: false }), + get return() { lookups++; throw failure; }, + }; + + expect(() => consume({ [Symbol.iterator]: () => iterator })).toThrow(failure); + expect(lookups).toBe(1); + } + }); + + it("does not close when next or its value getter throws", () => { + const { ir, native } = consumers("function bind(input: any) { const [first] = input; return first; }", "bind"); + const failure = new Error("iterator step failed"); + for (const consume of [native, ir]) { + for (const failNext of [false, true]) { + let closes = 0; + const iterator = { + next() { + if (failNext) throw failure; + return { done: false, get value() { throw failure; } }; + }, + return() { closes++; return {}; }, + }; + + expect(() => consume({ [Symbol.iterator]: () => iterator })).toThrow(failure); + expect(closes).toBe(0); + } + } + }); + + it("closes nested bindings from inner to outer", () => { + const { ir, native } = consumers("function bind(input: any) { const [[first]] = input; return first; }", "bind"); + for (const consume of [native, ir]) { + const events: string[] = []; + const inner = { + next: () => ({ value: 7, done: false }), + return() { events.push("inner"); return {}; }, + }; + const outer = { + next: () => ({ value: { [Symbol.iterator]: () => inner }, done: false }), + return() { events.push("outer"); return {}; }, + }; + + expect(consume({ [Symbol.iterator]: () => outer })).toBe(7); + expect(events).toEqual(["inner", "outer"]); + } + }); +}); diff --git a/jacodb-ets/ts-frontend/test/iterator-symbol-provenance.spec.ts b/jacodb-ets/ts-frontend/test/iterator-symbol-provenance.spec.ts index 68f3aedbd..7b8ecd85c 100644 --- a/jacodb-ets/ts-frontend/test/iterator-symbol-provenance.spec.ts +++ b/jacodb-ets/ts-frontend/test/iterator-symbol-provenance.spec.ts @@ -8,6 +8,7 @@ describe("intrinsic iterator symbol provenance", () => { for (const body of [ "return [...input];", "const [first, ...rest] = input; return rest;", + "const [first] = input; return first;", "for (const value of input) { if (value > 0) return value; } return 0;", ]) { it(`preserves symbol identity through JSON: ${body}`, () => { From 5f32daec9c84a63dc88d570b8d3bd4823bd7da09 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Tue, 13 Oct 2026 05:14:00 +0300 Subject: [PATCH 3/3] Verify iterator sequencing for nested bindings with holes --- .../array-binding-iterator-semantics.spec.ts | 53 +++++++++++++++++ jacodb-ets/ts-frontend/test/closures.spec.ts | 59 ++++++++++++++++--- 2 files changed, 104 insertions(+), 8 deletions(-) diff --git a/jacodb-ets/ts-frontend/test/array-binding-iterator-semantics.spec.ts b/jacodb-ets/ts-frontend/test/array-binding-iterator-semantics.spec.ts index ead726a9c..74629380e 100644 --- a/jacodb-ets/ts-frontend/test/array-binding-iterator-semantics.spec.ts +++ b/jacodb-ets/ts-frontend/test/array-binding-iterator-semantics.spec.ts @@ -175,4 +175,57 @@ describe("array bindings without rest consume and close iterators", () => { expect(events).toEqual(["inner", "outer"]); } }); + + it("advances holes before nested bindings without reading omitted values", () => { + const { ir, native } = consumers( + "function bind(data: any) { const [first, , [inner]] = data; return [first, inner]; }", + "bind", + ); + + for (const consume of [native, ir]) { + const events: string[] = []; + const inner = { + next() { + expect(this).toBe(inner); + events.push("inner next"); + return { done: false, get value() { events.push("inner value"); return "a"; } }; + }, + return() { expect(this).toBe(inner); events.push("inner close"); return {}; }, + }; + const nested = { + [Symbol.iterator]() { expect(this).toBe(nested); events.push("inner iterator"); return inner; }, + }; + let step = 0; + const outer = { + next() { + expect(this).toBe(outer); + const index = step++; + events.push(`outer next ${index}`); + if (index > 2) throw new Error("binding consumed past the nested element"); + + return { + done: false, + get value() { + if (index === 1) throw new Error("the omitted value must not be read"); + events.push(`outer value ${index}`); + return index === 0 ? 1 : nested; + }, + }; + }, + return() { expect(this).toBe(outer); events.push("outer close"); return {}; }, + }; + const data = { + [Symbol.iterator]() { expect(this).toBe(data); events.push("outer iterator"); return outer; }, + }; + + const result = consume(data); + + expect(result).toEqual([1, "a"]); + expect(events).toEqual([ + "outer iterator", "outer next 0", "outer value 0", "outer next 1", "outer next 2", "outer value 2", + "inner iterator", "inner next", "inner value", "inner close", "outer close", + ]); + expect(step).toBe(3); + } + }); }); diff --git a/jacodb-ets/ts-frontend/test/closures.spec.ts b/jacodb-ets/ts-frontend/test/closures.spec.ts index fcd0e0f7f..5044d4716 100644 --- a/jacodb-ets/ts-frontend/test/closures.spec.ts +++ b/jacodb-ets/ts-frontend/test/closures.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { AssignStmtDto, StmtDto } from "../src/dto/stmts"; import { serializeEtsFile } from "../src/serialize"; import { validateEtsFile } from "../src/validate"; +import { consumers } from "./array-test-consumers"; import { defaultMethod, lower, methodByName, singleBlockStmts } from "./util"; function allStmts(source: string): StmtDto[] { @@ -341,22 +342,64 @@ describe("destructuring", () => { }); it("unpacks array patterns with holes and nesting", () => { - const stmts = allStmts(` + const source = ` const data: [number, number, [string, string]] = [1, 2, ["a", "b"]]; const [first, , [inner]] = data; - `); - const arrayReads = stmts.filter( - (s): s is AssignStmtDto => s._ === "AssignStmt" && s.right._ === "ArrayRef", + function result() { return [first, inner]; } + `; + const stmts = allStmts(source); + const assignments = stmts.filter((stmt): stmt is AssignStmtDto => stmt._ === "AssignStmt"); + const iterators = assignments.flatMap((stmt) => + stmt.left._ === "Local" && stmt.right._ === "InstanceCallExpr" + && stmt.right.wellKnownSymbol === "iterator" ? [stmt.left] : [], + ); + const nextLookups = assignments.flatMap((stmt) => + stmt.right._ === "InstanceFieldRef" && stmt.right.field.name === "next" + ? [{ iterator: stmt.right.instance, local: stmt.left }] : [], + ); + const nextSteps = assignments.flatMap((stmt) => + stmt.right._ === "PtrCallExpr" && stmt.right.method.name === "next" + ? [{ call: stmt.right, result: stmt.left }] : [], ); - const indices = arrayReads.map((s) => (s.right as { index: { value: string } }).index.value); - expect(indices).toContain("0"); - expect(indices).toContain("2"); - expect(indices).not.toContain("1"); // hole skipped + const valueSources = assignments.flatMap((stmt) => + stmt.right._ === "InstanceFieldRef" && stmt.right.field.name === "value" ? [stmt.right.instance] : [], + ); + const normalCloses = assignments.flatMap((stmt) => + stmt.right._ === "PtrCallExpr" && stmt.right.method.name === "return" ? [stmt.right.receiver] : [], + ); + const { ir, native } = consumers(source, "result"); + + expect(iterators).toHaveLength(2); + const [outerIterator, innerIterator] = iterators; + expect(nextLookups).toHaveLength(2); + expect(nextLookups.map((lookup) => lookup.iterator)).toEqual(expect.arrayContaining(iterators)); + expect(nextSteps).toHaveLength(4); + for (const { iterator, steps, values } of [ + { iterator: outerIterator, steps: 3, values: 2 }, + { iterator: innerIterator, steps: 1, values: 1 }, + ]) { + const advances = nextSteps.filter((step) => step.call.receiver?.name === iterator.name); + const lookup = nextLookups.find((read) => read.iterator.name === iterator.name)!; + const yieldedValues = advances.filter((step) => { + const result = step.result; + return result._ === "Local" && valueSources.some((source) => source.name === result.name); + }); + + expect(advances).toHaveLength(steps); + expect(advances.map((step) => step.call.ptr)).toEqual(Array.from({ length: steps }, () => lookup.local)); + // The omitted element advances the outer iterator without reading its value. + expect(yieldedValues).toHaveLength(values); + } + expect(normalCloses).toHaveLength(2); + expect(normalCloses).toEqual(expect.arrayContaining(iterators)); + expect(assignments.filter((stmt) => stmt.right._ === "ArrayRef")).toEqual([]); expect(stmts.some((s) => s._ === "AssignStmt" && s.left._ === "StaticFieldRef" && s.left.field.name === "inner", )).toBe(true); + expect(ir()).toEqual(native()); + expect(ir()).toEqual([1, "a"]); }); it("supports destructuring in for-of", () => {