diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsComputedConstructorTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsComputedConstructorTest.kt
new file mode 100644
index 000000000..178cab15c
--- /dev/null
+++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsComputedConstructorTest.kt
@@ -0,0 +1,90 @@
+/*
+ * 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.AssignStmtDto
+import org.jacodb.ets.dto.ClassValueRefDto
+import org.jacodb.ets.dto.EtsFileDto
+import org.jacodb.ets.dto.LocalDto
+import org.jacodb.ets.dto.NewExprDto
+import org.jacodb.ets.dto.RawValueDto
+import org.jacodb.ets.dto.dtoModule
+import org.jacodb.ets.dto.toEtsFile
+import org.jacodb.ets.model.EtsAssignStmt
+import org.jacodb.ets.model.EtsNewExpr
+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 EtsTsComputedConstructorTest {
+ 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 `comma constructor values decode as locals after computed access`() {
+ val dto = frontendRoundTrip(
+ """
+ class A { x = 1; }
+ export function computed(holder: any): A { return new (holder['sideEffect'], A)(); }
+ export function direct(): A { return new (0, A)(); }
+ """.trimIndent(),
+ )
+
+ for (name in listOf("computed", "direct")) {
+ val stmts = assignments(dto, name)
+ val allocation = stmts.single { it.right is NewExprDto }
+ val constructor = (allocation.right as NewExprDto).constructorValue as LocalDto
+ val capture = stmts.single { it.left == constructor && it.right is ClassValueRefDto }
+
+ assertEquals("A", (capture.right as ClassValueRefDto).signature.name)
+ assertTrue(stmts.indexOf(capture) < stmts.indexOf(allocation))
+ 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 newExpr = model.single { it.rhv is EtsNewExpr }.rhv as EtsNewExpr
+ assertTrue(newExpr.constructorValue != null)
+ }
+ }
+
+}
diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts
index 5dbdb138d..01f2a493c 100644
--- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts
+++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts
@@ -421,13 +421,14 @@ export class ExprLowerer {
};
}
- private lowerElementAccess(node: ts.ElementAccessExpression): ValueDto {
- if (this.computedConstructorCalleeDepth > 0) {
- this.lowerToImmediate(node.expression);
- this.lowerToImmediate(node.argumentExpression);
- throw new LoweringError("computed constructor access is not represented in EtsIR");
- }
+ private isNamespaceObject(node: ts.Expression): boolean {
+ const expression = unwrapTransparentExpression(node);
+ const symbol = ts.isIdentifier(expression) ? this.m.converter.symbolOf(expression) : undefined;
+ const typeSymbol = this.m.checker.getTypeAtLocation(node).getSymbol();
+ return [symbol, typeSymbol].some((candidate) => candidate?.declarations?.some(ts.isModuleDeclaration));
+ }
+ private lowerElementAccess(node: ts.ElementAccessExpression): ValueDto {
const chain = optionalChain(node);
if (chain !== undefined) {
return this.lowerOptionalChain(node, chain);
@@ -435,6 +436,11 @@ export class ExprLowerer {
const receiverType = this.m.checker.getTypeAtLocation(node.expression);
if (!this.m.checker.isArrayType(receiverType)
&& !this.m.checker.isTupleType(receiverType)) {
+ if (this.computedConstructorCalleeDepth > 0 && this.isNamespaceObject(node.expression)) {
+ this.lowerToImmediate(node.expression);
+ this.lowerToImmediate(node.argumentExpression);
+ throw new LoweringError("computed namespace constructor access has no runtime namespace object in EtsIR");
+ }
const instance = this.snapshotToLocal(node.expression);
return {
_: "PropertyRef",
@@ -576,6 +582,10 @@ export class ExprLowerer {
}
const receiverType = this.m.checker.getTypeAtLocation(node.expression);
if (!this.m.checker.isArrayType(receiverType) && !this.m.checker.isTupleType(receiverType)) {
+ if (this.computedConstructorCalleeDepth > 0 && this.isNamespaceObject(node.expression)) {
+ this.lowerToImmediate(node.argumentExpression);
+ throw new LoweringError("computed namespace constructor access has no runtime namespace object in EtsIR");
+ }
return {
_: "PropertyRef",
instance,
@@ -1157,10 +1167,6 @@ export class ExprLowerer {
private lowerNew(node: ts.NewExpression): ValueDto {
const args = node.arguments ?? ts.factory.createNodeArray();
- if (containsElementAccess(node.expression)) {
- this.lowerDynamicConstructorValue(node.expression, args);
- throw new LoweringError("computed constructor access is not represented in EtsIR");
- }
if (this.isProjectClassProperty(node.expression)) {
this.evaluateProjectClassPropertyReceiver(node.expression);
@@ -1225,7 +1231,8 @@ export class ExprLowerer {
private lowerDynamicConstructorValue(node: ts.Expression, args: readonly ts.Expression[]): ImmediateDto {
this.computedConstructorCalleeDepth++;
try {
- return this.lowerImmediateBefore(node, args);
+ const value = this.lowerImmediateBefore(node, args);
+ return value._ === "ClassValueRef" ? this.materialize(value, value.type) : value;
} finally {
this.computedConstructorCalleeDepth--;
}
@@ -1785,19 +1792,6 @@ function containsPossibleSideEffect(node: ts.Node): boolean {
return found;
}
-function containsElementAccess(node: ts.Node): boolean {
- let found = false;
- const visit = (current: ts.Node): void => {
- if (found) return;
- if (ts.isElementAccessExpression(current)) {
- found = true;
- return;
- }
- ts.forEachChild(current, visit);
- };
- visit(node);
- return found;
-}
/** Erase syntax that leaves an expression's runtime value unchanged. */
function unwrapTransparentExpression(node: ts.Expression): ts.Expression {
diff --git a/jacodb-ets/ts-frontend/test/classes.spec.ts b/jacodb-ets/ts-frontend/test/classes.spec.ts
index f6781d9cb..12086eb99 100644
--- a/jacodb-ets/ts-frontend/test/classes.spec.ts
+++ b/jacodb-ets/ts-frontend/test/classes.spec.ts
@@ -4,6 +4,7 @@ import { Modifier } from "../src/dto/constants";
import { ClassDto, EtsFileDto, MethodDto } from "../src/dto/model";
import { StmtDto } from "../src/dto/stmts";
import { InstanceCallExprDto } from "../src/dto/values";
+import { serializeEtsFile } from "../src/serialize";
import { compile, lower, singleBlockStmts } from "./util";
const FILE_SIG = { projectName: "proj", fileName: "test.ts" };
@@ -857,7 +858,7 @@ describe("namespace lowering", () => {
expect(keyIndex).toBeGreaterThan(receiverIndex);
expect(unsupportedIndex).toBeGreaterThan(keyIndex);
expect(diagnostics.messages).toEqual(expect.arrayContaining([
- expect.stringContaining("computed constructor access"),
+ expect.stringContaining("computed namespace constructor access"),
]));
const js = ts.transpileModule(source, {
@@ -890,7 +891,7 @@ describe("namespace lowering", () => {
});
});
- it("rejects computed constructor callees without treating property reads as array reads", () => {
+ it("preserves computed constructor callees as property reads rather than array reads", () => {
const source = `
class A { kind = "A"; }
class B { kind = "B"; }
@@ -909,6 +910,9 @@ describe("namespace lowering", () => {
export function array(constructors: Array): A | B {
return new constructors[0]();
}
+ export function tuple(constructors: [typeof A, typeof B]): A | B {
+ return new constructors[0]();
+ }
export function replace(): void { holder.Ctor = B; }
export function readCounts(): number[] { return [receiverReads, keyReads]; }
`;
@@ -917,11 +921,22 @@ describe("namespace lowering", () => {
const { file, diagnostics } = lower(source);
const defaultClass = classByName(file, "%dflt");
- for (const name of ["computed", "wrapped", "nested", "conditional", "throughCall", "array"]) {
+ for (const name of ["computed", "wrapped", "nested", "conditional", "throughCall"]) {
const stmts = methodOf(defaultClass, name).body!.cfg.blocks.flatMap((block) => block.stmts);
- expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue")).toBe(true);
+ expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue")).toBe(false);
expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "ArrayRef")).toBe(false);
- expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewExpr")).toBe(false);
+ expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PropertyRef")).toBe(true);
+ expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewExpr"
+ && stmt.right.constructorValue?._ === "Local")).toBe(true);
+ }
+
+ for (const name of ["array", "tuple"]) {
+ const stmts = singleBlockStmts(methodOf(defaultClass, name));
+ expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue")).toBe(false);
+ expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PropertyRef")).toBe(false);
+ expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "ArrayRef")).toBe(true);
+ expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewExpr"
+ && stmt.right.constructorValue?._ === "Local")).toBe(true);
}
const computedStmts = singleBlockStmts(methodOf(defaultClass, "computed"));
@@ -929,14 +944,15 @@ describe("namespace lowering", () => {
&& stmt.right._ === "StaticCallExpr" && stmt.right.method.name === "getHolder");
const keyIndex = computedStmts.findIndex((stmt) => stmt._ === "AssignStmt"
&& stmt.right._ === "StaticCallExpr" && stmt.right.method.name === "getKey");
- const unsupportedIndex = computedStmts.findIndex((stmt) => stmt._ === "AssignStmt"
- && stmt.right._ === "UnsupportedValue");
+ const propertyIndex = computedStmts.findIndex((stmt) => stmt._ === "AssignStmt"
+ && stmt.right._ === "PropertyRef");
+ const conversionIndex = computedStmts.findIndex((stmt) => stmt._ === "AssignStmt"
+ && stmt.right._ === "ToPropertyKeyExpr");
expect(receiverIndex).toBeGreaterThanOrEqual(0);
expect(keyIndex).toBeGreaterThan(receiverIndex);
- expect(unsupportedIndex).toBeGreaterThan(keyIndex);
- expect(diagnostics.messages).toEqual(expect.arrayContaining([
- expect.stringContaining("computed constructor access"),
- ]));
+ expect(conversionIndex).toBeGreaterThan(keyIndex);
+ expect(propertyIndex).toBeGreaterThan(conversionIndex);
+ expect(diagnostics.messages).toEqual([]);
const js = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS },
diff --git a/jacodb-ets/ts-frontend/test/computed-new.spec.ts b/jacodb-ets/ts-frontend/test/computed-new.spec.ts
new file mode 100644
index 000000000..e29dbe904
--- /dev/null
+++ b/jacodb-ets/ts-frontend/test/computed-new.spec.ts
@@ -0,0 +1,83 @@
+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("computed constructors", () => {
+ it("keeps optional computed object constructors guarded and uses property semantics", () => {
+ const source = `
+ class A { x = 1; }
+ export function make(holder: { Ctor: typeof A } | undefined): number {
+ return new (holder?.["Ctor"])().x;
+ }
+ `;
+ const { file, diagnostics } = lower(source);
+ const method = methodByName(JSON.parse(serializeEtsFile(file)), "make");
+ const stmts = method.body!.cfg.blocks.flatMap((block) => block.stmts);
+
+ expect(method.body!.cfg.blocks.length).toBeGreaterThan(1);
+ expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PropertyRef")).toBe(true);
+ expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "ArrayRef")).toBe(false);
+ expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewExpr"
+ && stmt.right.constructorValue?._ === "Local")).toBe(true);
+ expect(diagnostics.messages).toEqual([]);
+ expect(runTypeScript(source, "exports.make({ Ctor: A })")).toBe(1);
+ });
+
+ it("reads an indexed constructor once before allocation and preserves JSON", () => {
+ const source = `
+ class A { kind = "A"; }
+ class B { kind = "B"; }
+ let reads = 0;
+ function constructors(): Array { reads++; return [A, B]; }
+ function index(): number { reads++; return 1; }
+ export function make(): string { return new (constructors()[index()])().kind; }
+ export function count(): number { return reads; }
+ `;
+ const { file, diagnostics } = lower(source);
+ const roundTrip = JSON.parse(serializeEtsFile(file));
+ const stmts = singleBlockStmts(methodByName(roundTrip, "make"));
+ const functionCalls = stmts.flatMap((stmt, index) => stmt._ === "AssignStmt"
+ && stmt.right._ === "StaticCallExpr" ? [{ name: stmt.right.method.name, index }] : []);
+ const readIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "ArrayRef");
+ const allocationIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewExpr"
+ && stmt.right.constructorValue?._ === "Local");
+
+ expect(functionCalls.map((call) => call.name)).toEqual(["constructors", "index"]);
+ expect(functionCalls[1]!.index).toBeLessThan(readIndex);
+ expect(readIndex).toBeLessThan(allocationIndex);
+ expect(diagnostics.messages).toEqual([]);
+ expect(runTypeScript(source, "{ kind: exports.make(), count: exports.count() }")).toEqual({ kind: "B", count: 2 });
+ });
+});