From 29c8e7c0de167b193e3ad4962f38fec60838fbe4 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 4 Oct 2026 23:02:52 +0300 Subject: [PATCH] [TS] Preserve computed constructor targets (#387) --- .../ets/test/EtsTsComputedConstructorTest.kt | 90 +++++++++++++++++++ .../ts-frontend/src/lowering/exprLowering.ts | 42 ++++----- jacodb-ets/ts-frontend/test/classes.spec.ts | 38 +++++--- .../ts-frontend/test/computed-new.spec.ts | 83 +++++++++++++++++ 4 files changed, 218 insertions(+), 35 deletions(-) create mode 100644 jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsComputedConstructorTest.kt create mode 100644 jacodb-ets/ts-frontend/test/computed-new.spec.ts 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 }); + }); +});