diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsConstructorSpreadTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsConstructorSpreadTest.kt
new file mode 100644
index 000000000..eaed01d30
--- /dev/null
+++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsConstructorSpreadTest.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2022 UnitTestBot contributors (utbot.org)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.jacodb.ets.test
+
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import org.jacodb.ets.dto.ArrayRefDto
+import org.jacodb.ets.dto.AssignStmtDto
+import org.jacodb.ets.dto.CallExprDto
+import org.jacodb.ets.dto.EtsFileDto
+import org.jacodb.ets.dto.RawValueDto
+import org.jacodb.ets.dto.SpreadExpansionExprDto
+import org.jacodb.ets.dto.dtoModule
+import org.jacodb.ets.dto.toEtsFile
+import org.jacodb.ets.model.EtsAssignStmt
+import org.jacodb.ets.model.EtsSpreadExpansionExpr
+import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME
+import org.jacodb.ets.utils.EtsIrProvider
+import org.jacodb.ets.utils.generateEtsIR
+import org.jacodb.ets.utils.getOperands
+import org.junit.jupiter.api.Test
+import kotlin.io.path.createTempDirectory
+import kotlin.io.path.readText
+import kotlin.io.path.writeText
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+
+class EtsTsConstructorSpreadTest {
+ private fun frontendRoundTrip(source: String): EtsFileDto {
+ val input = createTempDirectory("ts-calls-test").resolve("test.ts")
+ input.writeText(source)
+
+ val output = generateEtsIR(
+ input,
+ isProject = false,
+ timeout = null,
+ provider = EtsIrProvider.TS_FRONTEND,
+ )
+ val dto = EtsFileDto.loadFromJson(output.readText())
+ return EtsFileDto.loadFromJson(Json { serializersModule = dtoModule }.encodeToString(dto))
+ }
+
+ private fun assignments(dto: EtsFileDto, method: String): List =
+ dto.classes.single { it.signature.name == DEFAULT_ARK_CLASS_NAME }
+ .methods.single { it.signature.name == method }.body!!.cfg.blocks
+ .flatMap { it.stmts }.filterIsInstance()
+
+ @Test
+ fun `constructor spreads preserve iterator expansion and ordered arguments`() {
+ val dto = frontendRoundTrip(
+ """
+ function add(a: number, b: number): number { return a + b; }
+ class Point { constructor(public x: number, public y: number) {} }
+ export function sum(pair: [number, number]): number { return add(...pair); }
+ export function make(pair: [number, number]): number { return new Point(...pair).y; }
+ """.trimIndent(),
+ )
+
+ for ((name, target) in listOf("make" to "constructor")) {
+ val stmts = assignments(dto, name)
+ val expansion = stmts.single { it.right is SpreadExpansionExprDto }
+ val reads = stmts.filter { it.right is ArrayRefDto }
+ val call = stmts.single { (it.right as? CallExprDto)?.method?.name == target }.right as CallExprDto
+
+ assertEquals(2, (expansion.right as SpreadExpansionExprDto).expectedCount)
+ assertEquals(2, reads.size)
+ assertEquals(reads.map { it.left }, call.args)
+ assertTrue(reads.all { (it.right as ArrayRefDto).array == expansion.left })
+ assertTrue(stmts.indexOf(expansion) < stmts.indexOf(reads.first()))
+ assertTrue(stmts.none { it.right is RawValueDto })
+
+ val model = dto.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME }
+ .methods.single { it.name == name }.cfg.stmts.filterIsInstance()
+ val expanded = model.single { it.rhv is EtsSpreadExpansionExpr }.rhv as EtsSpreadExpansionExpr
+ assertEquals(2, expanded.expectedCount)
+ assertEquals(listOf(expanded.iterable), expanded.getOperands().toList())
+ }
+ }
+
+}
diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts
index de1b47e5e..0610698c4 100644
--- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts
+++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts
@@ -1170,9 +1170,7 @@ export class ExprLowerer {
throw new LoweringError("new constructor value cannot be represented in EtsIR");
}
- const loweredArgs = args.map((a, index) =>
- ts.isSpreadElement(a) ? this.spreadFallback(a) : this.lowerImmediateBefore(a, args.slice(index + 1)),
- );
+ const loweredArgs = this.lowerArguments(args);
const classType = staticConstructor ? this.newTargetClassType(node.expression) : inferredType;
const temp = this.m.newTemp(classType);
const allocation = constructorValue === undefined
diff --git a/jacodb-ets/ts-frontend/test/spread-constructor.spec.ts b/jacodb-ets/ts-frontend/test/spread-constructor.spec.ts
new file mode 100644
index 000000000..dbdc776bf
--- /dev/null
+++ b/jacodb-ets/ts-frontend/test/spread-constructor.spec.ts
@@ -0,0 +1,78 @@
+import * as ts from "typescript";
+import { describe, expect, it } from "vitest";
+import { StmtDto } from "../src/dto/stmts";
+import { serializeEtsFile } from "../src/serialize";
+import { lower, methodByName, singleBlockStmts } from "./util";
+
+function assertSpreadArgumentOrder(stmts: StmtDto[], methodName: string): void {
+ const expansion = stmts.find((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "SpreadExpansionExpr");
+ const reads = stmts.filter((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "ArrayRef");
+ const call = stmts.find((stmt) => stmt._ === "AssignStmt"
+ && (stmt.right._ === "StaticCallExpr" || stmt.right._ === "InstanceCallExpr")
+ && stmt.right.method.name === methodName);
+
+ expect(reads).toHaveLength(2);
+ expect(expansion).toMatchObject({ right: { _: "SpreadExpansionExpr", expectedCount: 2 } });
+ for (const read of reads) {
+ expect(read).toMatchObject({ right: { array: expansion?._ === "AssignStmt" ? expansion.left : undefined } });
+ }
+ expect(reads.map((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "ArrayRef"
+ && stmt.right.index._ === "Constant" ? stmt.right.index.value : "invalid")).toEqual(["0", "1"]);
+ expect(call).toMatchObject({
+ _: "AssignStmt",
+ right: {
+ args: reads.map((stmt) => stmt._ === "AssignStmt" ? stmt.left : undefined),
+ },
+ });
+}
+
+function runTypeScript(source: string, expression: string): unknown {
+ const js = ts.transpileModule(source, {
+ compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS },
+ }).outputText;
+
+ return new Function("exports", `${js}\nreturn ${expression};`)({});
+}
+
+describe("constructor call spreads", () => {
+ it("expands a tuple in a constructor call and preserves JSON argument order", () => {
+ const source = `
+ class Point {
+ y: number;
+ constructor(x: number, y: number) { this.y = y; }
+ }
+ export function make(pair: [number, number]): number { return new Point(...pair).y; }
+ `;
+ const { file, diagnostics } = lower(source);
+ const roundTrip = JSON.parse(serializeEtsFile(file));
+ const stmts = singleBlockStmts(methodByName(roundTrip, "make"));
+
+ assertSpreadArgumentOrder(stmts, "constructor");
+ expect(diagnostics.messages).toEqual([]);
+ expect(runTypeScript(source, "exports.make([2, 3])")).toBe(3);
+ });
+
+ it("rejects an unbounded spread without calling the target", () => {
+ const source = `
+ function add(a: number, b: number): number { return a + b; }
+ class Point { constructor(public x: number, public y: number) {} }
+ export function sum(values: number[]): number { return add(...values); }
+ export function make(values: number[]): Point { return new Point(...values); }
+ `;
+ const { file, diagnostics } = lower(source);
+ const stmts = singleBlockStmts(methodByName(file, "sum"));
+
+ expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr"
+ && stmt.right.method.name === "add")).toBe(false);
+ expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue"
+ && stmt.right.kindName === "CallExpression")).toBe(true);
+ expect(diagnostics.messages).toEqual(expect.arrayContaining([
+ expect.stringContaining("spread argument has no statically known length"),
+ ]));
+
+ const constructorStmts = singleBlockStmts(methodByName(file, "make"));
+ expect(constructorStmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewExpr")).toBe(false);
+ expect(constructorStmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue"
+ && stmt.right.kindName === "NewExpression")).toBe(true);
+ });
+});