From cfede30353ce07e2c972804e0cffabf602e26d44 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 03:15:47 +0300 Subject: [PATCH 01/63] Clarify ETS modifier arguments and test blocks Name modifier encodings and separate test setup and assertions. Preserve wire values and test content. Verification was static only at the user request. --- .../kotlin/org/jacodb/ets/model/Modifiers.kt | 34 +++++++++---------- .../kotlin/org/jacodb/ets/test/EnumTest.kt | 12 +++++++ .../kotlin/org/jacodb/ets/test/EtsFileTest.kt | 3 ++ 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Modifiers.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Modifiers.kt index 36bbbfd37..88b38e07f 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Modifiers.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Modifiers.kt @@ -17,21 +17,21 @@ package org.jacodb.ets.model enum class EtsModifier(val value: Int, val string: String) { - PRIVATE(1 shl 0, "private"), - PROTECTED(1 shl 1, "protected"), - PUBLIC(1 shl 2, "public"), - EXPORT(1 shl 3, "export"), - STATIC(1 shl 4, "static"), - ABSTRACT(1 shl 5, "abstract"), - ASYNC(1 shl 6, "async"), - CONST(1 shl 7, "const"), - ACCESSOR(1 shl 8, "accessor"), - DEFAULT(1 shl 9, "default"), - IN(1 shl 10, "in"), - READONLY(1 shl 11, "readonly"), - OUT(1 shl 12, "out"), - OVERRIDE(1 shl 13, "override"), - DECLARE(1 shl 14, "declare"); + PRIVATE(value = 1 shl 0, string = "private"), + PROTECTED(value = 1 shl 1, string = "protected"), + PUBLIC(value = 1 shl 2, string = "public"), + EXPORT(value = 1 shl 3, string = "export"), + STATIC(value = 1 shl 4, string = "static"), + ABSTRACT(value = 1 shl 5, string = "abstract"), + ASYNC(value = 1 shl 6, string = "async"), + CONST(value = 1 shl 7, string = "const"), + ACCESSOR(value = 1 shl 8, string = "accessor"), + DEFAULT(value = 1 shl 9, string = "default"), + IN(value = 1 shl 10, string = "in"), + READONLY(value = 1 shl 11, string = "readonly"), + OUT(value = 1 shl 12, string = "out"), + OVERRIDE(value = 1 shl 13, string = "override"), + DECLARE(value = 1 shl 14, string = "declare"); } interface WithModifiers { @@ -60,10 +60,10 @@ interface WithModifiers { @JvmInline value class EtsModifiers(val mask: Int) : WithModifiers { companion object { - val EMPTY = EtsModifiers(0) + val EMPTY = EtsModifiers(mask = 0) fun of(vararg modifiers: EtsModifier): EtsModifiers { - return EtsModifiers(modifiers.fold(0) { acc, modifier -> acc or modifier.value }) + return EtsModifiers(mask = modifiers.fold(initial = 0) { acc, modifier -> acc or modifier.value }) } } diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EnumTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EnumTest.kt index 921cf8c57..ffacc13cd 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EnumTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EnumTest.kt @@ -30,10 +30,12 @@ class EnumTest { fun testEnum() { val path = "/samples/source/lang/enum.ts" val res = getResourcePath(path) + val file = loadEtsFileAutoConvert(res) // Count enum classes val enumClasses = file.allClasses.filter { it.category == EtsClassCategory.ENUM } + assertTrue(enumClasses.size >= 5, "File should have multiple enums") // Check for specific enum types @@ -55,9 +57,11 @@ class EnumTest { fun testEnumEdgeCases() { val path = "/samples/source/lang/enum-edge-cases.ts" val res = getResourcePath(path) + val file = loadEtsFileAutoConvert(res) val enumClasses = file.allClasses.filter { it.category == EtsClassCategory.ENUM } + assertTrue(enumClasses.isNotEmpty(), "Edge cases file should have enums") // Check for specific edge case enums @@ -79,9 +83,11 @@ class EnumTest { fun testEnumModules() { val path = "/samples/source/lang/enum-modules.ts" val res = getResourcePath(path) + val file = loadEtsFileAutoConvert(res) val enumClasses = file.allClasses.filter { it.category == EtsClassCategory.ENUM } + assertTrue(enumClasses.isNotEmpty(), "Modules file should have enums") // Check for exported enums @@ -100,24 +106,29 @@ class EnumTest { fun testEnumUsageInClasses() { val path = "/samples/source/lang/enum.ts" val res = getResourcePath(path) + val file = loadEtsFileAutoConvert(res) // Find class that uses enums val usageClass = file.allClasses.find { it.name == "EnumUsageExamples" } + assertNotNull(usageClass, "Should find EnumUsageExamples class") // Verify the class has methods that work with enums val methods = usageClass.methods + assertTrue(methods.any { it.name == "setColor" }, "Should have setColor method") assertTrue(methods.any { it.name == "getDirection" }, "Should have getDirection method") assertTrue(methods.any { it.name == "handleDirection" }, "Should have handleDirection method") // Check method parameter counts val setColorMethod = methods.find { it.name == "setColor" } + assertNotNull(setColorMethod, "setColor method should exist") assertEquals(1, setColorMethod.parameters.size, "setColor should have 1 parameter") val getDirectionMethod = methods.find { it.name == "getDirection" } + assertNotNull(getDirectionMethod, "getDirection method should exist") assertEquals(0, getDirectionMethod.parameters.size, "getDirection should have no parameters") } @@ -126,6 +137,7 @@ class EnumTest { fun testEnumVsClassDistinction() { val path = "/samples/source/lang/enum.ts" val res = getResourcePath(path) + val file = loadEtsFileAutoConvert(res) // Count different class categories diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFileTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFileTest.kt index 045c44336..31fdba13a 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFileTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFileTest.kt @@ -62,6 +62,7 @@ class EtsFileTest { @Test fun `test sample TypeMismatch`() { val etsFile = load("TypeMismatch") + etsFile.classes.forEach { cls -> cls.methods.forEach { method -> when (method.name) { @@ -86,6 +87,7 @@ class EtsFileTest { // instance initializer run { val method = cls.methods.single { it.name == INSTANCE_INIT_METHOD_NAME } + assertEquals(3, method.cfg.stmts.size) // this := ThisRef @@ -133,6 +135,7 @@ class EtsFileTest { // static initializer run { val method = cls.methods.single { it.name == STATIC_INIT_METHOD_NAME } + assertEquals(3, method.cfg.stmts.size) // this := ThisRef From c063cad635979fa93078e03ca180a34cb83d4a9e Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 03:31:46 +0300 Subject: [PATCH 02/63] Clarify ETS array type test stages Name dimensional arguments and separate canonicalization actions from assertions. Preserve existing scenarios and expected types. Verification was static only at the user request. --- .../org/jacodb/ets/test/EtsArrayTypeTest.kt | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsArrayTypeTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsArrayTypeTest.kt index b398c9542..71774b61f 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsArrayTypeTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsArrayTypeTest.kt @@ -31,28 +31,42 @@ import kotlin.test.assertEquals class EtsArrayTypeTest { @Test fun `converts deeply nested DTO array dimensions`() { - val type = ArrayTypeDto(ArrayTypeDto(ArrayTypeDto(NumberTypeDto, 1), 1), 2) + val innerArrayType = ArrayTypeDto(NumberTypeDto, dimensions = 1) + val nestedArrayType = ArrayTypeDto(innerArrayType, dimensions = 1) + val type = ArrayTypeDto(nestedArrayType, dimensions = 2) - assertEquals(EtsArrayType(EtsNumberType, 4), type.toEtsType()) + val actualType = type.toEtsType() + + assertEquals(EtsArrayType(EtsNumberType, dimensions = 4), actualType) } @Test fun `DTO new array expression flattens deeply nested array dimensions`() { + val innerArrayType = ArrayTypeDto(NumberTypeDto, dimensions = 1) + val nestedArrayType = ArrayTypeDto(innerArrayType, dimensions = 1) + val elementType = ArrayTypeDto(nestedArrayType, dimensions = 1) val expression = NewArrayExprDto( - elementType = ArrayTypeDto(ArrayTypeDto(ArrayTypeDto(NumberTypeDto, 1), 1), 1), - size = ConstantDto("2", NumberTypeDto), + elementType = elementType, + size = ConstantDto(value = "2", type = NumberTypeDto), ) - assertEquals(ArrayTypeDto(NumberTypeDto, 4), expression.type) + val actualType = expression.type + + assertEquals(ArrayTypeDto(NumberTypeDto, dimensions = 4), actualType) } @Test fun `model new array expression flattens deeply nested array dimensions`() { + val innerArrayType = EtsArrayType(EtsNumberType, dimensions = 1) + val nestedArrayType = EtsArrayType(innerArrayType, dimensions = 1) + val elementType = EtsArrayType(nestedArrayType, dimensions = 1) val expression = EtsNewArrayExpr( - elementType = EtsArrayType(EtsArrayType(EtsArrayType(EtsNumberType, 1), 1), 1), - size = EtsNumberConstant(2.0), + elementType = elementType, + size = EtsNumberConstant(value = 2.0), ) - assertEquals(EtsArrayType(EtsNumberType, 4), expression.type) + val actualType = expression.type + + assertEquals(EtsArrayType(EtsNumberType, dimensions = 4), actualType) } } From 62ed0304b5d7c2df61987f8fa8e9c4d1fd3e448c Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 04:41:18 +0300 Subject: [PATCH 03/63] Clarify IR file test setup and logger import Review signature and entity contracts without production changes. Normalize the assigned file test import and logical phase spacing. Independent static verification only; no compiler, tests, JVM or containers were run. --- jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFileTest.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFileTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFileTest.kt index 31fdba13a..428c58695 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFileTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFileTest.kt @@ -16,6 +16,7 @@ package org.jacodb.ets.test +import mu.KotlinLogging import org.jacodb.ets.model.EtsAssignStmt import org.jacodb.ets.model.EtsFile import org.jacodb.ets.model.EtsInstanceFieldRef @@ -32,7 +33,7 @@ import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertTrue -private val logger = mu.KotlinLogging.logger {} +private val logger = KotlinLogging.logger {} class EtsFileTest { @@ -47,6 +48,7 @@ class EtsFileTest { @Test fun printEtsInstructions() { val etsFile = load("classes/SimpleClass") + etsFile.classes.forEach { cls -> cls.methods.forEach { method -> logger.info { From 8854322b0109c667fc6a5ef080e5db2c6bde5613 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 11:03:43 +0300 Subject: [PATCH 04/63] Separate local rendering branches Apply the agreed logical-block spacing. Static diff inspection only; no runtime processes were launched. --- jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Local.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Local.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Local.kt index c2041d926..1ca6c76fa 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Local.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Local.kt @@ -24,6 +24,7 @@ data class EtsLocal( if (type is EtsLexicalEnvType) { return "$name<${type.closures.joinToString(",")}>" } + return name } From d4dda98dea0c43790a58db465852a8ab2a10d755 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 11:14:49 +0300 Subject: [PATCH 05/63] Align expression spacing and location sentinel arguments Apply agreed Kotlin style to reviewed expression and statement models. Verification is static source inspection only. --- jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Expr.kt | 1 - jacodb-ets/src/main/kotlin/org/jacodb/ets/model/StmtLocation.kt | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) 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 3d2c72f0d..b61896d30 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 @@ -205,7 +205,6 @@ data class EtsSpreadExpansionExpr( override fun toString(): String = "expandSpread($iterable, $expectedCount)" - override fun accept(visitor: EtsExpr.Visitor): R = visitor.visit(this) } diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/StmtLocation.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/StmtLocation.kt index e53a9fb49..b6b99ba26 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/StmtLocation.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/StmtLocation.kt @@ -60,7 +60,7 @@ data class EtsStmtLocation( companion object { fun stub(method: EtsMethod, origin: EtsSourceSpan? = null): EtsStmtLocation { - return EtsStmtLocation(method, -1, origin) + return EtsStmtLocation(method = method, index = -1, origin = origin) } } } From e4830eb253f7e198bd109ce9c8dbe5edc9cbbcf8 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 12:04:08 +0300 Subject: [PATCH 06/63] Attach nested namespace classes to their declaring file Initialize file ownership recursively while preserving namespace and method owners. Add nested ownership regression source. Independent static verification only; no tests executed. --- .../main/kotlin/org/jacodb/ets/model/File.kt | 8 +++- .../kotlin/org/jacodb/ets/test/EtsFileTest.kt | 37 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/File.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/File.kt index a512333fc..45b19242e 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/File.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/File.kt @@ -25,7 +25,7 @@ class EtsFile( ) { init { classes.forEach { (it as EtsClassImpl).declaringFile = this } - namespaces.forEach { it.declaringFile = this } + namespaces.forEach { it.attachToFile(this) } } var scene: EtsScene? = null @@ -43,3 +43,9 @@ class EtsFile( return signature.toString() } } + +private fun EtsNamespace.attachToFile(file: EtsFile) { + declaringFile = file + classes.forEach { (it as EtsClassImpl).declaringFile = file } + namespaces.forEach { it.attachToFile(file) } +} diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFileTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFileTest.kt index 428c58695..81152c48c 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFileTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFileTest.kt @@ -17,10 +17,18 @@ package org.jacodb.ets.test import mu.KotlinLogging +import org.jacodb.ets.model.EtsAnyType import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsClassImpl +import org.jacodb.ets.model.EtsClassSignature import org.jacodb.ets.model.EtsFile +import org.jacodb.ets.model.EtsFileSignature import org.jacodb.ets.model.EtsInstanceFieldRef import org.jacodb.ets.model.EtsLocal +import org.jacodb.ets.model.EtsMethodImpl +import org.jacodb.ets.model.EtsMethodSignature +import org.jacodb.ets.model.EtsNamespace +import org.jacodb.ets.model.EtsNamespaceSignature import org.jacodb.ets.model.EtsNumberConstant import org.jacodb.ets.model.EtsReturnStmt import org.jacodb.ets.model.EtsStaticFieldRef @@ -31,6 +39,7 @@ import org.jacodb.ets.utils.STATIC_INIT_METHOD_NAME import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlin.test.assertSame import kotlin.test.assertTrue private val logger = KotlinLogging.logger {} @@ -45,6 +54,34 @@ class EtsFileTest { } } + @Test + fun `file owns classes in nested namespaces`() { + val fileSignature = EtsFileSignature(projectName = "TestProject", fileName = "nested.ts") + val outerSignature = EtsNamespaceSignature(name = "Outer", file = fileSignature) + val innerSignature = EtsNamespaceSignature(name = "Inner", file = fileSignature, namespace = outerSignature) + val classSignature = EtsClassSignature(name = "Nested", file = fileSignature, namespace = innerSignature) + val method = EtsMethodImpl( + signature = EtsMethodSignature( + enclosingClass = classSignature, + name = "run", + parameters = emptyList(), + returnType = EtsAnyType, + ), + ) + val nestedClass = EtsClassImpl(signature = classSignature, fields = emptyList(), methods = listOf(method)) + val inner = EtsNamespace(signature = innerSignature, classes = listOf(nestedClass), namespaces = emptyList()) + val outer = EtsNamespace(signature = outerSignature, classes = emptyList(), namespaces = listOf(inner)) + + val file = EtsFile(signature = fileSignature, classes = emptyList(), namespaces = listOf(outer)) + + assertSame(file, outer.declaringFile) + assertSame(file, inner.declaringFile) + assertSame(file, nestedClass.declaringFile) + assertSame(outer, inner.declaringNamespace) + assertSame(inner, nestedClass.declaringNamespace) + assertSame(nestedClass, method.enclosingClass) + } + @Test fun printEtsInstructions() { val etsFile = load("classes/SimpleClass") From 4083e6deec0ae2906b2ae52fda926faa3a61dbd2 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 12:32:19 +0300 Subject: [PATCH 07/63] Preserve alias and type-only export rendering Distinguish default declaration syntax from live aliases and retain type-only export metadata in diagnostic rendering. Correct import documentation. Independent static verification only. --- .../src/main/kotlin/org/jacodb/ets/model/Export.kt | 10 ++++++---- .../src/main/kotlin/org/jacodb/ets/model/Import.kt | 5 ++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Export.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Export.kt index a1a6b96a8..e80e48758 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Export.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Export.kt @@ -100,26 +100,28 @@ data class EtsExportInfo( get() = name != originalName override fun toString(): String { + val exportKeyword = if (isTypeOnly) "export type" else "export" + return when { // Re-exports from != null -> { val alias = if (isAliased) " as $name" else "" if (isStarReExport) { - "export *$alias from '$from'" + "$exportKeyword *$alias from '$from'" } else { - "export { $originalName$alias } from '$from'" + "$exportKeyword { $originalName$alias } from '$from'" } } // Direct default export - isDefaultExport -> { + isDefault -> { "export default ${nameBeforeAs ?: originalName}" } // Direct named export else -> { val alias = if (isAliased) " as $name" else "" - "export { $originalName$alias }" + "$exportKeyword { $originalName$alias }" } } } diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Import.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Import.kt index 18c434bb7..f6b0232c6 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Import.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Import.kt @@ -22,7 +22,7 @@ package org.jacodb.ets.model * @property name The name that will be used in the importing module (empty for side-effect imports) * @property type The [type][EtsImportType] of import. * @property from The module or path being imported from. - * @property nameBeforeAs The original name before 'as' aliasing (null if no aliasing, "*" for namespace imports). + * @property nameBeforeAs The imported name before 'as' in an aliased named import; null otherwise. * @property modifiers Import modifiers. */ data class EtsImportInfo( @@ -46,7 +46,7 @@ data class EtsImportInfo( override val decorators: List get() = emptyList() /** - * Import clause name without any aliasing. + * Imported name for a named import, or the local name for other import forms. */ val originalName: String get() = nameBeforeAs ?: name @@ -56,7 +56,6 @@ data class EtsImportInfo( * * ```ts * import React from 'react'; - * import { default as React } from 'react'; * ``` */ val isDefaultImport: Boolean From 29da7369fdd6e4c23230d202a0a64f0ad301a106 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 12:33:58 +0300 Subject: [PATCH 08/63] Validate CFG edges against actual block indices Reject nonexistent endpoint IDs at construction and retain the existing nonempty-block boundary guard. Simplify predecessor generation and add focused valid and invalid boundary regression source. Static verification only. --- .../kotlin/org/jacodb/ets/model/CfgBlock.kt | 4 +- .../kotlin/org/jacodb/ets/model/CfgLinear.kt | 7 +-- .../org/jacodb/ets/test/EtsCfgDslTest.kt | 49 +++++++++++++++++++ 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/CfgBlock.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/CfgBlock.kt index ae353d78d..895ca5605 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/CfgBlock.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/CfgBlock.kt @@ -40,9 +40,9 @@ class EtsBlockCfg( require(block.id == i) { "Block id ${block.id} mismatch index $i" } } for ((id, successorIds) in successors) { - require(id in 0..blocks.size) { "Block id $id is out of bounds" } + require(id in blocks.indices) { "Block id $id is out of bounds" } for (s in successorIds) { - require(s in 0..blocks.size) { "Successor $s is out of bounds" } + require(s in blocks.indices) { "Successor $s is out of bounds" } } } for ((blockId, stmtTargets) in exceptionalSuccessors) { diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/CfgLinear.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/CfgLinear.kt index 39ec596e7..b5bce4c92 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/CfgLinear.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/CfgLinear.kt @@ -28,7 +28,7 @@ class EtsLinearCfg( val predecessors: List> by lazy { val result: List> = List(stmts.size) { hashSetOf() } - for ((index, stmt) in stmts.withIndex()) { + for (index in stmts.indices) { for (next in successors[index]) { result[next].add(index) } @@ -68,11 +68,6 @@ class EtsLinearCfg( require(node.location.index >= 0) { "Invalid index: ${node.location.index}" } - for (p in predecessors[node.location.index]) { - require(p >= 0) { - "Invalid predecessor index: $p" - } - } predecessors[node.location.index].mapTo(hashSetOf()) { stmts[it] } } } diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsCfgDslTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsCfgDslTest.kt index 32ccca607..749a60601 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsCfgDslTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsCfgDslTest.kt @@ -24,14 +24,18 @@ import org.jacodb.ets.dsl.param import org.jacodb.ets.dsl.program import org.jacodb.ets.dsl.toBlockCfg import org.jacodb.ets.dsl.toDot +import org.jacodb.ets.model.BasicBlock import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsBlockCfg import org.jacodb.ets.model.EtsClassSignature import org.jacodb.ets.model.EtsFileSignature import org.jacodb.ets.model.EtsLocal import org.jacodb.ets.model.EtsMethodImpl import org.jacodb.ets.model.EtsMethodParameter import org.jacodb.ets.model.EtsMethodSignature +import org.jacodb.ets.model.EtsNopStmt import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsStmtLocation import org.jacodb.ets.model.EtsUnknownType import org.jacodb.ets.utils.linearize import org.jacodb.ets.utils.toDot @@ -39,8 +43,53 @@ import org.jacodb.ets.utils.toEtsBlockCfg import org.junit.jupiter.api.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class EtsCfgDslTest { + @Test + fun `block CFG rejects successor IDs equal to block count`() { + val method = EtsMethodImpl( + signature = EtsMethodSignature( + enclosingClass = EtsClassSignature.UNKNOWN, + name = "testMethod", + parameters = emptyList(), + returnType = EtsUnknownType, + ), + ) + val block = BasicBlock( + id = 0, + statements = listOf(EtsNopStmt(location = EtsStmtLocation.stub(method))), + ) + + assertFailsWith { + EtsBlockCfg(blocks = listOf(block), successors = mapOf(0 to listOf(1))) + } + assertFailsWith { + EtsBlockCfg(blocks = listOf(block), successors = mapOf(1 to emptyList())) + } + } + + @Test + fun `block CFG retains valid self-loop terminal and empty graphs`() { + val method = EtsMethodImpl( + signature = EtsMethodSignature( + enclosingClass = EtsClassSignature.UNKNOWN, + name = "testMethod", + parameters = emptyList(), + returnType = EtsUnknownType, + ), + ) + val stmt = EtsNopStmt(location = EtsStmtLocation.stub(method)) + val block = BasicBlock(id = 0, statements = listOf(stmt)) + + val selfLoop = EtsBlockCfg(blocks = listOf(block), successors = mapOf(0 to listOf(0))) + val terminal = EtsBlockCfg(blocks = listOf(block), successors = mapOf(0 to emptyList())) + + assertEquals(setOf(stmt), selfLoop.successors(stmt)) + assertEquals(emptySet(), terminal.successors(stmt)) + assertEquals(emptyList(), EtsBlockCfg.EMPTY.stmts) + } + @Test fun `test simple program`() { val prog = program { From b3c890fbc2dc0e7eac561863d1def8c39f14fb05 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 13:33:46 +0300 Subject: [PATCH 09/63] Align immediate constructor DTOs and remove inactive schema comments Accept ClassValueRef in the existing immediate constructor field and add a raw JSON decoder regression source. Remove inactive operator and model comments. Independent static verification only; no tests executed. --- .../main/kotlin/org/jacodb/ets/dto/Model.kt | 4 --- .../src/main/kotlin/org/jacodb/ets/dto/Ops.kt | 6 ----- .../main/kotlin/org/jacodb/ets/dto/Values.kt | 2 +- .../org/jacodb/ets/test/EtsTsFrontendTest.kt | 25 +++++++++++++++++++ 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Model.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Model.kt index 8f2203e59..a16568c39 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Model.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Model.kt @@ -127,7 +127,6 @@ data class ImportInfoDto( val importFrom: String, val nameBeforeAs: String? = null, val modifiers: Int, - // val decorators: List, ) @Serializable @@ -138,12 +137,9 @@ data class ExportInfoDto( val nameBeforeAs: String? = null, val modifiers: Int, val isTypeOnly: Boolean = false, - // val decorators: List, ) @Serializable data class DecoratorDto( val kind: String, - // val content: String? = null, - // val param: String? = null, ) diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Ops.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Ops.kt index 1d12caeb8..5c618b2d8 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Ops.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Ops.kt @@ -24,9 +24,6 @@ object Ops { const val BIT_NOT = "~" const val INC = "++" const val DEC = "--" - // const val DELETE = "delete" - // const val TYPEOF = "typeof" - // const val VOID = "void" } object Binary { @@ -45,8 +42,6 @@ object Ops { const val AND = "&&" const val OR = "||" const val NULLISH = "??" - // const val COMMA = "," - // const val AS = "as" } object Relational { @@ -59,6 +54,5 @@ object Ops { const val GT = ">" const val GT_EQ = ">=" const val IN = "in" - // const val INSTANCEOF = "instanceof" } } 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 4ef053bee..e5be66c36 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 @@ -63,7 +63,7 @@ data class ConstantDto( data class ClassValueRefDto( val signature: ClassSignatureDto, override val type: TypeDto, -) : ValueDto +) : ImmediateDto // TODO: uncomment and adapt the following code for different constants // Currently, ArkIR stores all constants as strings (see ConstantDto above), diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsFrontendTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsFrontendTest.kt index 15d2e9ca5..759bdd769 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsFrontendTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsFrontendTest.kt @@ -24,6 +24,7 @@ import kotlin.io.path.readText import kotlin.io.path.writeText import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertTrue import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -761,6 +762,30 @@ class EtsTsFrontendTest { assertEquals(null, decoded.constructorValue) } + @Test + fun `new JSON accepts a direct class constructor value`() { + val signature = """{"name":"A","declaringFile":{"projectName":"test","fileName":"class.ts"}}""" + val source = """ + { + "_": "NewExpr", + "classType": { "_": "UnknownType" }, + "constructorValue": { + "_": "ClassValueRef", + "signature": $signature, + "type": { "_": "ClassValueType", "signature": $signature } + } + } + """.trimIndent() + + val decoded = Json { serializersModule = dtoModule } + .decodeFromString(ValueDto.serializer(), source) as NewExprDto + + val constructorValue = decoded.constructorValue + assertIs(constructorValue) + assertEquals("A", constructorValue.signature.name) + assertEquals(ClassValueTypeDto(constructorValue.signature), constructorValue.type) + } + @Test fun `instanceof constructor call survives frontend JSON and model conversion`() { val dto = runFrontend( From b5750f562fb7df16030b9c7e27ead4f1fa3eb071 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 14:00:11 +0300 Subject: [PATCH 10/63] Correct DSL and namespace DTO graph rendering --- .../main/kotlin/org/jacodb/ets/dsl/ToDot.kt | 79 +++++---- .../org/jacodb/ets/utils/EtsFileDtoToDot.kt | 25 ++- .../org/jacodb/ets/utils/EtsFileDtoToText.kt | 13 +- .../org/jacodb/ets/test/EtsCfgDslTest.kt | 64 +++++++ .../ets/test/EtsFileDtoRenderingTest.kt | 165 ++++++++++++++++++ 5 files changed, 303 insertions(+), 43 deletions(-) create mode 100644 jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFileDtoRenderingTest.kt diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dsl/ToDot.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dsl/ToDot.kt index 081dab0f0..f99559585 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dsl/ToDot.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dsl/ToDot.kt @@ -16,8 +16,6 @@ package org.jacodb.ets.dsl -import java.util.IdentityHashMap - private fun Node.toDotLabel(): String { val label = when (this) { is Nop -> "nop" @@ -31,71 +29,76 @@ private fun Node.toDotLabel(): String { return label.replace("\"", "\\\"") } +private class DotNode( + val id: Int, + val node: Node, + val thenBranch: List = emptyList(), + val elseBranch: List = emptyList(), +) + fun Program.toDot(): String { val lines = mutableListOf() lines += "digraph cfg {" lines += " node [shape=rect fontname=\"monospace\"]" - val labelMap: MutableMap = hashMapOf() - val nodeToId: MutableMap = IdentityHashMap() + val labelMap: MutableMap = hashMapOf() var freeId = 0 - fun processForNodes(nodes: List) { - for (node in nodes) { - val id = nodeToId.computeIfAbsent(node) { freeId++ } + fun processForNodes(nodes: List): List { + return nodes.map { node -> + val id = freeId++ lines += " $id [label=\"${node.toDotLabel()}\"]" - if (node is If) { - processForNodes(node.thenBranch) - processForNodes(node.elseBranch) - } + + val thenBranch = if (node is If) processForNodes(node.thenBranch) else emptyList() + val elseBranch = if (node is If) processForNodes(node.elseBranch) else emptyList() + val dotNode = DotNode(id = id, node = node, thenBranch = thenBranch, elseBranch = elseBranch) + if (node is Label) { check(node.name !in labelMap) { "Duplicate label: ${node.name}" } - labelMap[node.name] = node + labelMap[node.name] = dotNode } + + dotNode } } - processForNodes(nodes) + val dotNodes = processForNodes(nodes) - fun processForEdges(nodes: List) { - for (node in nodes) { - val id = nodeToId[node] ?: error("No ID for $node") - when (node) { + fun processForEdges(nodes: List, continuation: DotNode? = null) { + for ((index, dotNode) in nodes.withIndex()) { + val next = nodes.getOrNull(index + 1) ?: continuation + when (val node = dotNode.node) { is If -> { - if (node.thenBranch.isNotEmpty()) { - val thenNode = node.thenBranch.first() - val thenId = nodeToId[thenNode] ?: error("No ID for $thenNode") - lines += " $id -> $thenId [label=\"true\"]" - processForEdges(node.thenBranch) + val thenEntry = dotNode.thenBranch.firstOrNull() ?: next + if (thenEntry != null) { + lines += " ${dotNode.id} -> ${thenEntry.id} [label=\"true\"]" } - if (node.elseBranch.isNotEmpty()) { - val elseNode = node.elseBranch.first() - val elseId = nodeToId[elseNode] ?: error("No ID for $elseNode") - lines += " $id -> $elseId [label=\"false\"]" - processForEdges(node.elseBranch) + processForEdges(dotNode.thenBranch, next) + + val elseEntry = dotNode.elseBranch.firstOrNull() ?: next + if (elseEntry != null) { + lines += " ${dotNode.id} -> ${elseEntry.id} [label=\"false\"]" } + processForEdges(dotNode.elseBranch, next) } is Goto -> { - val labelNode = labelMap[node.targetLabel] ?: error("Unknown label: ${node.targetLabel}") - val labelId = nodeToId[labelNode] ?: error("No ID for $labelNode") - lines += " $id -> $labelId" + val target = labelMap[node.targetLabel] ?: error("Unknown label: ${node.targetLabel}") + lines += " ${dotNode.id} -> ${target.id}" } + is Return -> Unit + else -> { - // See below. + if (next != null) { + lines += " ${dotNode.id} -> ${next.id}" + } } } } - - for ((cur, next) in nodes.zipWithNext()) { - val curId = nodeToId[cur] ?: error("No ID for $cur") - val nextId = nodeToId[next] ?: error("No ID for $next") - lines += " $curId -> $nextId" - } } - processForEdges(nodes) + processForEdges(dotNodes) lines += "}" return lines.joinToString("\n") diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/EtsFileDtoToDot.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/EtsFileDtoToDot.kt index f4f0a441a..25e739e1f 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/EtsFileDtoToDot.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/EtsFileDtoToDot.kt @@ -21,6 +21,7 @@ import org.jacodb.ets.dto.ClassDto import org.jacodb.ets.dto.EtsFileDto import org.jacodb.ets.dto.IfStmtDto import org.jacodb.ets.dto.MethodDto +import org.jacodb.ets.dto.NamespaceDto import org.jacodb.ets.dto.NopStmtDto import org.jacodb.ets.dto.StmtDto import java.io.File @@ -39,8 +40,16 @@ fun EtsFileDto.toDot(useLR: Boolean = false): String { lines += " $x [fontname=\"JetBrains Mono,Source Code Pro,monospace\"];" } + fun qualifiedClassName(clazz: ClassDto): String { + val namespaceNames = generateSequence(clazz.signature.declaringNamespace) { it.declaringNamespace } + .toList() + .asReversed() + .map { it.name } + return (namespaceNames + clazz.signature.name).joinToString(".") + } + fun classId(clazz: ClassDto): String { - return clazz.signature.name + return "class:${qualifiedClassName(clazz)}" } fun classLabel(clazz: ClassDto): String { @@ -78,7 +87,7 @@ fun EtsFileDto.toDot(useLR: Boolean = false): String { } fun methodId(clazz: ClassDto, method: MethodDto): String { - return "${clazz.signature.name}.${method.signature.name}" + return "method:${qualifiedClassName(clazz)}.${method.signature.name}" } fun methodLabel(clazz: ClassDto, method: MethodDto): String { @@ -100,7 +109,7 @@ fun EtsFileDto.toDot(useLR: Boolean = false): String { return labelLines.joinToString("") { "${it}\\l" } } - classes.forEach { clazz -> + fun renderClass(clazz: ClassDto) { // CLASS val c = classId(clazz) val clabel = classLabel(clazz) @@ -165,7 +174,7 @@ fun EtsFileDto.toDot(useLR: Boolean = false): String { for ((j, succ) in bb.successors.withIndex()) { val b = blockId(clazz, method, bb.id) val bs = blockId(clazz, method, succ) - val label = if (j == 0) "true" else "false" + val label = if (j == 0) "false" else "true" lines += """ "${b}.${i}" -> "${bs}.0" [lhead="$bs", label="$label"];""" } } @@ -183,6 +192,14 @@ fun EtsFileDto.toDot(useLR: Boolean = false): String { } } + fun renderNamespace(namespace: NamespaceDto) { + namespace.classes.forEach(::renderClass) + namespace.namespaces.forEach { renderNamespace(it) } + } + + classes.forEach(::renderClass) + namespaces.forEach(::renderNamespace) + lines += "}" return lines.joinToString("\n") } diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/EtsFileDtoToText.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/EtsFileDtoToText.kt index 61d293995..3dd03f267 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/EtsFileDtoToText.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/EtsFileDtoToText.kt @@ -16,12 +16,14 @@ package org.jacodb.ets.utils +import org.jacodb.ets.dto.ClassDto import org.jacodb.ets.dto.EtsFileDto +import org.jacodb.ets.dto.NamespaceDto fun EtsFileDto.toText(): String { val lines: MutableList = mutableListOf() lines += "EtsFileDto '${signature}':" - classes.forEach { clazz -> + fun renderClass(clazz: ClassDto) { lines += "= CLASS '${clazz.signature}':" lines += " superClass = '${clazz.superClassName}'" lines += " typeParameters = ${clazz.typeParameters}" @@ -56,5 +58,14 @@ fun EtsFileDto.toText(): String { } } } + + fun renderNamespace(namespace: NamespaceDto) { + namespace.classes.forEach(::renderClass) + namespace.namespaces.forEach { renderNamespace(it) } + } + + classes.forEach(::renderClass) + namespaces.forEach(::renderNamespace) + return lines.joinToString("\n") } diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsCfgDslTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsCfgDslTest.kt index 749a60601..3cde7c49c 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsCfgDslTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsCfgDslTest.kt @@ -43,9 +43,73 @@ import org.jacodb.ets.utils.toEtsBlockCfg import org.junit.jupiter.api.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertFailsWith +import kotlin.test.assertTrue class EtsCfgDslTest { + @Test + fun `program DOT gives repeated nop positions distinct nodes and stops at return`() { + val prog = program { + nop() + nop() + ret(const(0)) + nop() + } + + val dot = prog.toDot() + + assertTrue(dot.contains(" 0 -> 1")) + assertTrue(dot.contains(" 1 -> 2")) + assertFalse(dot.contains(" 0 -> 0")) + assertFalse(dot.contains(" 2 -> 3")) + } + + @Test + fun `program DOT joins both conditional branches without bypassing them`() { + val prog = program { + val i = local("i") + + ifStmt(const(true)) { + assign(i, const(1)) + }.`else` { + assign(i, const(2)) + } + ret(i) + } + + val dot = prog.toDot() + + assertTrue(dot.contains(" 0 -> 1 [label=\"true\"]")) + assertTrue(dot.contains(" 0 -> 2 [label=\"false\"]")) + assertTrue(dot.contains(" 1 -> 3")) + assertTrue(dot.contains(" 2 -> 3")) + assertFalse(dot.contains(" 0 -> 3")) + } + + @Test + fun `program DOT labels empty branch continuation and goto has no fallthrough`() { + val prog = program { + val i = local("i") + + ifStmt(const(true)) { + assign(i, const(1)) + } + goto("done") + nop() + label("done") + ret(i) + } + + val dot = prog.toDot() + + assertTrue(dot.contains(" 0 -> 1 [label=\"true\"]")) + assertTrue(dot.contains(" 0 -> 2 [label=\"false\"]")) + assertTrue(dot.contains(" 1 -> 2")) + assertTrue(dot.contains(" 2 -> 4")) + assertFalse(dot.contains(" 2 -> 3")) + } + @Test fun `block CFG rejects successor IDs equal to block count`() { val method = EtsMethodImpl( diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFileDtoRenderingTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFileDtoRenderingTest.kt new file mode 100644 index 000000000..baef61a92 --- /dev/null +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFileDtoRenderingTest.kt @@ -0,0 +1,165 @@ +/* + * 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 org.jacodb.ets.dto.BasicBlockDto +import org.jacodb.ets.dto.BodyDto +import org.jacodb.ets.dto.BooleanTypeDto +import org.jacodb.ets.dto.CfgDto +import org.jacodb.ets.dto.ClassDto +import org.jacodb.ets.dto.ClassSignatureDto +import org.jacodb.ets.dto.ConstantDto +import org.jacodb.ets.dto.EtsFileDto +import org.jacodb.ets.dto.FileSignatureDto +import org.jacodb.ets.dto.IfStmtDto +import org.jacodb.ets.dto.MethodDto +import org.jacodb.ets.dto.MethodSignatureDto +import org.jacodb.ets.dto.NamespaceDto +import org.jacodb.ets.dto.NamespaceSignatureDto +import org.jacodb.ets.dto.NumberTypeDto +import org.jacodb.ets.dto.ReturnVoidStmtDto +import org.jacodb.ets.utils.toDot +import org.jacodb.ets.utils.toText +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class EtsFileDtoRenderingTest { + private val fileSignature = FileSignatureDto(projectName = "Test", fileName = "test.ts") + + private fun classDto(name: String, namespace: NamespaceSignatureDto? = null, methods: List = emptyList()) = + ClassDto( + signature = ClassSignatureDto(name = name, declaringFile = fileSignature, declaringNamespace = namespace), + modifiers = 0, + decorators = emptyList(), + superClassName = null, + implementedInterfaceNames = emptyList(), + fields = emptyList(), + methods = methods, + ) + + @Test + fun `DTO renderers include classes in nested namespaces without DOT ID collisions`() { + val outerSignature = NamespaceSignatureDto(name = "Outer", declaringFile = fileSignature) + val innerSignature = NamespaceSignatureDto( + name = "Inner", + declaringFile = fileSignature, + declaringNamespace = outerSignature, + ) + val dto = EtsFileDto( + signature = fileSignature, + namespaces = listOf( + NamespaceDto( + signature = outerSignature, + classes = listOf(classDto("C", namespace = outerSignature)), + namespaces = listOf( + NamespaceDto( + signature = innerSignature, + classes = listOf(classDto("C", namespace = innerSignature)), + ), + ), + ), + ), + classes = listOf(classDto("C")), + importInfos = emptyList(), + exportInfos = emptyList(), + ) + + val dot = dto.toDot() + val text = dto.toText() + + assertTrue(dot.contains("\"class:C\" [shape=rect")) + assertTrue(dot.contains("\"class:Outer.C\" [shape=rect")) + assertTrue(dot.contains("\"class:Outer.Inner.C\" [shape=rect")) + assertEquals(3, text.lines().count { it.startsWith("= CLASS") }) + } + + @Test + fun `DTO DOT separates namespace class ID from equal qualified method name`() { + val namespaceSignature = NamespaceSignatureDto(name = "A", declaringFile = fileSignature) + val topClassSignature = ClassSignatureDto(name = "A", declaringFile = fileSignature) + val method = MethodDto( + signature = MethodSignatureDto( + declaringClass = topClassSignature, + name = "B", + parameters = emptyList(), + returnType = NumberTypeDto, + ), + modifiers = 0, + decorators = emptyList(), + ) + val dto = EtsFileDto( + signature = fileSignature, + namespaces = listOf( + NamespaceDto( + signature = namespaceSignature, + classes = listOf(classDto("B", namespace = namespaceSignature)), + ), + ), + classes = listOf(classDto("A", methods = listOf(method))), + importInfos = emptyList(), + exportInfos = emptyList(), + ) + + val dot = dto.toDot() + + assertTrue(dot.contains("\"class:A.B\" [shape=rect")) + assertTrue(dot.contains("\"method:A.B\" [shape=oval")) + assertTrue(dot.contains("\"class:A\" -> \"method:A.B\" [dir=none]")) + } + + @Test + fun `DTO DOT labels conditional successors in raw DTO order`() { + val classSignature = ClassSignatureDto(name = "C", declaringFile = fileSignature) + val method = MethodDto( + signature = MethodSignatureDto( + declaringClass = classSignature, + name = "f", + parameters = emptyList(), + returnType = NumberTypeDto, + ), + modifiers = 0, + decorators = emptyList(), + body = BodyDto( + locals = emptyList(), + cfg = CfgDto( + blocks = listOf( + BasicBlockDto( + id = 0, + successors = listOf(1, 2), + stmts = listOf(IfStmtDto(condition = ConstantDto(value = "true", type = BooleanTypeDto))), + ), + BasicBlockDto(id = 1, successors = emptyList(), stmts = listOf(ReturnVoidStmtDto)), + BasicBlockDto(id = 2, successors = emptyList(), stmts = listOf(ReturnVoidStmtDto)), + ), + ), + ), + ) + val dto = EtsFileDto( + signature = fileSignature, + namespaces = emptyList(), + classes = listOf(classDto("C", methods = listOf(method))), + importInfos = emptyList(), + exportInfos = emptyList(), + ) + + val dot = dto.toDot() + + assertTrue(dot.contains("\"method:C.f.bb0.0\" -> \"method:C.f.bb1.0\" [lhead=\"method:C.f.bb1\", label=\"false\"]")) + assertTrue(dot.contains("\"method:C.f.bb0.0\" -> \"method:C.f.bb2.0\" [lhead=\"method:C.f.bb2\", label=\"true\"]")) + } +} From a2213f4672cccf405ffaf144af54ee9f7039fd76 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 14:07:24 +0300 Subject: [PATCH 11/63] Decode canonical Raw DTO envelopes without losing metadata --- .../kotlin/org/jacodb/ets/dto/Serializers.kt | 24 +++++++ .../org/jacodb/ets/test/EtsFromJsonTest.kt | 68 +++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Serializers.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Serializers.kt index 0351ada97..8ad201938 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Serializers.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Serializers.kt @@ -120,6 +120,14 @@ object RawStmtSerializer : KSerializer { override fun deserialize(decoder: Decoder): RawStmtDto { require(decoder is JsonDecoder) val element = decoder.decodeJsonElement().jsonObject + val discriminator = element["_"]?.jsonPrimitive?.content + val isDirectRawEnvelope = discriminator == null && + element.keys == setOf("kind", "extra") && element["extra"] is JsonObject + if (discriminator == "RawStmt" || isDirectRawEnvelope) { + val payload = JsonObject(element.filterKeys { it != "_" }) + return decoder.json.decodeFromJsonElement(serializer, payload) + } + if ("_" in element.keys) { val kind = element.getValue("_").jsonPrimitive.content val details = element.toMutableMap() @@ -146,6 +154,14 @@ object RawValueSerializer : KSerializer { override fun deserialize(decoder: Decoder): RawValueDto { require(decoder is JsonDecoder) val element = decoder.decodeJsonElement().jsonObject + val discriminator = element["_"]?.jsonPrimitive?.content + val isDirectRawEnvelope = discriminator == null && + element.keys == setOf("kind", "extra", "type") && element["extra"] is JsonObject + if (discriminator == "RawValue" || isDirectRawEnvelope) { + val payload = JsonObject(element.filterKeys { it != "_" }) + return decoder.json.decodeFromJsonElement(serializer, payload) + } + if ("_" in element.keys) { val kind = element.getValue("_").jsonPrimitive.content val type = decoder.json.decodeFromJsonElement(element.getValue("type")) @@ -176,6 +192,14 @@ object RawTypeSerializer : KSerializer { override fun deserialize(decoder: Decoder): RawTypeDto { require(decoder is JsonDecoder) val element = decoder.decodeJsonElement().jsonObject + val discriminator = element["_"]?.jsonPrimitive?.content + val isDirectRawEnvelope = discriminator == null && + element.keys == setOf("kind", "extra") && element["extra"] is JsonObject + if (discriminator == "RawType" || isDirectRawEnvelope) { + val payload = JsonObject(element.filterKeys { it != "_" }) + return decoder.json.decodeFromJsonElement(serializer, payload) + } + if ("_" in element.keys) { val kind = element.getValue("_").jsonPrimitive.content val details = element.toMutableMap() diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFromJsonTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFromJsonTest.kt index 6de024112..f86af93ee 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFromJsonTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFromJsonTest.kt @@ -17,7 +17,11 @@ package org.jacodb.ets.test import kotlinx.serialization.SerializationException +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import mu.KotlinLogging import org.jacodb.ets.dto.AnyTypeDto @@ -652,6 +656,70 @@ class EtsFromJsonTest { assertEquals(42, stmtDto.extra.getValue("value").jsonPrimitive.content.toInt()) } + @Test + fun testRawStmtGeneratedEnvelopeRoundTrip() { + val raw = RawStmtDto( + kind = "DummyStmt", + extra = JsonObject(mapOf("kind" to JsonPrimitive("inner"), "extra" to JsonPrimitive(42))), + ) + + val polymorphicJson = json.encodeToString(raw) + val directJson = json.encodeToString(raw) + + assertEquals("RawStmt", json.parseToJsonElement(polymorphicJson).jsonObject.getValue("_").jsonPrimitive.content) + assertEquals(raw, json.decodeFromString(polymorphicJson)) + assertEquals(raw, json.decodeFromString(directJson)) + } + + @Test + fun testRawValueGeneratedEnvelopeRoundTripWithRawType() { + val rawType = RawTypeDto( + kind = "DummyType", + extra = JsonObject(mapOf("value" to JsonPrimitive(42))), + ) + val raw = RawValueDto( + kind = "DummyValue", + extra = JsonObject(mapOf("kind" to JsonPrimitive("inner"), "extra" to JsonPrimitive(42))), + type = rawType, + ) + + val polymorphicJson = json.encodeToString(raw) + val directJson = json.encodeToString(raw) + + assertEquals("RawValue", json.parseToJsonElement(polymorphicJson).jsonObject.getValue("_").jsonPrimitive.content) + assertEquals(raw, json.decodeFromString(polymorphicJson)) + assertEquals(raw, json.decodeFromString(directJson)) + } + + @Test + fun testRawTypeGeneratedEnvelopeRoundTrip() { + val raw = RawTypeDto( + kind = "DummyType", + extra = JsonObject(mapOf("kind" to JsonPrimitive("inner"), "extra" to JsonPrimitive(42))), + ) + + val polymorphicJson = json.encodeToString(raw) + val directJson = json.encodeToString(raw) + + assertEquals("RawType", json.parseToJsonElement(polymorphicJson).jsonObject.getValue("_").jsonPrimitive.content) + assertEquals(raw, json.decodeFromString(polymorphicJson)) + assertEquals(raw, json.decodeFromString(directJson)) + } + + @Test + fun testLegacyFlatRawObjectsWithDirectKind() { + val rawStmt = json.decodeFromString("""{"kind":"DummyStmt","value":42}""") + val rawValue = json.decodeFromString( + """{"kind":"DummyValue","value":42,"type":{"_":"NumberType"}}""" + ) + val rawType = json.decodeFromString("""{"kind":"DummyType","value":42}""") + + assertEquals(JsonPrimitive(42), rawStmt.extra.getValue("value")) + assertEquals(JsonPrimitive(42), rawValue.extra.getValue("value")) + assertEquals(NumberTypeDto, rawValue.type) + assertEquals(JsonPrimitive(42), rawType.extra.getValue("value")) + } + @Test fun testVararg() { val path = "/samples/etsir/ast/lang/vararg.ts.json" From ae25251b781db7238ee959dc75c7c80594c895b3 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 14:26:11 +0300 Subject: [PATCH 12/63] Avoid generated local name collisions in DTO normalization --- .../main/kotlin/org/jacodb/ets/dto/Convert.kt | 8 +++- .../jacodb/ets/test/EtsMethodBuilderTest.kt | 45 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) 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 6e072092d..8ae26cc24 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 @@ -181,6 +181,7 @@ class EtsMethodBuilder( isStrictMode: Boolean? = null, ) { private val locals = locals.toMutableList() + private val occupiedLocalNames = this.locals.mapTo(mutableSetOf()) { it.name } private val method = EtsMethodImpl( signature = signature, @@ -199,7 +200,12 @@ class EtsMethodBuilder( private var currentOrigin: EtsSourceSpan? = null private fun newTempLocal(): EtsLocal { - val local = EtsLocal("_tmp${freeTempLocal++}") + var name = "_tmp${freeTempLocal++}" + while (!occupiedLocalNames.add(name)) { + name = "_tmp${freeTempLocal++}" + } + + val local = EtsLocal(name) this@EtsMethodBuilder.locals += local return local } diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsMethodBuilderTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsMethodBuilderTest.kt index af3f2a42e..1399e5f5d 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsMethodBuilderTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsMethodBuilderTest.kt @@ -21,12 +21,14 @@ import org.jacodb.ets.dto.BodyDto import org.jacodb.ets.dto.CfgDto import org.jacodb.ets.dto.ClassSignatureDto import org.jacodb.ets.dto.ClosureFieldRefDto +import org.jacodb.ets.dto.ConstantDto import org.jacodb.ets.dto.FileSignatureDto import org.jacodb.ets.dto.AssignStmtDto import org.jacodb.ets.dto.LocalDto import org.jacodb.ets.dto.MethodDto import org.jacodb.ets.dto.MethodSignatureDto import org.jacodb.ets.dto.NumberTypeDto +import org.jacodb.ets.dto.ReturnStmtDto import org.jacodb.ets.dto.ReturnVoidStmtDto import org.jacodb.ets.dto.SourceSpanDto import org.jacodb.ets.dto.StmtOriginDto @@ -35,13 +37,56 @@ import org.jacodb.ets.dto.toEtsMethod import org.jacodb.ets.model.EtsAssignStmt import org.jacodb.ets.model.EtsClosureFieldRef import org.jacodb.ets.model.EtsNopStmt +import org.jacodb.ets.model.EtsReturnStmt import org.jacodb.ets.model.EtsSourceSpan import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNull +import kotlin.test.assertSame class EtsMethodBuilderTest { + @Test + fun `generated local skips names supplied by DTO`() { + val methodDto = MethodDto( + signature = MethodSignatureDto( + declaringClass = ClassSignatureDto( + name = "TestClass", + declaringFile = FileSignatureDto(projectName = "TestProject", fileName = "test.ts"), + ), + name = "testMethod", + parameters = emptyList(), + returnType = NumberTypeDto, + ), + modifiers = 0, + decorators = emptyList(), + body = BodyDto( + locals = listOf( + LocalDto(name = "_tmp0", type = NumberTypeDto), + LocalDto(name = "_tmp1", type = NumberTypeDto), + ), + cfg = CfgDto( + blocks = listOf( + BasicBlockDto( + id = 0, + successors = emptyList(), + stmts = listOf(ReturnStmtDto(arg = ConstantDto(value = "1", type = NumberTypeDto))), + ), + ), + ), + ), + ) + + val method = methodDto.toEtsMethod() + val statements = method.cfg.blocks.single().statements + val assignment = assertIs(statements[0]) + val returnStmt = assertIs(statements[1]) + + assertEquals(listOf("_tmp0", "_tmp1", "_tmp2"), method.locals.map { it.name }) + assertSame(method.locals[2], assignment.lhv) + assertSame(assignment.lhv, returnStmt.returnValue) + } + @Test fun `assignment accepts closure field reference as LHV`() { val methodDto = MethodDto( From 95f9e941ea9a9b4a6be0c3dfa3e90b6dbb098c2c Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 17:37:59 +0300 Subject: [PATCH 13/63] Retain field decorators during DTO conversion --- .../main/kotlin/org/jacodb/ets/dto/Convert.kt | 9 +++--- .../main/kotlin/org/jacodb/ets/model/Field.kt | 4 +-- .../org/jacodb/ets/test/EtsFromJsonTest.kt | 31 +++++++++++++++++++ 3 files changed, 36 insertions(+), 8 deletions(-) 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 8ae26cc24..08e61c520 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 @@ -938,14 +938,11 @@ fun SourceSpanDto.toEtsSourceSpan(enclosingFileName: String = ""): EtsSourceSpan fun FieldDto.toEtsField(): EtsField { return EtsFieldImpl( - signature = EtsFieldSignature( - enclosingClass = signature.declaringClass.toEtsClassSignature(), - name = signature.name, - type = signature.type.toEtsType() - ), + signature = signature.toEtsFieldSignature(), modifiers = EtsModifiers(modifiers), isOptional = isOptional, isDefinitelyAssigned = isDefinitelyAssigned, + decorators = decorators.map { it.toEtsDecorator() }, ) } @@ -953,6 +950,7 @@ fun NamespaceDto.toEtsNamespace(enclosingFileName: String = signature.declaringF val signature = signature.toEtsNamespaceSignature() val classes = classes.map { it.toEtsClass(enclosingFileName) } val namespaces = namespaces.map { it.toEtsNamespace(enclosingFileName) } + return EtsNamespace( signature = signature, classes = classes, @@ -966,6 +964,7 @@ fun EtsFileDto.toEtsFile(): EtsFile { val namespaces = namespaces.map { it.toEtsNamespace(signature.fileName) } val importInfos = importInfos.map { it.toEtsImportInfo() } val exportInfos = exportInfos.map { it.toEtsExportInfo() } + return EtsFile( signature = signature, classes = classes, diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Field.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Field.kt index 215d6087f..c77735f09 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Field.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Field.kt @@ -33,12 +33,10 @@ class EtsFieldImpl( override val modifiers: EtsModifiers = EtsModifiers.EMPTY, val isOptional: Boolean = false, // '?' val isDefinitelyAssigned: Boolean = false, // '!' + override val decorators: List = emptyList(), ) : EtsField { override var declaringClass: EtsClass? = null - override val decorators: List - get() = error("Fields do not have decorators") - override fun toString(): String { return signature.toString() } diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFromJsonTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFromJsonTest.kt index f86af93ee..4596aadb2 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFromJsonTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFromJsonTest.kt @@ -45,11 +45,13 @@ import org.jacodb.ets.dto.StmtDto import org.jacodb.ets.dto.TypeDto import org.jacodb.ets.dto.ValueDto import org.jacodb.ets.dto.dtoModule +import org.jacodb.ets.dto.toEtsField import org.jacodb.ets.dto.toEtsLocal import org.jacodb.ets.dto.toEtsMethod import org.jacodb.ets.model.EtsAnyType import org.jacodb.ets.model.EtsClassCategory import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsDecorator import org.jacodb.ets.model.EtsFile import org.jacodb.ets.model.EtsFileSignature import org.jacodb.ets.model.EtsLocal @@ -317,6 +319,35 @@ class EtsFromJsonTest { assertEquals(field, fieldDto) } + @Test + fun testFieldDecoratorsSurviveConversion() { + val signature = FieldSignatureDto( + declaringClass = ClassSignatureDto( + name = "TestClass", + declaringFile = FileSignatureDto( + projectName = "TestProject", + fileName = "test.ts", + ), + ), + name = "x", + type = NumberTypeDto, + ) + val decoratedDto = FieldDto( + signature = signature, + modifiers = 0, + decorators = listOf(DecoratorDto(kind = "marker")), + isOptional = false, + isDefinitelyAssigned = false, + ) + + val decoratedField = decoratedDto.toEtsField() + val plainField = decoratedDto.copy(decorators = emptyList()).toEtsField() + + assertEquals(listOf(EtsDecorator(name = "marker")), decoratedField.decorators) + assertTrue(decoratedField.hasDecorator(EtsDecorator(name = "marker"))) + assertEquals(emptyList(), plainField.decorators) + } + @Test fun testLoadReturnVoidStmtFromJson() { val jsonString = """ From 157faaa396ed3d467950a629c024bc28bada82ac Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 18:08:05 +0300 Subject: [PATCH 14/63] Preserve DSL static owners and reserve generated local names --- .../main/kotlin/org/jacodb/ets/dsl/Expr.kt | 9 ++ .../org/jacodb/ets/dsl/ProgramBuilder.kt | 3 + .../org/jacodb/ets/utils/BlockCfgBuilder.kt | 116 +++++++++++++- .../org/jacodb/ets/test/EtsCfgDslTest.kt | 144 ++++++++++++++++++ 4 files changed, 264 insertions(+), 8 deletions(-) diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dsl/Expr.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dsl/Expr.kt index b05ba25d6..55505dbef 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dsl/Expr.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dsl/Expr.kt @@ -16,6 +16,8 @@ package org.jacodb.ets.dsl +import org.jacodb.ets.model.EtsClassSignature + sealed interface Expr data class Local(val name: String) : LValue { @@ -96,7 +98,14 @@ data class FieldRef( data class StaticFieldRef( val className: String, val fieldName: String, + val classSignature: EtsClassSignature? = null, ) : LValue { + init { + require(classSignature == null || classSignature.name == className) { + "Static field class name does not match its signature" + } + } + override fun toString() = "$className.$fieldName" } diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dsl/ProgramBuilder.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dsl/ProgramBuilder.kt index 5f2c64c3d..46428b8c6 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dsl/ProgramBuilder.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dsl/ProgramBuilder.kt @@ -18,6 +18,7 @@ package org.jacodb.ets.dsl +import org.jacodb.ets.model.EtsClassSignature import org.jacodb.ets.model.EtsEntity import org.jacodb.ets.model.EtsStmt import org.jacodb.ets.model.EtsStmtLocation @@ -43,6 +44,8 @@ fun ProgramBuilder.const(value: String) = ConstantString(value) fun ProgramBuilder.fieldRef(instance: Expr, fieldName: String) = FieldRef(instance, fieldName) fun ProgramBuilder.staticFieldRef(className: String, fieldName: String) = StaticFieldRef(className, fieldName) +fun ProgramBuilder.staticFieldRef(owner: EtsClassSignature, fieldName: String) = + StaticFieldRef(owner.name, fieldName, classSignature = owner) fun ProgramBuilder.arrayAccess(array: Expr, index: Expr) = ArrayAccess(array, index) fun Expr.field(name: String) = FieldRef(this, name) diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/BlockCfgBuilder.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/BlockCfgBuilder.kt index 3c89471ad..c974a2184 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/BlockCfgBuilder.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/BlockCfgBuilder.kt @@ -26,6 +26,7 @@ import org.jacodb.ets.dsl.BlockCustomEts import org.jacodb.ets.dsl.BlockIf import org.jacodb.ets.dsl.BlockNop import org.jacodb.ets.dsl.BlockReturn +import org.jacodb.ets.dsl.BlockStmt import org.jacodb.ets.dsl.ConstantBoolean import org.jacodb.ets.dsl.ConstantInt import org.jacodb.ets.dsl.ConstantNumber @@ -53,6 +54,7 @@ import org.jacodb.ets.model.EtsDivExpr import org.jacodb.ets.model.EtsEntity import org.jacodb.ets.model.EtsEqExpr import org.jacodb.ets.model.EtsFieldSignature +import org.jacodb.ets.model.EtsFileSignature import org.jacodb.ets.model.EtsGtEqExpr import org.jacodb.ets.model.EtsGtExpr import org.jacodb.ets.model.EtsIfStmt @@ -83,6 +85,11 @@ import org.jacodb.ets.model.EtsSubExpr import org.jacodb.ets.model.EtsThis import org.jacodb.ets.model.EtsUnknownType +/** + * Custom DSL callbacks run once, in statement order. Declare any local names they produce in + * [EtsMethod.body].locals before conversion so generated temporaries avoid them. A late custom + * local that collides with an already generated temporary causes an [IllegalStateException]. + */ fun BlockCfg.toEtsBlockCfg(method: EtsMethod): EtsBlockCfg { return EtsBlockCfgBuilder(method).build(this) } @@ -91,6 +98,13 @@ class EtsBlockCfgBuilder( val method: EtsMethod, ) { fun build(blockCfg: BlockCfg): EtsBlockCfg { + occupiedLocalNames += method.locals.map { it.name } + for (block in blockCfg.blocks) { + for (stmt in block.statements) { + stmt.collectLocalNames(occupiedLocalNames) + } + } + return EtsBlockCfg( blocks = blockCfg.blocks.map { it.toEtsBasicBlock() }, successors = blockCfg.successors, @@ -98,10 +112,72 @@ class EtsBlockCfgBuilder( } private var freeTempLocal: Int = 0 + private val occupiedLocalNames: MutableSet = mutableSetOf() + private val generatedLocalNames: MutableSet = mutableSetOf() + private fun newTempLocal(): EtsLocal { - return EtsLocal( - name = "_tmp${freeTempLocal++}", - ) + var name = "_tmp${freeTempLocal++}" + while (!occupiedLocalNames.add(name)) { + name = "_tmp${freeTempLocal++}" + } + + generatedLocalNames += name + return EtsLocal(name = name) + } + + private fun BlockStmt.collectLocalNames(destination: MutableSet) { + when (this) { + is BlockAssign -> { + target.collectLocalNames(destination) + expr.collectLocalNames(destination) + } + + is BlockReturn -> expr.collectLocalNames(destination) + is BlockIf -> condition.collectLocalNames(destination) + BlockNop, is BlockCustomEts -> Unit + } + } + + private fun Expr.collectLocalNames(destination: MutableSet) { + when (this) { + is Local -> destination += name + is FieldRef -> instance.collectLocalNames(destination) + + is ArrayAccess -> { + array.collectLocalNames(destination) + index.collectLocalNames(destination) + } + + is UnaryExpr -> expr.collectLocalNames(destination) + + is BinaryExpr -> { + left.collectLocalNames(destination) + right.collectLocalNames(destination) + } + + is CustomUnaryExpr -> arg.collectLocalNames(destination) + + is CustomBinaryExpr -> { + left.collectLocalNames(destination) + right.collectLocalNames(destination) + } + + is Parameter, ThisRef, is ConstantInt, is ConstantNumber, is ConstantBoolean, + is ConstantString, is StaticFieldRef, is CustomValue -> Unit + } + } + + private fun reserveCustomLocalNames( + names: Set, + allowedGeneratedLocalNames: Set = emptySet(), + ) { + for (name in names) { + val collidesWithGeneratedLocal = name in generatedLocalNames && name !in allowedGeneratedLocalNames + check(!collidesWithGeneratedLocal) { + "Custom ETS value uses generated local name $name" + } + occupiedLocalNames += name + } } private val stub @@ -182,9 +258,13 @@ class EtsBlockCfgBuilder( } is StaticFieldRef -> { + val enclosingClass = classSignature + ?: method.signature.enclosingClass.takeIf { it.name == className } + ?: EtsClassSignature(name = className, file = EtsFileSignature.UNKNOWN) + EtsStaticFieldRef( field = EtsFieldSignature( - enclosingClass = EtsClassSignature.UNKNOWN, + enclosingClass = enclosingClass, name = fieldName, type = EtsUnknownType, ), @@ -307,18 +387,36 @@ class EtsBlockCfgBuilder( } is CustomValue -> { - toEts() + val entity = toEts() + reserveCustomLocalNames(entity.getLocals().mapTo(mutableSetOf()) { it.name }) + entity } is CustomUnaryExpr -> { val arg = ensureImmediate(arg.toEtsEntity()) - toEts(arg) + val entity = toEts(arg) + val passedGeneratedNames = setOfNotNull((arg as? EtsLocal)?.name) + .intersect(generatedLocalNames) + reserveCustomLocalNames( + names = entity.getLocals().mapTo(mutableSetOf()) { it.name }, + allowedGeneratedLocalNames = passedGeneratedNames, + ) + entity } is CustomBinaryExpr -> { val left = ensureImmediate(left.toEtsEntity()) val right = ensureImmediate(right.toEtsEntity()) - toEts(left, right) + val entity = toEts(left, right) + val passedGeneratedNames = setOfNotNull( + (left as? EtsLocal)?.name, + (right as? EtsLocal)?.name, + ).intersect(generatedLocalNames) + reserveCustomLocalNames( + names = entity.getLocals().mapTo(mutableSetOf()) { it.name }, + allowedGeneratedLocalNames = passedGeneratedNames, + ) + entity } } @@ -358,7 +456,9 @@ class EtsBlockCfgBuilder( } is BlockCustomEts -> { - etsStatements += stmt.toEts(stub) + val etsStmt = stmt.toEts(stub) + reserveCustomLocalNames(etsStmt.getLocals().mapTo(mutableSetOf()) { it.name }) + etsStatements += etsStmt } } } diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsCfgDslTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsCfgDslTest.kt index 3cde7c49c..c87e1a0b0 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsCfgDslTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsCfgDslTest.kt @@ -16,12 +16,14 @@ package org.jacodb.ets.test +import org.jacodb.ets.dsl.CustomValue import org.jacodb.ets.dsl.add import org.jacodb.ets.dsl.const import org.jacodb.ets.dsl.local import org.jacodb.ets.dsl.lt import org.jacodb.ets.dsl.param import org.jacodb.ets.dsl.program +import org.jacodb.ets.dsl.staticFieldRef import org.jacodb.ets.dsl.toBlockCfg import org.jacodb.ets.dsl.toDot import org.jacodb.ets.model.BasicBlock @@ -35,6 +37,7 @@ import org.jacodb.ets.model.EtsMethodParameter import org.jacodb.ets.model.EtsMethodSignature import org.jacodb.ets.model.EtsNopStmt import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsStaticFieldRef import org.jacodb.ets.model.EtsStmtLocation import org.jacodb.ets.model.EtsUnknownType import org.jacodb.ets.utils.linearize @@ -45,6 +48,7 @@ import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertFailsWith +import kotlin.test.assertSame import kotlin.test.assertTrue class EtsCfgDslTest { @@ -154,6 +158,146 @@ class EtsCfgDslTest { assertEquals(emptyList(), EtsBlockCfg.EMPTY.stmts) } + @Test + fun `DSL temporaries avoid local names throughout the graph and method`() { + val method = EtsMethodImpl( + signature = EtsMethodSignature( + enclosingClass = EtsClassSignature.UNKNOWN, + name = "testMethod", + parameters = emptyList(), + returnType = EtsUnknownType, + ), + ) + method.body.locals = listOf(EtsLocal(name = "_tmp2")) + val prog = program { + assign(local("_tmp0"), const(1)) + assign(local("result"), add(local("_tmp1"), const(2))) + ret(const(3)) + } + + val cfg = prog.toBlockCfg().toEtsBlockCfg(method) + val assignments = cfg.blocks.single().statements.filterIsInstance() + + assertEquals(listOf("_tmp0", "result", "_tmp3"), assignments.map { (it.lhv as EtsLocal).name }) + } + + @Test + fun `DSL temporaries avoid earlier custom value locals`() { + val method = EtsMethodImpl( + signature = EtsMethodSignature( + enclosingClass = EtsClassSignature.UNKNOWN, + name = "testMethod", + parameters = emptyList(), + returnType = EtsUnknownType, + ), + ) + val prog = program { + assign(local("value"), CustomValue(EtsLocal(name = "_tmp0"))) + ret(const(1)) + } + + val cfg = prog.toBlockCfg().toEtsBlockCfg(method) + val assignments = cfg.blocks.single().statements.filterIsInstance() + + assertEquals(listOf("value", "_tmp1"), assignments.map { (it.lhv as EtsLocal).name }) + } + + @Test + fun `late custom local collision fails after one callback invocation`() { + val method = EtsMethodImpl( + signature = EtsMethodSignature( + enclosingClass = EtsClassSignature.UNKNOWN, + name = "testMethod", + parameters = emptyList(), + returnType = EtsUnknownType, + ), + ) + var callbackCount = 0 + val prog = program { + assign(local("seed"), add(add(const(1), const(2)), const(3))) + assign(local("value"), CustomValue { + callbackCount++ + EtsLocal(name = "_tmp0") + }) + ret(local("value")) + } + + val failure = assertFailsWith { + prog.toBlockCfg().toEtsBlockCfg(method) + } + + assertEquals(1, callbackCount) + assertTrue(failure.message.orEmpty().contains("_tmp0")) + } + + @Test + fun `predeclared late custom local avoids temporary collision`() { + val method = EtsMethodImpl( + signature = EtsMethodSignature( + enclosingClass = EtsClassSignature.UNKNOWN, + name = "testMethod", + parameters = emptyList(), + returnType = EtsUnknownType, + ), + ) + method.body.locals = listOf(EtsLocal(name = "_tmp0")) + var callbackCount = 0 + val prog = program { + assign(local("seed"), add(add(const(1), const(2)), const(3))) + assign(local("value"), CustomValue { + callbackCount++ + EtsLocal(name = "_tmp0") + }) + ret(local("value")) + } + + val cfg = prog.toBlockCfg().toEtsBlockCfg(method) + val assignments = cfg.blocks.single().statements.filterIsInstance() + + assertEquals(1, callbackCount) + assertEquals(listOf("_tmp1", "seed", "value"), assignments.map { (it.lhv as EtsLocal).name }) + } + + @Test + fun `DSL static field conversion preserves available owner identity`() { + val ownClass = EtsClassSignature( + name = "OwnClass", + file = EtsFileSignature(projectName = "Project", fileName = "own.ts"), + ) + val externalClass = EtsClassSignature( + name = "ExternalA", + file = EtsFileSignature(projectName = "Project", fileName = "external.ts"), + ) + val method = EtsMethodImpl( + signature = EtsMethodSignature( + enclosingClass = ownClass, + name = "testMethod", + parameters = emptyList(), + returnType = EtsUnknownType, + ), + ) + val prog = program { + assign(staticFieldRef("OwnClass", "value"), const(1)) + assign(staticFieldRef("ExternalA", "value"), const(2)) + assign(staticFieldRef("ExternalB", "value"), const(3)) + assign(staticFieldRef(externalClass, "value"), const(4)) + ret(const(0)) + } + + val cfg = prog.toBlockCfg().toEtsBlockCfg(method) + val refs = cfg.blocks.single().statements + .filterIsInstance() + .mapNotNull { it.lhv as? EtsStaticFieldRef } + + assertEquals(4, refs.size) + assertSame(ownClass, refs[0].field.enclosingClass) + assertEquals("ExternalA", refs[1].field.enclosingClass.name) + assertEquals("ExternalB", refs[2].field.enclosingClass.name) + assertEquals(EtsFileSignature.UNKNOWN, refs[1].field.enclosingClass.file) + assertEquals(EtsFileSignature.UNKNOWN, refs[2].field.enclosingClass.file) + assertSame(externalClass, refs[3].field.enclosingClass) + } + @Test fun `test simple program`() { val prog = program { From dca74e73951ad8f7c6a4a4140fb8630e41810b50 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 18:26:21 +0300 Subject: [PATCH 15/63] Keep fluent conditional chains exclusive and snapshot built programs --- .../org/jacodb/ets/dsl/ProgramBuilder.kt | 20 ++-- .../jacodb/ets/test/EtsProgramBuilderTest.kt | 99 +++++++++++++++++++ 2 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsProgramBuilderTest.kt diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dsl/ProgramBuilder.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dsl/ProgramBuilder.kt index 46428b8c6..6cbefe6d1 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dsl/ProgramBuilder.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dsl/ProgramBuilder.kt @@ -87,13 +87,23 @@ class CustomBinaryExpr( val toEts: (left: EtsEntity, right: EtsEntity) -> EtsEntity, ) : Expr +private fun List.snapshot(): List = map { node -> + when (node) { + is If -> node.copy( + thenBranch = node.thenBranch.snapshot(), + elseBranch = node.elseBranch.snapshot(), + ) + + else -> node + } +} + class ProgramBuilderImpl : ProgramBuilder { private val _nodes: MutableList = mutableListOf() val nodes: List get() = _nodes - fun build(): Program { - return Program(nodes) - } + /** Capture the completed node tree; later builder calls cannot change this program. */ + fun build(): Program = Program(nodes.snapshot()) override fun nop() { _nodes += Nop @@ -141,10 +151,8 @@ class IfBuilder : ProgramBuilder { elseBuilder.apply(block) } - fun elseIf(condition: Expr, block: ProgramBuilder.() -> Unit): IfBuilder { + fun elseIf(condition: Expr, block: ProgramBuilder.() -> Unit): IfBuilder = elseBuilder.ifStmt(condition, block) - return this - } override fun ifStmt(condition: Expr, block: ProgramBuilder.() -> Unit): IfBuilder = thenBuilder.ifStmt(condition, block) diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsProgramBuilderTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsProgramBuilderTest.kt new file mode 100644 index 000000000..b540fcd18 --- /dev/null +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsProgramBuilderTest.kt @@ -0,0 +1,99 @@ +/* + * 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 org.jacodb.ets.dsl.Assign +import org.jacodb.ets.dsl.ConstantBoolean +import org.jacodb.ets.dsl.ConstantInt +import org.jacodb.ets.dsl.If +import org.jacodb.ets.dsl.IfBuilder +import org.jacodb.ets.dsl.Local +import org.jacodb.ets.dsl.Nop +import org.jacodb.ets.dsl.ProgramBuilderImpl +import org.jacodb.ets.dsl.const +import org.jacodb.ets.dsl.local +import org.jacodb.ets.dsl.program +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +class EtsProgramBuilderTest { + @Test + fun `else-if chain attaches each alternative to the preceding false branch`() { + val result = Local("result") + val firstCondition = ConstantBoolean(false) + val secondCondition = ConstantBoolean(true) + val thirdCondition = ConstantBoolean(true) + val firstAssignment = Assign(result, ConstantInt(1)) + val secondAssignment = Assign(result, ConstantInt(2)) + val thirdAssignment = Assign(result, ConstantInt(3)) + val fallbackAssignment = Assign(result, ConstantInt(4)) + + val built = program { + ifStmt(firstCondition) { + assign(result, const(1)) + }.elseIf(secondCondition) { + assign(result, const(2)) + }.elseIf(thirdCondition) { + assign(result, const(3)) + }.`else` { + assign(result, const(4)) + } + } + + val firstIf = built.nodes.single() as If + val secondIf = firstIf.elseBranch.single() as If + val thirdIf = secondIf.elseBranch.single() as If + + assertEquals(firstCondition, firstIf.condition) + assertEquals(listOf(firstAssignment), firstIf.thenBranch) + assertEquals(secondCondition, secondIf.condition) + assertEquals(listOf(secondAssignment), secondIf.thenBranch) + assertEquals(thirdCondition, thirdIf.condition) + assertEquals(listOf(thirdAssignment), thirdIf.thenBranch) + assertEquals(listOf(fallbackAssignment), thirdIf.elseBranch) + } + + @Test + fun `build snapshots top-level nodes before later builder calls`() { + val builder = ProgramBuilderImpl() + builder.nop() + + val built = builder.build() + builder.ret(ConstantInt(1)) + + assertEquals(listOf(Nop), built.nodes) + } + + @Test + fun `program snapshots nested branches before retained builder calls`() { + lateinit var retainedBranch: IfBuilder + val built = program { + retainedBranch = ifStmt(const(true)) { + assign(local("value"), const(1)) + } + } + + retainedBranch.assign(Local("value"), ConstantInt(2)) + retainedBranch.`else` { + assign(local("value"), const(3)) + } + + val builtIf = built.nodes.single() as If + assertEquals(listOf(Assign(Local("value"), ConstantInt(1))), builtIf.thenBranch) + assertEquals(emptyList(), builtIf.elseBranch) + } +} From f7a1bc5d7d5d9ff2ec7a570a032e628d15bf94fd Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 18:30:27 +0300 Subject: [PATCH 16/63] Resolve CFG jumps by occurrence and preserve empty branch edges --- .../kotlin/org/jacodb/ets/dsl/BlockCfg.kt | 128 ++++++---- .../jacodb/ets/test/EtsBlockCfgTargetsTest.kt | 229 ++++++++++++++++++ 2 files changed, 316 insertions(+), 41 deletions(-) create mode 100644 jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsBlockCfgTargetsTest.kt diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dsl/BlockCfg.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dsl/BlockCfg.kt index f81e98773..3d4af66ce 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dsl/BlockCfg.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dsl/BlockCfg.kt @@ -16,9 +16,6 @@ package org.jacodb.ets.dsl -import org.jacodb.ets.utils.IdentityHashSet -import java.util.IdentityHashMap - data class Block( val id: Int, val statements: List, @@ -29,41 +26,73 @@ data class BlockCfg( val successors: Map>, // for IF stmt, successors are (true, false) branches ) +private class NodeOccurrence( + val node: Node, + val thenBranch: List = emptyList(), + val elseBranch: List = emptyList(), +) + fun Program.toBlockCfg(): BlockCfg { - val labelToNode: MutableMap = hashMapOf() - val targets: MutableSet = IdentityHashSet() + fun collectOccurrences(nodes: List): List = nodes.map { node -> + if (node is If) { + NodeOccurrence( + node = node, + thenBranch = collectOccurrences(node.thenBranch), + elseBranch = collectOccurrences(node.elseBranch), + ) + } else { + NodeOccurrence(node) + } + } + + val occurrences = collectOccurrences(nodes) + val labelToOccurrence: MutableMap = hashMapOf() + val targets: MutableSet = hashSetOf() - fun findLabels(nodes: List) { - if (nodes.lastOrNull() is Label) { + fun findLabels(nodes: List) { + if (nodes.lastOrNull()?.node is Label) { error("Label at the end of the block: $nodes") } - for ((stmt, next) in nodes.zipWithNext()) { - if (stmt is Label) { - check(next !is Label) { "Two labels in a row: $stmt, $next" } - check(next !is Goto) { "Label followed by goto: $stmt, $next" } - check(stmt.name !in labelToNode) { "Duplicate label: ${stmt.name}" } - labelToNode[stmt.name] = next - } + for ((occurrence, next) in nodes.zipWithNext()) { + val label = occurrence.node as? Label ?: continue + + check(next.node !is Label) { "Two labels in a row: $label, ${next.node}" } + check(next.node !is Goto) { "Label followed by goto: $label, ${next.node}" } + check(label.name !in labelToOccurrence) { "Duplicate label: ${label.name}" } + labelToOccurrence[label.name] = next } - for (node in nodes) { - if (node is If) { - findLabels(node.thenBranch) - findLabels(node.elseBranch) - } - if (node is Goto) { - targets += labelToNode[node.targetLabel] ?: error("Unknown label: ${node.targetLabel}") + for (occurrence in nodes) { + findLabels(occurrence.thenBranch) + findLabels(occurrence.elseBranch) + } + } + + findLabels(occurrences) + + fun findTargets(nodes: List) { + for (occurrence in nodes) { + when (val node = occurrence.node) { + is If -> { + findTargets(occurrence.thenBranch) + findTargets(occurrence.elseBranch) + } + + is Goto -> targets += labelToOccurrence[node.targetLabel] + ?: error("Unknown label: ${node.targetLabel}") + + else -> Unit } } } - findLabels(nodes) + findTargets(occurrences) val blocks: MutableList = mutableListOf() val successors: MutableMap> = hashMapOf() - val stmtToBlock: MutableMap = IdentityHashMap() - val nodeToStmt: MutableMap = IdentityHashMap() + val occurrenceToBlock: MutableMap = hashMapOf() + val pendingGotos: MutableList> = mutableListOf() - fun buildBlocks(nodes: List): Pair? { + fun buildBlocks(nodes: List): Pair? { if (nodes.isEmpty()) return null lateinit var currentBlock: MutableList @@ -75,14 +104,20 @@ fun Program.toBlockCfg(): BlockCfg { return block } + fun newEmptyBranch(): Block { + val branch = Block(blocks.size, emptyList()) + blocks += branch + return branch + } + var block = newBlock() val firstBlockId = block.id - for (node in nodes) { + for ((index, occurrence) in nodes.withIndex()) { + val node = occurrence.node if (node is Label) continue - if (node in targets && currentBlock.isNotEmpty()) { - block.statements.forEach { stmtToBlock[it] = block.id } + if (occurrence in targets && currentBlock.isNotEmpty()) { val prevBlock = block block = newBlock() successors[prevBlock.id] = listOf(block.id) @@ -97,17 +132,16 @@ fun Program.toBlockCfg(): BlockCfg { is CustomEts -> BlockCustomEts(node.toEts) else -> error("Unexpected node: $node") } - nodeToStmt[node] = stmt + occurrenceToBlock[occurrence] = block.id currentBlock += stmt } if (node is If) { - block.statements.forEach { stmtToBlock[it] = block.id } val ifBlock = block block = newBlock() - val thenBlocks = buildBlocks(node.thenBranch) - val elseBlocks = buildBlocks(node.elseBranch) + val thenBlocks = buildBlocks(occurrence.thenBranch) + val elseBlocks = buildBlocks(occurrence.elseBranch) when { thenBlocks != null && elseBlocks != null -> { @@ -147,29 +181,41 @@ fun Program.toBlockCfg(): BlockCfg { } else -> { - successors[ifBlock.id] = listOf(block.id) + val trueBranch = newEmptyBranch() + val falseBranch = newEmptyBranch() + successors[ifBlock.id] = listOf(trueBranch.id, falseBranch.id) + successors[trueBranch.id] = listOf(block.id) + successors[falseBranch.id] = listOf(block.id) } } } else if (node is Goto) { - val targetNode = labelToNode[node.targetLabel] ?: error("Unknown label: ${node.targetLabel}") - val target = nodeToStmt[targetNode] ?: error("No statement for $targetNode") - val targetBlockId = stmtToBlock[target] ?: error("No block for $target") - successors[block.id] = listOf(targetBlockId) - block.statements.forEach { stmtToBlock[it] = block.id } + pendingGotos += block.id to node.targetLabel block = newBlock() } else if (node is Return) { successors[block.id] = emptyList() - break + if (index < nodes.lastIndex) { + block = newBlock() + } } } - block.statements.forEach { stmtToBlock[it] = block.id } val lastBlockId = block.id return Pair(firstBlockId, lastBlockId) } - buildBlocks(nodes) + buildBlocks(occurrences) + + for ((sourceBlockId, label) in pendingGotos) { + val targetOccurrence = labelToOccurrence.getValue(label) + val targetBlockId = occurrenceToBlock[targetOccurrence] + ?: error("No block for ${targetOccurrence.node}") + successors[sourceBlockId] = listOf(targetBlockId) + } + + for (block in blocks) { + successors.putIfAbsent(block.id, emptyList()) + } return BlockCfg(blocks, successors) } diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsBlockCfgTargetsTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsBlockCfgTargetsTest.kt new file mode 100644 index 000000000..43f7ed89f --- /dev/null +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsBlockCfgTargetsTest.kt @@ -0,0 +1,229 @@ +package org.jacodb.ets.test + +import org.jacodb.ets.dsl.BlockAssign +import org.jacodb.ets.dsl.BlockIf +import org.jacodb.ets.dsl.BlockReturn +import org.jacodb.ets.dsl.Assign +import org.jacodb.ets.dsl.ConstantBoolean +import org.jacodb.ets.dsl.ConstantInt +import org.jacodb.ets.dsl.Goto +import org.jacodb.ets.dsl.If +import org.jacodb.ets.dsl.Label +import org.jacodb.ets.dsl.Local +import org.jacodb.ets.dsl.Program +import org.jacodb.ets.dsl.Return +import org.jacodb.ets.dsl.const +import org.jacodb.ets.dsl.local +import org.jacodb.ets.dsl.program +import org.jacodb.ets.dsl.toBlockCfg +import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsIfStmt +import org.jacodb.ets.model.EtsMethodImpl +import org.jacodb.ets.model.EtsMethodSignature +import org.jacodb.ets.model.EtsNopStmt +import org.jacodb.ets.model.EtsUnknownType +import org.jacodb.ets.utils.toEtsBlockCfg +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class EtsBlockCfgTargetsTest { + @Test + fun `forward goto resolves after all blocks are built`() { + val cfg = program { + goto("end") + assign(local("unreachable"), const(1)) + label("end") + ret(const(2)) + }.toBlockCfg() + + val returnBlock = cfg.blocks.single { it.statements.lastOrNull() is BlockReturn } + + assertEquals(listOf(returnBlock.id), cfg.successors[0]) + assertEquals(emptyList(), cfg.successors[returnBlock.id]) + } + + @Test + fun `backward goto resolves target in the current block`() { + val cfg = program { + label("loop") + assign(local("x"), const(1)) + assign(local("y"), const(2)) + goto("loop") + }.toBlockCfg() + + assertEquals(2, cfg.blocks[0].statements.size) + assertTrue(cfg.blocks[0].statements.all { it is BlockAssign }) + assertEquals(listOf(0), cfg.successors[0]) + } + + @Test + fun `nested forward and backward targets preserve branch order`() { + val cfg = program { + ifStmt(const(true)) { + goto("after") + }.`else` { + label("again") + assign(local("x"), const(1)) + goto("again") + } + label("after") + ret(const(2)) + }.toBlockCfg() + + val ifBlock = cfg.blocks.first() + val elseBlock = cfg.blocks.single { block -> + block.statements.any { it is BlockAssign } + } + val returnBlock = cfg.blocks.single { it.statements.lastOrNull() is BlockReturn } + val thenBlockId = cfg.successors.getValue(ifBlock.id).first() + + assertEquals(elseBlock.id, cfg.successors.getValue(ifBlock.id)[1]) + assertEquals(listOf(returnBlock.id), cfg.successors[thenBlockId]) + assertEquals(listOf(elseBlock.id), cfg.successors[elseBlock.id]) + } + + @Test + fun `goto may target a label after an unreachable return`() { + val cfg = program { + goto("after") + ret(const(0)) + label("after") + ret(const(1)) + }.toBlockCfg() + + val targetBlock = cfg.blocks.last { it.statements.lastOrNull() is BlockReturn } + + assertEquals(listOf(targetBlock.id), cfg.successors[0]) + } + + @Test + fun `multiple gotos resolve to the same target`() { + val cfg = program { + ifStmt(const(true)) { + goto("done") + }.`else` { + goto("done") + } + label("done") + ret(const(0)) + }.toBlockCfg() + + val branchTargets = cfg.successors.getValue(0) + val targetBlock = cfg.blocks.single { it.statements.lastOrNull() is BlockReturn } + + assertEquals(2, branchTargets.size) + assertEquals(listOf(targetBlock.id), cfg.successors[branchTargets[0]]) + assertEquals(listOf(targetBlock.id), cfg.successors[branchTargets[1]]) + } + + @Test + fun `label on singleton nop retains following assignment`() { + val cfg = program { + goto("start") + label("start") + nop() + assign(local("x"), const(1)) + nop() + ret(local("x")) + }.toBlockCfg() + + val targetBlockId = cfg.successors.getValue(0).single() + val targetStatements = cfg.blocks[targetBlockId].statements + + assertEquals(1, targetStatements.count { it is BlockAssign }) + assertTrue(targetStatements.last() is BlockReturn) + } + + @Test + fun `repeated node instance keeps labeled occurrence within one list`() { + val shared = Assign(target = Local(name = "x"), expr = ConstantInt(value = 1)) + val prog = Program( + nodes = listOf( + Goto(targetLabel = "start"), + Label(name = "start"), + shared, + shared, + Return(expr = Local(name = "x")), + ), + ) + + val cfg = prog.toBlockCfg() + val targetBlockId = cfg.successors.getValue(0).single() + + assertEquals(2, cfg.blocks[targetBlockId].statements.count { it is BlockAssign }) + assertTrue(cfg.blocks[targetBlockId].statements.last() is BlockReturn) + } + + @Test + fun `repeated node instance in branches keeps labeled true occurrence`() { + val shared = Assign(target = Local(name = "x"), expr = ConstantInt(value = 1)) + val prog = Program( + nodes = listOf( + Goto(targetLabel = "then"), + If( + condition = ConstantBoolean(value = true), + thenBranch = listOf(Label(name = "then"), shared), + elseBranch = listOf(shared), + ), + Return(expr = Local(name = "x")), + ), + ) + + val cfg = prog.toBlockCfg() + val ifBlockId = cfg.blocks.single { it.statements.lastOrNull() is BlockIf }.id + val branchIds = cfg.successors.getValue(ifBlockId) + + assertEquals(branchIds[0], cfg.successors.getValue(0).single()) + assertTrue(branchIds[0] != branchIds[1]) + } + + @Test + fun `empty if branches retain distinct ETS successors`() { + val cfg = program { + ifStmt(local("condition")) {} + ret(const(0)) + }.toBlockCfg() + val method = EtsMethodImpl( + signature = EtsMethodSignature( + enclosingClass = EtsClassSignature.UNKNOWN, + name = "emptyBranches", + parameters = emptyList(), + returnType = EtsUnknownType, + ), + ) + + val branchIds = cfg.successors.getValue(0) + val continuationId = cfg.blocks.single { it.statements.lastOrNull() is BlockReturn }.id + val etsCfg = cfg.toEtsBlockCfg(method) + val ifStmt = etsCfg.blocks.first().statements.single() as EtsIfStmt + val etsBranches = etsCfg.successors(ifStmt) + + assertEquals(2, branchIds.size) + assertTrue(branchIds[0] != branchIds[1]) + assertEquals(listOf(continuationId), cfg.successors[branchIds[0]]) + assertEquals(listOf(continuationId), cfg.successors[branchIds[1]]) + assertEquals(2, etsBranches.size) + assertTrue(etsBranches.all { it is EtsNopStmt }) + } + + @Test + fun `unknown and duplicate labels remain invalid`() { + val unknown = program { + goto("missing") + } + val duplicate = program { + label("same") + ret(const(0)) + label("same") + ret(const(1)) + } + + val unknownError = assertFailsWith { unknown.toBlockCfg() } + val duplicateError = assertFailsWith { duplicate.toBlockCfg() } + + assertTrue(unknownError.message.orEmpty().contains("Unknown label")) + assertTrue(duplicateError.message.orEmpty().contains("Duplicate label")) + } +} From dfe522fd72615107f5470a39135fa2f9cf1e7f91 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 18:30:32 +0300 Subject: [PATCH 17/63] Make identity set operations consistent --- .../org/jacodb/ets/utils/IdentityHashSet.kt | 36 +++++++++ .../org/jacodb/ets/test/CollectorTest.kt | 6 +- .../jacodb/ets/test/IdentityHashSetTest.kt | 73 +++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 jacodb-ets/src/test/kotlin/org/jacodb/ets/test/IdentityHashSetTest.kt diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/IdentityHashSet.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/IdentityHashSet.kt index 4e97c4206..bdfcfc0df 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/IdentityHashSet.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/IdentityHashSet.kt @@ -29,7 +29,43 @@ class IdentityHashSet( return map.put(element, Unit) == null } + override fun contains(element: T): Boolean { + return map.containsKey(element) + } + + override fun remove(element: T): Boolean { + return map.remove(element) != null + } + + override fun removeAll(elements: Collection): Boolean { + val elementsByIdentity = IdentityHashMap() + elements.forEach { elementsByIdentity[it] = Unit } + + return removeKeysWhere { elementsByIdentity.containsKey(it) } + } + + override fun retainAll(elements: Collection): Boolean { + val elementsByIdentity = IdentityHashMap() + elements.forEach { elementsByIdentity[it] = Unit } + + return removeKeysWhere { !elementsByIdentity.containsKey(it) } + } + override fun iterator(): MutableIterator { return map.keys.iterator() } + + private fun removeKeysWhere(predicate: (T) -> Boolean): Boolean { + val iterator = map.keys.iterator() + var changed = false + + while (iterator.hasNext()) { + if (predicate(iterator.next())) { + iterator.remove() + changed = true + } + } + + return changed + } } diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/CollectorTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/CollectorTest.kt index 8fd8f464a..087750969 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/CollectorTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/CollectorTest.kt @@ -57,8 +57,9 @@ class CollectorTest { result += stmt.toString() } } + stmt.accept(c) - println(result) + assertEquals("[a := b + 42.0, a, b + 42.0, b, 42.0]", result.toString()) } @@ -68,8 +69,9 @@ class CollectorTest { val c = EntityCollector(mutableSetOf()) { it.toString() } + stmt.accept(c) - println(c.result) + assertEquals("[a, b + 42.0, b, 42.0]", c.result.toString()) } } diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/IdentityHashSetTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/IdentityHashSetTest.kt new file mode 100644 index 000000000..3a2f1c951 --- /dev/null +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/IdentityHashSetTest.kt @@ -0,0 +1,73 @@ +/* + * 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 org.jacodb.ets.model.EtsLocal +import org.jacodb.ets.utils.IdentityHashSet +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotSame +import kotlin.test.assertTrue + +class IdentityHashSetTest { + + @Test + fun `membership and removal distinguish equal instances`() { + val first = EtsLocal(name = "x") + val equalButDistinct = EtsLocal(name = "x") + val set = IdentityHashSet() + + set.add(first) + + assertNotSame(first, equalButDistinct) + assertTrue(first in set) + assertFalse(equalButDistinct in set) + assertFalse(set.remove(equalButDistinct)) + assertEquals(expected = 1, actual = set.size) + assertTrue(set.remove(first)) + assertTrue(set.isEmpty()) + } + + @Test + fun `bulk removal and retention use identity including self collections`() { + val first = EtsLocal(name = "x") + val equalButDistinct = EtsLocal(name = "x") + val other = EtsLocal(name = "y") + val set = IdentityHashSet() + + set.addAll(listOf(first, equalButDistinct, other)) + + assertTrue(set.removeAll(listOf(equalButDistinct))) + assertTrue(first in set) + assertFalse(equalButDistinct in set) + assertTrue(other in set) + + assertTrue(set.retainAll(listOf(first))) + assertEquals(expected = 1, actual = set.size) + assertTrue(first in set) + + assertFalse(set.retainAll(set)) + assertTrue(set.retainAll(listOf(equalButDistinct))) + assertTrue(set.isEmpty()) + + set.add(first) + + assertTrue(set.removeAll(set)) + assertTrue(set.isEmpty()) + } + +} From a697c541a7e930cff48dedf302910ad7a1e78671 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 18:35:12 +0300 Subject: [PATCH 18/63] Verify observed descendant cleanup and decode frontend diagnostics as UTF-8 --- .../org/jacodb/ets/utils/LoadEtsFile.kt | 3 +- .../org/jacodb/ets/utils/ProcessUtil.kt | 93 +++++++++++++++++-- .../jacodb/ets/test/EtsIrGenerationTest.kt | 4 +- .../org/jacodb/ets/test/ProcessUtilTest.kt | 38 +++++++- 4 files changed, 124 insertions(+), 14 deletions(-) diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/LoadEtsFile.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/LoadEtsFile.kt index 89c231027..bd104b2db 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/LoadEtsFile.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/LoadEtsFile.kt @@ -22,6 +22,7 @@ import org.jacodb.ets.dto.toEtsFile import org.jacodb.ets.model.EtsFile import org.jacodb.ets.model.EtsScene import java.io.FileNotFoundException +import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.Path @@ -244,7 +245,7 @@ fun generateEtsIR( } logger.debug { "Running EtsIR generation ($provider): ${cmd.joinToString(" ")}" } try { - val res = ProcessUtil.run(cmd, timeout = timeout) + val res = ProcessUtil.run(cmd, timeout = timeout, charset = StandardCharsets.UTF_8) val failure = when { res.isTimeout -> "EtsIR generation ($provider) timed out after $timeout" res.exitCode != 0 -> "EtsIR generation ($provider) failed with exit code ${res.exitCode}" diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/ProcessUtil.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/ProcessUtil.kt index 5b0dea907..e8c94c866 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/ProcessUtil.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/ProcessUtil.kt @@ -18,29 +18,48 @@ package org.jacodb.ets.utils import mu.KotlinLogging import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method import java.nio.charset.Charset import java.nio.file.Files +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutionException import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException import java.util.stream.Stream import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds private val logger = KotlinLogging.logger {} +private val descendantTerminationGrace = 5.seconds + +private class ObservedDescendants( + val handles: List, + val isAlive: Method, + val onExit: Method, +) /** Java 8 exposes parent cleanup only; process-tree cleanup is available on Java 9 and later. */ -private fun destroyDescendantsIfSupported(process: Process) { +private fun destroyDescendantsIfSupported(process: Process): ObservedDescendants? { val handleClass = try { Class.forName("java.lang.ProcessHandle") } catch (_: ClassNotFoundException) { - return + return null } try { val handle = Process::class.java.getMethod("toHandle").invoke(process) val descendants = handleClass.getMethod("descendants").invoke(handle) as Stream<*> val destroy = handleClass.getMethod("destroyForcibly") - descendants.use { children -> - children.forEach { child -> destroy.invoke(child) } + val handles = descendants.use { children -> + children.toArray().filterNotNull() } + handles.forEach { child -> destroy.invoke(child) } + + return ObservedDescendants( + handles = handles, + isAlive = handleClass.getMethod("isAlive"), + onExit = handleClass.getMethod("onExit"), + ) } catch (error: InvocationTargetException) { val cause = error.targetException if (cause is RuntimeException) throw cause @@ -50,21 +69,62 @@ private fun destroyDescendantsIfSupported(process: Process) { } } +private fun awaitDescendantTermination(descendants: ObservedDescendants?) { + if (descendants == null) return + + val startedAt = System.nanoTime() + try { + for (handle in descendants.handles) { + val isAlive = descendants.isAlive.invoke(handle) as Boolean + if (!isAlive) continue + + val elapsed = System.nanoTime() - startedAt + val remaining = descendantTerminationGrace.inWholeNanoseconds - elapsed + if (remaining <= 0) { + throw IllegalStateException("Timed out waiting for process descendants to terminate") + } + + val completion = descendants.onExit.invoke(handle) as CompletableFuture<*> + try { + completion.get(remaining, TimeUnit.NANOSECONDS) + } catch (error: TimeoutException) { + throw IllegalStateException("Timed out waiting for process descendants to terminate", error) + } catch (error: ExecutionException) { + throw IllegalStateException("Could not verify process descendant termination", error) + } + } + + val stillAlive = descendants.handles.any { handle -> + descendants.isAlive.invoke(handle) as Boolean + } + if (stillAlive) { + throw IllegalStateException("A process descendant remained alive after termination") + } + } catch (error: InvocationTargetException) { + throw IllegalStateException("Could not verify process descendant termination", error.targetException) + } catch (error: ReflectiveOperationException) { + throw IllegalStateException("Cannot access process descendant termination on this runtime", error) + } +} + private fun terminateAndReap( process: Process, initialInterruption: InterruptedException? = null, ) { var interruption = initialInterruption var cleanupFailure: RuntimeException? = null - try { + val descendants = try { destroyDescendantsIfSupported(process) } catch (error: RuntimeException) { cleanupFailure = error + null } + process.destroy() if (process.isAlive) { process.destroyForcibly() } + while (true) { try { process.waitFor() @@ -80,6 +140,23 @@ private fun terminateAndReap( } } } + + try { + awaitDescendantTermination(descendants) + } catch (error: InterruptedException) { + if (interruption == null) { + interruption = error + } else if (interruption !== error) { + interruption.addSuppressed(error) + } + } catch (error: RuntimeException) { + if (cleanupFailure == null) { + cleanupFailure = error + } else if (cleanupFailure !== error) { + cleanupFailure.addSuppressed(error) + } + } + if (interruption != null) { cleanupFailure?.let { interruption.addSuppressed(it) } Thread.currentThread().interrupt() @@ -100,9 +177,9 @@ object ProcessUtil { command: List, input: String? = null, timeout: Duration? = null, + charset: Charset = Charset.defaultCharset(), ): Result { logger.debug { "Running command: $command" } - val charset = Charset.defaultCharset() val tempDirectory = Files.createTempDirectory("jacodb-process-") try { val stdinFile = tempDirectory.resolve("stdin") @@ -123,12 +200,12 @@ object ProcessUtil { false } else { !process.waitFor( - timeout.inWholeNanoseconds.coerceAtLeast(0), + timeout.inWholeNanoseconds.coerceAtLeast(minimumValue = 0), TimeUnit.NANOSECONDS, ) } } catch (error: InterruptedException) { - terminateAndReap(process, error) + terminateAndReap(process, initialInterruption = error) throw error } diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsIrGenerationTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsIrGenerationTest.kt index 2269c8823..fd1e0f4ac 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsIrGenerationTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsIrGenerationTest.kt @@ -65,7 +65,7 @@ class EtsIrGenerationTest { val frontend = createTempDirectory("failing-ets-frontend") frontend.resolve("dist").createDirectories() frontend.resolve("dist/index.js").writeText( - "require('fs').writeFileSync(process.argv.at(process.argv.at(-1) === '-v' ? -2 : -1), 'partial'); console.error('frontend failed'); process.exit(7);" + "require('fs').writeFileSync(process.argv.at(process.argv.at(-1) === '-v' ? -2 : -1), 'partial'); console.error('неверный путь: café'); process.exit(7);" ) val source = frontend.resolve("input.ts").also { it.writeText("const value = 1;") } @@ -76,7 +76,7 @@ class EtsIrGenerationTest { } assertFalse(error is EtsIrGenerationTimeoutException) assertTrue(error.message.orEmpty().contains("exit code 7")) - assertTrue(error.message.orEmpty().contains("frontend failed")) + assertTrue(error.message.orEmpty().contains("неверный путь: café")) val output = outputPath(error) assertEquals("partial", output.readText()) Files.delete(output) diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/ProcessUtilTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/ProcessUtilTest.kt index 4e3b4af08..fa3b7b09d 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/ProcessUtilTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/ProcessUtilTest.kt @@ -21,8 +21,10 @@ import org.junit.jupiter.api.Assumptions.assumeTrue import org.junit.jupiter.api.Test import java.net.ConnectException import java.net.Socket +import java.nio.charset.StandardCharsets import java.nio.file.FileSystems import java.nio.file.StandardWatchEventKinds +import java.util.Optional import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference @@ -37,6 +39,10 @@ import kotlin.test.assertIs import kotlin.test.assertTrue class ProcessUtilTest { + private companion object { + const val CLEANUP_TEST_WAIT_MILLIS = 8_000L + } + private val node = System.getenv("NODE_EXECUTABLE") ?: "node" @Test @@ -82,6 +88,19 @@ class ProcessUtilTest { assertFalse(result.isTimeout) } + @Test + fun `decodes UTF-8 diagnostics when selected for the command`() { + val expected = "неверный путь: café" + + val result = ProcessUtil.run( + command = listOf(node, "-e", "process.stderr.write('неверный путь: café')"), + charset = StandardCharsets.UTF_8, + ) + + assertEquals(0, result.exitCode) + assertEquals(expected, result.stderr) + } + @Test fun `retains output larger than one MiB in full`() { val outputSize = (1 shl 20) + 17 @@ -196,10 +215,11 @@ class ProcessUtilTest { runner.start() assertTrue(watcher.poll(5, TimeUnit.SECONDS) != null, "descendant did not publish readiness") val (childPid, port) = ready.readText().trim().split(' ') - pid = childPid.toLong() + val childPidLong = childPid.toLong() + pid = childPidLong if (interrupt) runner.interrupt() - runner.join(5000) + runner.join(CLEANUP_TEST_WAIT_MILLIS) assertFalse(runner.isAlive, "process cleanup did not finish") if (interrupt) assertIs(failure.get()) @@ -209,9 +229,10 @@ class ProcessUtilTest { } val connection = runCatching { Socket("127.0.0.1", port.toInt()).use { } } assertIs(connection.exceptionOrNull(), "descendant survived command cleanup") + assertFalse(isProcessAlive(childPidLong), "descendant remained alive after cleanup returned") } finally { runner.interrupt() - runner.join(5000) + runner.join(CLEANUP_TEST_WAIT_MILLIS) pid?.let { childPid -> ProcessBuilder(node, "-e", "try { process.kill(Number(process.argv[1]), 'SIGKILL'); } catch {}", "$childPid") .start() @@ -221,4 +242,15 @@ class ProcessUtilTest { directory.toFile().deleteRecursively() } } + + private fun isProcessAlive(pid: Long): Boolean { + val handleClass = Class.forName("java.lang.ProcessHandle") + val pidParameterType = checkNotNull(Long::class.javaPrimitiveType) + val optionalHandle = handleClass.getMethod("of", pidParameterType) + .invoke(null, pid) as Optional<*> + if (!optionalHandle.isPresent) return false + + val handle = optionalHandle.get() + return handleClass.getMethod("isAlive").invoke(handle) as Boolean + } } From 44745cb9007ce36f9132acc28a01a0658ed1314a Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 18:35:16 +0300 Subject: [PATCH 19/63] Clarify the recursive visitor contract --- .../src/main/kotlin/org/jacodb/ets/utils/Handler.kt | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/Handler.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/Handler.kt index a0e609854..c25a4c267 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/Handler.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/Handler.kt @@ -98,15 +98,7 @@ import org.jacodb.ets.model.EtsUnsignedRightShiftExpr import org.jacodb.ets.model.EtsVoidExpr import org.jacodb.ets.model.EtsYieldExpr -/** - * An example handler for [EtsEntity] and [EtsStmt]. - * - * This class is not meant to be used directly, but rather serves as a template for creating custom handlers. - * - * **Note:** this class might seem as completely useless, but it actually guards - * from missing any of the visit methods in the future. - * If this fails to compile, you might need to implement new methods in [AbstractHandler]. - */ +/** An example of observing the statements and entities visited by [AbstractHandler]. */ class ExampleHandler : AbstractHandler() { override fun handle(value: EtsEntity) { // Handle the entity @@ -119,6 +111,7 @@ class ExampleHandler : AbstractHandler() { } } +/** Calls [handle] before visiting each node's children; it does not transform the IR. */ abstract class AbstractHandler : EtsEntity.Visitor, EtsStmt.Visitor { abstract fun handle(value: EtsEntity) @@ -524,6 +517,7 @@ abstract class AbstractHandler : EtsEntity.Visitor, EtsStmt.Visitor expr.receiver?.accept(this) expr.args.forEach { it.accept(this) } } + final override fun visit(expr: EtsSpreadExpansionExpr) { handle(expr) expr.iterable.accept(this) From c3b8cb8ed236d54a17b3aa34ee627c31c3455b92 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 18:50:52 +0300 Subject: [PATCH 20/63] Render all model classes with distinct IDs and context-specific escaping --- .../org/jacodb/ets/utils/BlockCfgToDot.kt | 2 +- .../kotlin/org/jacodb/ets/utils/DotUtils.kt | 8 +- .../org/jacodb/ets/utils/EtsFileToDot.kt | 38 +-- .../org/jacodb/ets/utils/EtsFileToText.kt | 2 +- .../jacodb/ets/utils/InterproceduralCfg.kt | 4 +- .../org/jacodb/ets/utils/LinearCfgToDot.kt | 2 +- .../kotlin/org/jacodb/ets/utils/StmtToDot.kt | 6 +- .../jacodb/ets/test/EtsModelRenderingTest.kt | 216 ++++++++++++++++++ 8 files changed, 252 insertions(+), 26 deletions(-) create mode 100644 jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsModelRenderingTest.kt diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/BlockCfgToDot.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/BlockCfgToDot.kt index 52730bcdd..7846e6688 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/BlockCfgToDot.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/BlockCfgToDot.kt @@ -37,7 +37,7 @@ fun EtsBlockCfg.toDot( "" lines += " ${block.id} [label=<${h}>]" } else { - val s = block.statements.joinToString("") { it.toDotLabel() + "\\l" } + val s = block.statements.joinToString("") { it.toDotLabel().dotQuoteEncode() + "\\l" } lines += " ${block.id} [label=\"Block #${block.id}\\n$s\"]" } } diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/DotUtils.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/DotUtils.kt index 721740c11..7679d5584 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/DotUtils.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/DotUtils.kt @@ -26,9 +26,15 @@ internal fun String.htmlEncode(): String = this .replace("&", "&") .replace("<", "<") .replace(">", ">") - .replace("\\\"", """) .replace("\"", """) +/** Escape source text inside a quoted DOT string; renderer-owned escapes are added afterward. */ +internal fun String.dotQuoteEncode(): String = this + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\r", "\\r") + .replace("\n", "\\n") + /** * Sanitize an integer ID for use as a Graphviz node ID. * Negative IDs are prefixed with "N" to avoid issues with Graphviz. diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/EtsFileToDot.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/EtsFileToDot.kt index 30f89c5b3..4be63493c 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/EtsFileToDot.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/EtsFileToDot.kt @@ -38,13 +38,13 @@ fun EtsFile.toDot(useLR: Boolean = false): String { lines += " $x [fontname=\"$fontname\", fontsize=$fontsize];" } - fun classId(clazz: EtsClass): String { - return clazz.signature.name + fun classId(classIndex: Int): String { + return "class:$classIndex" } fun classLabel(clazz: EtsClass): String { val labelLines: MutableList = mutableListOf() - labelLines += clazz.signature.name + labelLines += clazz.signature.toString() labelLines += "Fields: (${clazz.fields.size})" clazz.fields.forEach { field -> labelLines += " ${field.signature.name}: ${field.signature.type}" @@ -62,37 +62,37 @@ fun EtsFile.toDot(useLR: Boolean = false): String { } labelLines += " $name$generics($params): $returnType" } - return labelLines.joinToString("") { "$it\\l" } + return labelLines.joinToString("") { "${it.dotQuoteEncode()}\\l" } } - fun methodId(clazz: EtsClass, method: EtsMethod): String { - return "${clazz.signature.name}.${method.signature.name}" + fun methodId(classIndex: Int, methodIndex: Int): String { + return "method:$classIndex:$methodIndex" } - fun methodLabel(clazz: EtsClass, method: EtsMethod): String { - return "${clazz.signature.name}::${method.signature.name}" + fun methodLabel(method: EtsMethod): String { + return method.signature.toString().dotQuoteEncode() } - fun stmtId(clazz: EtsClass, method: EtsMethod, stmt: EtsStmt): String { - return "${methodId(clazz, method)}.${stmt.location.index}" + fun stmtId(classIndex: Int, methodIndex: Int, stmt: EtsStmt): String { + return "stmt:$classIndex:$methodIndex:${stmt.location.index}" } fun stmtLabel(stmt: EtsStmt): String { - return stmt.toString().replace("\"", "\\\"") + return stmt.toString().dotQuoteEncode() } - classes.forEach { clazz -> + allClasses.forEachIndexed { classIndex, clazz -> // CLASS - val c = classId(clazz) + val c = classId(classIndex) val clabel = classLabel(clazz) lines += "" lines += """ "$c" [shape=box,label="$clabel"]""" // Methods inside class: - clazz.methods.forEach { method -> + clazz.methods.forEachIndexed { methodIndex, method -> // METHOD - val m = methodId(clazz, method) - val mlabel = methodLabel(clazz, method) + val m = methodId(classIndex, methodIndex) + val mlabel = methodLabel(method) lines += """ "$m" [shape=oval,label="$mlabel"];""" // Link class to method: @@ -100,14 +100,14 @@ fun EtsFile.toDot(useLR: Boolean = false): String { // Link method to the entry point: method.cfg.entries.forEach { entry -> - val f = stmtId(clazz, method, entry) + val f = stmtId(classIndex, methodIndex, entry) lines += """ "$m" -> "$f";""" } // Statements inside method: method.cfg.stmts.forEach { stmt -> // STATEMENT - val s = stmtId(clazz, method, stmt) + val s = stmtId(classIndex, methodIndex, stmt) val slabel = stmtLabel(stmt) lines += """ "$s" [shape=box,label="$slabel"];""" @@ -133,7 +133,7 @@ fun EtsFile.toDot(useLR: Boolean = false): String { else -> { method.cfg.successors(stmt).forEach { succ -> - val w = stmtId(clazz, method, succ) + val w = stmtId(classIndex, methodIndex, succ) lines += """ "$s" -> "$w";""" } } diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/EtsFileToText.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/EtsFileToText.kt index f15b4714a..ee11c28aa 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/EtsFileToText.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/EtsFileToText.kt @@ -21,7 +21,7 @@ import org.jacodb.ets.model.EtsFile fun EtsFile.toText(): String { val lines: MutableList = mutableListOf() lines += "EtsFile '${signature}':" - classes.forEach { clazz -> + allClasses.forEach { clazz -> lines += "= CLASS '${clazz.signature}':" lines += " typeParameters = ${clazz.typeParameters}" lines += " modifiers = ${clazz.modifiers}" diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/InterproceduralCfg.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/InterproceduralCfg.kt index 9694c720c..17076163c 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/InterproceduralCfg.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/InterproceduralCfg.kt @@ -101,7 +101,7 @@ fun InterproceduralCfg.toHighlightedDotWithCalls( val h = sanitize(stmt.hashCode()) val clusterName = "cluster_${h}_B${parentBlock}" // method signature label - val methodSig = cfg.entries.first().location.method.signature + val methodSig = cfg.entries.first().location.method.signature.toString().dotQuoteEncode() // open subgraph lines += " subgraph \"$clusterName\" {" lines += " label=\"$methodSig\";" @@ -181,7 +181,7 @@ private fun buildPlainLabel( in pathStmts -> "[·] " else -> "" } - "$pfx$raw\\l" + "${(pfx + raw).dotQuoteEncode()}\\l" } return "Block #${block.id}\\n" + body } diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/LinearCfgToDot.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/LinearCfgToDot.kt index 8851ffdf4..7ec682154 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/LinearCfgToDot.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/LinearCfgToDot.kt @@ -27,7 +27,7 @@ fun EtsLinearCfg.toDot(): String { // Nodes for (stmt in stmts) { val id = stmt.location.index - val label = stmt.toDotLabel().replace("\"", "\\\"") + val label = stmt.toDotLabel().dotQuoteEncode() lines += " $id [label=\"$id: $label\"]" } diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/StmtToDot.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/StmtToDot.kt index 1b2009736..93c2387dd 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/StmtToDot.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/StmtToDot.kt @@ -18,6 +18,9 @@ package org.jacodb.ets.utils import org.jacodb.ets.model.EtsAssignStmt import org.jacodb.ets.model.EtsCallStmt +import org.jacodb.ets.model.EtsCopyDataPropertiesStmt +import org.jacodb.ets.model.EtsDefineAccessorStmt +import org.jacodb.ets.model.EtsDefineDataPropertyStmt import org.jacodb.ets.model.EtsIfStmt import org.jacodb.ets.model.EtsNopStmt import org.jacodb.ets.model.EtsRawStmt @@ -33,8 +36,9 @@ internal fun EtsStmt.toDotLabel(): String { is EtsThrowStmt -> "throw $exception" is EtsIfStmt -> "if ($condition)" is EtsCallStmt -> "call $expr" + is EtsDefineDataPropertyStmt, is EtsCopyDataPropertiesStmt, is EtsDefineAccessorStmt -> toString() is EtsRawStmt -> "raw $kind" else -> error("Unsupported statement: $this") } - return label.replace("\"", "\\\"") + return label } diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsModelRenderingTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsModelRenderingTest.kt new file mode 100644 index 000000000..f5c13af48 --- /dev/null +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsModelRenderingTest.kt @@ -0,0 +1,216 @@ +/* + * 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 org.jacodb.ets.model.BasicBlock +import org.jacodb.ets.model.EtsAnyType +import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsBlockCfg +import org.jacodb.ets.model.EtsClassImpl +import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsCopyDataPropertiesStmt +import org.jacodb.ets.model.EtsDefineAccessorStmt +import org.jacodb.ets.model.EtsDefineDataPropertyStmt +import org.jacodb.ets.model.EtsFile +import org.jacodb.ets.model.EtsFileSignature +import org.jacodb.ets.model.EtsLocal +import org.jacodb.ets.model.EtsMethodImpl +import org.jacodb.ets.model.EtsMethodParameter +import org.jacodb.ets.model.EtsMethodSignature +import org.jacodb.ets.model.EtsNamespace +import org.jacodb.ets.model.EtsNamespaceSignature +import org.jacodb.ets.model.EtsReturnStmt +import org.jacodb.ets.model.EtsStmt +import org.jacodb.ets.model.EtsStmtLocation +import org.jacodb.ets.model.EtsStringConstant +import org.jacodb.ets.utils.InterproceduralCfg +import org.jacodb.ets.utils.toDot +import org.jacodb.ets.utils.toHighlightedDotWithCalls +import org.jacodb.ets.utils.toText +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals + +class EtsModelRenderingTest { + + @Test + fun `model file renderers include nested classes and distinct overload nodes`() { + val fileSignature = EtsFileSignature(projectName = "Project", fileName = "sample.ts") + val rootClassSignature = EtsClassSignature(name = "Same", file = fileSignature) + val namespaceSignature = EtsNamespaceSignature(name = "Nested", file = fileSignature) + val nestedClassSignature = EtsClassSignature( + name = "Same", + file = fileSignature, + namespace = namespaceSignature, + ) + val noArg = method(rootClassSignature, name = "run") + val oneArg = method( + rootClassSignature, + name = "run", + parameters = listOf(EtsMethodParameter(index = 0, name = "value", type = EtsAnyType)), + ) + val nestedMethod = method(nestedClassSignature, name = "run") + cfg(noArg, listOf(EtsReturnStmt(location = EtsStmtLocation(method = noArg, index = 0), returnValue = null))) + cfg(oneArg, listOf(EtsReturnStmt(location = EtsStmtLocation(method = oneArg, index = 0), returnValue = null))) + val rootClass = EtsClassImpl( + signature = rootClassSignature, + fields = emptyList(), + methods = listOf(noArg, oneArg), + ) + val nestedClass = EtsClassImpl( + signature = nestedClassSignature, + fields = emptyList(), + methods = listOf(nestedMethod), + ) + val namespace = EtsNamespace( + signature = namespaceSignature, + classes = listOf(nestedClass), + namespaces = emptyList(), + ) + val file = EtsFile( + signature = fileSignature, + classes = listOf(rootClass), + namespaces = listOf(namespace), + ) + + val dot = file.toDot() + val text = file.toText() + + assertContains(dot, "\"class:0\" [shape=box") + assertContains(dot, "\"class:1\" [shape=box") + assertContains(dot, "\"method:0:0\" [shape=oval") + assertContains(dot, "\"method:0:1\" [shape=oval") + assertContains(dot, "\"method:1:0\" [shape=oval") + assertContains(dot, "\"stmt:0:0:0\" [shape=box") + assertContains(dot, "\"stmt:0:1:0\" [shape=box") + assertContains(dot, "Nested::Same") + assertContains(dot, "Same::run(value: any)") + assertEquals(2, Regex("= CLASS '").findAll(text).count()) + assertContains(text, "Nested::Same") + } + + @Test + fun `CFG renderers show supported object operations`() { + val fileSignature = EtsFileSignature(projectName = "Project", fileName = "sample.ts") + val classSignature = EtsClassSignature(name = "Sample", file = fileSignature) + val method = method(classSignature, name = "objects") + val target = EtsLocal(name = "target") + val key = EtsStringConstant(value = "key") + val statements: List = listOf( + EtsDefineDataPropertyStmt( + location = EtsStmtLocation(method = method, index = 0), + target = target, + key = key, + value = EtsStringConstant(value = "value"), + ), + EtsCopyDataPropertiesStmt( + location = EtsStmtLocation(method = method, index = 1), + target = target, + source = target, + excludedKeys = emptyList(), + throwOnNullishSource = false, + ), + EtsDefineAccessorStmt( + location = EtsStmtLocation(method = method, index = 2), + target = target, + key = key, + getter = target, + ), + ) + val cfg = cfg(method, statements) + + val blockDot = cfg.toDot() + val linearDot = cfg.linear.toDot() + val interproceduralDot = InterproceduralCfg(main = cfg, callees = emptyMap()) + .toHighlightedDotWithCalls(pathStmts = emptySet(), currentStmt = null) + + for (label in listOf("defineDataProperty", "copyDataProperties", "defineAccessor")) { + assertContains(blockDot, label) + assertContains(linearDot, label) + assertContains(interproceduralDot, label) + } + } + + @Test + fun `quoted and HTML labels encode source text once`() { + val fileSignature = EtsFileSignature(projectName = "Project", fileName = "sample.ts") + val classSignature = EtsClassSignature(name = "Sa\"mple", file = fileSignature) + val method = method(classSignature, name = "str\\ings") + val value = EtsStringConstant(value = "a\\b\"c\r\nd") + val statement = EtsAssignStmt( + location = EtsStmtLocation(method = method, index = 0), + lhv = EtsLocal(name = "x"), + rhv = value, + ) + val cfg = cfg(method, listOf(statement)) + val file = EtsFile( + signature = fileSignature, + classes = listOf(EtsClassImpl(signature = classSignature, fields = emptyList(), methods = listOf(method))), + namespaces = emptyList(), + ) + + val linearDot = cfg.linear.toDot() + val plainBlockDot = cfg.toDot(useHtml = false) + val htmlBlockDot = cfg.toDot(useHtml = true) + val modelDot = file.toDot() + val interprocedural = InterproceduralCfg(main = cfg, callees = emptyMap()) + val plainInterproceduralDot = interprocedural.toHighlightedDotWithCalls( + pathStmts = emptySet(), + currentStmt = null, + useHtml = false, + ) + val htmlInterproceduralDot = interprocedural.toHighlightedDotWithCalls( + pathStmts = emptySet(), + currentStmt = null, + useHtml = true, + ) + + val quotedFragment = "a\\\\b\\\"c\\r\\nd" + for (dot in listOf(linearDot, plainBlockDot, modelDot, plainInterproceduralDot)) { + assertContains(dot, quotedFragment) + } + assertContains(modelDot, "Sa\\\"mple") + assertContains(modelDot, "str\\\\ings") + val htmlFragment = "a\\b"c" + assertContains(htmlBlockDot, htmlFragment) + assertContains(htmlInterproceduralDot, htmlFragment) + } + + private fun method( + classSignature: EtsClassSignature, + name: String, + parameters: List = emptyList(), + ): EtsMethodImpl { + return EtsMethodImpl( + signature = EtsMethodSignature( + enclosingClass = classSignature, + name = name, + parameters = parameters, + returnType = EtsAnyType, + ), + ) + } + + private fun cfg(method: EtsMethodImpl, statements: List): EtsBlockCfg { + val cfg = EtsBlockCfg( + blocks = listOf(BasicBlock(id = 0, statements = statements)), + successors = mapOf(0 to emptyList()), + ) + method.body.cfg = cfg + return cfg + } +} From 5c0488bb44d97db9fdbb1a0cc83a67dbeafdf9a3 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 18:56:55 +0300 Subject: [PATCH 21/63] Give DTO method overloads distinct DOT identities --- .../org/jacodb/ets/utils/EtsFileDtoToDot.kt | 29 +++++---- .../ets/test/EtsFileDtoRenderingTest.kt | 65 +++++++++++++++++-- 2 files changed, 76 insertions(+), 18 deletions(-) diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/EtsFileDtoToDot.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/EtsFileDtoToDot.kt index 25e739e1f..2e5268a3e 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/EtsFileDtoToDot.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/EtsFileDtoToDot.kt @@ -86,17 +86,18 @@ fun EtsFileDto.toDot(useLR: Boolean = false): String { return labelLines.joinToString("") { "$it\\l" } } - fun methodId(clazz: ClassDto, method: MethodDto): String { - return "method:${qualifiedClassName(clazz)}.${method.signature.name}" + fun methodId(clazz: ClassDto, method: MethodDto, methodIndex: Int): String { + return "method:${qualifiedClassName(clazz)}.${method.signature.name}#$methodIndex" } fun methodLabel(clazz: ClassDto, method: MethodDto): String { - return "${clazz.signature.name}::${method.signature.name}" + val parameters = method.signature.parameters.joinToString { "${it.name}: ${it.type}" } + val signature = "${clazz.signature.name}::${method.signature.name}($parameters): ${method.signature.returnType}" + return signature.replace("\"", "\\\"") } - fun blockId(clazz: ClassDto, method: MethodDto, id: Int): String { - val m = methodId(clazz, method) - return "${m}.bb${id}" + fun blockId(methodId: String, id: Int): String { + return "${methodId}.bb${id}" } fun blockLabel(bb: BasicBlockDto): String { @@ -117,9 +118,9 @@ fun EtsFileDto.toDot(useLR: Boolean = false): String { lines += """ "$c" [shape=rect,label="$clabel"]""" // Methods inside class: - clazz.methods.forEach { method -> + clazz.methods.forEachIndexed { methodIndex, method -> // METHOD - val m = methodId(clazz, method) + val m = methodId(clazz, method, methodIndex) val mlabel = methodLabel(clazz, method) lines += """ "$m" [shape=oval,label="$mlabel"];""" @@ -129,14 +130,14 @@ fun EtsFileDto.toDot(useLR: Boolean = false): String { if (method.body != null) { // Link method to its first basic block: if (method.body.cfg.blocks.isNotEmpty()) { - val b = blockId(clazz, method, method.body.cfg.blocks.first().id) + val b = blockId(m, method.body.cfg.blocks.first().id) lines += """ "$m" -> "${b}.0" [lhead="$b"];""" } // Basic blocks with instructions: method.body.cfg.blocks.forEach { bb -> // BLOCK - val b = blockId(clazz, method, bb.id) + val b = blockId(m, bb.id) val blabel = blockLabel(bb) lines += "" lines += """ subgraph "$b" {""" @@ -172,8 +173,8 @@ fun EtsFileDto.toDot(useLR: Boolean = false): String { when (last ?: NopStmtDto) { is IfStmtDto -> { for ((j, succ) in bb.successors.withIndex()) { - val b = blockId(clazz, method, bb.id) - val bs = blockId(clazz, method, succ) + val b = blockId(m, bb.id) + val bs = blockId(m, succ) val label = if (j == 0) "false" else "true" lines += """ "${b}.${i}" -> "${bs}.0" [lhead="$bs", label="$label"];""" } @@ -181,8 +182,8 @@ fun EtsFileDto.toDot(useLR: Boolean = false): String { else -> { for (succ in bb.successors) { - val b = blockId(clazz, method, bb.id) - val bs = blockId(clazz, method, succ) + val b = blockId(m, bb.id) + val bs = blockId(m, succ) lines += """ "${b}.${i}" -> "${bs}.0" [lhead="$bs"];""" } } diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFileDtoRenderingTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFileDtoRenderingTest.kt index baef61a92..78b0dc073 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFileDtoRenderingTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsFileDtoRenderingTest.kt @@ -16,6 +16,7 @@ package org.jacodb.ets.test +import org.jacodb.ets.dto.AnyTypeDto import org.jacodb.ets.dto.BasicBlockDto import org.jacodb.ets.dto.BodyDto import org.jacodb.ets.dto.BooleanTypeDto @@ -27,11 +28,14 @@ import org.jacodb.ets.dto.EtsFileDto import org.jacodb.ets.dto.FileSignatureDto import org.jacodb.ets.dto.IfStmtDto import org.jacodb.ets.dto.MethodDto +import org.jacodb.ets.dto.MethodParameterDto import org.jacodb.ets.dto.MethodSignatureDto import org.jacodb.ets.dto.NamespaceDto import org.jacodb.ets.dto.NamespaceSignatureDto import org.jacodb.ets.dto.NumberTypeDto import org.jacodb.ets.dto.ReturnVoidStmtDto +import org.jacodb.ets.dto.TypeDto +import org.jacodb.ets.dto.VoidTypeDto import org.jacodb.ets.utils.toDot import org.jacodb.ets.utils.toText import org.junit.jupiter.api.Test @@ -118,8 +122,8 @@ class EtsFileDtoRenderingTest { val dot = dto.toDot() assertTrue(dot.contains("\"class:A.B\" [shape=rect")) - assertTrue(dot.contains("\"method:A.B\" [shape=oval")) - assertTrue(dot.contains("\"class:A\" -> \"method:A.B\" [dir=none]")) + assertTrue(dot.contains("\"method:A.B#0\" [shape=oval")) + assertTrue(dot.contains("\"class:A\" -> \"method:A.B#0\" [dir=none]")) } @Test @@ -159,7 +163,60 @@ class EtsFileDtoRenderingTest { val dot = dto.toDot() - assertTrue(dot.contains("\"method:C.f.bb0.0\" -> \"method:C.f.bb1.0\" [lhead=\"method:C.f.bb1\", label=\"false\"]")) - assertTrue(dot.contains("\"method:C.f.bb0.0\" -> \"method:C.f.bb2.0\" [lhead=\"method:C.f.bb2\", label=\"true\"]")) + assertTrue(dot.contains("\"method:C.f#0.bb0.0\" -> \"method:C.f#0.bb1.0\" [lhead=\"method:C.f#0.bb1\", label=\"false\"]")) + assertTrue(dot.contains("\"method:C.f#0.bb0.0\" -> \"method:C.f#0.bb2.0\" [lhead=\"method:C.f#0.bb2\", label=\"true\"]")) + } + + @Test + fun `DTO DOT gives overload declarations and implementation distinct method and block IDs`() { + val classSignature = ClassSignatureDto(name = "C", declaringFile = fileSignature) + fun method(parameterType: TypeDto, body: BodyDto? = null) = MethodDto( + signature = MethodSignatureDto( + declaringClass = classSignature, + name = "f", + parameters = listOf(MethodParameterDto(name = "value", type = parameterType)), + returnType = VoidTypeDto, + ), + modifiers = 0, + decorators = emptyList(), + body = body, + ) + val body = BodyDto( + locals = emptyList(), + cfg = CfgDto( + blocks = listOf( + BasicBlockDto(id = 0, successors = listOf(1), stmts = emptyList()), + BasicBlockDto(id = 1, successors = emptyList(), stmts = listOf(ReturnVoidStmtDto)), + ), + ), + ) + val dto = EtsFileDto( + signature = fileSignature, + namespaces = emptyList(), + classes = listOf( + classDto( + name = "C", + methods = listOf( + method(parameterType = NumberTypeDto), + method(parameterType = BooleanTypeDto), + method(parameterType = AnyTypeDto, body = body), + ), + ), + ), + importInfos = emptyList(), + exportInfos = emptyList(), + ) + + val dot = dto.toDot() + + assertEquals(1, dot.lines().count { it.contains("\"method:C.f#0\" [shape=oval") }) + assertEquals(1, dot.lines().count { it.contains("\"method:C.f#1\" [shape=oval") }) + assertEquals(1, dot.lines().count { it.contains("\"method:C.f#2\" [shape=oval") }) + assertTrue(dot.contains("C::f(value: NumberTypeDto): VoidTypeDto")) + assertTrue(dot.contains("C::f(value: BooleanTypeDto): VoidTypeDto")) + assertTrue(dot.contains("\"method:C.f#2\" -> \"method:C.f#2.bb0.0\" [lhead=\"method:C.f#2.bb0\"]")) + assertTrue(dot.contains("subgraph \"method:C.f#2.bb0\"")) + assertTrue(dot.contains("\"method:C.f#2.bb0.0\" -> \"method:C.f#2.bb1.0\" [lhead=\"method:C.f#2.bb1\"]")) + assertTrue(dot.contains("\"method:C.f#2.bb1.0\" [shape=box")) } } From bb6082b83c44494c0db846604c67b34f7d7ec5c0 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 22:35:49 +0300 Subject: [PATCH 22/63] Fix interprocedural rendering for empty and colliding callees --- .../jacodb/ets/utils/InterproceduralCfg.kt | 67 ++++----- .../test/EtsInterproceduralRenderingTest.kt | 141 ++++++++++++++++++ 2 files changed, 174 insertions(+), 34 deletions(-) create mode 100644 jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsInterproceduralRenderingTest.kt diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/InterproceduralCfg.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/InterproceduralCfg.kt index 17076163c..6bdd7ee73 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/InterproceduralCfg.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/InterproceduralCfg.kt @@ -43,6 +43,11 @@ fun InterproceduralCfg.toHighlightedDotWithCalls( currentStmt: EtsStmt?, useHtml: Boolean = true, ): String { + val discoveredCallees = callees.entries.toList() + val calleeIdsByCallSite = discoveredCallees.withIndex().associate { (id, entry) -> + entry.key to id + } + val lines = mutableListOf() lines += "digraph world {" lines += " compound=true" @@ -51,11 +56,6 @@ fun InterproceduralCfg.toHighlightedDotWithCalls( // --- 1) Main CFG with ports on call statements --- for (block in main.blocks) { val nodeId = "M_${block.id}" - // compute all call hashes in this block - val callHashes = callees.keys - .filter { it.second == block.id } - .map { sanitize(it.first.hashCode()) } - .toSet() if (useHtml) { // build HTML table rows, adding port attribute on call lines @@ -66,8 +66,8 @@ fun InterproceduralCfg.toHighlightedDotWithCalls( in pathStmts -> "yellow" else -> "white" } - val stmtHash = sanitize(stmt.hashCode()) - val portAttr = if (stmtHash in callHashes) " port=\"p$stmtHash\"" else "" + val calleeId = calleeIdsByCallSite[stmt to block.id] + val portAttr = if (calleeId != null) " port=\"p$calleeId\"" else "" "$txt" } val table = buildString { @@ -82,6 +82,7 @@ fun InterproceduralCfg.toHighlightedDotWithCalls( lines += " $nodeId [label=\"$lbl\"];" } } + // Main CFG edges for ((bid, succs) in main.successors) { val from = "M_$bid" @@ -95,21 +96,21 @@ fun InterproceduralCfg.toHighlightedDotWithCalls( } } - // --- 2) Callee clusters with call-edge from port --- - for ((key, cfg) in callees) { - val (stmt, parentBlock) = key - val h = sanitize(stmt.hashCode()) - val clusterName = "cluster_${h}_B${parentBlock}" - // method signature label - val methodSig = cfg.entries.first().location.method.signature.toString().dotQuoteEncode() - // open subgraph + // --- 2) Callee clusters and call edges --- + for ((calleeId, entry) in discoveredCallees.withIndex()) { + val (_, parentBlock) = entry.key + val cfg = entry.value + val clusterName = "cluster_${calleeId}_B${parentBlock}" + val entryBlock = cfg.blocks.firstOrNull() + val methodSig = entryBlock?.statements?.first()?.location?.method?.signature?.toString() + val clusterLabel = (methodSig ?: "empty CFG").dotQuoteEncode() + lines += " subgraph \"$clusterName\" {" - lines += " label=\"$methodSig\";" + lines += " label=\"$clusterLabel\";" lines += " style=dashed;" - // render callee nodes for (blk in cfg.blocks) { - val calleeNode = "C_${h}_${blk.id}" + val calleeNode = "C_${calleeId}_${blk.id}" if (useHtml) { val table = buildHtmlTable(blk, pathStmts, currentStmt) lines += " $calleeNode [label=<$table>];" @@ -118,27 +119,29 @@ fun InterproceduralCfg.toHighlightedDotWithCalls( lines += " $calleeNode [label=\"$lbl\"];" } } - // render callee edges + + val emptyNode = "C_${calleeId}_empty" + if (entryBlock == null) { + lines += " $emptyNode [label=\"empty CFG\"];" + } + for ((bid, succs) in cfg.successors) { - val from = "C_${h}_$bid" + val from = "C_${calleeId}_$bid" when (succs.size) { - 1 -> lines += " $from -> C_${h}_${succs[0]};" + 1 -> lines += " $from -> C_${calleeId}_${succs[0]};" 2 -> { val (t, f) = succs - lines += " $from -> C_${h}_$t [label=\"true\"];" - lines += " $from -> C_${h}_$f [label=\"false\"];" + lines += " $from -> C_${calleeId}_$t [label=\"true\"];" + lines += " $from -> C_${calleeId}_$f [label=\"false\"];" } } } lines += " }" - // connect from the specific port on the caller block - // connect from the specific port on the caller block using tailport val caller = "M_${parentBlock}" - val entryId = cfg.blocks.first().id - val calleeEntry = "C_${h}_$entryId" - val stmtHash = sanitize(stmt.hashCode()) - lines += " $caller:p$stmtHash -> $calleeEntry [tailport=\"p$stmtHash\" ltail=\"$clusterName\" lhead=\"$clusterName\" style=dotted label=\"call\"];" + val calleeEntry = if (entryBlock == null) emptyNode else "C_${calleeId}_${entryBlock.id}" + val callerPort = if (useHtml) "$caller:p$calleeId" else caller + lines += " $callerPort -> $calleeEntry [lhead=\"$clusterName\" style=dotted label=\"call\"];" } lines += "}" @@ -150,19 +153,15 @@ private fun buildHtmlTable( pathStmts: Set, currentStmt: EtsStmt?, ): String { - var i = 0 val rows = block.statements.joinToString(separator = "") { stmt -> val txt = stmt.toDotLabel().htmlEncode() - val stmtHash = sanitize(stmt.hashCode()) - val portAttr = if (stmt.callExpr != null) " port=\"p$stmtHash\"" else "" val bg = when (stmt) { currentStmt -> "lightblue" in pathStmts -> "yellow" else -> "white" } - i++ - "$txt" + "$txt" } return "" + "" + rows + diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsInterproceduralRenderingTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsInterproceduralRenderingTest.kt new file mode 100644 index 000000000..3af67cb1d --- /dev/null +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsInterproceduralRenderingTest.kt @@ -0,0 +1,141 @@ +/* + * 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 org.jacodb.ets.model.BasicBlock +import org.jacodb.ets.model.EtsBlockCfg +import org.jacodb.ets.model.EtsCallStmt +import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsFileSignature +import org.jacodb.ets.model.EtsMethodImpl +import org.jacodb.ets.model.EtsMethodSignature +import org.jacodb.ets.model.EtsReturnStmt +import org.jacodb.ets.model.EtsStaticCallExpr +import org.jacodb.ets.model.EtsStmtLocation +import org.jacodb.ets.model.EtsVoidType +import org.jacodb.ets.utils.InterproceduralCfg +import org.jacodb.ets.utils.toHighlightedDotWithCalls +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class EtsInterproceduralRenderingTest { + + @Test + fun `empty callee has a visible placeholder in HTML and plain DOT`() { + val file = EtsFileSignature(projectName = "Project", fileName = "empty.ts") + val owner = EtsClassSignature(name = "Caller", file = file) + val caller = method(owner, name = "run") + val target = method(owner, name = "target") + val call = call(caller, target) + val main = cfg(blocks = listOf(BasicBlock(id = 0, statements = listOf(call)))) + val graph = InterproceduralCfg( + main = main, + callees = mapOf((call to 0) to EtsBlockCfg.EMPTY), + ) + + val html = graph.toHighlightedDotWithCalls(pathStmts = emptySet(), currentStmt = null, useHtml = true) + val plain = graph.toHighlightedDotWithCalls(pathStmts = emptySet(), currentStmt = null, useHtml = false) + + for (dot in listOf(html, plain)) { + assertContains(dot, "subgraph \"cluster_0_B0\"") + assertContains(dot, "C_0_empty [label=\"empty CFG\"]") + assertContains(dot, "lhead=\"cluster_0_B0\"") + } + assertContains(html, "M_0:p0 -> C_0_empty") + assertContains(plain, "M_0 -> C_0_empty") + } + + @Test + fun `colliding statement hashes keep separate callee nodes and call edges`() { + val file = EtsFileSignature(projectName = "Project", fileName = "collisions.ts") + val ownerAa = EtsClassSignature(name = "Aa", file = file) + val ownerBb = EtsClassSignature(name = "BB", file = file) + val callerAa = method(ownerAa, name = "run") + val callerBb = method(ownerBb, name = "run") + val targetAa = method(ownerAa, name = "target") + val targetBb = method(ownerBb, name = "target") + val callAa = call(callerAa, targetAa) + val callBb = call(callerBb, targetBb) + val main = cfg( + blocks = listOf( + BasicBlock(id = 0, statements = listOf(callAa)), + BasicBlock(id = 1, statements = listOf(callBb)), + ), + ) + val graph = InterproceduralCfg( + main = main, + callees = linkedMapOf( + (callAa to 0) to returnedCfg(targetAa), + (callBb to 1) to returnedCfg(targetBb), + ), + ) + + assertEquals(ownerAa.hashCode(), ownerBb.hashCode()) + assertEquals(callAa.hashCode(), callBb.hashCode()) + assertNotEquals(callAa, callBb) + + val html = graph.toHighlightedDotWithCalls(pathStmts = emptySet(), currentStmt = null, useHtml = true) + val plain = graph.toHighlightedDotWithCalls(pathStmts = emptySet(), currentStmt = null, useHtml = false) + + for (dot in listOf(html, plain)) { + assertContains(dot, "subgraph \"cluster_0_B0\"") + assertContains(dot, "subgraph \"cluster_1_B1\"") + assertContains(dot, "C_0_0 [label=") + assertContains(dot, "C_1_0 [label=") + assertContains(dot, "Aa::target(): void") + assertContains(dot, "BB::target(): void") + } + assertContains(html, "M_0:p0 -> C_0_0") + assertContains(html, "M_1:p1 -> C_1_0") + assertContains(plain, "M_0 -> C_0_0") + assertContains(plain, "M_1 -> C_1_0") + } + + private fun method(owner: EtsClassSignature, name: String): EtsMethodImpl = EtsMethodImpl( + signature = EtsMethodSignature( + enclosingClass = owner, + name = name, + parameters = emptyList(), + returnType = EtsVoidType, + ), + ) + + private fun call(caller: EtsMethodImpl, target: EtsMethodImpl): EtsCallStmt = EtsCallStmt( + location = EtsStmtLocation(method = caller, index = 0), + expr = EtsStaticCallExpr( + callee = target.signature, + args = emptyList(), + type = EtsVoidType, + ), + ) + + private fun returnedCfg(method: EtsMethodImpl): EtsBlockCfg = cfg( + blocks = listOf( + BasicBlock( + id = 0, + statements = listOf(EtsReturnStmt(location = EtsStmtLocation(method = method, index = 0), returnValue = null)), + ), + ), + ) + + private fun cfg(blocks: List): EtsBlockCfg { + val successors = blocks.associate { block -> block.id to emptyList() } + return EtsBlockCfg(blocks = blocks, successors = successors) + } +} From cbda44de996c20ad015894ba97f5a4a19bd467f2 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 22:35:55 +0300 Subject: [PATCH 23/63] Check DOT rendering failures and preserve process arguments --- .../kotlin/org/jacodb/ets/utils/RenderDot.kt | 53 ++++++--- .../kotlin/org/jacodb/ets/utils/ViewDot.kt | 67 ++++++++++- .../jacodb/ets/test/DotProcessCommandTest.kt | 110 ++++++++++++++++++ 3 files changed, 213 insertions(+), 17 deletions(-) create mode 100644 jacodb-ets/src/test/kotlin/org/jacodb/ets/test/DotProcessCommandTest.kt diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/RenderDot.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/RenderDot.kt index fa89e8a96..6db7866f3 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/RenderDot.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/RenderDot.kt @@ -25,6 +25,43 @@ import kotlin.io.path.nameWithoutExtension import kotlin.time.Duration.Companion.seconds private val logger = KotlinLogging.logger {} +private val renderTimeout = 30.seconds + +internal fun dotRenderCommand( + dotCommand: List, + format: String, + dotFile: Path, + outputFile: Path, +): List = dotCommand + listOf( + "-T$format", + dotFile.toString(), + "-o", + outputFile.toString(), +) + +internal fun checkRenderResult(result: ProcessUtil.Result, outputFile: Path) { + if (result.isTimeout) { + throw IllegalStateException("DOT rendering timed out for $outputFile: ${result.stderr}") + } + + if (result.exitCode != 0) { + throw IllegalStateException( + "DOT rendering failed for $outputFile with exit code ${result.exitCode}: ${result.stderr}" + ) + } +} + +internal fun renderDotFile( + dotCommand: List, + format: String, + dotFile: Path, + outputFile: Path, +) { + val command = dotRenderCommand(dotCommand, format, dotFile, outputFile) + val result = ProcessUtil.run(command = command, timeout = renderTimeout) + + checkRenderResult(result, outputFile) +} fun render( dotDir: Path, @@ -37,18 +74,8 @@ fun render( val formatFile = (dotDir.resolveSibling(format) / relative) .resolveSibling("${relative.nameWithoutExtension}.$format") formatFile.parent?.createDirectories() - val cmd: List = listOf( - "dot", - "-T$format", - "$dotPath", - "-o", - "$formatFile" - ) - val res = ProcessUtil.run(cmd, timeout = 30.seconds) - if (res.isTimeout) { - logger.error { "Rendering DOT to ${format.uppercase()} timed out" } - } else { - logger.info { "Generated ${format.uppercase()} file: ${formatFile.absolute()}" } - } + renderDotFile(dotCommand = listOf("dot"), format = format, dotFile = dotPath, outputFile = formatFile) + + logger.info { "Generated ${format.uppercase()} file: ${formatFile.absolute()}" } } } diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/ViewDot.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/ViewDot.kt index 8edb162b4..bd2d19703 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/ViewDot.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/ViewDot.kt @@ -21,15 +21,42 @@ import org.jacodb.ets.model.EtsBlockCfg import org.jacodb.ets.model.EtsFile import org.jacodb.ets.model.EtsLinearCfg import java.nio.file.Path +import java.util.StringTokenizer import kotlin.io.path.createTempDirectory import kotlin.io.path.div import kotlin.io.path.writeText +private const val windowsViewerCommand = "cmd /c start" +private val windowsViewerPrefix = listOf("cmd", "/c", "start", "") + +private fun legacyCommandArguments(command: String): List { + val tokens = StringTokenizer(command) + val arguments = mutableListOf() + while (tokens.hasMoreTokens()) { + arguments.add(tokens.nextToken()) + } + + return arguments +} + +internal fun viewerLaunchCommand(viewerCommand: List, outputFile: Path): List { + if (viewerCommand == windowsViewerPrefix) { + return viewerCommand + "\"${outputFile.toAbsolutePath()}\"" + } + + return viewerCommand + outputFile.toString() +} + +/** + * Writes and renders DOT, then launches a viewer. String commands retain their whitespace-separated prefix syntax; + * use the list overload for executable paths or arguments containing spaces. Returning means launch succeeded, + * not that the viewer has exited. + */ fun view( dot: String, viewerCmd: String = when { System.getProperty("os.name").startsWith("Mac") -> "open" - System.getProperty("os.name").startsWith("Win") -> "cmd /c start" + System.getProperty("os.name").startsWith("Win") -> windowsViewerCommand else -> "xdg-open" }, dotCmd: String = "dot", @@ -37,14 +64,46 @@ fun view( name: String = "graph", tempDir: Path = createTempDirectory("dot"), ) { + val viewerCommand = if (viewerCmd == windowsViewerCommand) { + windowsViewerPrefix // cmd's empty window title keeps the following path as the file to open. + } else { + legacyCommandArguments(viewerCmd) + } + val dotCommand = legacyCommandArguments(dotCmd) + + view( + dot = dot, + viewerCommand = viewerCommand, + dotCommand = dotCommand, + outputFormat = outputFormat, + name = name, + tempDir = tempDir, + ) +} + +/** Supply executable and arguments separately when a custom renderer or viewer needs options. */ +fun view( + dot: String, + viewerCommand: List, + dotCommand: List = listOf("dot"), + outputFormat: String = "svg", + name: String = "graph", + tempDir: Path = createTempDirectory("dot"), +) { + require(viewerCommand.isNotEmpty()) { "Viewer command must contain an executable" } + require(dotCommand.isNotEmpty()) { "DOT command must contain an executable" } + val dotFile = tempDir / "$name.dot" println("Writing DOT file to $dotFile") dotFile.writeText(dot) val outputFile = tempDir / "$name.$outputFormat" println("Rendering ${outputFormat.uppercase()} to '$outputFile'...") - Runtime.getRuntime().exec("$dotCmd -T$outputFormat -o $outputFile $dotFile").waitFor() - println("Opening rendered file '$outputFile'...") - Runtime.getRuntime().exec("$viewerCmd $outputFile").waitFor() + + renderDotFile(dotCommand = dotCommand, format = outputFormat, dotFile = dotFile, outputFile = outputFile) + + println("Launching viewer for '$outputFile'...") + val launchCommand = viewerLaunchCommand(viewerCommand, outputFile) + ProcessBuilder(launchCommand).inheritIO().start() } fun T.view(dot: (T) -> String) { diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/DotProcessCommandTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/DotProcessCommandTest.kt new file mode 100644 index 000000000..a2c658478 --- /dev/null +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/DotProcessCommandTest.kt @@ -0,0 +1,110 @@ +/* + * 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 org.jacodb.ets.utils.ProcessUtil +import org.jacodb.ets.utils.checkRenderResult +import org.jacodb.ets.utils.dotRenderCommand +import org.jacodb.ets.utils.viewerLaunchCommand +import org.junit.jupiter.api.Test +import kotlin.io.path.Path +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class DotProcessCommandTest { + @Test + fun `render command preserves paths with spaces as separate arguments`() { + val dotFile = Path("graph files/input.dot") + val outputFile = Path("graph files/output.svg") + + val command = dotRenderCommand( + dotCommand = listOf("dot", "-v"), + format = "svg", + dotFile = dotFile, + outputFile = outputFile, + ) + + assertEquals( + listOf("dot", "-v", "-Tsvg", dotFile.toString(), "-o", outputFile.toString()), + command, + ) + } + + @Test + fun `viewer command preserves the file path as one argument`() { + val outputFile = Path("graph files/output.svg") + + val command = viewerLaunchCommand( + viewerCommand = listOf("viewer", "--option"), + outputFile = outputFile, + ) + + assertEquals(listOf("viewer", "--option", outputFile.toString()), command) + } + + @Test + fun `Windows start command reserves an empty title and quotes the path`() { + val outputFile = Path("graph files/output.svg") + + val command = viewerLaunchCommand( + viewerCommand = listOf("cmd", "/c", "start", ""), + outputFile = outputFile, + ) + + assertEquals( + listOf("cmd", "/c", "start", "", "\"${outputFile.toAbsolutePath()}\""), + command, + ) + } + + @Test + fun `nonzero render result reports exit code and stderr`() { + val outputFile = Path("graph files/output.svg") + val result = ProcessUtil.Result( + exitCode = 7, + stdout = "", + stderr = "invalid graph", + isTimeout = false, + ) + + val error = assertFailsWith { + checkRenderResult(result, outputFile) + } + + assertContains(error.message.orEmpty(), "exit code 7") + assertContains(error.message.orEmpty(), "invalid graph") + } + + @Test + fun `timed out render result is not accepted`() { + val outputFile = Path("graph files/output.svg") + val result = ProcessUtil.Result( + exitCode = 137, + stdout = "", + stderr = "stopped", + isTimeout = true, + ) + + val error = assertFailsWith { + checkRenderResult(result, outputFile) + } + + assertContains(error.message.orEmpty(), "timed out") + assertContains(error.message.orEmpty(), "stopped") + } +} From 7aab19d41d21a93acee046f6e09a2c6a2b9afd1d Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 22:35:59 +0300 Subject: [PATCH 24/63] Name frontend AST branch predicates --- .../ts-frontend/src/lowering/astUtils.ts | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/jacodb-ets/ts-frontend/src/lowering/astUtils.ts b/jacodb-ets/ts-frontend/src/lowering/astUtils.ts index 6968bbe4e..f211f0b8a 100644 --- a/jacodb-ets/ts-frontend/src/lowering/astUtils.ts +++ b/jacodb-ets/ts-frontend/src/lowering/astUtils.ts @@ -121,13 +121,17 @@ function staticMemberStorage( if (!ts.isIdentifier(node.name)) return "unsupported"; const declarations = resolvedSymbolOf(node.name, checker)?.declarations ?? []; - const member = declarations.find((declaration) => - declaration.parent === classDecl - && (ts.isPropertyDeclaration(declaration) + const member = declarations.find((declaration) => { + const belongsToClass = declaration.parent === classDecl; + if (!belongsToClass) return false; + + const hasSupportedMemberKind = ts.isPropertyDeclaration(declaration) || ts.isMethodDeclaration(declaration) - || ts.isEnumMember(declaration)) - && memberName(declaration.name) === node.name.text, - ); + || ts.isEnumMember(declaration); + if (!hasSupportedMemberKind) return false; + + return memberName(declaration.name) === node.name.text; + }); if (member === undefined) return "unsupported"; if (ts.isEnumMember(member) && ts.isEnumDeclaration(classDecl)) return "field"; @@ -156,9 +160,12 @@ function isUnmaterializedValue(identifier: ts.Identifier, checker: ts.TypeChecke function usesMutableClassProperty(expression: ts.Expression, checker: ts.TypeChecker): boolean { const visit = (node: ts.Node): boolean => { if (ts.isTypeNode(node)) return false; - if (ts.isPropertyAccessExpression(node) && resolvedSymbolOf(node.name, checker)?.declarations?.some( - (declaration) => ts.isClassDeclaration(declaration) && !declaration.getSourceFile().isDeclarationFile, - )) return true; + if (ts.isPropertyAccessExpression(node)) { + const referencesProjectClass = resolvedSymbolOf(node.name, checker)?.declarations?.some( + (declaration) => ts.isClassDeclaration(declaration) && !declaration.getSourceFile().isDeclarationFile, + ) ?? false; + if (referencesProjectClass) return true; + } let found = false; ts.forEachChild(node, (child) => { found = visit(child) || found; }); @@ -234,10 +241,13 @@ export function usesModuleLexicalThis(expression: ts.Expression): boolean { /** Computed method and setter names still lack a runtime definition. */ export function hasUnsupportedComputedObjectKey(expression: ts.Expression): boolean { const visit = (node: ts.Node): boolean => { - if (ts.isObjectLiteralExpression(node) && node.properties.some((property) => - (ts.isMethodDeclaration(property) || ts.isSetAccessorDeclaration(property)) - && ts.isComputedPropertyName(property.name), - )) return true; + if (ts.isObjectLiteralExpression(node)) { + const hasComputedMethodOrSetter = node.properties.some((property) => { + const isMethodOrSetter = ts.isMethodDeclaration(property) || ts.isSetAccessorDeclaration(property); + return isMethodOrSetter && ts.isComputedPropertyName(property.name); + }); + if (hasComputedMethodOrSetter) return true; + } let found = false; ts.forEachChild(node, (child) => { found = visit(child) || found; }); From 6175856ba41f10a63cf4fa2bc0a55a9a049237e6 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 22:36:01 +0300 Subject: [PATCH 25/63] Clarify unsupported frontend fallback contract --- jacodb-ets/ts-frontend/src/lowering/diagnostics.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/jacodb-ets/ts-frontend/src/lowering/diagnostics.ts b/jacodb-ets/ts-frontend/src/lowering/diagnostics.ts index 668643161..2da288b8b 100644 --- a/jacodb-ets/ts-frontend/src/lowering/diagnostics.ts +++ b/jacodb-ets/ts-frontend/src/lowering/diagnostics.ts @@ -17,9 +17,9 @@ /** * Diagnostics collection + Raw* fallback emission. * - * The frontend must never fail on exotic-but-parseable input: anything we cannot - * lower degrades to a raw statement/value with an "Unsupported*" discriminator, - * which the Kotlin side deserializes into RawStmtDto / RawValueDto automatically. + * Known unsupported constructs degrade to a raw statement/value with an + * "Unsupported*" discriminator, which the Kotlin side deserializes into + * RawStmtDto / RawValueDto. Unexpected lowering errors still propagate. */ import * as ts from "typescript"; From ff6dae73f4888121633aca6f7c558153d6894b95 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 22:37:40 +0300 Subject: [PATCH 26/63] Clarify CFG successor and throwing-effect predicates --- jacodb-ets/ts-frontend/src/lowering/cfg.ts | 42 +++++++++++++--------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/jacodb-ets/ts-frontend/src/lowering/cfg.ts b/jacodb-ets/ts-frontend/src/lowering/cfg.ts index dfb716501..b0d10ae6f 100644 --- a/jacodb-ets/ts-frontend/src/lowering/cfg.ts +++ b/jacodb-ets/ts-frontend/src/lowering/cfg.ts @@ -219,11 +219,9 @@ export class CfgBuilder { // DTO convention: [false, true]. const falseSuccessor = idOf.get(t.falseTarget); const trueSuccessor = idOf.get(t.trueTarget); - if ( - falseSuccessor === undefined || - trueSuccessor === undefined || - falseSuccessor === trueSuccessor - ) { + const hasInvalidSuccessors = falseSuccessor === undefined || + trueSuccessor === undefined || falseSuccessor === trueSuccessor; + if (hasInvalidSuccessors) { throw new Error("if terminator must resolve to two distinct successor ids"); } successors = [falseSuccessor, trueSuccessor]; @@ -279,13 +277,14 @@ export class CfgBuilder { } function blockTargets(block: BuilderBlock): Label[] { + const terminator = block.terminator; + const exceptionalTerminator = terminator.kind === "throw" || terminator.kind === "if" ? terminator : undefined; + const terminatorExceptionTarget = exceptionalTerminator?.exceptionTarget; + return [ - ...terminatorTargets(block.terminator), + ...terminatorTargets(terminator), ...block.stmts.flatMap((stmt) => stmt.exceptionTarget === undefined ? [] : [stmt.exceptionTarget]), - ...((block.terminator.kind === "throw" || block.terminator.kind === "if") && - block.terminator.exceptionTarget !== undefined - ? [block.terminator.exceptionTarget] - : []), + ...(terminatorExceptionTarget === undefined ? [] : [terminatorExceptionTarget]), ]; } @@ -312,13 +311,22 @@ function mayThrowValue(value: ValueDto): boolean { case "ParameterRef": case "CaughtExceptionRef": return false; - case "ConditionExpr": - return value.op === "in" || - (!["===", "!=="].includes(value.op) && - (!isPrimitive(value.left) || !isPrimitive(value.right))); - case "BinopExpr": - return !["&&", "||", "??"].includes(value.op) && - (!isPrimitive(value.left) || !isPrimitive(value.right)); + case "ConditionExpr": { + if (value.op === "in") return true; + + const requiresCoercion = !["===", "!=="].includes(value.op); + if (!requiresCoercion) return false; + + const hasNonPrimitiveOperand = !isPrimitive(value.left) || !isPrimitive(value.right); + return hasNonPrimitiveOperand; + } + case "BinopExpr": { + const mayCoerceOperands = !["&&", "||", "??"].includes(value.op); + if (!mayCoerceOperands) return false; + + const hasNonPrimitiveOperand = !isPrimitive(value.left) || !isPrimitive(value.right); + return hasNonPrimitiveOperand; + } case "UnopExpr": return value.op !== "!" && !isPrimitive(value.arg); case "TypeOfExpr": From f2340df79da69f7ef96c6e1d1ecd6787e660de41 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 22:44:41 +0300 Subject: [PATCH 27/63] Name frontend IR validation predicates --- jacodb-ets/ts-frontend/src/validate.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/jacodb-ets/ts-frontend/src/validate.ts b/jacodb-ets/ts-frontend/src/validate.ts index 1b17a0bf0..51338d874 100644 --- a/jacodb-ets/ts-frontend/src/validate.ts +++ b/jacodb-ets/ts-frontend/src/validate.ts @@ -171,11 +171,13 @@ function validateBody(body: BodyDto, ctx: string, errors: string[]): void { }); const last = block.stmts.length > 0 ? block.stmts[block.stmts.length - 1] : undefined; + const endsWithExit = last !== undefined && + (last._ === "ReturnStmt" || last._ === "ReturnVoidStmt" || last._ === "ThrowStmt"); if (last !== undefined && last._ === "IfStmt") { if (block.successors.length !== 2) { err(`${blockCtx}: ends with IfStmt but has ${block.successors.length} successors (need exactly 2: [false, true])`); } - } else if (last !== undefined && (last._ === "ReturnStmt" || last._ === "ReturnVoidStmt" || last._ === "ThrowStmt")) { + } else if (last !== undefined && endsWithExit) { if (block.successors.length !== 0) { err(`${blockCtx}: ends with '${last._}' but has ${block.successors.length} successors`); } @@ -209,13 +211,10 @@ function validateBody(body: BodyDto, ctx: string, errors: string[]): void { if (source.startOffset < 0 || source.endOffset < source.startOffset) { err(`source origin ${key} has invalid offset range [${source.startOffset}, ${source.endOffset})`); } - if ( - source.startLine < 0 || - source.startColumn < 0 || - source.endLine < source.startLine || - source.endColumn < 0 || - (source.endLine === source.startLine && source.endColumn < source.startColumn) - ) { + const hasNegativePosition = source.startLine < 0 || source.startColumn < 0 || source.endColumn < 0; + const endsBeforeStart = source.endLine < source.startLine || + (source.endLine === source.startLine && source.endColumn < source.startColumn); + if (hasNegativePosition || endsBeforeStart) { err( `source origin ${key} has invalid line/column range ` + `${source.startLine}:${source.startColumn}-${source.endLine}:${source.endColumn}`, @@ -235,14 +234,14 @@ function validateBody(body: BodyDto, ctx: string, errors: string[]): void { for (const b of blocks) { const actual = new Set(b.predecessors); const expected = expectedPreds.get(b.id)!; - if (actual.size !== expected.size || [...expected].some((p) => !actual.has(p))) { + const hasMissingPredecessor = [...expected].some((predecessor) => !actual.has(predecessor)); + if (actual.size !== expected.size || hasMissingPredecessor) { err(`block ${b.id}: predecessors [${[...actual]}] inconsistent with successors (expected [${[...expected]}])`); } } } } - function validateStmt( stmt: StmtDto, ctx: string, From 5eff0f2d7de22c7ce9b3e812c2851607101ec2e9 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 22:55:05 +0300 Subject: [PATCH 28/63] Preserve Array type identity and generic callable metadata --- .../ts-frontend/src/lowering/fileBuilder.ts | 5 +- jacodb-ets/ts-frontend/src/types/convert.ts | 33 +++++-- jacodb-ets/ts-frontend/test/types.spec.ts | 99 ++++++++++++++++++- 3 files changed, 124 insertions(+), 13 deletions(-) diff --git a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts index d318e6c9b..f94e4ecb0 100644 --- a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts +++ b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts @@ -80,7 +80,8 @@ export function buildEtsFile( }; const checker = program.getTypeChecker(); - const converter = new TypeConverter(checker, fileSignatureFor); + const isDefaultLibrarySourceFile = (sf: ts.SourceFile): boolean => program.isSourceFileDefaultLibrary(sf); + const converter = new TypeConverter(checker, fileSignatureFor, isDefaultLibrarySourceFile); const anonymous: AnonymousRegistry = { defaultClassSignature: { name: DEFAULT_ARK_CLASS_NAME, declaringFile: fileSignature }, methods: [], @@ -92,7 +93,7 @@ export function buildEtsFile( checker, converter, fileSignatureFor, - isDefaultLibrarySourceFile: (sf) => program.isSourceFileDefaultLibrary(sf), + isDefaultLibrarySourceFile, diagnostics, anonymous, moduleFields: new Map(), diff --git a/jacodb-ets/ts-frontend/src/types/convert.ts b/jacodb-ets/ts-frontend/src/types/convert.ts index 68bf4c137..0f93668de 100644 --- a/jacodb-ets/ts-frontend/src/types/convert.ts +++ b/jacodb-ets/ts-frontend/src/types/convert.ts @@ -45,6 +45,7 @@ import { BOOLEAN_TYPE, BIGINT_TYPE, ClassTypeDto, + FunctionTypeDto, GenericTypeDto, NEVER_TYPE, NULL_TYPE, @@ -74,6 +75,7 @@ export class TypeConverter { constructor( private readonly checker: ts.TypeChecker, private readonly fileSignatureFor: (sf: ts.SourceFile) => FileSignatureDto, + private readonly isDefaultLibrarySourceFile: (sf: ts.SourceFile) => boolean, ) {} // ------------------------------------------------------------------ @@ -209,10 +211,17 @@ export class TypeConverter { }; } if (ts.isFunctionTypeNode(node)) { - return { + const result: FunctionTypeDto = { _: "FunctionType", signature: this.functionSignatureFromTypeNode(node, depth, substitutions), }; + + const typeParameters = this.convertTypeParameters(node.typeParameters, depth, substitutions); + if (typeParameters !== undefined) { + result.typeParameters = typeParameters; + } + + return result; } if (ts.isTypeQueryNode(node)) { const symbol = this.symbolOf(node.exprName); @@ -401,12 +410,14 @@ export class TypeConverter { const name = entityNameToString(node.typeName); const typeArgs = node.typeArguments?.map((t) => this.convertTypeNode(t, depth + 1, substitutions)); - // `Array` is a proper array type. - if (name === "Array" && typeArgs !== undefined && typeArgs.length === 1) { + const symbol = this.resolveSymbol(node.typeName); + const isDefaultLibraryArray = name === "Array" && symbol?.declarations?.some( + (declaration) => this.isDefaultLibrarySourceFile(declaration.getSourceFile()), + ) === true; + if (isDefaultLibraryArray && typeArgs !== undefined && typeArgs.length === 1) { return foldArray(typeArgs[0]); } - const symbol = this.resolveSymbol(node.typeName); if (symbol !== undefined) { const classDecl = findClassLikeDeclaration(symbol); if (classDecl !== undefined && isProjectDeclaration(classDecl)) { @@ -451,13 +462,17 @@ export class TypeConverter { } /** Type parameter declaration -> GenericType{name, constraint?, defaultType?}. */ - convertTypeParameter(decl: ts.TypeParameterDeclaration, depth: number = 0): GenericTypeDto { + convertTypeParameter( + decl: ts.TypeParameterDeclaration, + depth: number = 0, + substitutions?: ReadonlyMap, + ): GenericTypeDto { const result: GenericTypeDto = { _: "GenericType", name: decl.name.text }; if (decl.constraint !== undefined) { - result.constraint = this.convertTypeNode(decl.constraint, depth + 1); + result.constraint = this.convertTypeNode(decl.constraint, depth + 1, substitutions); } if (decl.default !== undefined) { - result.defaultType = this.convertTypeNode(decl.default, depth + 1); + result.defaultType = this.convertTypeNode(decl.default, depth + 1, substitutions); } return result; } @@ -465,11 +480,13 @@ export class TypeConverter { /** Type parameter list of a class/method, or undefined when absent. */ convertTypeParameters( decls: readonly ts.TypeParameterDeclaration[] | undefined, + depth: number = 0, + substitutions?: ReadonlyMap, ): TypeDto[] | undefined { if (decls === undefined || decls.length === 0) { return undefined; } - return decls.map((d) => this.convertTypeParameter(d)); + return decls.map((d) => this.convertTypeParameter(d, depth, substitutions)); } private functionSignatureFromTypeNode( diff --git a/jacodb-ets/ts-frontend/test/types.spec.ts b/jacodb-ets/ts-frontend/test/types.spec.ts index 2aef33dc9..d0f600378 100644 --- a/jacodb-ets/ts-frontend/test/types.spec.ts +++ b/jacodb-ets/ts-frontend/test/types.spec.ts @@ -5,13 +5,13 @@ import { FileSignatureDto } from "../src/dto/signatures"; import { TypeDto } from "../src/dto/types"; import { serializeEtsFile } from "../src/serialize"; import { TypeConverter } from "../src/types/convert"; -import { compile, findNode, findVariable, lower, methodByName } from "./util"; +import { compile, findNode, findVariable, lower, lowerProject, methodByName } from "./util"; const FILE_SIG: FileSignatureDto = { projectName: "proj", fileName: "test.ts" }; function makeConverter(source: string): { converter: TypeConverter; sourceFile: ts.SourceFile } { - const { checker, sourceFile } = compile(source); - const converter = new TypeConverter(checker, () => FILE_SIG); + const { program, checker, sourceFile } = compile(source); + const converter = new TypeConverter(checker, () => FILE_SIG, (sf) => program.isSourceFileDefaultLibrary(sf)); return { converter, sourceFile }; } @@ -101,6 +101,35 @@ describe("convertTypeNode (annotations)", () => { }); }); + it("keeps a project class named Array distinct from the default-library array", () => { + const type = annotationOf("export {}; class Array { value!: T } let x: Array;"); + + expect(type).toEqual({ + _: "ClassType", + signature: { name: "Array", declaringFile: FILE_SIG }, + typeParameters: [{ _: "NumberType" }], + }); + }); + + it("keeps an imported class aliased as Array distinct from the default-library array", () => { + const { file } = lowerProject({ + "/model.ts": "export class Box { value!: T }", + "/consumer.ts": ` + import { Box as Array } from "./model"; + export function read(value: Array): Array { return value; } + `, + }, "/consumer.ts"); + const expectedType = { + _: "ClassType", + signature: { name: "Box", declaringFile: { projectName: "proj", fileName: "/model.ts" } }, + typeParameters: [{ _: "NumberType" }], + }; + + const signature = methodByName(file, "read").signature; + expect(signature.parameters[0].type).toEqual(expectedType); + expect(signature.returnType).toEqual(expectedType); + }); + it("converts tuples", () => { expect(annotationOf("let x: [number, string];")).toEqual({ _: "TupleType", @@ -262,6 +291,70 @@ describe("convertTypeNode (annotations)", () => { }); }); + it("preserves a generic function type's binder, constraint, and default", () => { + const type = annotationOf('let x: (value: T) => T;'); + + expect(type).toEqual({ + _: "FunctionType", + signature: { + declaringClass: { name: "", declaringFile: { projectName: "%unk", fileName: "%unk" } }, + name: "", + parameters: [{ name: "value", type: { _: "GenericType", name: "T" } }], + returnType: { _: "GenericType", name: "T" }, + }, + typeParameters: [{ + _: "GenericType", + name: "T", + constraint: { _: "StringType" }, + defaultType: { _: "LiteralType", literal: "fallback" }, + }], + }); + }); + + it("instantiates outer alias parameters in a generic function binder", () => { + const type = annotationOf(` + type Callback = (value: T) => T; + let x: Callback; + `); + + expect(type).toEqual({ + _: "FunctionType", + signature: { + declaringClass: { name: "", declaringFile: { projectName: "%unk", fileName: "%unk" } }, + name: "", + parameters: [{ name: "value", type: { _: "GenericType", name: "T" } }], + returnType: { _: "GenericType", name: "T" }, + }, + typeParameters: [{ + _: "GenericType", + name: "T", + constraint: { _: "StringType" }, + defaultType: { _: "StringType" }, + }], + }); + }); + + it("keeps a function's own type parameter when it shadows an alias parameter", () => { + const type = annotationOf(` + type Shadow = (value: T) => T; + let x: Shadow; + `); + + expect(type).toMatchObject({ + _: "FunctionType", + signature: { + parameters: [{ name: "value", type: { _: "GenericType", name: "T" } }], + returnType: { _: "GenericType", name: "T" }, + }, + typeParameters: [{ + _: "GenericType", + name: "T", + constraint: { _: "StringType" }, + defaultType: { _: "LiteralType", literal: "local" }, + }], + }); + }); + it("materializes object type literals as structural classes", () => { const { file } = lower(` class C { From 63351263c90007be1f8ce595cbba043769e1897b Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 23:00:26 +0300 Subject: [PATCH 29/63] Clean generated IR after convenience loader consumption --- .../org/jacodb/ets/utils/LoadEtsFile.kt | 36 +++++++- .../jacodb/ets/test/EtsIrGenerationTest.kt | 87 +++++++++++++++++++ 2 files changed, 119 insertions(+), 4 deletions(-) diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/LoadEtsFile.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/LoadEtsFile.kt index bd104b2db..cbcf8091b 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/LoadEtsFile.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/LoadEtsFile.kt @@ -309,9 +309,11 @@ fun loadEtsFileAutoConvert( useArkAnalyzerTypeInference = useArkAnalyzerTypeInference, provider = provider, ) - irFilePath.inputStream().use { stream -> - val etsFileDto = EtsFileDto.loadFromJson(stream) - return etsFileDto.toEtsFile() + return withGeneratedIrOutput(irFilePath, isProject = false) { + irFilePath.inputStream().use { stream -> + val etsFileDto = EtsFileDto.loadFromJson(stream) + etsFileDto.toEtsFile() + } } } @@ -330,7 +332,33 @@ fun loadEtsProjectAutoConvert( provider = provider, ) - return loadEtsProjectFromIR(irFolderPath, sdkIRPath) + return withGeneratedIrOutput(irFolderPath, isProject = true) { + loadEtsProjectFromIR(irFolderPath, sdkIRPath) + } +} + +private inline fun withGeneratedIrOutput(output: Path, isProject: Boolean, load: () -> T): T { + var loadFailure: Throwable? = null + try { + return load() + } catch (failure: Throwable) { + loadFailure = failure + throw failure + } finally { + try { + if (isProject) { + check(output.toFile().deleteRecursively()) { "Could not remove generated EtsIR output '$output'" } + } else { + Files.deleteIfExists(output) + } + } catch (cleanupFailure: Throwable) { + if (loadFailure != null) { + loadFailure.addSuppressed(cleanupFailure) + } else { + throw cleanupFailure + } + } + } } fun loadEtsProjectFromIR( diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsIrGenerationTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsIrGenerationTest.kt index fd1e0f4ac..045400315 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsIrGenerationTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsIrGenerationTest.kt @@ -16,10 +16,13 @@ package org.jacodb.ets.test +import kotlinx.serialization.SerializationException import org.jacodb.ets.utils.EtsIrGenerationException import org.jacodb.ets.utils.EtsIrGenerationTimeoutException import org.jacodb.ets.utils.EtsIrProvider import org.jacodb.ets.utils.generateEtsIR +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.jacodb.ets.utils.loadEtsProjectAutoConvert import org.junit.jupiter.api.Test import java.nio.file.Files import java.nio.file.Path @@ -183,6 +186,90 @@ class EtsIrGenerationTest { } } + @Test + fun `file auto convert removes generated output after loading`() = withAutoConvertFrontend(validJson = true) { frontend -> + val source = frontend.resolve("source.ts").also { it.writeText("const value = 1;") } + val outputMarker = frontend.resolve("generated-output") + + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + + assertEquals("source.ts", file.signature.fileName) + assertFalse(Files.exists(Paths.get(outputMarker.readText()))) + } + + @Test + fun `file auto convert removes generated output after invalid JSON`() = withAutoConvertFrontend(validJson = false) { frontend -> + val source = frontend.resolve("source.ts").also { it.writeText("const value = 1;") } + val outputMarker = frontend.resolve("generated-output") + + assertFailsWith { + loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + } + + assertFalse(Files.exists(Paths.get(outputMarker.readText()))) + } + + @Test + fun `project auto convert removes generated output after loading`() = withAutoConvertFrontend(validJson = true) { frontend -> + val project = frontend.resolve("project").createDirectories() + project.resolve("source.ts").writeText("const value = 1;") + val sdkIr = frontend.resolve("sdk-ir").createDirectories() + val sdkFile = sdkIr.resolve("sdk.json") + sdkFile.writeText( + """{"signature":{"projectName":"sdk","fileName":"sdk.ts"},"namespaces":[],"classes":[],"importInfos":[],"exportInfos":[]}""" + ) + val outputMarker = frontend.resolve("generated-output") + + val scene = loadEtsProjectAutoConvert(project, sdkIRPath = sdkIr, provider = EtsIrProvider.TS_FRONTEND) + + assertEquals("source.ts", scene.projectFiles.single().signature.fileName) + assertEquals("sdk.ts", scene.sdkFiles.single().signature.fileName) + assertTrue(Files.exists(sdkFile)) + assertFalse(Files.exists(Paths.get(outputMarker.readText()))) + } + + @Test + fun `project auto convert removes generated output after invalid JSON`() = withAutoConvertFrontend(validJson = false) { frontend -> + val project = frontend.resolve("project").createDirectories() + project.resolve("source.ts").writeText("const value = 1;") + val outputMarker = frontend.resolve("generated-output") + + assertFailsWith { + loadEtsProjectAutoConvert(project, provider = EtsIrProvider.TS_FRONTEND) + } + + assertFalse(Files.exists(Paths.get(outputMarker.readText()))) + } + + private fun withAutoConvertFrontend(validJson: Boolean, block: (Path) -> Unit) { + val frontend = createTempDirectory("auto-convert-frontend") + frontend.resolve("dist").createDirectories() + val payload = if (validJson) { + "JSON.stringify({signature:{projectName:'',fileName:'source.ts'},namespaces:[],classes:[],importInfos:[],exportInfos:[]})" + } else { + "'invalid JSON'" + } + + frontend.resolve("dist/index.js").writeText( + """ + const fs = require('fs'); + const path = require('path'); + const output = process.argv.at(process.argv.at(-1) === '-v' ? -2 : -1); + fs.writeFileSync(path.join(__dirname, '..', 'generated-output'), output); + const target = process.argv.includes('-p') ? path.join(output, 'source.ts.json') : output; + fs.writeFileSync(target, $payload); + """.trimIndent() + ) + + System.setProperty("ets.frontend.dir", frontend.toString()) + try { + block(frontend) + } finally { + System.clearProperty("ets.frontend.dir") + frontend.toFile().deleteRecursively() + } + } + private fun outputPath(error: Throwable): Path = Paths.get( error.message.orEmpty().substringAfter("Output: '").substringBefore("'"), ) From ef9c6e517cddedf1add4332ed012e4752a37d545 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 23:15:14 +0300 Subject: [PATCH 30/63] Preserve live generic binders in inferred function types --- jacodb-ets/ts-frontend/src/types/convert.ts | 22 +++++++- jacodb-ets/ts-frontend/test/types.spec.ts | 60 +++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/jacodb-ets/ts-frontend/src/types/convert.ts b/jacodb-ets/ts-frontend/src/types/convert.ts index 0f93668de..caa320cdd 100644 --- a/jacodb-ets/ts-frontend/src/types/convert.ts +++ b/jacodb-ets/ts-frontend/src/types/convert.ts @@ -709,7 +709,7 @@ export class TypeConverter { ts.isIdentifier(declaration.name) ? declaration.name.text : ""; - return { + const result: FunctionTypeDto = { _: "FunctionType", signature: { declaringClass: UNKNOWN_CLASS_SIGNATURE, @@ -718,6 +718,26 @@ export class TypeConverter { returnType, }, }; + const typeParameters = signature.getTypeParameters(); + if (typeParameters !== undefined && typeParameters.length > 0) { + result.typeParameters = typeParameters.map((typeParameter): GenericTypeDto => { + const binder: GenericTypeDto = { _: "GenericType", name: this.typeName(typeParameter) }; + + const constraint = this.checker.getBaseConstraintOfType(typeParameter); + if (constraint !== undefined) { + binder.constraint = this.convertType(constraint, depth + 1); + } + + const defaultType = this.checker.getDefaultFromTypeParameter(typeParameter); + if (defaultType !== undefined) { + binder.defaultType = this.convertType(defaultType, depth + 1); + } + + return binder; + }); + } + + return result; } catch { return UNKNOWN_TYPE; } diff --git a/jacodb-ets/ts-frontend/test/types.spec.ts b/jacodb-ets/ts-frontend/test/types.spec.ts index d0f600378..6d9349577 100644 --- a/jacodb-ets/ts-frontend/test/types.spec.ts +++ b/jacodb-ets/ts-frontend/test/types.spec.ts @@ -479,6 +479,66 @@ describe("typeOfNode (inference)", () => { }); }); + it("preserves the binder of an inferred generic function value", () => { + const type = inferredOf('const x = (value: T): T => value;'); + + expect(type).toMatchObject({ + _: "FunctionType", + signature: { + parameters: [{ name: "value", type: { _: "GenericType", name: "T" } }], + returnType: { _: "GenericType", name: "T" }, + }, + typeParameters: [{ + _: "GenericType", + name: "T", + constraint: { _: "StringType" }, + defaultType: { _: "LiteralType", literal: "fallback" }, + }], + }); + }); + + it("keeps an inferred generic field binder and its outer class parameter", () => { + const { file } = lower(` + class Holder { + callback = (value: T): T => value; + } + `); + const holder = file.classes.find((candidate) => candidate.signature.name === "Holder"); + const callbackType = holder?.fields.find((field) => field.signature.name === "callback")?.signature.type; + + expect(callbackType).toMatchObject({ + _: "FunctionType", + signature: { + parameters: [{ name: "value", type: { _: "GenericType", name: "T" } }], + returnType: { _: "GenericType", name: "T" }, + }, + typeParameters: [{ + _: "GenericType", + name: "T", + constraint: { _: "GenericType", name: "U" }, + defaultType: { _: "GenericType", name: "U" }, + }], + }); + }); + + it("does not copy a generic declaration binder onto a specialized callable", () => { + const type = inferredOf(` + const identity = (value: T): T => value; + const specialized: (value: number) => number = identity; + const x = specialized; + `); + + expect(type).toMatchObject({ + _: "FunctionType", + signature: { + parameters: [{ name: "value", type: { _: "NumberType" } }], + returnType: { _: "NumberType" }, + }, + }); + if (type._ !== "FunctionType") throw new Error("expected a specialized function type"); + expect(type.typeParameters).toBeUndefined(); + }); + it("never throws on weird nodes", () => { expect(inferredOf("let x = Symbol('s');")).toBeDefined(); }); From 0f53ade176ff5d09947908088afcef7c9cb4adb2 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 23:29:03 +0300 Subject: [PATCH 31/63] Share scope function type construction with generic binders --- .../ts-frontend/src/lowering/astUtils.ts | 12 ++++- .../ts-frontend/src/lowering/fileBuilder.ts | 6 +-- .../ts-frontend/src/lowering/methodBuilder.ts | 22 ++++---- .../ts-frontend/test/named-functions.spec.ts | 53 +++++++++++++++++++ 4 files changed, 78 insertions(+), 15 deletions(-) diff --git a/jacodb-ets/ts-frontend/src/lowering/astUtils.ts b/jacodb-ets/ts-frontend/src/lowering/astUtils.ts index f211f0b8a..db38a4ded 100644 --- a/jacodb-ets/ts-frontend/src/lowering/astUtils.ts +++ b/jacodb-ets/ts-frontend/src/lowering/astUtils.ts @@ -20,7 +20,7 @@ import * as ts from "typescript"; import { COMPUTED_MEMBER_NAME, DEFAULT_ARK_CLASS_NAME, Modifier, PATTERN_PARAMETER_PREFIX } from "../dto/constants"; import { DecoratorDto } from "../dto/model"; import { MethodParameterDto, MethodSignatureDto } from "../dto/signatures"; -import { TypeDto, UNKNOWN_TYPE } from "../dto/types"; +import { FunctionTypeDto, TypeDto, UNKNOWN_TYPE } from "../dto/types"; import type { LoweringContext } from "./methodBuilder"; export function memberName(name: ts.PropertyName | ts.BindingName | ts.EntityName): string { @@ -382,3 +382,13 @@ export function scopeFunctionSignature(ctx: LoweringContext, decl: ts.FunctionDe returnType: returnTypeOf(ctx, decl), }; } + +/** The declared function value, including its generic binders. */ +export function scopeFunctionType(ctx: LoweringContext, decl: ts.FunctionDeclaration): FunctionTypeDto { + const type: FunctionTypeDto = { _: "FunctionType", signature: scopeFunctionSignature(ctx, decl) }; + const typeParameters = ctx.converter.convertTypeParameters(decl.typeParameters); + if (typeParameters !== undefined) { + type.typeParameters = typeParameters; + } + return type; +} diff --git a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts index f94e4ecb0..166b1b8fc 100644 --- a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts +++ b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts @@ -42,7 +42,7 @@ import { ClassDto, EtsFileDto, ExportInfoDto, FieldDto, ImportInfoDto, MethodDto import { ClassSignatureDto, FileSignatureDto, NamespaceSignatureDto } from "../dto/signatures"; import { VOID_TYPE } from "../dto/types"; import { TypeConverter } from "../types/convert"; -import { classValueDeclarationOf, exportAssignmentSupport, modifiersOf, scopeFunctionImplementation, scopeFunctionSignature } from "./astUtils"; +import { classValueDeclarationOf, exportAssignmentSupport, modifiersOf, scopeFunctionImplementation, scopeFunctionType } from "./astUtils"; import { ClassBuilder } from "./classBuilder"; import { Diagnostics } from "./diagnostics"; import { AnonymousRegistry, LoweringContext, MethodContext } from "./methodBuilder"; @@ -400,7 +400,7 @@ class FileBuilder { continue; } - const type = { _: "FunctionType" as const, signature: scopeFunctionSignature(this.ctx, statement) }; + const type = scopeFunctionType(this.ctx, statement); const signature = { declaringClass, name: statement.name.text, type }; this.ctx.moduleFields.set(symbol, signature); fields.push({ @@ -513,7 +513,7 @@ class FileBuilder { if (field === undefined) continue; const reference = m.getOrCreateLocal( `${NAMED_FUNCTION_REFERENCE_PREFIX}${statement.name.text}`, - { _: "FunctionType", signature: scopeFunctionSignature(this.ctx, statement) }, + scopeFunctionType(this.ctx, statement), ); m.withOrigin(statement, () => m.cfg.emit({ _: "AssignStmt", left: field, right: reference })); } diff --git a/jacodb-ets/ts-frontend/src/lowering/methodBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/methodBuilder.ts index 97782ff99..f461fe685 100644 --- a/jacodb-ets/ts-frontend/src/lowering/methodBuilder.ts +++ b/jacodb-ets/ts-frontend/src/lowering/methodBuilder.ts @@ -21,7 +21,7 @@ import { ClassSignatureDto, FieldSignatureDto, FileSignatureDto } from "../dto/s import { ClassTypeDto, LexicalEnvTypeDto, TypeDto, UNKNOWN_TYPE } from "../dto/types"; import { ClosureFieldRefDto, LocalDto, StaticFieldRefDto, ValueDto } from "../dto/values"; import { TypeConverter } from "../types/convert"; -import { scopeFunctionImplementation, scopeFunctionSignature } from "./astUtils"; +import { scopeFunctionImplementation, scopeFunctionType } from "./astUtils"; import type { BuiltParameters } from "./astUtils"; import { CfgBuilder } from "./cfg"; import { Diagnostics, syntaxKindName } from "./diagnostics"; @@ -237,11 +237,11 @@ export class MethodContext { private moduleFieldFromSymbol(symbol: ts.Symbol): FieldSignatureDto | undefined { const functionDeclaration = scopeFunctionImplementation(symbol); if (functionDeclaration !== undefined) { - const signature = scopeFunctionSignature(this.ctx, functionDeclaration); + const type = scopeFunctionType(this.ctx, functionDeclaration); return { - declaringClass: signature.declaringClass, - name: signature.name, - type: { _: "FunctionType", signature }, + declaringClass: type.signature.declaringClass, + name: type.signature.name, + type, }; } @@ -263,13 +263,14 @@ export class MethodContext { const directScope = ts.isVariableStatement(statement) && (ts.isSourceFile(statement.parent) || ts.isModuleBlock(statement.parent)); const functionScoped = (declarationList.flags & ts.NodeFlags.BlockScoped) === 0; - if (!directScope && (!functionScoped || moduleScopeOf(variableDeclaration) === undefined)) return undefined; + const moduleScope = moduleScopeOf(variableDeclaration); + const hasModuleStorage = directScope || (functionScoped && moduleScope !== undefined); + if (!hasModuleStorage) return undefined; const declaringClass: ClassSignatureDto = { name: DEFAULT_ARK_CLASS_NAME, declaringFile: this.ctx.fileSignatureFor(identifier.getSourceFile()), }; - const moduleScope = directScope ? statement.parent : moduleScopeOf(variableDeclaration); if (moduleScope !== undefined && ts.isModuleBlock(moduleScope) && ts.isModuleDeclaration(moduleScope.parent)) { const namespace = this.converter.namespaceSignatureOf(moduleScope.parent); if (namespace !== undefined) declaringClass.declaringNamespace = namespace; @@ -476,16 +477,15 @@ function declaredBindingIdentifier(declaration: ts.Declaration): ts.Identifier | function moduleScopeOf(node: ts.Node): ts.SourceFile | ts.ModuleBlock | undefined { for (let current: ts.Node | undefined = node.parent; current !== undefined; current = current.parent) { if (ts.isSourceFile(current) || ts.isModuleBlock(current)) return current; - if ( - ts.isFunctionDeclaration(current) + const isFunctionBoundary = ts.isFunctionDeclaration(current) || ts.isFunctionExpression(current) || ts.isArrowFunction(current) || ts.isMethodDeclaration(current) || ts.isConstructorDeclaration(current) || ts.isGetAccessorDeclaration(current) || ts.isSetAccessorDeclaration(current) - || ts.isClassStaticBlockDeclaration(current) - ) return undefined; + || ts.isClassStaticBlockDeclaration(current); + if (isFunctionBoundary) return undefined; } return undefined; } diff --git a/jacodb-ets/ts-frontend/test/named-functions.spec.ts b/jacodb-ets/ts-frontend/test/named-functions.spec.ts index 72bd3c5aa..67f71093d 100644 --- a/jacodb-ets/ts-frontend/test/named-functions.spec.ts +++ b/jacodb-ets/ts-frontend/test/named-functions.spec.ts @@ -47,6 +47,32 @@ describe("named function bindings", () => { expect(nativeResult(source, "[before, alias === named, new Example().check()]")).toEqual([3, true, true]); }); + it("keeps generic binders on the direct scope field and its initial reference", () => { + const source = 'function identity(value: T): T { return value; }'; + const { file, diagnostics } = lower(source); + const defaultClass = file.classes.find((clazz) => clazz.signature.name === "%dflt")!; + const fieldType = defaultClass.fields.find((field) => field.signature.name === "identity")!.signature.type; + const initialization = statements(defaultMethod(file)).find((stmt): stmt is AssignStmtDto => + stmt._ === "AssignStmt" && stmt.left._ === "StaticFieldRef" && stmt.left.field.name === "identity")!; + + expect(fieldType).toEqual({ + _: "FunctionType", + signature: methodByName(file, "identity").signature, + typeParameters: [{ + _: "GenericType", + name: "T", + constraint: { _: "StringType" }, + defaultType: { _: "LiteralType", literal: "fallback" }, + }], + }); + expect(initialization.right).toEqual({ + _: "Local", + name: `${NAMED_FUNCTION_REFERENCE_PREFIX}identity`, + type: fieldType, + }); + expect(diagnostics.messages).toEqual([]); + }); + it("uses the same binding for aliases, reassignment and direct calls, selected before arguments", () => { const source = ` function original(value: number): number { return value + 1; } @@ -152,6 +178,33 @@ describe("named function bindings", () => { expect(diagnostics.messages).toEqual([]); }); + it("keeps a generic binder on an imported scope function field", () => { + const sources = { + "/module.ts": 'export function identity(value: T): T { return value; }', + "/consumer.ts": 'import { identity } from "./module"; export function use() { const alias = identity; return alias("ok"); }', + }; + const { file, diagnostics } = lowerProject(sources, "/consumer.ts"); + const read = statements(methodByName(file, "use")).find((stmt): stmt is AssignStmtDto => + stmt._ === "AssignStmt" && stmt.right._ === "StaticFieldRef" && stmt.right.field.name === "identity")!; + + expect(read.right).toMatchObject({ + _: "StaticFieldRef", + field: { + declaringClass: { declaringFile: { fileName: "/module.ts" } }, + type: { + _: "FunctionType", + typeParameters: [{ + _: "GenericType", + name: "T", + constraint: { _: "StringType" }, + defaultType: { _: "LiteralType", literal: "fallback" }, + }], + }, + }, + }); + expect(diagnostics.messages).toEqual([]); + }); + it("initializes an overload binding from its body signature exactly once", () => { const source = ` function named(value: number): number; From ea8e9fa8ded23a258289d06521c0a66fe302c732 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 23:44:00 +0300 Subject: [PATCH 32/63] Respect shadowed undefined bindings during expression lowering --- .../ts-frontend/src/lowering/exprLowering.ts | 21 ++++---- .../ts-frontend/test/regressions.spec.ts | 52 +++++++++++++++++++ 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index 1edf6acc7..dc1c458f9 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -352,7 +352,7 @@ export class ExprLowerer { isDelegating: node.asteriskToken !== undefined, }; } - throw new LoweringError(ts.SyntaxKind[node.kind]); + throw new LoweringError(syntaxKindName(node.kind)); } // ------------------------------------------------------------------ @@ -368,7 +368,7 @@ export class ExprLowerer { if (nonFiniteNumber !== undefined) { return constant(nonFiniteNumber, NUMBER_TYPE); } - if (node.text === "undefined") { + if (node.text === "undefined" && this.defaultLibraryGlobalStatus(node) !== false) { return constant("undefined", UNDEFINED_TYPE); } const declaration = this.m.converter.symbolOf(node)?.declarations?.find((candidate) => @@ -399,14 +399,17 @@ export class ExprLowerer { /** Built-in numeric globals are constants, but a project declaration must still shadow them. */ private builtInNonFiniteNumber(node: ts.Identifier): "Infinity" | "NaN" | undefined { if (node.text !== "Infinity" && node.text !== "NaN") return undefined; - const symbol = this.m.converter.symbolOf(node); - const declarations = symbol?.declarations; - if (declarations === undefined || declarations.length === 0 || !declarations.every((declaration) => + return this.defaultLibraryGlobalStatus(node) === true ? node.text : undefined; + } + + /** Declaration-less globals include TypeScript's intrinsic `undefined`; resolved project bindings do not. */ + private defaultLibraryGlobalStatus(node: ts.Identifier): boolean | undefined { + const declarations = this.m.converter.symbolOf(node)?.declarations; + if (declarations === undefined || declarations.length === 0) return undefined; + + return declarations.every((declaration) => this.m.ctx.isDefaultLibrarySourceFile(declaration.getSourceFile()), - )) { - return undefined; - } - return node.text; + ); } // ------------------------------------------------------------------ diff --git a/jacodb-ets/ts-frontend/test/regressions.spec.ts b/jacodb-ets/ts-frontend/test/regressions.spec.ts index 1da71e4fc..05a91d643 100644 --- a/jacodb-ets/ts-frontend/test/regressions.spec.ts +++ b/jacodb-ets/ts-frontend/test/regressions.spec.ts @@ -377,6 +377,58 @@ describe("non-finite numeric literals", () => { }); }); +describe("undefined identifier bindings", () => { + it("reads a parameter named undefined from its local binding", () => { + const { file, diagnostics } = lower("function value(undefined: number): number { return undefined; }"); + const stmts = allStmts(methodByName(file, "value")); + + expect(diagnostics.messages).toEqual([]); + expect(stmts).toContainEqual(expect.objectContaining({ + _: "AssignStmt", + left: { _: "Local", name: "undefined", type: { _: "NumberType" } }, + right: { _: "ParameterRef", index: 0, type: { _: "NumberType" } }, + })); + expect(stmts).toContainEqual({ + _: "ReturnStmt", + arg: { _: "Local", name: "undefined", type: { _: "NumberType" } }, + }); + }); + + it("reads a local named undefined after its initializer", () => { + const { file, diagnostics } = lower("function value(): number { const undefined = 7; return undefined; }"); + const stmts = allStmts(methodByName(file, "value")); + const returned = stmts.find((stmt) => stmt._ === "ReturnStmt"); + + expect(diagnostics.messages).toEqual([]); + expect(returned).toMatchObject({ + _: "ReturnStmt", + arg: { _: "Local", name: "undefined" }, + }); + }); + + it("reads a captured undefined binding through the closure environment", () => { + const { file, diagnostics } = lower("function outer(undefined: number) { return () => undefined; }"); + const stmts = allStmts(methodByName(file, "%AM0$outer")); + + expect(diagnostics.messages).toEqual([]); + expect(stmts.some((stmt) => stmt._ === "AssignStmt" + && stmt.right._ === "ClosureFieldRef" + && stmt.right.fieldName === "undefined" + && stmt.right.type._ === "NumberType")).toBe(true); + }); + + it("retains the built-in undefined constant", () => { + const { file, diagnostics } = lower("function value() { return undefined; }"); + const stmts = allStmts(methodByName(file, "value")); + + expect(diagnostics.messages).toEqual([]); + expect(stmts).toContainEqual({ + _: "ReturnStmt", + arg: { _: "Constant", value: "undefined", type: { _: "UndefinedType" } }, + }); + }); +}); + describe("function hoisting", () => { it("materializes a capture only after the captured local is assigned", () => { const { file } = lower(` From 86c9a78f202d870734ef1b8c906902b2dda258f7 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 00:01:08 +0300 Subject: [PATCH 33/63] Preserve explicit superclass call metadata --- jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Convert.kt | 1 + jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Values.kt | 1 + jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Expr.kt | 1 + jacodb-ets/ts-frontend/src/dto/values.ts | 2 ++ jacodb-ets/ts-frontend/src/lowering/exprLowering.ts | 1 + 5 files changed, 6 insertions(+) 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 08e61c520..85b3a810e 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 @@ -530,6 +530,7 @@ class EtsMethodBuilder( callee = method.toEtsMethodSignature(), args = args.map { ensureLocal(it.toEtsEntity()) }, type = type.toEtsType(), + isSuperCall = isSuperCall, wellKnownSymbol = when (wellKnownSymbol) { null -> null "iterator" -> EtsWellKnownSymbol.ITERATOR 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 e5be66c36..f06a603fd 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 isSuperCall: Boolean = false, val wellKnownSymbol: String? = null, ) : CallExprDto 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 b61896d30..b80a1e822 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 @@ -859,6 +859,7 @@ data class EtsInstanceCallExpr( override val args: List, override val type: EtsType, val wellKnownSymbol: EtsWellKnownSymbol? = null, + val isSuperCall: Boolean = false, ) : EtsCallExpr, CommonInstanceCallExpr { override fun toString(): String { return "call ${instance}.${callee.name}(${args.joinToString()})" diff --git a/jacodb-ets/ts-frontend/src/dto/values.ts b/jacodb-ets/ts-frontend/src/dto/values.ts index 098f6deb8..093318793 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[]; + /** Explicit `super.m()` dispatch keeps the declaring superclass independent of the receiver's runtime class. */ + isSuperCall?: boolean; /** Intrinsic key identity, not a property whose string name happens to mention Symbol. */ wellKnownSymbol?: "iterator"; } diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index dc1c458f9..5b1d62bed 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -1098,6 +1098,7 @@ export class ExprLowerer { instance: thisLocal, method: this.methodSignatureForCall(node, callee.name.text, superSignature), args: this.lowerCallArguments(node), + isSuperCall: true, }; } From 9ea5d163bef0d2fb9240429f7ba112ca32fcca70 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 00:08:40 +0300 Subject: [PATCH 34/63] Separate iterator protocol operations into readable blocks --- jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts b/jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts index f6ecd3470..0fee72044 100644 --- a/jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts @@ -45,6 +45,7 @@ export class IteratorLowerer { }); this.requireObject(this.iterator); this.next = this.field(this.iterator, "next", UNKNOWN_TYPE); + this.exhausted = m.newTemp(BOOLEAN_TYPE); m.cfg.emit({ _: "AssignStmt", left: this.exhausted, right: bool(false) }); } @@ -54,11 +55,13 @@ export class IteratorLowerer { const cfg = this.m.cfg; const value = this.m.newTemp(type); cfg.emit({ _: "AssignStmt", left: value, right: { _: "Constant", value: "undefined", type: { _: "UndefinedType" } } }); + const step = cfg.newLabel(); const read = cfg.newLabel(); const done = cfg.newLabel(); cfg.branch(this.isExhausted(), done, step); cfg.placeLabel(step); + const result = this.advance(); cfg.branch(this.isExhausted(), done, read); cfg.placeLabel(read); @@ -77,6 +80,7 @@ export class IteratorLowerer { const step = cfg.newLabel(); const store = cfg.newLabel(); const done = cfg.newLabel(); + cfg.placeLabel(head); cfg.branch(this.isExhausted(), done, step); cfg.placeLabel(step); @@ -96,6 +100,7 @@ export class IteratorLowerer { 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) }); @@ -123,6 +128,7 @@ export class IteratorLowerer { const invoke = cfg.newLabel(); const rethrow = cfg.newLabel(); const continuation = cfg.newLabel(); + cfg.withExceptionTarget(close, action); cfg.goto(continuation); cfg.placeLabel(close); @@ -160,6 +166,7 @@ export class IteratorLowerer { }, }); this.requireObject(result); + const done = this.field(result, "done", UNKNOWN_TYPE); const notDone = this.m.newTemp(BOOLEAN_TYPE); cfg.emit({ _: "AssignStmt", left: notDone, right: { _: "UnopExpr", op: "!", arg: done } }); @@ -171,6 +178,7 @@ export class IteratorLowerer { const cfg = this.m.cfg; const kind = this.m.newTemp(STRING_TYPE); cfg.emit({ _: "AssignStmt", left: kind, right: { _: "TypeOfExpr", arg: value } }); + const object = cfg.newLabel(); const functionCheck = cfg.newLabel(); const valid = cfg.newLabel(); From 2223a29441504a93c6e8699dfbbc5851524b1625 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 00:16:26 +0300 Subject: [PATCH 35/63] Clarify property reference and namespace predicates --- .../ts-frontend/src/lowering/exprLowering.ts | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index 5b1d62bed..3faf35089 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -448,14 +448,18 @@ export class ExprLowerer { : ts.isPropertyAccessExpression(node.expression) ? node.expression.name : undefined; - const receiverIsNamespace = receiverName !== undefined && - this.m.converter.symbolOf(receiverName)?.declarations?.some( - (declaration) => ts.isModuleDeclaration(declaration) || ts.isSourceFile(declaration), - ); + const receiverDeclarations = receiverName === undefined + ? undefined + : this.m.converter.symbolOf(receiverName)?.declarations; + const receiverIsNamespace = receiverDeclarations?.some( + (declaration) => ts.isModuleDeclaration(declaration) || ts.isSourceFile(declaration), + ); if (receiverIsNamespace) { const declaration = this.m.converter.symbolOf(node.name)?.declarations?.find(ts.isClassDeclaration); - if (declaration !== undefined && isProjectFile(declaration) && - (ts.isSourceFile(declaration.parent) || ts.isModuleBlock(declaration.parent))) { + const isRuntimeNamespaceClass = declaration !== undefined + && isProjectFile(declaration) + && (ts.isSourceFile(declaration.parent) || ts.isModuleBlock(declaration.parent)); + if (isRuntimeNamespaceClass) { throw new LoweringError("mutable namespace class property is not represented in EtsIR"); } } @@ -469,9 +473,10 @@ export class ExprLowerer { } const instance = this.lowerToLocal(node.expression); - if (this.m.converter.symbolOf(node.name)?.declarations?.some((declaration) => + const isObjectLiteralAccessor = this.m.converter.symbolOf(node.name)?.declarations?.some((declaration) => ts.isGetAccessorDeclaration(declaration) && ts.isObjectLiteralExpression(declaration.parent), - )) { + ); + if (isObjectLiteralAccessor) { return { _: "PropertyRef", instance, key: constant(fieldName, STRING_TYPE), type: fieldType }; } return { @@ -651,9 +656,10 @@ export class ExprLowerer { ): ValueDto { if (ts.isPropertyAccessExpression(node)) { const fieldType = this.safeTypeOf(node); - if (this.m.converter.symbolOf(node.name)?.declarations?.some((declaration) => + const isObjectLiteralAccessor = this.m.converter.symbolOf(node.name)?.declarations?.some((declaration) => ts.isGetAccessorDeclaration(declaration) && ts.isObjectLiteralExpression(declaration.parent), - )) { + ); + if (isObjectLiteralAccessor) { return { _: "PropertyRef", instance, key: constant(node.name.text, STRING_TYPE), type: fieldType }; } return { @@ -1930,8 +1936,11 @@ export class ExprLowerer { private importedScopeFunctionField(node: ts.PropertyAccessExpression): StaticFieldRefDto | undefined { const receiver = unwrapTransparentExpression(node.expression); - if (!ts.isIdentifier(node.name) || !ts.isIdentifier(receiver) - || !this.m.converter.symbolOf(receiver)?.declarations?.some(ts.isSourceFile)) return undefined; + if (!ts.isIdentifier(node.name) || !ts.isIdentifier(receiver)) return undefined; + + const isSourceFileNamespace = this.m.converter.symbolOf(receiver)?.declarations?.some(ts.isSourceFile); + if (!isSourceFileNamespace) return undefined; + const field = this.m.moduleFieldForIdentifier(node.name); return field?.field.type._ === "FunctionType" ? field : undefined; } From 577c75b4250bb2effd5a4a9f920eacbd5e787eba Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 00:31:07 +0300 Subject: [PATCH 36/63] Snapshot compound local values before lowering the right operand --- .../ts-frontend/src/lowering/exprLowering.ts | 2 +- .../test/assignment-reference.spec.ts | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index 3faf35089..9a1ef9050 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -920,7 +920,7 @@ export class ExprLowerer { if (compoundOp !== undefined) { target = this.snapshotReference(target); const oldValue = target._ === "Local" - ? target + ? this.snapshotImmediateIfReassigned(target, node.left, node.right) : this.materialize(this.propertyReferenceForOperation(target), lvalueType(target)); rhs = { _: "BinopExpr", diff --git a/jacodb-ets/ts-frontend/test/assignment-reference.spec.ts b/jacodb-ets/ts-frontend/test/assignment-reference.spec.ts index 73d5430ac..a60829985 100644 --- a/jacodb-ets/ts-frontend/test/assignment-reference.spec.ts +++ b/jacodb-ets/ts-frontend/test/assignment-reference.spec.ts @@ -44,6 +44,18 @@ const source = [ state.stored = value; return result; }`), + `export function local_rhs_add(state: any): number { + let value = 1; + const result = value += (value = 2); + state.stored = value; + return result; + }`, + `export function local_rhs_multiply(state: any): number { + let value = 2; + const result = value *= (value = 3, 4); + state.stored = value; + return result; + }`, ].join("\n"); let serialized: EtsFileDto; @@ -204,4 +216,20 @@ describe("read-modify-write assignment reference capture", () => { expect(actual).toEqual(expected); expect(actual.stored).toBe(name === "add" ? 4 : 6); }); + + it.each([ + ["local_rhs_add", 3], + ["local_rhs_multiply", 8], + ] as const)("%s reads the old local before the RHS changes its binding", (name, result) => { + const expectedState: { stored?: number } = {}; + const actualState: { stored?: number } = {}; + + const expected = native[name](expectedState); + const actual = executeObjectIr(serialized, name, [actualState]); + + expect(expected).toBe(result); + expect(expectedState.stored).toBe(result); + expect(actual).toBe(expected); + expect(actualState).toEqual(expectedState); + }); }); From 41066e3d97665f4dc91ee597e6f819ddfea93355 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 01:00:00 +0300 Subject: [PATCH 37/63] Mark optional superclass property calls as explicit unsupported lowering --- jacodb-ets/ts-frontend/README.md | 3 + .../ts-frontend/src/lowering/exprLowering.ts | 4 + .../test/optional-callee-wrappers.spec.ts | 98 ++++++++++++++++++- 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/jacodb-ets/ts-frontend/README.md b/jacodb-ets/ts-frontend/README.md index eb36ec7e4..a8d82919c 100644 --- a/jacodb-ets/ts-frontend/README.md +++ b/jacodb-ets/ts-frontend/README.md @@ -80,6 +80,9 @@ represented as `RawStmt` or `RawValue` and reported with `-v`. The frontend records per-statement exceptional-successor edges for `try`/`catch`. Raw constructs remain opaque and are treated conservatively as potentially throwing; EtsIR has no trap table for precise exception semantics. +Optional calls on `super` properties (including computed properties) use this +raw fallback because EtsIR cannot currently preserve superclass-relative +property lookup before the nullish guard and conditional call. ## Development diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index 9a1ef9050..13f5105f5 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -549,6 +549,10 @@ export class ExprLowerer { private lowerOptionalChainCallee(root: ts.CallExpression): OptionalChainValue { const callee = unwrapTransparentExpression(root.expression); if (ts.isPropertyAccessExpression(callee) || ts.isElementAccessExpression(callee)) { + if (callee.expression.kind === ts.SyntaxKind.SuperKeyword) { + throw new LoweringError("optional super property call needs superclass-relative property lookup"); + } + if (ts.isPropertyAccessExpression(callee) && this.importedScopeFunctionField(callee) !== undefined) { throw new LoweringError("module namespace call receiver is not represented in EtsIR"); diff --git a/jacodb-ets/ts-frontend/test/optional-callee-wrappers.spec.ts b/jacodb-ets/ts-frontend/test/optional-callee-wrappers.spec.ts index 5d42e086b..df883a73b 100644 --- a/jacodb-ets/ts-frontend/test/optional-callee-wrappers.spec.ts +++ b/jacodb-ets/ts-frontend/test/optional-callee-wrappers.spec.ts @@ -1,8 +1,9 @@ import { runInNewContext } from "node:vm"; import * as ts from "typescript"; import { describe, expect, it } from "vitest"; +import { StmtDto } from "../src/dto/stmts"; import { executeObjectIr } from "./object-runtime"; -import { lower } from "./util"; +import { lower, methodByName } from "./util"; function executions(source: string): ((...args: unknown[]) => unknown)[] { const { file, diagnostics } = lower(source); @@ -16,6 +17,21 @@ function executions(source: string): ((...args: unknown[]) => unknown)[] { return [native, (...args) => executeObjectIr(serialized, "value", args)]; } +function emitsCall(stmt: StmtDto): boolean { + if (stmt._ === "CallStmt") return true; + if (stmt._ !== "AssignStmt") return false; + + const kind = stmt.right._; + return kind === "StaticCallExpr" || kind === "InstanceCallExpr" || kind === "PtrCallExpr"; +} + +function emitsPropertyRead(stmt: StmtDto): boolean { + if (stmt._ !== "AssignStmt") return false; + + const kind = stmt.right._; + return kind === "InstanceFieldRef" || kind === "PropertyRef" || kind === "ArrayRef"; +} + describe("transparent optional callees", () => { it.each([ "(object.m)?.()", @@ -170,3 +186,83 @@ describe("transparent optional callees", () => { } }); }); + +describe("optional super callees", () => { + it.each([ + { call: "super.m?.()", kindName: "CallExpression", typeKind: "NumberType" }, + { call: "(super.m as any)?.()", kindName: "CallExpression", typeKind: "AnyType" }, + { call: "super['m']?.()", kindName: "CallExpression", typeKind: "NumberType" }, + { + call: "(super[this.key()] as any)?.(this.argument())", + kindName: "CallExpression", + typeKind: "AnyType", + }, + { call: "super.m?.().toString()", kindName: "CallExpression", typeKind: "StringType" }, + ])("uses an explicit fallback before lowering the property or call: $call", ({ call, kindName, typeKind }) => { + const source = ` + class Base { m(): number { return 1; } } + class Derived extends Base { + override m(): number { return 2; } + key(): "m" { return "m"; } + argument(): number { return 0; } + f(): unknown { return ${call}; } + } + `; + + const { file, diagnostics } = lower(source); + const body = methodByName(file, "f").body!; + const stmts = body.cfg.blocks.flatMap((block) => block.stmts); + const rawAssign = stmts.find((stmt): stmt is Extract => + stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue", + ); + const startOffset = source.indexOf(call); + const precedingLines = source.slice(0, startOffset).split("\n"); + const line = precedingLines.length; + const column = precedingLines[precedingLines.length - 1].length + 1; + const rawOrigin = body.stmtOrigins?.find(({ blockId, stmtIndex }) => + body.cfg.blocks[blockId].stmts[stmtIndex] === rawAssign, + ); + + expect(diagnostics.messages).toEqual([ + `test.ts:${line}:${column}: unsupported expression: optional super property call needs superclass-relative property lookup`, + ]); + expect(rawAssign).toBeDefined(); + expect(rawAssign?.right).toEqual({ + _: "UnsupportedValue", + kindName, + text: call, + type: { _: typeKind }, + }); + expect(rawOrigin?.source).toMatchObject({ + startOffset, + endOffset: startOffset + call.length, + nodeKind: kindName, + }); + expect(stmts.some(emitsPropertyRead)).toBe(false); + expect(stmts.some(emitsCall)).toBe(false); + }); + + it("still lowers an ordinary super call before optional invocation of its result", () => { + const source = ` + class Base { m(): () => number { return () => 1; } } + class Derived extends Base { + override m(): () => number { return () => 2; } + f(): number | undefined { + const direct = super.m(); + return super.m()?.(); + } + } + `; + + const { file, diagnostics } = lower(source); + const stmts = methodByName(file, "f").body!.cfg.blocks.flatMap((block) => block.stmts); + const superCalls = stmts.filter((stmt) => stmt._ === "AssignStmt" + && stmt.right._ === "InstanceCallExpr" + && stmt.right.isSuperCall === true); + + expect(diagnostics.messages).toEqual([]); + expect(superCalls).toHaveLength(2); + expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue")) + .toBe(false); + }); +}); From 5124674ca76397373fb599d9b3a343f122198862 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 01:14:56 +0300 Subject: [PATCH 38/63] Fix external corpus preparation lifecycle and failures --- jacodb-ets/src/test/resources/.gitignore | 1 + .../src/test/resources/prepare_projects.sh | 104 ++++++++++++++---- .../src/test/resources/prepare_repos.sh | 19 ++-- 3 files changed, 90 insertions(+), 34 deletions(-) diff --git a/jacodb-ets/src/test/resources/.gitignore b/jacodb-ets/src/test/resources/.gitignore index cc22931a1..188b4b3ca 100644 --- a/jacodb-ets/src/test/resources/.gitignore +++ b/jacodb-ets/src/test/resources/.gitignore @@ -2,4 +2,5 @@ !/samples/ !/samples/source/ /projects +/.project-work /repos diff --git a/jacodb-ets/src/test/resources/prepare_projects.sh b/jacodb-ets/src/test/resources/prepare_projects.sh index 57c52ef82..0ea47a8c4 100644 --- a/jacodb-ets/src/test/resources/prepare_projects.sh +++ b/jacodb-ets/src/test/resources/prepare_projects.sh @@ -1,14 +1,14 @@ -#/bin/bash +#!/bin/bash set -euo pipefail -if [[ -z "${ARKANALYZER_DIR}" ]]; then +if [[ -z "${ARKANALYZER_DIR:-}" ]]; then echo "ARKANALYZER_DIR is undefined" exit 1 fi echo "ARKANALYZER_DIR=${ARKANALYZER_DIR}" -SCRIPT_TS=$ARKANALYZER_DIR/src/save/serializeArkIR.ts -SCRIPT_JS=$ARKANALYZER_DIR/out/src/save/serializeArkIR.js +SCRIPT_TS="$ARKANALYZER_DIR/src/save/serializeArkIR.ts" +SCRIPT_JS="$ARKANALYZER_DIR/out/src/save/serializeArkIR.js" if [[ ! -f $SCRIPT_JS ]]; then echo "Script not found: $SCRIPT_JS" @@ -36,7 +36,7 @@ while getopts ":f" opt; do esac done -cd "$(dirname $0)" +cd "$(dirname "$0")" mkdir -p projects cd projects @@ -51,24 +51,73 @@ function check_repo() { fi } +function finish_project() { + local status=$1 + local backup_dir="" + trap - EXIT + + if [[ $status -ne 0 ]]; then + if ! rm -rf "$STAGING_PROJECT"; then + echo "Could not remove failed staging project: $STAGING_PROJECT" >&2 + fi + exit "$status" + fi + + if ! touch "$STAGING_PROJECT/.prepared"; then + rm -rf "$STAGING_PROJECT" + exit 1 + fi + + if [[ -e $PROJECT_TARGET || -L $PROJECT_TARGET ]]; then + backup_dir=$(mktemp -d "$WORK_DIR/.${PROJECT_NAME}.backup.XXXXXX") || { + rm -rf "$STAGING_PROJECT" + exit 1 + } + if ! mv "$PROJECT_TARGET" "$backup_dir/previous"; then + rm -rf "$STAGING_PROJECT" "$backup_dir" + exit 1 + fi + fi + + if ! mv "$STAGING_PROJECT" "$PROJECT_TARGET"; then + if [[ -n $backup_dir ]]; then + if mv "$backup_dir/previous" "$PROJECT_TARGET"; then + rm -rf "$backup_dir" + else + echo "Could not restore project; previous output remains at: $backup_dir/previous" >&2 + fi + fi + rm -rf "$STAGING_PROJECT" + exit 1 + fi + + if [[ -n $backup_dir ]]; then + rm -rf "$backup_dir" + fi +} + function prepare_project_dir() { if [[ $# -ne 1 ]]; then echo "Usage: prepare_project " exit 1 fi - NAME=$1 + local name=$1 echo - echo "=== Preparing project: $NAME" + echo "=== Preparing project: $name" echo - if [[ -d $NAME ]]; then - echo "Directory already exists: $NAME" - # If `-f` (force mode) is not provided, exit the preparation for this project: - if [[ $do_force -eq 0 ]]; then - exit - fi + if [[ -d $name && -f $name/.prepared && $do_force -eq 0 ]]; then + echo "Project already prepared: $name" + exit 0 fi - mkdir -p $NAME - cd $NAME + + PROJECTS_DIR=$PWD + PROJECT_NAME=$name + PROJECT_TARGET="$PROJECTS_DIR/$name" + WORK_DIR="$(dirname "$PROJECTS_DIR")/.project-work" + mkdir -p "$WORK_DIR" + STAGING_PROJECT=$(mktemp -d "$WORK_DIR/.${name}.staging.XXXXXX") + trap 'finish_project "$?"' EXIT + cd "$STAGING_PROJECT" } function prepare_module() { @@ -76,19 +125,26 @@ function prepare_module() { echo "Usage: prepare_module " exit 1 fi - local MODULE=$1 - local ROOT=$2 - echo "= Preparing module: $MODULE" - local SRC="source/$MODULE" - local ETSIR="etsir/$MODULE" - mkdir -p $(dirname $SRC) + local module=$1 + local root=$2 + local source="source/$module" + local etsir="etsir/$module" + local source_dir + + echo "= Preparing module: $module" + source_dir=$(cd "$root/src/main/ets" && pwd -P) + mkdir -p "$(dirname "$source")" echo "Linking sources..." echo "pwd = $(pwd)" - ln -srfT "$ROOT/src/main/ets" $SRC + ln -s "$source_dir" "$source" echo "Serializing..." # TODO: add switch for using npx/node - # npx ts-node --files --transpileOnly $SCRIPT_TS -p $SRC $ETSIR -v -t 1 - node $SCRIPT_JS -p $SRC $ETSIR -v -t 1 + # npx ts-node --files --transpileOnly "$SCRIPT_TS" -p "$source" "$etsir" -v -t 1 + node "$SCRIPT_JS" -p "$source" "$etsir" -v -t 1 + if [[ ! -d $etsir ]]; then + echo "Serializer did not create output: $etsir" >&2 + return 1 + fi } ( diff --git a/jacodb-ets/src/test/resources/prepare_repos.sh b/jacodb-ets/src/test/resources/prepare_repos.sh index f8db30f07..e55ab62e7 100644 --- a/jacodb-ets/src/test/resources/prepare_repos.sh +++ b/jacodb-ets/src/test/resources/prepare_repos.sh @@ -1,7 +1,7 @@ #!/bin/bash set -euo pipefail -cd "$(dirname $0)" +cd "$(dirname "$0")" mkdir -p repos cd repos @@ -10,15 +10,16 @@ function prepare_repo() { echo "Usage: prepare_repo " exit 1 fi - REPO=$1 - DIR=$(basename -s .git $(git ls-remote --get-url $REPO)) - echo "Preparing repository: $REPO" - if [[ ! -d $DIR ]]; then + local repo=$1 + local dir + dir=$(basename -s .git "$(git ls-remote --get-url "$repo")") + echo "Preparing repository: $repo" + if [[ ! -d $dir ]]; then echo "Cloning..." - git clone $REPO + git clone "$repo" else - echo "Directory '$DIR' already exists. Pulling latest changes..." - git -C $DIR pull & + echo "Directory '$dir' already exists. Pulling latest changes..." + git -C "$dir" pull fi } @@ -36,5 +37,3 @@ prepare_repo https://gitcode.com/openharmony/applications_screenlock prepare_repo https://gitcode.com/openharmony/applications_settings prepare_repo https://gitcode.com/openharmony/applications_settings_data prepare_repo https://gitcode.com/openharmony/applications_systemui - -wait From b633b1db3c6eeff286a2fac61e44ceb02069f5a0 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 01:25:57 +0300 Subject: [PATCH 39/63] Reject unsupported direct superclass getter calls --- jacodb-ets/ts-frontend/README.md | 3 ++ .../ts-frontend/src/lowering/exprLowering.ts | 21 +++++++-- .../test/optional-callee-wrappers.spec.ts | 46 +++++++++++++++++++ 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/jacodb-ets/ts-frontend/README.md b/jacodb-ets/ts-frontend/README.md index a8d82919c..d34ccffef 100644 --- a/jacodb-ets/ts-frontend/README.md +++ b/jacodb-ets/ts-frontend/README.md @@ -83,6 +83,9 @@ throwing; EtsIR has no trap table for precise exception semantics. Optional calls on `super` properties (including computed properties) use this raw fallback because EtsIR cannot currently preserve superclass-relative property lookup before the nullish guard and conditional call. +Direct named calls such as `super.m(...)` through a superclass getter that +returns a function use the same fallback; their property lookup must precede +argument evaluation and invocation. ## Development diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index 13f5105f5..20f773631 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -1101,6 +1101,11 @@ export class ExprLowerer { // `super.m(...)` — instance call on `this` with the superclass as declaring class. if (ts.isPropertyAccessExpression(callee) && callee.expression.kind === ts.SyntaxKind.SuperKeyword) { + const isAccessorCall = this.m.converter.symbolOf(callee.name)?.declarations?.some(ts.isGetAccessorDeclaration); + if (isAccessorCall) { + throw new LoweringError("super getter call needs superclass-relative property lookup"); + } + const thisLocal = this.m.getOrCreateLocal("this", this.m.thisType()); const superSignature = this.classSignatureFromType(this.safeTypeOf(callee.expression)); return { @@ -1235,16 +1240,22 @@ export class ExprLowerer { private fixedSpreadElementTypes(expression: ts.Expression): TypeDto[] | undefined { const value = unwrapTransparentExpression(expression); - if (ts.isArrayLiteralExpression(value) && value.elements.every((element) => - !ts.isSpreadElement(element) && !ts.isOmittedExpression(element))) { - return value.elements.map((element) => this.safeTypeOf(element)); + if (ts.isArrayLiteralExpression(value)) { + const hasFixedElements = value.elements.every((element) => + !ts.isSpreadElement(element) && !ts.isOmittedExpression(element)); + if (hasFixedElements) { + return value.elements.map((element) => this.safeTypeOf(element)); + } } const type = this.m.checker.getTypeAtLocation(expression); - if (!this.m.checker.isTupleType(type)) return undefined; + if (!this.m.checker.isTupleType(type)) { + return undefined; + } const tuple = (type as ts.TupleTypeReference).target; - return tuple.elementFlags.every((flag) => flag === ts.ElementFlags.Required) + const hasOnlyRequiredElements = tuple.elementFlags.every((flag) => flag === ts.ElementFlags.Required); + return hasOnlyRequiredElements ? this.m.checker.getTypeArguments(type as ts.TupleTypeReference) .map((elementType) => this.m.converter.convertType(elementType)) : undefined; diff --git a/jacodb-ets/ts-frontend/test/optional-callee-wrappers.spec.ts b/jacodb-ets/ts-frontend/test/optional-callee-wrappers.spec.ts index df883a73b..7379984ce 100644 --- a/jacodb-ets/ts-frontend/test/optional-callee-wrappers.spec.ts +++ b/jacodb-ets/ts-frontend/test/optional-callee-wrappers.spec.ts @@ -266,3 +266,49 @@ describe("optional super callees", () => { .toBe(false); }); }); + +describe("direct super getter calls", () => { + it("falls back before reading the getter or evaluating call arguments", () => { + const call = "super.m(this.argument())"; + const source = ` + class Base { + get m(): (value: number) => number { return (value) => value + 1; } + } + class Derived extends Base { + argument(): number { return 2; } + f(): number { return ${call}; } + } + `; + + const { file, diagnostics } = lower(source); + const body = methodByName(file, "f").body!; + const stmts = body.cfg.blocks.flatMap((block) => block.stmts); + const rawAssign = stmts.find((stmt): stmt is Extract => + stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue", + ); + const startOffset = source.indexOf(call); + const precedingLines = source.slice(0, startOffset).split("\n"); + const line = precedingLines.length; + const column = precedingLines[precedingLines.length - 1].length + 1; + const rawOrigin = body.stmtOrigins?.find(({ blockId, stmtIndex }) => + body.cfg.blocks[blockId].stmts[stmtIndex] === rawAssign, + ); + + expect(diagnostics.messages).toEqual([ + `test.ts:${line}:${column}: unsupported expression: super getter call needs superclass-relative property lookup`, + ]); + expect(rawAssign?.right).toEqual({ + _: "UnsupportedValue", + kindName: "CallExpression", + text: call, + type: { _: "NumberType" }, + }); + expect(rawOrigin?.source).toMatchObject({ + startOffset, + endOffset: startOffset + call.length, + nodeKind: "CallExpression", + }); + expect(stmts.some(emitsPropertyRead)).toBe(false); + expect(stmts.some(emitsCall)).toBe(false); + }); +}); From b92df43c53a67e409a79599f79739551489017b7 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 01:30:09 +0300 Subject: [PATCH 40/63] Clarify class expression guards and remove unused test helper --- .../ts-frontend/src/lowering/exprLowering.ts | 5 ++-- .../ts-frontend/test/computed-new.spec.ts | 23 ------------------- 2 files changed, 3 insertions(+), 25 deletions(-) diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index 20f773631..79d8b256e 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -1630,10 +1630,11 @@ export class ExprLowerer { throw new LoweringError("class expression heritage is not represented in EtsIR"); } - if (node.name !== undefined || node.members.some((member) => + const hasUnrepresentedMember = node.members.some((member) => ts.isClassStaticBlockDeclaration(member) || (ts.getCombinedModifierFlags(member) & ts.ModifierFlags.Static) !== 0 - || (member.name !== undefined && ts.isComputedPropertyName(member.name)))) { + || (member.name !== undefined && ts.isComputedPropertyName(member.name))); + if (node.name !== undefined || hasUnrepresentedMember) { throw new LoweringError("named, static, or computed class expression members are not represented in EtsIR"); } diff --git a/jacodb-ets/ts-frontend/test/computed-new.spec.ts b/jacodb-ets/ts-frontend/test/computed-new.spec.ts index bbf9dfd8a..555717ddf 100644 --- a/jacodb-ets/ts-frontend/test/computed-new.spec.ts +++ b/jacodb-ets/ts-frontend/test/computed-new.spec.ts @@ -1,31 +1,8 @@ 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 }, From 7d66db583e1e96f57f371a73e0c47f9731a56bef Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 01:35:24 +0300 Subject: [PATCH 41/63] Reject unsupported superclass property reads --- jacodb-ets/ts-frontend/README.md | 7 +++ .../ts-frontend/src/lowering/exprLowering.ts | 24 ++++++++ .../test/optional-callee-wrappers.spec.ts | 56 +++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/jacodb-ets/ts-frontend/README.md b/jacodb-ets/ts-frontend/README.md index d34ccffef..b9191b044 100644 --- a/jacodb-ets/ts-frontend/README.md +++ b/jacodb-ets/ts-frontend/README.md @@ -86,6 +86,13 @@ property lookup before the nullish guard and conditional call. Direct named calls such as `super.m(...)` through a superclass getter that returns a function use the same fallback; their property lookup must precede argument evaluation and invocation. +Nonoptional `super` property reads, including `super.x` and `super["x"]`, +also use this fallback because ordinary field references cannot select the +superclass getter. Computed calls such as `super["m"](...)` require that same +property read and use the fallback before key or argument evaluation. Ordinary +named superclass method calls such as `super.m(...)` retain their direct call +representation. +Assignments to named or computed `super` properties are also explicitly unsupported. ## Development diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index 79d8b256e..7f7d27e7b 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -417,6 +417,10 @@ export class ExprLowerer { // ------------------------------------------------------------------ private lowerPropertyAccess(node: ts.PropertyAccessExpression): ValueDto { + if (node.expression.kind === ts.SyntaxKind.SuperKeyword) { + throw new LoweringError("super property read needs superclass-relative property lookup"); + } + const fieldName = node.name.text; const fieldType = this.safeTypeOf(node); @@ -498,6 +502,10 @@ export class ExprLowerer { } private lowerElementAccess(node: ts.ElementAccessExpression): ValueDto { + if (node.expression.kind === ts.SyntaxKind.SuperKeyword) { + throw new LoweringError("super property read needs superclass-relative property lookup"); + } + const chain = optionalChain(node); if (chain !== undefined) { return this.lowerOptionalChain(node, chain); @@ -658,6 +666,10 @@ export class ExprLowerer { node: ts.PropertyAccessExpression | ts.ElementAccessExpression, instance: LocalDto, ): ValueDto { + if (node.expression.kind === ts.SyntaxKind.SuperKeyword) { + throw new LoweringError("super property read needs superclass-relative property lookup"); + } + if (ts.isPropertyAccessExpression(node)) { const fieldType = this.safeTypeOf(node); const isObjectLiteralAccessor = this.m.converter.symbolOf(node.name)?.declarations?.some((declaration) => @@ -713,6 +725,10 @@ export class ExprLowerer { return this.m.localForIdentifier(node, this.safeTypeOf(node)); } if (ts.isPropertyAccessExpression(node)) { + if (node.expression.kind === ts.SyntaxKind.SuperKeyword) { + throw new LoweringError("super property assignment needs superclass-relative property lookup"); + } + if (this.importedScopeFunctionField(node) !== undefined) { throw new LoweringError("module namespace function properties are read-only"); } @@ -730,6 +746,10 @@ export class ExprLowerer { throw new LoweringError("property access did not produce a field ref"); } if (ts.isElementAccessExpression(node)) { + if (node.expression.kind === ts.SyntaxKind.SuperKeyword) { + throw new LoweringError("super property assignment needs superclass-relative property lookup"); + } + const receiverType = this.m.checker.getTypeAtLocation(node.expression); if (!this.m.checker.isArrayType(receiverType) && !this.m.checker.isTupleType(receiverType)) { const instance = this.snapshotToLocal(node.expression); @@ -1172,6 +1192,10 @@ export class ExprLowerer { } if (ts.isElementAccessExpression(callee)) { + if (callee.expression.kind === ts.SyntaxKind.SuperKeyword) { + throw new LoweringError("super property read needs superclass-relative property lookup"); + } + const receiver = this.snapshotToLocal(callee.expression); const ptr = this.materialize({ _: "PropertyRef", diff --git a/jacodb-ets/ts-frontend/test/optional-callee-wrappers.spec.ts b/jacodb-ets/ts-frontend/test/optional-callee-wrappers.spec.ts index 7379984ce..ea25e0ec3 100644 --- a/jacodb-ets/ts-frontend/test/optional-callee-wrappers.spec.ts +++ b/jacodb-ets/ts-frontend/test/optional-callee-wrappers.spec.ts @@ -312,3 +312,59 @@ describe("direct super getter calls", () => { expect(stmts.some(emitsCall)).toBe(false); }); }); + +describe("nonoptional super property reads", () => { + it.each([ + { expression: "super.x", kindName: "PropertyAccessExpression" }, + { expression: "super['x']", kindName: "ElementAccessExpression" }, + { expression: "super[this.key()]", kindName: "ElementAccessExpression" }, + { expression: "super['m'](this.argument())", kindName: "CallExpression" }, + { expression: "super[this.methodKey()](this.argument())", kindName: "CallExpression" }, + ])("falls back before a superclass-relative read or computed call: $expression", ({ expression, kindName }) => { + const source = ` + class Base { + get x(): number { return 1; } + m(value: number): number { return value + 1; } + } + class Derived extends Base { + override get x(): number { return 2; } + override m(value: number): number { return value + 2; } + key(): "x" { return "x"; } + methodKey(): "m" { return "m"; } + argument(): number { return 5; } + f(): number { return ${expression}; } + } + `; + + const { file, diagnostics } = lower(source); + const body = methodByName(file, "f").body!; + const stmts = body.cfg.blocks.flatMap((block) => block.stmts); + const rawAssign = stmts.find((stmt): stmt is Extract => + stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue", + ); + const startOffset = source.indexOf(expression); + const precedingLines = source.slice(0, startOffset).split("\n"); + const line = precedingLines.length; + const column = precedingLines[precedingLines.length - 1].length + 1; + const rawOrigin = body.stmtOrigins?.find(({ blockId, stmtIndex }) => + body.cfg.blocks[blockId].stmts[stmtIndex] === rawAssign, + ); + + expect(diagnostics.messages).toEqual([ + `test.ts:${line}:${column}: unsupported expression: super property read needs superclass-relative property lookup`, + ]); + expect(rawAssign?.right).toEqual({ + _: "UnsupportedValue", + kindName, + text: expression, + type: { _: "NumberType" }, + }); + expect(rawOrigin?.source).toMatchObject({ + startOffset, + endOffset: startOffset + expression.length, + nodeKind: kindName, + }); + expect(stmts.some(emitsPropertyRead)).toBe(false); + expect(stmts.some(emitsCall)).toBe(false); + }); +}); From 24542b72d382210b60ccbc7484e481836ac067f8 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 01:39:38 +0300 Subject: [PATCH 42/63] Remove unreachable array spread fallback --- jacodb-ets/ts-frontend/src/lowering/exprLowering.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index 7f7d27e7b..ca339220a 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -1448,7 +1448,7 @@ export class ExprLowerer { node.elements.forEach((element, index) => { if (ts.isOmittedExpression(element)) return; - const value = ts.isSpreadElement(element) ? this.spreadFallback(element) : this.lowerToImmediate(element); + const value = this.lowerToImmediate(element); this.m.cfg.emit({ _: "AssignStmt", left: { @@ -2117,15 +2117,6 @@ export class ExprLowerer { } return null; } - - private spreadFallback(node: ts.SpreadElement): ValueDto { - this.unsupportedExpressionCount++; - this.m.diagnostics.warn(node, "spread arguments are not supported yet"); - // Hoisted for the same reason as in lowerExpr: raw values are only - // legal as the RHS of a Local assignment. - const type = this.safeTypeOf(node.expression); - return this.materialize(unsupportedValue(node, type), type); - } } function preserveDeclaredNullishReturn(resolved: TypeDto, declared: TypeDto): TypeDto { From 2c9062d33ae75d626def32c7e36906255cccca9c Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 02:17:49 +0300 Subject: [PATCH 43/63] Reject lifted functions with unrepresented recursive bindings --- jacodb-ets/ts-frontend/README.md | 5 + .../ts-frontend/src/lowering/exprLowering.ts | 48 +++++++- .../ts-frontend/test/regressions.spec.ts | 109 +++++++++++++++++- 3 files changed, 157 insertions(+), 5 deletions(-) diff --git a/jacodb-ets/ts-frontend/README.md b/jacodb-ets/ts-frontend/README.md index b9191b044..3bc21aefd 100644 --- a/jacodb-ets/ts-frontend/README.md +++ b/jacodb-ets/ts-frontend/README.md @@ -93,6 +93,11 @@ property read and use the fallback before key or argument evaluation. Ordinary named superclass method calls such as `super.m(...)` retain their direct call representation. Assignments to named or computed `super` properties are also explicitly unsupported. +Nested function declarations and named function expressions that refer to their +own name at runtime use the raw fallback. Their recursive binding cannot be +represented by the current lifted-closure environment; type-only references +and shadowed names do not trigger this fallback. Top-level named functions +retain their existing recursion representation. ## Development diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index ca339220a..eac610e9d 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -1718,6 +1718,13 @@ export class ExprLowerer { if (node.body === undefined) { throw new LoweringError("function declaration without a body"); } + if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) { + const referencesOwnName = node.name !== undefined && referencesOwnFunctionName(node, this.m.checker); + if (referencesOwnName) { + throw new LoweringError("recursive lifted function binding is not represented in EtsIR"); + } + } + const registry = this.m.ctx.anonymous; const name = `${ANONYMOUS_METHOD_PREFIX}${registry.nextMethodId++}$${this.m.methodName}`; const declaringClass = this.m.declaringClass; @@ -1726,7 +1733,10 @@ export class ExprLowerer { const returnType = returnTypeOf(this.m.ctx, node); const baseSignature: MethodSignatureDto = { declaringClass, name, parameters, returnType }; const capturedIdentifiers = collectCapturedIdentifiers(node, this.m.checker); - if (capturedIdentifiers.some((identifier) => this.m.isUninitializedParameter(identifier))) { + const capturesUninitializedParameter = capturedIdentifiers.some((identifier) => + this.m.isUninitializedParameter(identifier), + ); + if (capturesUninitializedParameter) { throw new LoweringError("closure capturing an uninitialized parameter requires a runtime temporal-dead-zone cell"); } const captures = capturedIdentifiers @@ -2396,6 +2406,42 @@ function collectCapturedIdentifiers( return [...captures.values()]; } +/** A lifted function cannot read its own binding from the enclosing method's locals. */ +function referencesOwnFunctionName( + node: ts.FunctionDeclaration | ts.FunctionExpression, + checker: ts.TypeChecker, +): boolean { + const body = node.body; + if (node.name === undefined || body === undefined) return false; + + const ownSymbol = checker.getSymbolAtLocation(node.name); + if (ownSymbol === undefined) return false; + + let found = false; + const visit = (current: ts.Node): void => { + if (found || ts.isTypeNode(current)) return; + + if (ts.isIdentifier(current) && current !== node.name) { + const isPropertyName = ts.isPropertyAccessExpression(current.parent) && current.parent.name === current; + if (!isPropertyName) { + const symbol = ts.isShorthandPropertyAssignment(current.parent) && current.parent.name === current + ? checker.getShorthandAssignmentValueSymbol(current.parent) ?? checker.getSymbolAtLocation(current) + : checker.getSymbolAtLocation(current); + if (symbol === ownSymbol) { + found = true; + return; + } + } + } + + ts.forEachChild(current, visit); + }; + + for (const parameter of node.parameters) visit(parameter); + visit(body); + return found; +} + function isCapturableDeclaration(node: ts.Declaration): boolean { return ts.isVariableDeclaration(node) || ts.isParameter(node) diff --git a/jacodb-ets/ts-frontend/test/regressions.spec.ts b/jacodb-ets/ts-frontend/test/regressions.spec.ts index 05a91d643..aebd94211 100644 --- a/jacodb-ets/ts-frontend/test/regressions.spec.ts +++ b/jacodb-ets/ts-frontend/test/regressions.spec.ts @@ -452,8 +452,8 @@ describe("function hoisting", () => { expect(seedAssign).toBeLessThan(closureCreate); }); - it("does not make a recursive nested function capture itself", () => { - const { file } = lower(` + it("marks a recursive nested declaration unsupported before emitting a callable", () => { + const { file, diagnostics } = lower(` function run(n: number): number { return fact(n); function fact(k: number): number { @@ -461,7 +461,108 @@ describe("function hoisting", () => { } } `); - const closure = methodByName(file, "%AM0$run"); - expect(closure.signature.parameters.some((p) => p.type._ === "LexicalEnvType")).toBe(false); + const run = methodByName(file, "run"); + const fallback = allStmts(run).find((stmt) => stmt._ === "UnsupportedStmt"); + + expect(fallback).toMatchObject({ _: "UnsupportedStmt", kindName: "FunctionDeclaration" }); + expect(diagnostics.messages).toContainEqual(expect.stringContaining( + "unsupported statement: recursive lifted function binding is not represented in EtsIR", + )); + expect(file.classes.flatMap((clazz) => clazz.methods).some((method) => method.signature.name.startsWith("%AM"))) + .toBe(false); + }); + + it("rejects a nested declaration whose default reads its own mutable binding", () => { + const { file, diagnostics } = lower(` + function run(): number { + function fact(value: number = fact(1)): number { return value; } + return fact(); + } + `); + + expect(allStmts(methodByName(file, "run"))).toContainEqual(expect.objectContaining({ + _: "UnsupportedStmt", kindName: "FunctionDeclaration", + })); + expect(diagnostics.messages).toContainEqual(expect.stringContaining("recursive lifted function binding")); + }); + + it("marks a recursive named expression unsupported even when its outer alias is rebound", () => { + const source = ` + function make() { + let alias = function self(n: number): number { return n ? self(n - 1) : 0; }; + const original = alias; + alias = undefined as any; + return original; + } + `; + const { file, diagnostics } = lower(source); + const body = methodByName(file, "make").body!; + const assignments = allStmts(methodByName(file, "make")).filter( + (stmt): stmt is AssignStmtDto => stmt._ === "AssignStmt", + ); + const fallback = assignments.find((stmt) => stmt.right._ === "UnsupportedValue"); + const fallbackOrigin = body.stmtOrigins?.find(({ blockId, stmtIndex }) => + body.cfg.blocks[blockId].stmts[stmtIndex] === fallback, + ); + + expect(fallback?.right).toMatchObject({ + _: "UnsupportedValue", kindName: "FunctionExpression", type: { _: "FunctionType" }, + }); + expect(fallbackOrigin?.source.nodeKind).toBe("FunctionExpression"); + expect(source.slice(fallbackOrigin!.source.startOffset, fallbackOrigin!.source.endOffset)) + .toContain("function self(n: number)"); + expect(diagnostics.messages).toContainEqual(expect.stringContaining( + "unsupported expression: recursive lifted function binding is not represented in EtsIR", + )); + expect(file.classes.flatMap((clazz) => clazz.methods).some((method) => method.signature.name.startsWith("%AM"))) + .toBe(false); + }); + + it("rejects a named expression whose parameter default captures its private name", () => { + const { file, diagnostics } = lower(` + function make() { + return function self(next = () => self(1)) { return next(); }; + } + `); + + expect(allStmts(methodByName(file, "make"))).toContainEqual(expect.objectContaining({ + right: expect.objectContaining({ _: "UnsupportedValue", kindName: "FunctionExpression" }), + })); + expect(diagnostics.messages).toContainEqual(expect.stringContaining("recursive lifted function binding")); + }); + + it("rejects repeated creation of a recursive named expression in a factory", () => { + const { file, diagnostics } = lower(` + function make() { + return function self(n: number): number { return n ? self(n - 1) : 0; }; + } + const first = make(); + const second = make(); + `); + + expect(allStmts(methodByName(file, "make"))).toContainEqual(expect.objectContaining({ + right: expect.objectContaining({ _: "UnsupportedValue", kindName: "FunctionExpression" }), + })); + expect(diagnostics.messages.filter((message) => message.includes("recursive lifted function binding"))) + .toHaveLength(1); + }); + + it("keeps shadowed spelling and type-only self references on the supported path", () => { + const { file, diagnostics } = lower(` + function run(): number { + function fact(value: number): number { + type Own = typeof fact; + const next = (fact: number): number => fact + 1; + return next(value); + } + return fact(2); + } + const named = function self(value: typeof self): typeof self { return value; }; + `); + + expect(methodByName(file, "%AM0$run")).toBeDefined(); + expect(file.classes.flatMap((clazz) => clazz.methods).some((method) => method.signature.name.startsWith("%AM"))) + .toBe(true); + expect(diagnostics.messages.some((message) => message.includes("recursive lifted function binding"))).toBe(false); }); }); From 405b06d090ffa5e4b65e620a220bf56679c15ff5 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 02:30:04 +0300 Subject: [PATCH 44/63] Reject object methods without callable own-property semantics --- .../org/jacodb/ets/test/EtsTsFrontendTest.kt | 20 ++++++- jacodb-ets/ts-frontend/README.md | 5 ++ .../ts-frontend/src/lowering/exprLowering.ts | 36 +++-------- jacodb-ets/ts-frontend/test/closures.spec.ts | 5 +- .../ts-frontend/test/export-default.spec.ts | 12 +++- .../ts-frontend/test/object-accessors.spec.ts | 59 +++++++++++++++++++ .../ts-frontend/test/strictMode.spec.ts | 13 +++- 7 files changed, 110 insertions(+), 40 deletions(-) diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsFrontendTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsFrontendTest.kt index 759bdd769..aa65ffaa7 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsFrontendTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsFrontendTest.kt @@ -585,6 +585,20 @@ class EtsTsFrontendTest { assertTrue(stmts.any { it is RawStmtDto && it.kind == "UnsupportedStmt" }) } + @Test + fun `named object method is unsupported before allocation through JSON conversion`() { + val dto = runFrontend("export function value() { return { x: 1, method() { return this.x; } }; }") + val serialized = Json { serializersModule = dtoModule }.encodeToString(dto) + val roundTripped = EtsFileDto.loadFromJson(serialized) + val stmts = roundTripped.classes.single { it.signature.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.signature.name == "value" }.body!!.cfg.blocks.flatMap { it.stmts } + + assertTrue(stmts.filterIsInstance().any { it.right is RawValueDto }) + assertTrue(stmts.filterIsInstance().none { it.right is NewExprDto }) + assertTrue(stmts.none { it is DefineDataPropertyStmtDto }) + assertTrue(roundTripped.classes.none { it.signature.name.startsWith("%AC") }) + } + @Test fun `nested ordinary function this does not suppress default export in JSON`() { val dto = runFrontend( @@ -1377,7 +1391,7 @@ class EtsTsFrontendTest { fun `closures, destructuring and object literals convert to the model`() { val etsFileDto = runFrontend( """ - const config = { host: "localhost", port: 8080, describe(): string { return this.host; } }; + const config = { host: "localhost", port: 8080 }; const { host, port: p = 80 } = config; const handlers = [1, 2, 3].map((x: number) => x * 2); @@ -1426,10 +1440,10 @@ class EtsTsFrontendTest { "captured locals must be writable through the lexical environment", ) - // Object literal became an anonymous class with fields and a method. + // Object literal became an anonymous class with data fields. val anonymousClass = scene.projectClasses.single { it.name.startsWith("%AC0") } assertTrue(anonymousClass.fields.any { it.name == "host" }) - assertTrue(anonymousClass.methods.any { it.name == "describe" }) + assertTrue(anonymousClass.methods.isEmpty()) // Destructured module bindings use the default class's shared storage. assertTrue(defaultClass.fields.any { it.name == "host" }) diff --git a/jacodb-ets/ts-frontend/README.md b/jacodb-ets/ts-frontend/README.md index 3bc21aefd..b1c7155e5 100644 --- a/jacodb-ets/ts-frontend/README.md +++ b/jacodb-ets/ts-frontend/README.md @@ -98,6 +98,11 @@ own name at runtime use the raw fallback. Their recursive binding cannot be represented by the current lifted-closure environment; type-only references and shadowed names do not trigger this fallback. Top-level named functions retain their existing recursion representation. +Object literal method declarations use the raw fallback, including named and +computed methods. Anonymous-class method metadata alone cannot install a fresh +callable own property at each object evaluation in source order. Data properties +containing function values and getter descriptors keep their existing lowering +paths. ## Development diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index eac610e9d..1888ae4fd 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -1806,13 +1806,17 @@ export class ExprLowerer { /** * Object literal -> anonymous `%AC$` class (category OBJECT) plus - * `new` + per-property stores at the use site. Literal methods become methods - * of the anonymous class. + * `new` + per-property stores at the use site. */ private lowerObjectLiteral(node: ts.ObjectLiteralExpression): ValueDto { if (this.lowerFunctionBody === undefined) { throw new LoweringError("object literal in a context without a body lowerer"); } + const hasUnmaterializedMethod = node.properties.some(ts.isMethodDeclaration); + if (hasUnmaterializedMethod) { + throw new LoweringError("object literal method needs a per-evaluation callable own property"); + } + const registry = this.m.ctx.anonymous; const name = `${ANONYMOUS_CLASS_PREFIX}${registry.nextClassId++}$${this.m.methodName}`; const signature: ClassSignatureDto = { @@ -1822,7 +1826,6 @@ export class ExprLowerer { const classType: ClassTypeDto = { _: "ClassType", signature }; const fields: FieldDto[] = []; - const methods: MethodDto[] = []; const temp = this.m.newTemp(classType); this.m.cfg.emit({ _: "AssignStmt", left: temp, right: { _: "NewExpr", classType } }); @@ -1867,31 +1870,6 @@ export class ExprLowerer { : constant(memberName(property.name), STRING_TYPE); const getter = this.lowerClosure(property); this.m.cfg.emit({ _: "DefineAccessorStmt", target: temp, key, getter }); - } else if (ts.isMethodDeclaration(property)) { - if (ts.isComputedPropertyName(property.name)) { - throw new LoweringError("computed object method name is not represented in EtsIR"); - } - const methodName = memberName(property.name); - const { parameters, prologueParams } = buildParameters(this.m.ctx, property); - const methodSignature: MethodSignatureDto = { - declaringClass: signature, - name: methodName, - parameters, - returnType: returnTypeOf(this.m.ctx, property), - }; - const methodContext = new MethodContext(this.m.ctx, signature, methodName); - if (property.body !== undefined) { - this.lowerFunctionBody(methodContext, property.body, (afterParameter) => - methodContext.emitPrologue(prologueParams, afterParameter), - ); - } - methods.push({ - signature: methodSignature, - modifiers: modifiersOf(property), - decorators: [], - isStrictMode: isStrictMode(property), - body: methodContext.build(), - }); } else { // Setters remain separately unsupported. throw new LoweringError(`object literal member: ${syntaxKindName(property.kind)}`); @@ -1906,7 +1884,7 @@ export class ExprLowerer { superClassName: "", implementedInterfaceNames: [], fields, - methods, + methods: [], }); return temp; diff --git a/jacodb-ets/ts-frontend/test/closures.spec.ts b/jacodb-ets/ts-frontend/test/closures.spec.ts index 5044d4716..4e43c8a7b 100644 --- a/jacodb-ets/ts-frontend/test/closures.spec.ts +++ b/jacodb-ets/ts-frontend/test/closures.spec.ts @@ -268,14 +268,13 @@ describe("object literals", () => { it("creates %AC classes with fields and per-property stores", () => { const { file } = lower(` let radius = 3; - const shape = { kind: "circle", radius, describe(): string { return this.kind; } }; + const shape = { kind: "circle", radius }; `); const anonClass = file.classes.find((c) => c.signature.name.startsWith("%AC0")); expect(anonClass).toBeDefined(); expect(anonClass!.category).toBe(5); expect(anonClass!.fields.map((f) => f.signature.name)).toEqual(["kind", "radius"]); - expect(anonClass!.methods.map((m) => m.signature.name)).toEqual(["describe"]); - expect(anonClass!.methods[0].body).toBeDefined(); + expect(anonClass!.methods).toEqual([]); const stmts = singleBlockStmts(defaultMethod(file)); const newExpr = stmts.find( diff --git a/jacodb-ets/ts-frontend/test/export-default.spec.ts b/jacodb-ets/ts-frontend/test/export-default.spec.ts index 8024ae851..e47eb408b 100644 --- a/jacodb-ets/ts-frontend/test/export-default.spec.ts +++ b/jacodb-ets/ts-frontend/test/export-default.spec.ts @@ -68,14 +68,22 @@ describe("export default expressions", () => { expect(oracle).toBe("true"); }); - it("keeps this inside an object method bound to its ordinary receiver", () => { + it("marks an object-method default export unsupported while preserving export metadata", () => { const source = `export default ({ read() { return this; } });`; - const { file, stmts } = moduleStatements(source); + const { file, diagnostics } = lower(source); + const stmts = defaultMethod(file).body!.cfg.blocks.flatMap((block) => block.stmts); expect(stmts).toContainEqual(expect.objectContaining({ _: "AssignStmt", left: expect.objectContaining({ _: "StaticFieldRef", field: expect.objectContaining({ name: "default" }) }), })); + expect(stmts).toContainEqual(expect.objectContaining({ + _: "AssignStmt", + right: expect.objectContaining({ _: "UnsupportedValue", kindName: "ObjectLiteralExpression" }), + })); + expect(diagnostics.messages).toEqual(expect.arrayContaining([ + expect.stringContaining("object literal method needs a per-evaluation callable own property"), + ])); expect(file.exportInfos).toContainEqual(expect.objectContaining({ exportName: "default" })); const moduleUrl = `data:text/javascript,${encodeURIComponent(source)}`; diff --git a/jacodb-ets/ts-frontend/test/object-accessors.spec.ts b/jacodb-ets/ts-frontend/test/object-accessors.spec.ts index 1c5841391..21a1464e6 100644 --- a/jacodb-ets/ts-frontend/test/object-accessors.spec.ts +++ b/jacodb-ets/ts-frontend/test/object-accessors.spec.ts @@ -100,3 +100,62 @@ describe("object getter descriptors", () => { expect(diagnostics.messages.some((message) => message.includes("SetAccessor"))).toBe(true); }); }); + +describe("object method materialization boundary", () => { + it.each([ + ["direct and detached receiver", `export function run() { + const object = { method() { return this; } }; + const detached = object.method; + return [object.method(), detached()]; + }`], + ["captured local", `export function run(seed: number) { + return { method(value = seed) { return value + seed; } }; + }`], + ["fresh identity", `export function run() { + const make = () => ({ method() { return 1; } }); + return make().method === make().method; + }`], + ["method after data property", `export function run() { + return { method: 1, method() { return 2; } }; + }`], + ["data property after method", `export function run() { + return { method() { return 1; }, method: 2 }; + }`], + ["method after spread", `export function run(source: object) { + return { ...source, method() { return 1; } }; + }`], + ["spread after method", `export function run(source: object) { + return { method() { return 1; }, ...source }; + }`], + ])("rejects %s before object allocation or member effects", (_scenario, source) => { + const { file, diagnostics } = lower(source); + const stmts = file.classes.flatMap((clazz) => clazz.methods) + .flatMap((method) => method.body?.cfg.blocks.flatMap((block) => block.stmts) ?? []); + + expect(stmts).toContainEqual(expect.objectContaining({ + _: "AssignStmt", + right: expect.objectContaining({ _: "UnsupportedValue", kindName: "ObjectLiteralExpression" }), + })); + expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewExpr")).toBe(false); + expect(stmts.some((stmt) => stmt._ === "DefineDataPropertyStmt" || stmt._ === "CopyDataPropertiesStmt")).toBe(false); + expect(diagnostics.messages).toEqual(expect.arrayContaining([ + expect.stringContaining("object literal method needs a per-evaluation callable own property"), + ])); + expect(file.classes.some((clazz) => clazz.signature.name.startsWith("%AC"))).toBe(false); + }); + + it("keeps an ordinary function-valued data property on the supported store path", () => { + const { file, diagnostics } = lower(`export function run(seed: number) { + return { method: function (value: number) { return value + seed; } }; + }`); + const stmts = methodByName(file, "run").body!.cfg.blocks.flatMap((block) => block.stmts); + + expect(diagnostics.messages).toEqual([]); + expect(stmts).toContainEqual(expect.objectContaining({ + _: "DefineDataPropertyStmt", + key: { _: "Constant", value: "method" }, + value: expect.objectContaining({ _: "Local", type: expect.objectContaining({ _: "FunctionType" }) }), + })); + expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue")).toBe(false); + }); +}); diff --git a/jacodb-ets/ts-frontend/test/strictMode.spec.ts b/jacodb-ets/ts-frontend/test/strictMode.spec.ts index 42a9b11fa..a1b172c14 100644 --- a/jacodb-ets/ts-frontend/test/strictMode.spec.ts +++ b/jacodb-ets/ts-frontend/test/strictMode.spec.ts @@ -42,8 +42,8 @@ describe("lexical strict-mode metadata", () => { expect(methodByName(ownDirective, "%dflt").isStrictMode).toBe(false); }); - it("keeps sloppy object methods distinct from strict class code and initializers", () => { - const { file } = lower(` + it("keeps an unsupported sloppy object method distinct from strict class code", () => { + const { file, diagnostics } = lower(` function object() { return { sloppy() { return 1; } }; } class Box { value = 1; @@ -53,8 +53,15 @@ describe("lexical strict-mode metadata", () => { `); const box = file.classes.find(clazz => clazz.signature.name === "Box")!; - expect(methodByName(file, "sloppy").isStrictMode).toBe(false); expect(methodByName(file, "object").isStrictMode).toBe(false); + const objectStmts = methodByName(file, "object").body!.cfg.blocks.flatMap((block) => block.stmts); + expect(objectStmts).toContainEqual(expect.objectContaining({ + _: "AssignStmt", + right: expect.objectContaining({ _: "UnsupportedValue", kindName: "ObjectLiteralExpression" }), + })); + expect(diagnostics.messages).toEqual(expect.arrayContaining([ + expect.stringContaining("object literal method needs a per-evaluation callable own property"), + ])); expect(box.methods.length).toBeGreaterThan(3); expect(box.methods.every(method => method.isStrictMode === true)).toBe(true); }); From dde308ea2200df4adcbdba6b59a1cddffb5fd36c Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 02:40:10 +0300 Subject: [PATCH 45/63] Reject tagged superclass property reads before lowering effects --- .../ts-frontend/src/lowering/exprLowering.ts | 4 ++ .../ts-frontend/test/tagged-templates.spec.ts | 48 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index 1888ae4fd..7c9b81b23 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -1511,6 +1511,10 @@ export class ExprLowerer { let name = "%tag"; if (ts.isPropertyAccessExpression(tag) || ts.isElementAccessExpression(tag)) { + if (tag.expression.kind === ts.SyntaxKind.SuperKeyword) { + throw new LoweringError("super property read needs superclass-relative property lookup"); + } + if (ts.isPropertyAccessExpression(tag) && this.importedScopeFunctionField(tag) !== undefined) { throw new LoweringError("module namespace call receiver is not represented in EtsIR"); diff --git a/jacodb-ets/ts-frontend/test/tagged-templates.spec.ts b/jacodb-ets/ts-frontend/test/tagged-templates.spec.ts index 50a716226..bbff5b7a7 100644 --- a/jacodb-ets/ts-frontend/test/tagged-templates.spec.ts +++ b/jacodb-ets/ts-frontend/test/tagged-templates.spec.ts @@ -102,6 +102,54 @@ describe("tagged templates", () => { expect(runTypeScript(source, "exports.value()")).toBe("undefined\\unicode"); }); + it.each([ + "super.tag`x${this.substitution()}`", + "super[this.key()]`x${this.substitution()}`", + ])("keeps tagged superclass property lookup unsupported before evaluating keys or substitutions: %s", (tag) => { + const source = ` + class Base { + tag(parts: TemplateStringsArray, value: string): string { return "base" + value; } + } + class Derived extends Base { + effects = 0; + override tag(parts: TemplateStringsArray, value: string): string { return "derived" + value; } + key(): "tag" { this.effects++; return "tag"; } + substitution(): string { this.effects++; return "value"; } + value(): string { return ${tag}; } + } + `; + const { file, diagnostics } = lower(source); + const body = methodByName(file, "value").body!; + const stmts = body.cfg.blocks.flatMap((block) => block.stmts); + const rawAssign = stmts.find((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue"); + const startOffset = source.indexOf(tag); + const precedingLines = source.slice(0, startOffset).split("\n"); + const line = precedingLines.length; + const column = precedingLines[precedingLines.length - 1].length + 1; + const rawOrigin = body.stmtOrigins?.find(({ blockId, stmtIndex }) => + body.cfg.blocks[blockId].stmts[stmtIndex] === rawAssign, + ); + + expect(diagnostics.messages).toEqual([ + `test.ts:${line}:${column}: unsupported expression: super property read needs superclass-relative property lookup`, + ]); + expect(rawAssign).toMatchObject({ right: { + _: "UnsupportedValue", + kindName: "TaggedTemplateExpression", + text: tag, + type: { _: "StringType" }, + } }); + expect(rawOrigin?.source).toMatchObject({ + startOffset, + endOffset: startOffset + tag.length, + nodeKind: "TaggedTemplateExpression", + }); + expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PropertyRef")).toBe(false); + expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "TemplateObjectExpr")).toBe(false); + expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr")).toBe(false); + expect(stmts.some((stmt) => stmt._ === "CallStmt")).toBe(false); + }); + it("reads the current named tag and selects it before a substitution reassigns the binding", () => { const source = ` function tag(parts: TemplateStringsArray, value: number): number { return value + 1; } From fe283e115edf70e83196c03e38d1c3105143fbf3 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 02:43:23 +0300 Subject: [PATCH 46/63] Remove an unreachable type guard from regex literal lowering --- jacodb-ets/ts-frontend/src/lowering/exprLowering.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index 7c9b81b23..8f865bf1d 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -1485,7 +1485,7 @@ export class ExprLowerer { _: "InstanceCallExpr", instance: value, method: { - declaringClass: classType._ === "ClassType" ? classType.signature : UNKNOWN_CLASS_SIGNATURE, + declaringClass: classType.signature, name: CONSTRUCTOR_NAME, parameters: [ { name: "pattern", type: STRING_TYPE }, From a2f6b4aa66107e1ae3be1f7f29abc739d98faa76 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 02:49:09 +0300 Subject: [PATCH 47/63] Preserve closure hoist timing and preflight unsupported bindings --- .../ts-frontend/src/lowering/stmtLowering.ts | 142 +++++++++++++++--- .../ts-frontend/test/regressions.spec.ts | 134 +++++++++++++++++ 2 files changed, 259 insertions(+), 17 deletions(-) diff --git a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts index 4e3d223f2..64188f78a 100644 --- a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts @@ -65,6 +65,7 @@ export class StmtLowerer { private readonly finallyScopes: (ts.Block | (() => void))[] = []; private readonly finallyExceptionTargets: Label[] = []; private readonly catchScopes: CatchContext[] = []; + private readonly unsupportedForwardFunctionCaptures = new Set(); private pendingLabel: string | undefined; constructor( @@ -88,18 +89,28 @@ export class StmtLowerer { // Function declarations are hoisted in JavaScript, but lowering materializes // their captured values at the creation point: hoisting unconditionally would // snapshot captures BEFORE the captured variables are assigned. Hoist only the - // declarations that are actually referenced earlier in the same statement list; - // everything else keeps source order, so its captures are already initialized. + // declarations whose values are evaluated earlier in the same statement list; + // a reference in a deferred closure body cannot force early capture. const hoisted = new Set(); statements.forEach((statement, index) => { if (!ts.isFunctionDeclaration(statement) || statement.name === undefined) { return; } - const name = statement.name.text; + const functionSymbol = this.m.converter.symbolOf(statement.name); + if (functionSymbol === undefined) { + return; + } + for (let i = 0; i < index; i++) { - if (referencesName(statements[i], name)) { + const earlierStatement = statements[i]; + const uses = this.functionUses(earlierStatement, functionSymbol); + if (uses.eager) { hoisted.add(statement); - return; + } + if (uses.deferred) { + // Capturing a function before its binding is assigned needs a + // live binding cell, which the lifted environment cannot express. + this.unsupportedForwardFunctionCaptures.add(earlierStatement); } } }); @@ -115,6 +126,87 @@ export class StmtLowerer { } } + private functionUses(node: ts.Node, functionSymbol: ts.Symbol): { eager: boolean; deferred: boolean } { + let eager = false; + let deferred = false; + const visit = (current: ts.Node, inDeferredBody: boolean): void => { + if ((eager && deferred) || ts.isTypeNode(current)) { + return; + } + + if (ts.isCallExpression(current)) { + const invokedFunction = this.immediatelyCalledFunction(current.expression); + if (invokedFunction !== undefined) { + for (const argument of current.arguments) { + visit(argument, inDeferredBody); + } + for (const parameter of invokedFunction.parameters) { + visit(parameter, inDeferredBody); + } + visit(invokedFunction.body, inDeferredBody); + return; + } + } + + if (ts.isFunctionLike(current)) { + if (current.name !== undefined && ts.isComputedPropertyName(current.name)) { + visit(current.name.expression, inDeferredBody); + } + for (const parameter of current.parameters) { + visit(parameter, true); + } + const hasRuntimeBody = ts.isFunctionDeclaration(current) + || ts.isFunctionExpression(current) + || ts.isArrowFunction(current) + || ts.isMethodDeclaration(current) + || ts.isConstructorDeclaration(current) + || ts.isGetAccessorDeclaration(current) + || ts.isSetAccessorDeclaration(current); + if (hasRuntimeBody && current.body !== undefined) { + visit(current.body, true); + } + return; + } + + if (ts.isIdentifier(current)) { + const parent = current.parent; + const isPropertyName = (ts.isPropertyAccessExpression(parent) && parent.name === current) + || (ts.isPropertyAssignment(parent) && parent.name === current); + const isBindingName = (ts.isVariableDeclaration(parent) + || ts.isBindingElement(parent) + || ts.isParameter(parent)) && parent.name === current; + const isValueReference = !isPropertyName && !isBindingName; + if (isValueReference) { + const referenceSymbol = this.m.converter.symbolOf(current); + if (referenceSymbol === functionSymbol) { + if (inDeferredBody) deferred = true; + else eager = true; + return; + } + } + } + + ts.forEachChild(current, (child) => visit(child, inDeferredBody)); + }; + + visit(node, false); + return { eager, deferred }; + } + + private immediatelyCalledFunction(expression: ts.Expression): ts.ArrowFunction | ts.FunctionExpression | undefined { + while ( + ts.isParenthesizedExpression(expression) + || ts.isAsExpression(expression) + || ts.isTypeAssertionExpression(expression) + || ts.isNonNullExpression(expression) + || ts.isSatisfiesExpression(expression) + ) { + expression = expression.expression; + } + + return ts.isArrowFunction(expression) || ts.isFunctionExpression(expression) ? expression : undefined; + } + lowerStatement(node: ts.Statement): void { this.m.withOrigin(node, () => { try { @@ -135,6 +227,10 @@ export class StmtLowerer { const label = this.pendingLabel; this.pendingLabel = undefined; + if (this.unsupportedForwardFunctionCaptures.has(node)) { + throw new LoweringError("closure capturing a later function binding requires a runtime binding cell"); + } + if (ts.isVariableStatement(node)) { this.lowerVariableDeclarations(node.declarationList); return; @@ -264,7 +360,7 @@ export class StmtLowerer { } return; } - if ( + const isDeclarationHandledOutsideBody = ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node) || ts.isEnumDeclaration(node) || @@ -272,9 +368,9 @@ export class StmtLowerer { ts.isTypeAliasDeclaration(node) || ts.isImportDeclaration(node) || ts.isExportDeclaration(node) || - ts.isExportAssignment(node) || - ts.isImportEqualsDeclaration(node) - ) { + ts.isImportEqualsDeclaration(node); + + if (isDeclarationHandledOutsideBody) { // Declarations are handled at file/class level, not as body statements. return; } @@ -996,6 +1092,8 @@ export class StmtLowerer { /** Object bindings read properties; array bindings consume the iterator protocol. */ private lowerBindingPattern(pattern: ts.BindingPattern, source: LocalDto): void { + this.ensureSupportedArrayRestBindings(pattern); + if (ts.isObjectBindingPattern(pattern)) { const stableSource = this.expr.materialize({ _: "RequireObjectCoercibleExpr", @@ -1055,14 +1153,6 @@ export class StmtLowerer { 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"); - } - const iterator = new IteratorLowerer(this.m, source); for (const element of pattern.elements) { if (ts.isOmittedExpression(element)) { @@ -1087,6 +1177,24 @@ export class StmtLowerer { iterator.close(); } + private ensureSupportedArrayRestBindings(pattern: ts.BindingPattern): void { + if (ts.isArrayBindingPattern(pattern)) { + 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"); + } + } + + for (const element of pattern.elements) { + if (ts.isBindingElement(element) && !ts.isIdentifier(element.name)) { + this.ensureSupportedArrayRestBindings(element.name); + } + } + } + private bindingType(name: ts.BindingName): TypeDto { return ts.isIdentifier(name) ? this.m.converter.typeOfNode(name) : UNKNOWN_TYPE; } diff --git a/jacodb-ets/ts-frontend/test/regressions.spec.ts b/jacodb-ets/ts-frontend/test/regressions.spec.ts index aebd94211..f72a57e33 100644 --- a/jacodb-ets/ts-frontend/test/regressions.spec.ts +++ b/jacodb-ets/ts-frontend/test/regressions.spec.ts @@ -30,6 +30,22 @@ describe("unsupported loop bindings do not break the whole file", () => { } }); +it("finalizes a for initializer with an unsupported nested array rest binding", () => { + const { file, diagnostics } = lower(` + function f(input: any): void { + for (const [[...[value]]] = input; false;) {} + } + `); + const stmts = allStmts(methodByName(file, "f")); + + expect(stmts).toContainEqual(expect.objectContaining({ + _: "UnsupportedStmt", kindName: "ForStatement", + })); + expect(diagnostics.messages).toContainEqual(expect.stringContaining( + "unsupported statement: nested bindings in array rest are not represented with iterator semantics", + )); +}); + it("lowers a computed object key in a for-of binding", () => { const { file, diagnostics } = lower(` const xs = [{ a: 1 }]; @@ -430,6 +446,124 @@ describe("undefined identifier bindings", () => { }); describe("function hoisting", () => { + it.each([ + ["function declaration", "function first(): number { return later(); }", "FunctionDeclaration"], + ["arrow initializer", "const first = (): number => later();", "VariableStatement"], + ])("marks a forward function captured by an earlier %s unsupported", (_case, earlierStatement, kindName) => { + const { file, diagnostics } = lower(` + function outer(): number { + ${earlierStatement} + let seed = 1; + function later(): number { return seed; } + return first(); + } + `); + const stmts = singleBlockStmts(methodByName(file, "outer")); + const fallback = stmts.find((stmt) => stmt._ === "UnsupportedStmt"); + const seedAssignment = stmts.findIndex((stmt) => stmt._ === "AssignStmt" + && stmt.left._ === "Local" && stmt.left.name === "seed"); + const laterAssignment = stmts.findIndex((stmt) => stmt._ === "AssignStmt" + && stmt.right._ === "Local" && stmt.right.name.startsWith("%AM")); + + expect(fallback).toMatchObject({ _: "UnsupportedStmt", kindName }); + expect(seedAssignment).toBeGreaterThanOrEqual(0); + expect(laterAssignment).toBeGreaterThan(seedAssignment); + expect(diagnostics.messages).toContainEqual(expect.stringContaining( + "closure capturing a later function binding requires a runtime binding cell", + )); + }); + + it.each([ + ["arrow", "(() => later())()"], + ["function expression", "(function (): number { return later(); })()"], + ["arrow with a parameter default", "((value: number = later()) => value)()"], + ])("hoists a function used by an immediately invoked %s", (_case, invocation) => { + const { file, diagnostics } = lower(` + function outer(): number { + const value = ${invocation}; + function later(): number { return 7; } + return value; + } + `); + const stmts = singleBlockStmts(methodByName(file, "outer")); + const laterBinding = stmts.findIndex((stmt) => stmt._ === "AssignStmt" + && stmt.left._ === "Local" && stmt.left.name === "later" + && stmt.right._ === "Local" && stmt.right.name.startsWith("%AM")); + const invokedCall = stmts.findIndex((stmt) => stmt._ === "AssignStmt" + && stmt.right._ === "PtrCallExpr"); + + expect(laterBinding).toBeGreaterThanOrEqual(0); + expect(invokedCall).toBeGreaterThanOrEqual(0); + expect(laterBinding).toBeLessThan(invokedCall); + expect(stmts.some((stmt) => stmt._ === "UnsupportedStmt")).toBe(false); + expect(diagnostics.messages).toEqual([]); + }); + + it.each([ + ["unrelated property key", "const object = { later: 0 };"], + ["shadowed shorthand value", "{ const later = 0; const object = { later }; }"], + ["type-only reference", "type Later = typeof later;"], + ])("keeps a later closure after its capture for %s", (_case, earlierStatement) => { + const { file, diagnostics } = lower(` + function outer(): number { + ${earlierStatement} + let seed = 1; + function later(): number { return seed; } + return later(); + } + `); + const stmts = singleBlockStmts(methodByName(file, "outer")); + const seedAssignment = stmts.findIndex((stmt) => stmt._ === "AssignStmt" + && stmt.left._ === "Local" && stmt.left.name === "seed"); + const closureAssignment = stmts.findIndex((stmt) => stmt._ === "AssignStmt" + && stmt.right._ === "Local" && stmt.right.name.startsWith("%AM")); + + expect(seedAssignment).toBeGreaterThanOrEqual(0); + expect(closureAssignment).toBeGreaterThanOrEqual(0); + expect(seedAssignment).toBeLessThan(closureAssignment); + expect(diagnostics.messages).toEqual([]); + }); + + it("hoists a function referenced by an earlier call", () => { + const { file, diagnostics } = lower(` + function outer(): number { + const result = later(); + function later(): number { return 7; } + return result; + } + `); + const stmts = singleBlockStmts(methodByName(file, "outer")); + const closureAssignment = stmts.findIndex((stmt) => stmt._ === "AssignStmt" + && stmt.right._ === "Local" && stmt.right.name.startsWith("%AM")); + const call = stmts.findIndex((stmt) => stmt._ === "AssignStmt" + && stmt.right._ === "PtrCallExpr"); + + expect(closureAssignment).toBeGreaterThanOrEqual(0); + expect(call).toBeGreaterThanOrEqual(0); + expect(closureAssignment).toBeLessThan(call); + expect(diagnostics.messages).toEqual([]); + }); + + it("hoists a function referenced by an earlier shorthand property", () => { + const { file, diagnostics } = lower(` + function outer() { + const object = { later }; + function later(): number { return 7; } + return object.later; + } + `); + const stmts = singleBlockStmts(methodByName(file, "outer")); + const closureAssignment = stmts.findIndex((stmt) => stmt._ === "AssignStmt" + && stmt.right._ === "Local" && stmt.right.name.startsWith("%AM")); + const objectCreation = stmts.findIndex((stmt) => stmt._ === "AssignStmt" + && stmt.right._ === "NewExpr"); + + expect(closureAssignment).toBeGreaterThanOrEqual(0); + expect(objectCreation).toBeGreaterThanOrEqual(0); + expect(closureAssignment).toBeLessThan(objectCreation); + expect(diagnostics.messages).toEqual([]); + }); + it("materializes a capture only after the captured local is assigned", () => { const { file } = lower(` function outer(): number { From c4b39e1b47bc8db73596ca6d4f3c925729e27f1d Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 03:01:54 +0300 Subject: [PATCH 48/63] Reuse synchronous iterator protocol and close abrupt for-of exits --- .../src/lowering/iteratorLowering.ts | 15 +- .../ts-frontend/src/lowering/stmtLowering.ts | 125 ++++++++-------- .../ts-frontend/test/async-iterator.spec.ts | 134 ++++++++++++++++++ 3 files changed, 216 insertions(+), 58 deletions(-) diff --git a/jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts b/jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts index 0fee72044..dd944d2b9 100644 --- a/jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts @@ -2,7 +2,7 @@ import { CONSTRUCTOR_NAME } from "../dto/constants"; import { UNKNOWN_CLASS_SIGNATURE, UNKNOWN_FILE_SIGNATURE } from "../dto/signatures"; import { BOOLEAN_TYPE, NUMBER_TYPE, STRING_TYPE, TypeDto, UNKNOWN_TYPE } from "../dto/types"; -import { ImmediateDto, LocalDto } from "../dto/values"; +import { ImmediateDto, LocalDto, ValueDto } from "../dto/values"; import { MethodContext } from "./methodBuilder"; const number = (value: number): ImmediateDto => ({ _: "Constant", value: String(value), type: NUMBER_TYPE }); @@ -94,6 +94,19 @@ export class IteratorLowerer { cfg.placeLabel(done); } + /** Advance a loop without reading value until IteratorComplete is false. */ + step(): LocalDto { + return this.advance(); + } + + isDone(): ValueDto { + return this.isExhausted(); + } + + valueOf(result: LocalDto, type: TypeDto): ValueDto { + return this.fieldRef(result, "value", type); + } + /** A binding pattern that stops before exhaustion performs normal IteratorClose. */ close(): 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 64188f78a..dee812084 100644 --- a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts @@ -470,31 +470,22 @@ export class StmtLowerer { const isAsync = node.awaitModifier !== undefined; // GetMethod may invoke a getter that reassigns the source binding. const iterable = isAsync ? this.m.snapshotToLocal(source, source.type) : source; + const syncIterator = isAsync ? undefined : new IteratorLowerer(this.m, iterable); const asyncIterator = isAsync ? this.lowerAsyncIterator(iterable) : undefined; - const iterator = asyncIterator?.iterator ?? this.expr.materialize({ - _: "InstanceCallExpr", - instance: iterable, - method: unknownMethod("Symbol.iterator"), - args: [], - wellKnownSymbol: "iterator", - }); - const headLabel = cfg.newLabel(); const bodyLabel = cfg.newLabel(); const exitLabel = cfg.newLabel(); cfg.placeLabel(headLabel); - const result = this.m.newTemp(UNKNOWN_TYPE); - const nextResult = isAsync ? this.m.newTemp(UNKNOWN_TYPE) : result; - cfg.emit({ - _: "AssignStmt", - left: nextResult, - right: asyncIterator === undefined - ? { _: "InstanceCallExpr", instance: iterator, method: unknownMethod("next"), args: [] } - : { _: "PtrCallExpr", ptr: asyncIterator.next, receiver: iterator, method: unknownMethod("next"), args: [] }, - }); + const result = syncIterator?.step() ?? this.m.newTemp(UNKNOWN_TYPE); if (asyncIterator !== undefined) { + const nextResult = this.m.newTemp(UNKNOWN_TYPE); + cfg.emit({ _: "AssignStmt", left: nextResult, right: { + _: "PtrCallExpr", ptr: asyncIterator.next, receiver: asyncIterator.iterator, + method: unknownMethod("next"), args: [], + } }); + const syncResult = cfg.newLabel(); const asyncResult = cfg.newLabel(); const resultReady = cfg.newLabel(); @@ -510,13 +501,15 @@ export class StmtLowerer { this.requireIteratorObject(result); } - const doneType = asyncIterator === undefined ? BOOLEAN_TYPE : UNKNOWN_TYPE; - const rawDone = this.expr.materialize(this.iteratorField(result, "done", doneType), doneType); - // IteratorComplete uses ToBoolean, including non-boolean truthy done values. - const notDone = asyncIterator === undefined ? undefined - : this.expr.materialize({ _: "UnopExpr", op: "!", arg: rawDone }, BOOLEAN_TYPE); - const done = notDone === undefined ? rawDone - : this.expr.materialize({ _: "UnopExpr", op: "!", arg: notDone }, BOOLEAN_TYPE); + let done: ValueDto; + if (syncIterator !== undefined) { + done = syncIterator.isDone(); + } else { + const rawDone = this.expr.materialize(this.iteratorField(result, "done")); + // IteratorComplete uses ToBoolean, including non-boolean truthy done values. + const notDone = this.expr.materialize({ _: "UnopExpr", op: "!", arg: rawDone }, BOOLEAN_TYPE); + done = this.expr.materialize({ _: "UnopExpr", op: "!", arg: notDone }, BOOLEAN_TYPE); + } const value = asyncIterator === undefined ? undefined : this.m.newTemp(UNKNOWN_TYPE); if (asyncIterator !== undefined && value !== undefined) { @@ -535,6 +528,15 @@ export class StmtLowerer { cfg.branch(this.expr.relation("==", done, constant("true", BOOLEAN_TYPE)), exitLabel, bodyLabel); cfg.placeLabel(bodyLabel); + if (syncIterator !== undefined) { + // IteratorValue happens before the body, so a failed getter does not close the iterator. + const value = this.m.newTemp(UNKNOWN_TYPE); + cfg.emit({ _: "AssignStmt", left: value, right: syncIterator.valueOf(result, UNKNOWN_TYPE) }); + this.lowerLoopBodyWithClose(node, label, headLabel, exitLabel, () => syncIterator.close(), value); + cfg.placeLabel(exitLabel); + return; + } + if (asyncIterator !== undefined && value !== undefined) { const asyncValue = cfg.newLabel(); const valueReady = cfg.newLabel(); @@ -543,43 +545,52 @@ export class StmtLowerer { cfg.emit({ _: "AssignStmt", left: value, right: this.iteratorField(result, "value") }); cfg.placeLabel(valueReady); } - const lowerBody = (breakFinallyDepth?: number) => { - this.emitLoopBinding(node.initializer, (binding) => value ?? this.iteratorField(result, "value", binding.type)); - this.inBreakable({ kind: "loop", breakTarget: exitLabel, continueTarget: headLabel, label, breakFinallyDepth }, () => { - this.lowerStatement(node.statement); - }); - }; - if (asyncIterator === undefined) { - lowerBody(); - cfg.goto(headLabel); - } else { - const closeOnThrow = cfg.newLabel(); - const rethrow = cfg.newLabel(); - const outerFinallyDepth = this.finallyScopes.length; - this.finallyScopes.push(() => this.lowerAsyncIteratorClose(asyncIterator)); - this.finallyExceptionTargets.push(closeOnThrow); - this.catchScopes.push({ target: closeOnThrow, finallyDepth: this.finallyScopes.length }); - try { - cfg.withExceptionTarget(closeOnThrow, () => lowerBody(outerFinallyDepth)); - } finally { - this.catchScopes.pop(); - this.finallyScopes.pop(); - this.finallyExceptionTargets.pop(); - } - cfg.goto(headLabel); - - cfg.placeLabel(closeOnThrow); - const original = this.m.newTemp(UNKNOWN_TYPE); - cfg.emit({ _: "AssignStmt", left: original, right: { _: "CaughtExceptionRef", type: UNKNOWN_TYPE } }); - // AsyncIteratorClose preserves an existing throw over any cleanup failure. - cfg.withExceptionTarget(rethrow, () => this.lowerAsyncIteratorClose(asyncIterator)); - cfg.goto(rethrow); - cfg.placeLabel(rethrow); - cfg.throwValue(original); + if (asyncIterator !== undefined && value !== undefined) { + this.lowerLoopBodyWithClose(node, label, headLabel, exitLabel, + () => this.lowerAsyncIteratorClose(asyncIterator), value); } cfg.placeLabel(exitLabel); } + private lowerLoopBodyWithClose( + node: ts.ForOfStatement, + label: string | undefined, + headLabel: Label, + exitLabel: Label, + close: () => void, + value: LocalDto, + ): void { + const cfg = this.m.cfg; + const closeOnThrow = cfg.newLabel(); + const rethrow = cfg.newLabel(); + const outerFinallyDepth = this.finallyScopes.length; + + this.finallyScopes.push(close); + this.finallyExceptionTargets.push(closeOnThrow); + this.catchScopes.push({ target: closeOnThrow, finallyDepth: this.finallyScopes.length }); + try { + cfg.withExceptionTarget(closeOnThrow, () => { + this.emitLoopBinding(node.initializer, () => value); + this.inBreakable({ kind: "loop", breakTarget: exitLabel, continueTarget: headLabel, + label, breakFinallyDepth: outerFinallyDepth }, () => this.lowerStatement(node.statement)); + }); + } finally { + this.catchScopes.pop(); + this.finallyScopes.pop(); + this.finallyExceptionTargets.pop(); + } + + cfg.goto(headLabel); + cfg.placeLabel(closeOnThrow); + const original = this.m.newTemp(UNKNOWN_TYPE); + cfg.emit({ _: "AssignStmt", left: original, right: { _: "CaughtExceptionRef", type: UNKNOWN_TYPE } }); + // IteratorClose preserves the original throw over a cleanup failure. + cfg.withExceptionTarget(rethrow, close); + cfg.goto(rethrow); + cfg.placeLabel(rethrow); + cfg.throwValue(original); + } + private lowerAsyncIteratorClose(record: { iterator: LocalDto; fromSync: LocalDto }): void { const cfg = this.m.cfg; const method = this.expr.materialize(this.iteratorField(record.iterator, "return")); diff --git a/jacodb-ets/ts-frontend/test/async-iterator.spec.ts b/jacodb-ets/ts-frontend/test/async-iterator.spec.ts index 723fa8b1b..911df95e6 100644 --- a/jacodb-ets/ts-frontend/test/async-iterator.spec.ts +++ b/jacodb-ets/ts-frontend/test/async-iterator.spec.ts @@ -567,3 +567,137 @@ describe("for-await abrupt completion closes its iterator", () => { }); }); + +describe("synchronous for-of iterator protocol and cleanup", () => { + it("caches next, converts done to boolean, and rejects primitive iterator results", async () => { + const run = program("function run(input) { let total = 0; for (const value of input) total += value; return total; }"); + const input = (lookups: { count: number }) => { + let step = 0; + const iterator = { + get next() { + lookups.count++; + return function () { + expect(this).toBe(iterator); + return step++ === 0 ? { value: 7, done: false } : { done: "finished" }; + }; + }, + }; + return { [Symbol.iterator]: () => iterator }; + }; + const irLookups = { count: 0 }; + const nativeLookups = { count: 0 }; + + expect(await run.ir(input(irLookups))).toBe(7); + expect(await run.native(input(nativeLookups))).toBe(7); + expect(irLookups.count).toBe(nativeLookups.count); + expect(irLookups.count).toBe(1); + + for (const invalid of [ + { [Symbol.iterator]: () => 1 }, + { [Symbol.iterator]: () => ({ next: () => 1 }) }, + ]) { + await expect(run.ir(invalid)).rejects.toBeInstanceOf(TypeError); + await expect(run.native(invalid)).rejects.toBeInstanceOf(TypeError); + } + }); + + for (const completion of ["break;", "return value;", "throw value;"]) { + it(`closes on ${completion} with the original completion`, async () => { + const run = program(`function run(input) { for (const value of input) { ${completion} } return 9; }`); + const failure = new Error("body failure"); + const results: unknown[] = []; + const logs: string[][] = []; + + for (const consume of [run.native, run.ir]) { + const events: string[] = []; + const input = closingInput(events, { sync: true, value: completion.startsWith("throw") ? failure : 7 }); + try { results.push(await consume(input)); } catch (error) { results.push(error); } + logs.push(events); + } + + expect(results[1]).toBe(results[0]); + expect(logs[1]).toEqual(logs[0]); + expect(logs[1]).toEqual(["return lookup", "return call"]); + }); + } + + it("closes on a binding failure and preserves the body throw over cleanup failure", async () => { + const bindingRun = program("function run(input) { for (const {field} of input) return field; }"); + const bodyRun = program("function run(input) { for (const value of input) throw value; }"); + const bodyFailure = new Error("body failure"); + const closeFailure = new Error("close failure"); + + for (const consume of [bindingRun.native, bindingRun.ir]) { + const events: string[] = []; + await expect(consume(closingInput(events, { sync: true, value: null }))).rejects.toBeInstanceOf(TypeError); + expect(events).toEqual(["return lookup", "return call"]); + } + for (const consume of [bodyRun.native, bodyRun.ir]) { + const input = closingInput([], { sync: true, value: bodyFailure, close: () => { throw closeFailure; } }); + await expect(consume(input)).rejects.toBe(bodyFailure); + } + }); + + it("propagates cleanup failures on break and runs inner finally before close", async () => { + const run = program(`function run(input) { + for (const value of input) { + try { break; } finally { input.events.push("inner finally"); } + } + return 9; + }`); + const closeFailure = new Error("close failure"); + + for (const consume of [run.native, run.ir]) { + const events: string[] = []; + const input = Object.assign(closingInput(events, { sync: true }), { events }); + expect(await consume(input)).toBe(9); + expect(events).toEqual(["inner finally", "return lookup", "return call"]); + + const failingEvents: string[] = []; + const failingInput = Object.assign( + closingInput(failingEvents, { sync: true, close: () => { throw closeFailure; } }), + { events: failingEvents }, + ); + await expect(consume(failingInput)).rejects.toBe(closeFailure); + } + }); + + it("does not close when reading the step value fails before binding", async () => { + const run = program("function run(input) { for (const value of input) break; }"); + const failure = new Error("value getter failure"); + + for (const consume of [run.native, run.ir]) { + let closes = 0; + const input = { [Symbol.iterator]: () => ({ + next: () => ({ done: false, get value() { throw failure; } }), + return: () => { closes++; return {}; }, + }) }; + + await expect(consume(input)).rejects.toBe(failure); + expect(closes).toBe(0); + } + }); + + it("skips close on natural exhaustion, same-loop continue, and a failing next call", async () => { + const run = program("function run(input) { for (const value of input) continue; return 9; }"); + const failure = new Error("next failure"); + + for (const rejectNext of [false, true]) { + for (const consume of [run.native, run.ir]) { + const events: string[] = []; + let step = 0; + const input = { [Symbol.iterator]: () => ({ + next: () => { + if (rejectNext) throw failure; + return { value: 1, done: step++ > 0 }; + }, + return: () => { events.push("closed"); return {}; }, + }) }; + + if (rejectNext) await expect(consume(input)).rejects.toBe(failure); + else expect(await consume(input)).toBe(9); + expect(events).toEqual([]); + } + } + }); +}); From 5d2d4eb5782515703b31c5062583c6956cce782e Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 03:16:24 +0300 Subject: [PATCH 49/63] Share chained loop labels and reject unsound for-in lowering --- jacodb-ets/ts-frontend/README.md | 4 + .../ts-frontend/src/lowering/stmtLowering.ts | 144 ++++++------------ .../ts-frontend/test/async-iterator.spec.ts | 22 +++ jacodb-ets/ts-frontend/test/cfg.spec.ts | 51 +++++-- 4 files changed, 112 insertions(+), 109 deletions(-) diff --git a/jacodb-ets/ts-frontend/README.md b/jacodb-ets/ts-frontend/README.md index b1c7155e5..183725e00 100644 --- a/jacodb-ets/ts-frontend/README.md +++ b/jacodb-ets/ts-frontend/README.md @@ -80,6 +80,10 @@ represented as `RawStmt` or `RawValue` and reported with `-v`. The frontend records per-statement exceptional-successor edges for `try`/`catch`. Raw constructs remain opaque and are treated conservatively as potentially throwing; EtsIR has no trap table for precise exception semantics. +`for-in` statements use this raw fallback, including loops over objects with +only own keys. EtsIR currently has no operation for enumerating inherited +enumerable keys and checking pending keys as the object changes during the loop; +an `Object.keys` snapshot would silently change the loop's behavior. Optional calls on `super` properties (including computed properties) use this raw fallback because EtsIR cannot currently preserve superclass-relative property lookup before the nullish guard and conditional call. diff --git a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts index dee812084..e0fb6bf11 100644 --- a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts @@ -46,7 +46,7 @@ interface BreakableContext { kind: "loop" | "switch" | "labeled-block"; breakTarget: Label; continueTarget?: Label; - label?: string; + labels: readonly string[]; /** finallyScopes depth at the moment this breakable was entered. */ finallyDepth?: number; /** Iterator cleanup also runs on break from this loop, but not on continue. */ @@ -66,7 +66,7 @@ export class StmtLowerer { private readonly finallyExceptionTargets: Label[] = []; private readonly catchScopes: CatchContext[] = []; private readonly unsupportedForwardFunctionCaptures = new Set(); - private pendingLabel: string | undefined; + private pendingLabels: readonly string[] = []; constructor( private readonly m: MethodContext, @@ -223,9 +223,9 @@ export class StmtLowerer { } private lowerStatementImpl(node: ts.Statement): void { - // The pending label (from a LabeledStatement) applies only to the directly wrapped stmt. - const label = this.pendingLabel; - this.pendingLabel = undefined; + // Contiguous labels name the same directly wrapped loop or switch. + const labels = this.pendingLabels; + this.pendingLabels = []; if (this.unsupportedForwardFunctionCaptures.has(node)) { throw new LoweringError("closure capturing a later function binding requires a runtime binding cell"); @@ -240,27 +240,27 @@ export class StmtLowerer { return; } if (ts.isWhileStatement(node)) { - this.lowerWhile(node, label); + this.lowerWhile(node, labels); return; } if (ts.isDoStatement(node)) { - this.lowerDoWhile(node, label); + this.lowerDoWhile(node, labels); return; } if (ts.isForStatement(node)) { - this.lowerFor(node, label); + this.lowerFor(node, labels); return; } if (ts.isForOfStatement(node)) { - this.lowerForOf(node, label); + this.lowerForOf(node, labels); return; } if (ts.isForInStatement(node)) { - this.lowerForIn(node, label); + this.lowerForIn(node); return; } if (ts.isSwitchStatement(node)) { - this.lowerSwitch(node, label); + this.lowerSwitch(node, labels); return; } if (ts.isBreakStatement(node)) { @@ -399,7 +399,7 @@ export class StmtLowerer { cfg.placeLabel(endLabel); } - private lowerWhile(node: ts.WhileStatement, label: string | undefined): void { + private lowerWhile(node: ts.WhileStatement, labels: readonly string[]): void { const cfg = this.m.cfg; const headLabel = cfg.newLabel(); const bodyLabel = cfg.newLabel(); @@ -408,21 +408,21 @@ export class StmtLowerer { cfg.placeLabel(headLabel); this.expr.lowerCondition(node.expression, bodyLabel, exitLabel); cfg.placeLabel(bodyLabel); - this.inBreakable({ kind: "loop", breakTarget: exitLabel, continueTarget: headLabel, label }, () => { + this.inBreakable({ kind: "loop", breakTarget: exitLabel, continueTarget: headLabel, labels }, () => { this.lowerStatement(node.statement); }); cfg.goto(headLabel); cfg.placeLabel(exitLabel); } - private lowerDoWhile(node: ts.DoStatement, label: string | undefined): void { + private lowerDoWhile(node: ts.DoStatement, labels: readonly string[]): void { const cfg = this.m.cfg; const bodyLabel = cfg.newLabel(); const condLabel = cfg.newLabel(); const exitLabel = cfg.newLabel(); cfg.placeLabel(bodyLabel); - this.inBreakable({ kind: "loop", breakTarget: exitLabel, continueTarget: condLabel, label }, () => { + this.inBreakable({ kind: "loop", breakTarget: exitLabel, continueTarget: condLabel, labels }, () => { this.lowerStatement(node.statement); }); cfg.placeLabel(condLabel); @@ -430,7 +430,7 @@ export class StmtLowerer { cfg.placeLabel(exitLabel); } - private lowerFor(node: ts.ForStatement, label: string | undefined): void { + private lowerFor(node: ts.ForStatement, labels: readonly string[]): void { const cfg = this.m.cfg; if (node.initializer !== undefined) { if (ts.isVariableDeclarationList(node.initializer)) { @@ -451,7 +451,7 @@ export class StmtLowerer { cfg.goto(bodyLabel); } cfg.placeLabel(bodyLabel); - this.inBreakable({ kind: "loop", breakTarget: exitLabel, continueTarget: updateLabel, label }, () => { + this.inBreakable({ kind: "loop", breakTarget: exitLabel, continueTarget: updateLabel, labels }, () => { this.lowerStatement(node.statement); }); cfg.goto(updateLabel); @@ -464,7 +464,7 @@ export class StmtLowerer { } /** Lower iterator reads, including Async-from-Sync value unwrapping for `for await`. */ - private lowerForOf(node: ts.ForOfStatement, label: string | undefined): void { + private lowerForOf(node: ts.ForOfStatement, labels: readonly string[]): void { const cfg = this.m.cfg; const source = this.expr.lowerToLocal(node.expression); const isAsync = node.awaitModifier !== undefined; @@ -532,7 +532,7 @@ export class StmtLowerer { // IteratorValue happens before the body, so a failed getter does not close the iterator. const value = this.m.newTemp(UNKNOWN_TYPE); cfg.emit({ _: "AssignStmt", left: value, right: syncIterator.valueOf(result, UNKNOWN_TYPE) }); - this.lowerLoopBodyWithClose(node, label, headLabel, exitLabel, () => syncIterator.close(), value); + this.lowerLoopBodyWithClose(node, labels, headLabel, exitLabel, () => syncIterator.close(), value); cfg.placeLabel(exitLabel); return; } @@ -546,7 +546,7 @@ export class StmtLowerer { cfg.placeLabel(valueReady); } if (asyncIterator !== undefined && value !== undefined) { - this.lowerLoopBodyWithClose(node, label, headLabel, exitLabel, + this.lowerLoopBodyWithClose(node, labels, headLabel, exitLabel, () => this.lowerAsyncIteratorClose(asyncIterator), value); } cfg.placeLabel(exitLabel); @@ -554,7 +554,7 @@ export class StmtLowerer { private lowerLoopBodyWithClose( node: ts.ForOfStatement, - label: string | undefined, + labels: readonly string[], headLabel: Label, exitLabel: Label, close: () => void, @@ -572,7 +572,7 @@ export class StmtLowerer { cfg.withExceptionTarget(closeOnThrow, () => { this.emitLoopBinding(node.initializer, () => value); this.inBreakable({ kind: "loop", breakTarget: exitLabel, continueTarget: headLabel, - label, breakFinallyDepth: outerFinallyDepth }, () => this.lowerStatement(node.statement)); + labels, breakFinallyDepth: outerFinallyDepth }, () => this.lowerStatement(node.statement)); }); } finally { this.catchScopes.pop(); @@ -690,67 +690,10 @@ export class StmtLowerer { cfg.placeLabel(valid); } - /** - * `for (const k in obj)` — index loop over `Object.keys(obj)`: - * %keys := Object.keys(obj); %i := 0 - * head: %len := %keys.length; if (%i < %len) body else exit - * body: k := %keys[%i]; ...; %i := %i ++ - */ - private lowerForIn(node: ts.ForInStatement, label: string | undefined): void { - const cfg = this.m.cfg; - const target = this.expr.lowerToImmediate(node.expression); - - const keys = this.m.newTemp({ _: "ArrayType", elementType: { _: "StringType" }, dimensions: 1 }); - cfg.emit({ - _: "AssignStmt", - left: keys, - right: { - _: "StaticCallExpr", - method: { - declaringClass: { name: "Object", declaringFile: UNKNOWN_FILE_SIGNATURE }, - name: "keys", - parameters: [], - returnType: { _: "ArrayType", elementType: { _: "StringType" }, dimensions: 1 }, - }, - args: [target], - }, - }); - const index = this.m.newTemp(NUMBER_TYPE); - cfg.emit({ _: "AssignStmt", left: index, right: constant("0", NUMBER_TYPE) }); - - const headLabel = cfg.newLabel(); - const bodyLabel = cfg.newLabel(); - const exitLabel = cfg.newLabel(); - - cfg.placeLabel(headLabel); - const length = this.m.newTemp(NUMBER_TYPE); - cfg.emit({ - _: "AssignStmt", - left: length, - right: { - _: "InstanceFieldRef", - instance: keys, - field: { declaringClass: UNKNOWN_CLASS_SIGNATURE, name: "length", type: NUMBER_TYPE }, - }, - }); - cfg.branch(this.expr.relation("<", index, length), bodyLabel, exitLabel); - - cfg.placeLabel(bodyLabel); - this.emitLoopBinding(node.initializer, () => ({ - _: "ArrayRef", - array: keys, - index, - type: { _: "StringType" }, - })); - const continueLabel = cfg.newLabel(); - this.inBreakable({ kind: "loop", breakTarget: exitLabel, continueTarget: continueLabel, label }, () => { - this.lowerStatement(node.statement); - }); - cfg.goto(continueLabel); - cfg.placeLabel(continueLabel); - cfg.emit({ _: "AssignStmt", left: index, right: { _: "UnopExpr", op: "++", arg: index } }); - cfg.goto(headLabel); - cfg.placeLabel(exitLabel); + /** EtsIR has no prototype-aware, live for-in key enumerator. */ + private lowerForIn(node: ts.ForInStatement): void { + this.m.diagnostics.warn(node, "unsupported for-in enumeration: inherited and changing keys cannot be represented"); + this.m.cfg.emit(unsupportedStmt(node)); } /** @@ -813,7 +756,7 @@ export class StmtLowerer { throw new LoweringError("unsupported loop binding"); } - private lowerSwitch(node: ts.SwitchStatement, label: string | undefined): void { + private lowerSwitch(node: ts.SwitchStatement, labels: readonly string[]): void { const cfg = this.m.cfg; const caseExpressions = node.caseBlock.clauses.flatMap((clause) => ts.isCaseClause(clause) ? [clause.expression] : []); @@ -838,7 +781,7 @@ export class StmtLowerer { cfg.goto(defaultIndex >= 0 ? bodyLabels[defaultIndex] : exitLabel); // Bodies in source order with natural fallthrough. - this.inBreakable({ kind: "switch", breakTarget: exitLabel, label }, () => { + this.inBreakable({ kind: "switch", breakTarget: exitLabel, labels }, () => { clauses.forEach((clause, i) => { cfg.placeLabel(bodyLabels[i]); for (const statement of clause.statements) { @@ -872,24 +815,27 @@ export class StmtLowerer { } private lowerLabeled(node: ts.LabeledStatement): void { - const inner = node.statement; - if ( - ts.isWhileStatement(inner) || - ts.isDoStatement(inner) || - ts.isForStatement(inner) || - ts.isForOfStatement(inner) || - ts.isForInStatement(inner) || - ts.isSwitchStatement(inner) - ) { - this.pendingLabel = node.label.text; - this.lowerStatement(inner); + const labels: string[] = []; + let target: ts.Statement = node; + while (ts.isLabeledStatement(target)) { + labels.push(target.label.text); + target = target.statement; + } + + const targetsLoop = ts.isWhileStatement(target) || ts.isDoStatement(target) + || ts.isForStatement(target) || ts.isForOfStatement(target) || ts.isForInStatement(target); + const targetsSwitch = ts.isSwitchStatement(target); + if (targetsLoop || targetsSwitch) { + this.pendingLabels = labels; + this.lowerStatement(target); return; } + // Labeled block: breakable region. const cfg = this.m.cfg; const exitLabel = cfg.newLabel(); - this.inBreakable({ kind: "labeled-block", breakTarget: exitLabel, label: node.label.text }, () => { - this.lowerStatement(inner); + this.inBreakable({ kind: "labeled-block", breakTarget: exitLabel, labels: [node.label.text] }, () => { + this.lowerStatement(node.statement); }); cfg.goto(exitLabel); cfg.placeLabel(exitLabel); @@ -1038,7 +984,7 @@ export class StmtLowerer { for (let i = this.breakables.length - 1; i >= 0; i--) { const context = this.breakables[i]; if (label !== undefined) { - if (context.label === label && (!forContinue || context.kind === "loop")) { + if (context.labels.includes(label) && (!forContinue || context.kind === "loop")) { return context; } } else if (!forContinue || context.kind === "loop") { diff --git a/jacodb-ets/ts-frontend/test/async-iterator.spec.ts b/jacodb-ets/ts-frontend/test/async-iterator.spec.ts index 911df95e6..b7619f775 100644 --- a/jacodb-ets/ts-frontend/test/async-iterator.spec.ts +++ b/jacodb-ets/ts-frontend/test/async-iterator.spec.ts @@ -514,6 +514,28 @@ describe("for-await abrupt completion closes its iterator", () => { } }); + it("shares a for-of target across contiguous labels without closing on same-loop continue", async () => { + const run = program(`function run(input) { + outer: inner: for (const value of input) { + if (value === 1) continue outer; + break inner; + } + return 9; + }`); + + for (const consume of [run.native, run.ir]) { + const events: string[] = []; + let step = 0; + const input = { [Symbol.iterator]: () => ({ + next: () => ({ value: ++step, done: false }), + return: () => { events.push(`close after ${step}`); return { done: true }; }, + }) }; + + expect(await consume(input)).toBe(9); + expect(events).toEqual(["close after 2"]); + } + }); + it("routes a cleanup failure to the enclosing catch without closing twice", async () => { const run = program(`async function run(input) { try { for await (const value of input) break; } diff --git a/jacodb-ets/ts-frontend/test/cfg.spec.ts b/jacodb-ets/ts-frontend/test/cfg.spec.ts index 5f29ce492..37000a3e7 100644 --- a/jacodb-ets/ts-frontend/test/cfg.spec.ts +++ b/jacodb-ets/ts-frontend/test/cfg.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { BasicBlockDto, MethodDto } from "../src/dto/model"; import { IfStmtDto, StmtDto } from "../src/dto/stmts"; import { serializeEtsFile } from "../src/serialize"; +import { executor } from "./execute"; import { defaultMethod, lower, methodByName } from "./util"; function blocksOf(method: MethodDto): BasicBlockDto[] { @@ -305,17 +306,47 @@ describe("control flow lowering", () => { expect(concrete).toBe(7); }); - it("lowers for-in via Object.keys indexing", () => { - const { file } = lower(` - let obj = [1]; - for (const k in obj) { console.log(k); } + it.each([ + "function enumerate() { for (const key in Object.create({ inherited: 1 })) return key; }", + "function enumerate(input: any) { for (const key in input) { if (key === 'a') delete input.b; } }", + ])("marks prototype-aware and changing-key for-in enumeration unsupported: %s", (source) => { + const { file, diagnostics } = lower(source); + const method = methodByName(file, "enumerate"); + const stmts = allStmts(method); + const raw = stmts.find((stmt) => (stmt as { _: string })._ === "UnsupportedStmt"); + + expect(diagnostics.messages).toHaveLength(1); + expect(diagnostics.messages[0]).toContain("unsupported for-in enumeration"); + expect(raw).toMatchObject({ kindName: "ForInStatement" }); + expect(method.body!.stmtOrigins!.some(({ blockId, stmtIndex, source: origin }) => + method.body!.cfg.blocks[blockId].stmts[stmtIndex] === raw && origin.nodeKind === "ForInStatement", + )).toBe(true); + expect(stmts.some((stmt) => stmt._ === "IfStmt" + || (stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr"))).toBe(false); + }); + + it("resolves contiguous while labels through continue, break, and nested finally", () => { + const { file, diagnostics } = lower(` + function run(limit: number): number { + let i = 0; + let trace = 0; + outer: inner: while (i < limit) { + try { + i++; + if (i === 1) continue outer; + break inner; + } finally { + trace += 10; + } + } + return trace + i; + } `); - const stmts = allStmts(defaultMethod(file)); - const staticCalls = stmts.filter((s) => s._ === "AssignStmt" && s.right._ === "StaticCallExpr"); - expect(staticCalls.length).toBeGreaterThan(0); - expect(staticCalls[0]).toMatchObject({ - right: { method: { declaringClass: { name: "Object" }, name: "keys" } }, - }); + const stmts = allStmts(methodByName(file, "run")); + + expect(diagnostics.messages).toEqual([]); + expect(stmts.some((stmt) => (stmt as { _: string })._ === "UnsupportedStmt")).toBe(false); + expect(executor(file).call("run", 2)).toBe(22); }); it("supports labeled break from nested loops", () => { From 391b0e382aee380c0a6cdb6a51f1936e11e295a1 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 03:27:43 +0300 Subject: [PATCH 50/63] fix(frontend): bind destructured catch parameters through cleanup targets --- .../ts-frontend/src/lowering/stmtLowering.ts | 69 ++++++++++++------ jacodb-ets/ts-frontend/test/try.spec.ts | 71 +++++++++++++++++++ 2 files changed, 118 insertions(+), 22 deletions(-) diff --git a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts index e0fb6bf11..8ae792e25 100644 --- a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts @@ -843,6 +843,11 @@ export class StmtLowerer { /** Lower the catch as an exceptional target of statements inside the try body. */ private lowerTry(node: ts.TryStatement): void { + const catchBinding = node.catchClause?.variableDeclaration?.name; + if (catchBinding !== undefined && !ts.isIdentifier(catchBinding)) { + this.ensureSupportedBindingPattern(catchBinding); + } + const cfg = this.m.cfg; const joinLabel = cfg.newLabel(); const finallyBlock = node.finallyBlock; @@ -866,25 +871,29 @@ export class StmtLowerer { cfg.placeLabel(catchLabel); const decl = catchClause.variableDeclaration; - if ( - decl !== undefined && - ts.isIdentifier(decl.name) && - this.referencesCatchBinding(catchClause.block, decl.name) - ) { - const caughtType = - decl.type !== undefined ? this.m.converter.convertTypeNode(decl.type) : UNKNOWN_TYPE; - const caught = this.m.localForIdentifier(decl.name, caughtType); - cfg.emit({ - _: "AssignStmt", - left: caught, - right: { _: "CaughtExceptionRef", type: caughtType }, - }); - } - if (finallyExceptionLabel === undefined) { + const lowerCatch = (): void => { + if (decl !== undefined && ts.isIdentifier(decl.name)) { + const bindingIsReferenced = this.referencesCatchBinding(catchClause.block, decl.name); + if (bindingIsReferenced) { + const caughtType = + decl.type !== undefined ? this.m.converter.convertTypeNode(decl.type) : UNKNOWN_TYPE; + const caught = this.m.localForIdentifier(decl.name, caughtType); + cfg.emit({ + _: "AssignStmt", + left: caught, + right: { _: "CaughtExceptionRef", type: caughtType }, + }); + } + } else if (decl !== undefined) { + const caught = this.m.newTemp(UNKNOWN_TYPE); + cfg.emit({ _: "AssignStmt", left: caught, right: { _: "CaughtExceptionRef", type: UNKNOWN_TYPE } }); + this.lowerBindingPattern(decl.name, caught); + } + this.lowerStatement(catchClause.block); - } else { - cfg.withExceptionTarget(finallyExceptionLabel, () => this.lowerStatement(catchClause.block)); - } + }; + if (finallyExceptionLabel === undefined) lowerCatch(); + else cfg.withExceptionTarget(finallyExceptionLabel, lowerCatch); cfg.goto(joinLabel); } else { if (finallyExceptionLabel === undefined) { @@ -1049,7 +1058,7 @@ export class StmtLowerer { /** Object bindings read properties; array bindings consume the iterator protocol. */ private lowerBindingPattern(pattern: ts.BindingPattern, source: LocalDto): void { - this.ensureSupportedArrayRestBindings(pattern); + this.ensureSupportedBindingPattern(pattern); if (ts.isObjectBindingPattern(pattern)) { const stableSource = this.expr.materialize({ @@ -1134,7 +1143,7 @@ export class StmtLowerer { iterator.close(); } - private ensureSupportedArrayRestBindings(pattern: ts.BindingPattern): void { + private ensureSupportedBindingPattern(pattern: ts.BindingPattern): void { if (ts.isArrayBindingPattern(pattern)) { const hasRest = pattern.elements.some((element) => ts.isBindingElement(element) && element.dotDotDotToken !== undefined); @@ -1146,8 +1155,24 @@ export class StmtLowerer { } for (const element of pattern.elements) { - if (ts.isBindingElement(element) && !ts.isIdentifier(element.name)) { - this.ensureSupportedArrayRestBindings(element.name); + if (!ts.isBindingElement(element)) continue; + + if (ts.isObjectBindingPattern(pattern)) { + if (element.dotDotDotToken !== undefined && !ts.isIdentifier(element.name)) { + throw new LoweringError("nested object-rest target"); + } + + const propertyName = element.propertyName; + const unsupportedPropertyName = propertyName !== undefined + && !ts.isComputedPropertyName(propertyName) + && propertyNameText(propertyName) === undefined; + if (unsupportedPropertyName) { + throw new LoweringError("computed property in destructuring"); + } + } + + if (!ts.isIdentifier(element.name)) { + this.ensureSupportedBindingPattern(element.name); } } } diff --git a/jacodb-ets/ts-frontend/test/try.spec.ts b/jacodb-ets/ts-frontend/test/try.spec.ts index b17c440c7..771e4cea4 100644 --- a/jacodb-ets/ts-frontend/test/try.spec.ts +++ b/jacodb-ets/ts-frontend/test/try.spec.ts @@ -165,6 +165,77 @@ describe("try/catch/finally lowering", () => { expect(catchBindings).toHaveLength(1); }); + it("binds destructured catch values, including an unused property read", () => { + const source = ` + function f(): number { + try { throw { message: 7, unused: 8 }; } + catch ({ message, unused }) { return message; } + } + `; + const { file, diagnostics } = lower(source); + const stmts = methodByName(file, "f").body!.cfg.blocks.flatMap((block) => block.stmts); + const caught = stmts.find((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "CaughtExceptionRef"); + const propertyReads = stmts.filter((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PropertyRef"); + + expect(diagnostics.messages).toEqual([]); + expect(caught).toMatchObject({ left: { _: "Local", name: expect.stringMatching(/^%/) } }); + expect(propertyReads).toEqual(expect.arrayContaining([ + expect.objectContaining({ left: { name: "message" }, right: { key: { value: "message" } } }), + expect.objectContaining({ left: { name: "unused" }, right: { key: { value: "unused" } } }), + ])); + expect(stmts).toContainEqual({ _: "ReturnStmt", arg: expect.objectContaining({ name: "message" }) }); + }); + + it("binds an array catch pattern through the iterator protocol", () => { + const source = ` + function f(): number { + try { throw [7]; } + catch ([first]) { return first; } + } + `; + const { file, diagnostics } = lower(source); + const stmts = methodByName(file, "f").body!.cfg.blocks.flatMap((block) => block.stmts); + + expect(diagnostics.messages).toEqual([]); + expect(stmts).toContainEqual(expect.objectContaining({ + _: "AssignStmt", right: expect.objectContaining({ _: "CaughtExceptionRef" }), + })); + expect(stmts.some((stmt) => stmt._ === "AssignStmt" + && stmt.right._ === "InstanceCallExpr" && stmt.right.method.name === "Symbol.iterator")).toBe(true); + expect(stmts).toContainEqual({ _: "ReturnStmt", arg: expect.objectContaining({ name: "first" }) }); + }); + + it("runs finally and reaches an outer catch when catch destructuring fails", () => { + const blocks = blocksOf(` + function f(input: any): number { + try { + try { throw input; } + catch ({ message }) { return message; } + finally { console.log("fin"); } + } catch { return 9; } + } + `); + const bindingBlock = blocks.find((block) => block.stmts.some((stmt) => stmt._ === "AssignStmt" + && stmt.right._ === "RequireObjectCoercibleExpr"))!; + const bindingIndex = bindingBlock.stmts.findIndex((stmt) => stmt._ === "AssignStmt" + && stmt.right._ === "RequireObjectCoercibleExpr"); + const finallyTarget = bindingBlock.exceptionalSuccessors?.find((edge) => edge.stmtIndex === bindingIndex)?.target; + const finallyBlock = blocks[finallyTarget!]; + const rethrowIndex = finallyBlock.stmts.findIndex((stmt) => stmt._ === "ThrowStmt"); + const outerCatch = blocks.find((block) => block.stmts.some((stmt) => stmt._ === "ReturnStmt" + && stmt.arg._ === "Constant" && stmt.arg.value === "9"))!; + + expect(finallyTarget).toBeDefined(); + expect(finallyTarget).not.toBe(bindingBlock.id); + expect(finallyBlock.stmts).toContainEqual(expect.objectContaining({ + _: "CallStmt", expr: expect.objectContaining({ + args: expect.arrayContaining([expect.objectContaining({ value: "fin" })]), + }), + })); + expect(rethrowIndex).toBeGreaterThanOrEqual(0); + expect(finallyBlock.exceptionalSuccessors).toContainEqual({ stmtIndex: rethrowIndex, target: outerCatch.id }); + }); + it("joins try and catch paths on the finally block", () => { const blocks = blocksOf(` function f(): void { From cf409e624d6673092cbe1a5ee4bb45ae1e276f17 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 03:40:54 +0300 Subject: [PATCH 51/63] fix(frontend): preflight unsupported binding patterns before effects --- .../ts-frontend/src/lowering/classBuilder.ts | 52 +++++++++++- .../ts-frontend/src/lowering/stmtLowering.ts | 79 +++++++++-------- .../ts-frontend/test/regressions.spec.ts | 84 +++++++++++++++++++ 3 files changed, 176 insertions(+), 39 deletions(-) diff --git a/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts index bf7adffec..6d7d2b81a 100644 --- a/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts +++ b/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts @@ -37,13 +37,14 @@ import { PATTERN_PARAMETER_PREFIX, STATIC_INIT_METHOD_NAME, } from "../dto/constants"; -import { ClassDto, FieldDto, MethodDto } from "../dto/model"; +import { BodyDto, ClassDto, FieldDto, MethodDto } from "../dto/model"; import { ClassSignatureDto, UNKNOWN_FILE_SIGNATURE } from "../dto/signatures"; import { ClassTypeDto, TypeDto, BOOLEAN_TYPE, NUMBER_TYPE, STRING_TYPE, UNKNOWN_TYPE, VOID_TYPE } from "../dto/types"; import { buildParameters, decoratorsOf, memberName, modifiersOf, parameterType, returnTypeOf } from "./astUtils"; -import { constant } from "./exprLowering"; +import { unsupportedStmt } from "./diagnostics"; +import { LoweringError, constant } from "./exprLowering"; import { LoweringContext, MethodContext } from "./methodBuilder"; -import { StmtLowerer } from "./stmtLowering"; +import { StmtLowerer, ensureSupportedBindingPattern } from "./stmtLowering"; type ClassMemberDecl = ts.MethodDeclaration | ts.GetAccessorDeclaration | ts.SetAccessorDeclaration; @@ -313,6 +314,12 @@ export class ClassBuilder { if (decl.body !== undefined) { const isStaticMethod = (modifiersOf(decl) & Modifier.STATIC) !== 0; + const patternError = this.parameterPatternError(decl); + if (patternError !== undefined) { + method.body = this.unsupportedParameterBody(decl, declaringClass, name, patternError, isStaticMethod); + return method; + } + const m = new MethodContext(this.ctx, declaringClass, name, isStaticMethod); const lowerer = new StmtLowerer(m); m.emitPrologue(prologueParams, (parameter, local) => @@ -345,6 +352,16 @@ export class ClassBuilder { ): MethodDto { const { parameters, prologueParams } = buildParameters(this.ctx, decl); const classType: ClassTypeDto = { _: "ClassType", signature: declaringClass }; + const patternError = this.parameterPatternError(decl); + if (patternError !== undefined) { + return { + signature: { declaringClass, name: CONSTRUCTOR_NAME, parameters, returnType: classType }, + modifiers: modifiersOf(decl), + decorators: decoratorsOf(decl), + isStrictMode: true, + body: this.unsupportedParameterBody(decl, declaringClass, CONSTRUCTOR_NAME, patternError), + }; + } const m = new MethodContext(this.ctx, declaringClass, CONSTRUCTOR_NAME); const lowerer = new StmtLowerer(m); @@ -380,6 +397,35 @@ export class ClassBuilder { }; } + private parameterPatternError(decl: ts.SignatureDeclarationBase): string | undefined { + try { + for (const parameter of decl.parameters) { + if (!ts.isIdentifier(parameter.name)) { + ensureSupportedBindingPattern(parameter.name); + } + } + } catch (error) { + if (error instanceof LoweringError) { + return error.message; + } + throw error; + } + return undefined; + } + + private unsupportedParameterBody( + decl: ts.SignatureDeclarationBase, + declaringClass: ClassSignatureDto, + name: string, + reason: string, + isStaticMethod?: boolean, + ): BodyDto { + this.ctx.diagnostics.warn(decl, `unsupported parameter binding: ${reason}`); + const m = new MethodContext(this.ctx, declaringClass, name, isStaticMethod); + m.withOrigin(decl, () => m.cfg.emit(unsupportedStmt(decl))); + return m.build(); + } + private synthesizeDefaultConstructor( declaringClass: ClassSignatureDto, superClass?: { signature: ClassSignatureDto; constructorDecl?: ts.ConstructorDeclaration }, diff --git a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts index 8ae792e25..3fa53392b 100644 --- a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts @@ -845,7 +845,7 @@ export class StmtLowerer { private lowerTry(node: ts.TryStatement): void { const catchBinding = node.catchClause?.variableDeclaration?.name; if (catchBinding !== undefined && !ts.isIdentifier(catchBinding)) { - this.ensureSupportedBindingPattern(catchBinding); + ensureSupportedBindingPattern(catchBinding); } const cfg = this.m.cfg; @@ -1004,6 +1004,12 @@ export class StmtLowerer { } lowerVariableDeclarations(list: ts.VariableDeclarationList): void { + for (const decl of list.declarations) { + if (!ts.isIdentifier(decl.name)) { + ensureSupportedBindingPattern(decl.name); + } + } + for (const decl of list.declarations) { this.lowerVariableDeclaration(decl); } @@ -1058,7 +1064,7 @@ export class StmtLowerer { /** Object bindings read properties; array bindings consume the iterator protocol. */ private lowerBindingPattern(pattern: ts.BindingPattern, source: LocalDto): void { - this.ensureSupportedBindingPattern(pattern); + ensureSupportedBindingPattern(pattern); if (ts.isObjectBindingPattern(pattern)) { const stableSource = this.expr.materialize({ @@ -1143,40 +1149,6 @@ export class StmtLowerer { iterator.close(); } - private ensureSupportedBindingPattern(pattern: ts.BindingPattern): void { - if (ts.isArrayBindingPattern(pattern)) { - 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"); - } - } - - for (const element of pattern.elements) { - if (!ts.isBindingElement(element)) continue; - - if (ts.isObjectBindingPattern(pattern)) { - if (element.dotDotDotToken !== undefined && !ts.isIdentifier(element.name)) { - throw new LoweringError("nested object-rest target"); - } - - const propertyName = element.propertyName; - const unsupportedPropertyName = propertyName !== undefined - && !ts.isComputedPropertyName(propertyName) - && propertyNameText(propertyName) === undefined; - if (unsupportedPropertyName) { - throw new LoweringError("computed property in destructuring"); - } - } - - if (!ts.isIdentifier(element.name)) { - this.ensureSupportedBindingPattern(element.name); - } - } - } - private bindingType(name: ts.BindingName): TypeDto { return ts.isIdentifier(name) ? this.m.converter.typeOfNode(name) : UNKNOWN_TYPE; } @@ -1220,6 +1192,41 @@ export class StmtLowerer { } } +/** Reject structural patterns before emitting any source effects or CFG labels. */ +export function ensureSupportedBindingPattern(pattern: ts.BindingPattern): void { + if (ts.isArrayBindingPattern(pattern)) { + 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"); + } + } + + for (const element of pattern.elements) { + if (!ts.isBindingElement(element)) continue; + + if (ts.isObjectBindingPattern(pattern)) { + if (element.dotDotDotToken !== undefined && !ts.isIdentifier(element.name)) { + throw new LoweringError("nested object-rest target"); + } + + const propertyName = element.propertyName; + const unsupportedPropertyName = propertyName !== undefined + && !ts.isComputedPropertyName(propertyName) + && propertyNameText(propertyName) === undefined; + if (unsupportedPropertyName) { + throw new LoweringError("computed property in destructuring"); + } + } + + if (!ts.isIdentifier(element.name)) { + ensureSupportedBindingPattern(element.name); + } + } +} + /** Whether [node] mentions the identifier [name] anywhere in its subtree. */ function referencesName(node: ts.Node, name: string): boolean { let found = false; diff --git a/jacodb-ets/ts-frontend/test/regressions.spec.ts b/jacodb-ets/ts-frontend/test/regressions.spec.ts index f72a57e33..634dc857e 100644 --- a/jacodb-ets/ts-frontend/test/regressions.spec.ts +++ b/jacodb-ets/ts-frontend/test/regressions.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { executor } from "./execute"; +import { CONSTRUCTOR_NAME } from "../src/dto/constants"; import { AssignStmtDto, StmtDto } from "../src/dto/stmts"; import { serializeEtsFile } from "../src/serialize"; import { syntaxKindName } from "../src/lowering/diagnostics"; @@ -11,6 +12,89 @@ function allStmts(method: { body?: { cfg: { blocks: { stmts: StmtDto[] }[] } } } return (method.body?.cfg.blocks ?? []).flatMap((block) => block.stmts); } +function callName(stmt: StmtDto): string | undefined { + if (stmt._ !== "AssignStmt" && stmt._ !== "CallStmt") return undefined; + + const value = stmt._ === "AssignStmt" ? stmt.right : stmt.expr; + if (value._ === "InstanceCallExpr" || value._ === "StaticCallExpr" || value._ === "PtrCallExpr") { + return value.method.name; + } + return undefined; +} + +it("keeps direct methods with unsupported parameter patterns opaque without losing later declarations", () => { + const source = ` + function direct([[x], ...rest]: any[]) { return x; } + class Holder { + constructor([[created], ...rest]: any[]) {} + method([[value], ...tail]: any[]) { return value; } + healthy(value = 3) { return value; } + } + function later(value: number) { return value + 1; } + function supported([first, ...tail]: any[] = [1, 2]) { return first; } + `; + const { file, diagnostics } = lower(source); + const holder = file.classes.find((clazz) => clazz.signature.name === "Holder")!; + const unsupported = [ + { method: methodByName(file, "direct"), kind: "FunctionDeclaration" }, + { method: holder.methods.find((method) => method.signature.name === CONSTRUCTOR_NAME)!, kind: "Constructor" }, + { method: holder.methods.find((method) => method.signature.name === "method")!, kind: "MethodDeclaration" }, + ]; + + for (const { method, kind } of unsupported) { + const body = method.body!; + const stmts = allStmts(method); + const rawIndex = stmts.findIndex((stmt) => stmt._ === "UnsupportedStmt"); + const rawOrigin = body.stmtOrigins?.find(({ blockId, stmtIndex }) => + body.cfg.blocks[blockId].stmts[stmtIndex] === stmts[rawIndex], + ); + + expect(method.signature.parameters).toHaveLength(1); + expect(stmts[rawIndex]).toMatchObject({ _: "UnsupportedStmt", kindName: kind }); + expect(stmts.some((stmt) => stmt._ === "AssignStmt")).toBe(false); + expect(rawOrigin?.source.nodeKind).toBe(kind); + expect(source.slice(rawOrigin!.source.startOffset, rawOrigin!.source.endOffset)).toContain("[["); + } + + expect(diagnostics.messages.filter((message) => message.includes("unsupported parameter binding"))).toHaveLength(3); + expect(allStmts(methodByName(file, "later")).some((stmt) => stmt._ === "ReturnStmt")).toBe(true); + expect(allStmts(holder.methods.find((method) => method.signature.name === "healthy")!) + .some((stmt) => stmt._ === "IfStmt")).toBe(true); + + const supportedStmts = allStmts(methodByName(file, "supported")); + expect(supportedStmts.some((stmt) => stmt._ === "IfStmt")).toBe(true); + expect(supportedStmts.some((stmt) => callName(stmt) === "next")).toBe(true); + expect(supportedStmts.some((stmt) => stmt._ === "UnsupportedStmt")).toBe(false); +}); + +it("preflights every variable declaration pattern before emitting any initializer in the statement", () => { + const { file, diagnostics } = lower(` + declare function first(): any[]; + declare function second(): any[]; + function bad() { + const keep = first(), [[value], ...tail] = second(); + return 1; + } + function good() { + const [value, ...tail] = second(); + return value; + } + `); + const badStmts = allStmts(methodByName(file, "bad")); + const rawIndex = badStmts.findIndex((stmt) => stmt._ === "UnsupportedStmt"); + const goodStmts = allStmts(methodByName(file, "good")); + + expect(badStmts[rawIndex]).toMatchObject({ _: "UnsupportedStmt", kindName: "VariableStatement" }); + expect(badStmts.slice(0, rawIndex).map(callName)).not.toContain("first"); + expect(badStmts.slice(0, rawIndex).map(callName)).not.toContain("second"); + expect(diagnostics.messages).toContainEqual(expect.stringContaining( + "unsupported statement: nested bindings in array rest are not represented with iterator semantics", + )); + expect(goodStmts.some((stmt) => callName(stmt) === "second")).toBe(true); + expect(goodStmts.some((stmt) => callName(stmt) === "next")).toBe(true); + expect(goodStmts.some((stmt) => stmt._ === "UnsupportedStmt")).toBe(false); +}); + describe("unsupported loop bindings do not break the whole file", () => { // `lower()` throws on unplaced labels via finalize(), so merely lowering is the assertion. const cases: Record = { From 93322391012c98ab92329bcdb84cf30f74191b87 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 04:05:29 +0300 Subject: [PATCH 52/63] fix(ets-tests): keep model and DTO graph output paths distinct --- .../src/test/kotlin/org/jacodb/ets/test/utils/Entrypoints.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/utils/Entrypoints.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/utils/Entrypoints.kt index 899e031ec..04f9f473d 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/utils/Entrypoints.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/utils/Entrypoints.kt @@ -59,7 +59,7 @@ object DumpEtsFileToDot { private const val NAME = "basic" private const val PATH = "/etsir/samples/$NAME.ts.json" private val DOT_DIR = Path("dot") - private val DOT_PATH = Path("$NAME.dto.dot") // relative to DOT_DIR + private val DOT_PATH = Path("$NAME.dot") // relative to DOT_DIR @JvmStatic fun main(args: Array) { From dedbb228a8ab7e58c5541e9b4b9de1ef28953865 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 04:12:14 +0300 Subject: [PATCH 53/63] fix(frontend): reject unsupported class definitions and preserve inferred literals --- jacodb-ets/ts-frontend/README.md | 7 + .../ts-frontend/src/lowering/astUtils.ts | 54 +++++- .../ts-frontend/src/lowering/classBuilder.ts | 20 +-- .../ts-frontend/src/lowering/exprLowering.ts | 14 +- .../ts-frontend/src/lowering/fileBuilder.ts | 23 ++- .../ts-frontend/src/lowering/stmtLowering.ts | 9 +- jacodb-ets/ts-frontend/test/classes.spec.ts | 170 +++++++++++++++++- 7 files changed, 271 insertions(+), 26 deletions(-) diff --git a/jacodb-ets/ts-frontend/README.md b/jacodb-ets/ts-frontend/README.md index 183725e00..18691961c 100644 --- a/jacodb-ets/ts-frontend/README.md +++ b/jacodb-ets/ts-frontend/README.md @@ -107,6 +107,13 @@ computed methods. Anonymous-class method metadata alone cannot install a fresh callable own property at each object evaluation in source order. Data properties containing function values and getter descriptors keep their existing lowering paths. +Class declarations with computed property, method, or accessor names use a +whole-declaration raw fallback, as do classes inheriting from such a declaration. +EtsIR class fields cannot represent the definition-time evaluation of those +keys or their runtime names. An `extends` value that does not resolve directly +to a class declaration also uses this fallback; its runtime base cannot be +fixed from the source name alone. Class expressions with heritage remain +unsupported by the existing expression fallback. ## Development diff --git a/jacodb-ets/ts-frontend/src/lowering/astUtils.ts b/jacodb-ets/ts-frontend/src/lowering/astUtils.ts index db38a4ded..6ce52b7a1 100644 --- a/jacodb-ets/ts-frontend/src/lowering/astUtils.ts +++ b/jacodb-ets/ts-frontend/src/lowering/astUtils.ts @@ -30,6 +30,37 @@ export function memberName(name: ts.PropertyName | ts.BindingName | ts.EntityNam return COMPUTED_MEMBER_NAME; } +/** Reject unsupported members, unsupported bases, and runtime base values without a stable class declaration. */ +export function unsupportedClassDeclarationReason(decl: ts.ClassDeclaration, checker: ts.TypeChecker): string | undefined { + const visited = new Set(); + const reasonOf = (current: ts.ClassDeclaration): string | undefined => { + if (visited.has(current)) return undefined; + visited.add(current); + + const hasComputedMember = current.members.some((member) => + member.name !== undefined && ts.isComputedPropertyName(member.name)); + if (hasComputedMember) return "computed class member needs definition-time key evaluation"; + + const extendsType = current.heritageClauses + ?.find((clause) => clause.token === ts.SyntaxKind.ExtendsKeyword) + ?.types[0]; + if (extendsType === undefined) return undefined; + + const base = classLikeDeclarationOf(extendsType.expression, checker); + if (base === undefined || !ts.isClassDeclaration(base)) { + return `base expression '${extendsType.expression.getText()}' has no directly resolved class declaration`; + } + if (base.getSourceFile().isDeclarationFile) { + return undefined; + } + + const baseReason = reasonOf(base); + return baseReason === undefined ? undefined : `base class '${base.name?.getText() ?? ""}' is unsupported: ${baseReason}`; + }; + + return reasonOf(decl); +} + export function modifiersOf(node: ts.Node): number { const flags = ts.getCombinedModifierFlags(node as ts.Declaration); let result = 0; @@ -147,7 +178,9 @@ function isUnmaterializedValue(identifier: ts.Identifier, checker: ts.TypeChecke const hasScopeFunctionBody = scopeFunctionImplementation(symbol) !== undefined; return symbol?.declarations?.some((declaration) => - (ts.isClassDeclaration(declaration) && declaration.getSourceFile().isDeclarationFile) + (ts.isClassDeclaration(declaration) + && (declaration.getSourceFile().isDeclarationFile + || unsupportedClassDeclarationReason(declaration, checker) !== undefined)) || (ts.isFunctionDeclaration(declaration) && !hasScopeFunctionBody && (ts.isSourceFile(declaration.parent) || ts.isModuleBlock(declaration.parent))) @@ -181,7 +214,24 @@ export function usesUnmaterializedDeclarationValue(expression: ts.Expression, ch if (ts.isTypeNode(node)) return false; if (ts.isIdentifier(node)) return isUnmaterializedValue(node, checker); - if (ts.isNewExpression(node) && classLikeDeclarationOf(node.expression, checker) !== undefined) { + if (ts.isPropertyAccessExpression(node)) { + const receiverClass = classLikeDeclarationOf(node.expression, checker); + if (receiverClass !== undefined + && ts.isClassDeclaration(receiverClass) + && unsupportedClassDeclarationReason(receiverClass, checker) !== undefined) { + return true; + } + } + + const constructedClass = ts.isNewExpression(node) + ? classLikeDeclarationOf(node.expression, checker) + : undefined; + if (ts.isNewExpression(node) && constructedClass !== undefined) { + if (ts.isClassDeclaration(constructedClass) + && unsupportedClassDeclarationReason(constructedClass, checker) !== undefined) { + return true; + } + return node.arguments?.some(visit) ?? false; } if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { diff --git a/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts index 6d7d2b81a..a8e792662 100644 --- a/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts +++ b/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts @@ -39,7 +39,7 @@ import { } from "../dto/constants"; import { BodyDto, ClassDto, FieldDto, MethodDto } from "../dto/model"; import { ClassSignatureDto, UNKNOWN_FILE_SIGNATURE } from "../dto/signatures"; -import { ClassTypeDto, TypeDto, BOOLEAN_TYPE, NUMBER_TYPE, STRING_TYPE, UNKNOWN_TYPE, VOID_TYPE } from "../dto/types"; +import { ClassTypeDto, TypeDto, NUMBER_TYPE, STRING_TYPE, UNKNOWN_TYPE, VOID_TYPE } from "../dto/types"; import { buildParameters, decoratorsOf, memberName, modifiersOf, parameterType, returnTypeOf } from "./astUtils"; import { unsupportedStmt } from "./diagnostics"; import { LoweringError, constant } from "./exprLowering"; @@ -643,8 +643,7 @@ export class ClassBuilder { return this.ctx.converter.convertTypeNode(decl.type); } if (decl.initializer !== undefined) { - const inferred = this.ctx.converter.typeOfNode(decl.name); - return widenLiteral(inferred); + return this.ctx.converter.typeOfNode(decl.name); } return UNKNOWN_TYPE; } @@ -693,18 +692,3 @@ function extendedInterfacesOf(decl: ts.InterfaceDeclaration): string[] { } return []; } - -/** Widen literal types inferred from initializers (fields are mutable). */ -function widenLiteral(type: TypeDto): TypeDto { - if (type._ === "LiteralType") { - switch (typeof type.literal) { - case "number": - return NUMBER_TYPE; - case "string": - return STRING_TYPE; - case "boolean": - return BOOLEAN_TYPE; - } - } - return type; -} diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index 8f865bf1d..2c6b52ac1 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -38,7 +38,7 @@ import { PATTERN_PARAMETER_PREFIX, } from "../dto/constants"; import { FieldDto, MethodDto } from "../dto/model"; -import { bindingIdentifier, buildParameters, BuiltParameters, classLikeDeclarationOf, memberName, modifiersOf, returnTypeOf } from "./astUtils"; +import { bindingIdentifier, buildParameters, BuiltParameters, classLikeDeclarationOf, memberName, modifiersOf, returnTypeOf, unsupportedClassDeclarationReason } from "./astUtils"; import { BinaryOp, RelationOp, UnaryOp } from "../dto/ops"; import { ClassSignatureDto, @@ -381,6 +381,11 @@ export class ExprLowerer { if (declaration !== undefined && ts.isClassDeclaration(declaration) && isProjectFile(declaration) && (ts.isSourceFile(declaration.parent) || ts.isModuleBlock(declaration.parent))) { + const unsupportedReason = unsupportedClassDeclarationReason(declaration, this.m.checker); + if (unsupportedReason !== undefined) { + throw new LoweringError(`class value has no EtsIR representation: ${unsupportedReason}`); + } + const signature = this.m.converter.classSignatureOf(declaration); return { _: "ClassValueRef", signature, type: { _: "ClassValueType", signature } }; } @@ -1991,6 +1996,13 @@ export class ExprLowerer { const decl = classLikeDeclarationOf(value, this.m.checker); if (decl === undefined) return undefined; + const unsupportedReason = ts.isClassDeclaration(decl) && isProjectFile(decl) + ? unsupportedClassDeclarationReason(decl, this.m.checker) + : undefined; + if (unsupportedReason !== undefined) { + throw new LoweringError(`class value has no EtsIR representation: ${unsupportedReason}`); + } + if (isProjectFile(decl)) { return this.m.converter.classSignatureOf(decl); } diff --git a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts index 166b1b8fc..73a82bbfe 100644 --- a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts +++ b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts @@ -42,7 +42,7 @@ import { ClassDto, EtsFileDto, ExportInfoDto, FieldDto, ImportInfoDto, MethodDto import { ClassSignatureDto, FileSignatureDto, NamespaceSignatureDto } from "../dto/signatures"; import { VOID_TYPE } from "../dto/types"; import { TypeConverter } from "../types/convert"; -import { classValueDeclarationOf, exportAssignmentSupport, modifiersOf, scopeFunctionImplementation, scopeFunctionType } from "./astUtils"; +import { classValueDeclarationOf, exportAssignmentSupport, modifiersOf, scopeFunctionImplementation, scopeFunctionType, unsupportedClassDeclarationReason } from "./astUtils"; import { ClassBuilder } from "./classBuilder"; import { Diagnostics } from "./diagnostics"; import { AnonymousRegistry, LoweringContext, MethodContext } from "./methodBuilder"; @@ -218,6 +218,11 @@ class FileBuilder { private buildExportInfos(sourceFile: ts.SourceFile): ExportInfoDto[] { const infos: ExportInfoDto[] = []; for (const statement of sourceFile.statements) { + if (ts.isClassDeclaration(statement) + && unsupportedClassDeclarationReason(statement, this.ctx.checker) !== undefined) { + continue; + } + // Exported declarations: `export class C {}`, `export function f() {}`, ... const modifiers = modifiersOf(statement); if ((modifiers & Modifier.EXPORT) !== 0) { @@ -261,6 +266,12 @@ class FileBuilder { infos.push(info); } else if (ts.isNamedExports(statement.exportClause)) { for (const element of statement.exportClause.elements) { + const exportedClass = classValueDeclarationOf(element.name, this.ctx.checker); + if (exportedClass !== undefined + && unsupportedClassDeclarationReason(exportedClass, this.ctx.checker) !== undefined) { + continue; + } + const info: ExportInfoDto = { exportName: element.name.text, exportType: this.exportTypeOfSymbol(element.name), @@ -288,6 +299,12 @@ class FileBuilder { } // `export default ;` stores a snapshot in `%dflt.default`. if (ts.isExportAssignment(statement)) { + const exportedClass = classValueDeclarationOf(statement.expression, this.ctx.checker); + if (exportedClass !== undefined + && unsupportedClassDeclarationReason(exportedClass, this.ctx.checker) !== undefined) { + continue; + } + if (exportAssignmentSupport(statement, this.ctx.checker) !== "supported") { const name = ts.isIdentifier(statement.expression) ? statement.expression.text : "default"; infos.push({ @@ -356,7 +373,9 @@ class FileBuilder { methods.push(this.classBuilder.buildMethodFromDecl(defaultClassSignature, statement, name)); } } else if (ts.isClassDeclaration(statement)) { - classes.push(this.classBuilder.buildClass(statement, anonymousDefaultExportBinding(statement))); + if (unsupportedClassDeclarationReason(statement, this.ctx.checker) === undefined) { + classes.push(this.classBuilder.buildClass(statement, anonymousDefaultExportBinding(statement))); + } } else if (ts.isInterfaceDeclaration(statement)) { classes.push(this.classBuilder.buildInterface(statement)); } else if (ts.isEnumDeclaration(statement)) { diff --git a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts index 3fa53392b..a50ad3ddb 100644 --- a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts @@ -25,7 +25,7 @@ import * as ts from "typescript"; import { MethodSignatureDto, UNKNOWN_CLASS_SIGNATURE, UNKNOWN_FILE_SIGNATURE } from "../dto/signatures"; import { BOOLEAN_TYPE, NUMBER_TYPE, STRING_TYPE, SYMBOL_TYPE, TypeDto, UNDEFINED_TYPE, UNKNOWN_TYPE } from "../dto/types"; import { ImmediateDto, LValueDto, LocalDto, ValueDto } from "../dto/values"; -import { exportAssignmentSupport } from "./astUtils"; +import { exportAssignmentSupport, unsupportedClassDeclarationReason } from "./astUtils"; import type { BuiltParameters } from "./astUtils"; import { Label } from "./cfg"; import { IteratorLowerer, throwRuntimeError } from "./iteratorLowering"; @@ -360,6 +360,13 @@ export class StmtLowerer { } return; } + const unsupportedClassReason = ts.isClassDeclaration(node) + ? unsupportedClassDeclarationReason(node, this.m.checker) + : undefined; + if (unsupportedClassReason !== undefined) { + throw new LoweringError(unsupportedClassReason); + } + const isDeclarationHandledOutsideBody = ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node) || diff --git a/jacodb-ets/ts-frontend/test/classes.spec.ts b/jacodb-ets/ts-frontend/test/classes.spec.ts index b13109564..787b49ca4 100644 --- a/jacodb-ets/ts-frontend/test/classes.spec.ts +++ b/jacodb-ets/ts-frontend/test/classes.spec.ts @@ -2,10 +2,10 @@ import * as ts from "typescript"; import { describe, expect, it } from "vitest"; import { Modifier } from "../src/dto/constants"; import { ClassDto, EtsFileDto, MethodDto } from "../src/dto/model"; -import { StmtDto } from "../src/dto/stmts"; +import { RawStmtDto, StmtDto } from "../src/dto/stmts"; import { InstanceCallExprDto } from "../src/dto/values"; import { serializeEtsFile } from "../src/serialize"; -import { compile, lower, singleBlockStmts } from "./util"; +import { compile, lower, lowerProject, singleBlockStmts } from "./util"; const FILE_SIG = { projectName: "proj", fileName: "test.ts" }; @@ -33,6 +33,172 @@ function constructorCallOf(stmt: StmtDto): InstanceCallExprDto | undefined { } describe("class lowering", () => { + it("keeps computed class declarations wholly raw before claiming class storage", () => { + const { file, diagnostics } = lower(` + let evaluations = 0; + function key(): "dynamic" { evaluations++; return "dynamic"; } + export class Constant { ["x"] = 1; } + class SideEffect { [key()] = 2; } + class StaticKey { static [key()] = 3; } + class NoInitializer { [key()]: number; } + class ComputedMethod { [key()](): number { return 4; } } + class ComputedAccessor { get [key()](): number { return 5; } } + class Supported { x = 6; } + export function refer(): typeof Constant { return Constant; } + export default Constant; + `); + const rejected = ["Constant", "SideEffect", "StaticKey", "NoInitializer", "ComputedMethod", "ComputedAccessor"]; + const defaultBody = methodOf(classByName(file, "%dflt"), "%dflt").body!; + const referenceBody = methodOf(classByName(file, "%dflt"), "refer").body!; + const rawClasses = defaultBody.cfg.blocks.flatMap((block) => block.stmts) + .map((stmt) => stmt as RawStmtDto) + .filter((stmt) => stmt._ === "UnsupportedStmt" && stmt.kindName === "ClassDeclaration"); + + expect(rawClasses).toHaveLength(rejected.length); + expect(rawClasses.map((stmt) => stmt.text)) + .toEqual(rejected.map((name) => expect.stringContaining(`class ${name}`))); + expect(defaultBody.stmtOrigins?.filter((origin) => origin.source.nodeKind === "ClassDeclaration")) + .toHaveLength(rejected.length); + expect(file.classes.filter((clazz) => rejected.includes(clazz.signature.name))).toEqual([]); + expect(file.classes.flatMap((clazz) => clazz.fields).some((field) => field.signature.name === "%computed")) + .toBe(false); + expect(file.exportInfos.some((info) => info.exportName === "Constant")).toBe(false); + expect(file.exportInfos.some((info) => info.exportName === "default")).toBe(false); + expect(defaultBody.cfg.blocks.flatMap((block) => block.stmts)) + .toContainEqual(expect.objectContaining({ _: "UnsupportedStmt", kindName: "ExportAssignment" })); + expect(referenceBody.cfg.blocks.flatMap((block) => block.stmts).some((stmt) => + stmt._ === "ReturnStmt" && stmt.arg._ === "ClassValueRef", + )).toBe(false); + expect(referenceBody.cfg.blocks.flatMap((block) => block.stmts)) + .toContainEqual(expect.objectContaining({ _: "AssignStmt", right: expect.objectContaining({ _: "UnsupportedValue" }) })); + expect(classByName(file, "Supported").fields[0].signature.name).toBe("x"); + expect(diagnostics.messages.filter((message) => message.includes("unsupported statement: computed class member"))) + .toHaveLength(rejected.length); + }); + + it("rejects derived declarations transitively when a computed base has no class DTO", () => { + const source = ` + export class Base { ["x"] = 1; } + export class Derived extends Base { own = 2; } + export class Grandchild extends Derived {} + class OrdinaryBase { value = 3; } + export class OrdinaryDerived extends OrdinaryBase {} + export function refer(): typeof Derived { return Derived; } + `; + const { file, diagnostics } = lower(source); + const defaultBody = methodOf(classByName(file, "%dflt"), "%dflt").body!; + const rejected = ["Base", "Derived", "Grandchild"]; + const rawClasses = defaultBody.cfg.blocks.flatMap((block) => block.stmts) + .map((stmt) => stmt as RawStmtDto) + .filter((stmt) => stmt._ === "UnsupportedStmt" && stmt.kindName === "ClassDeclaration"); + const rawOrigins = defaultBody.stmtOrigins?.filter((origin) => origin.source.nodeKind === "ClassDeclaration") ?? []; + + expect(rawClasses).toHaveLength(rejected.length); + expect(rawOrigins.map((origin) => source.slice(origin.source.startOffset, origin.source.endOffset))) + .toEqual(rejected.map((name) => expect.stringContaining(`class ${name}`))); + expect(file.classes.filter((clazz) => rejected.includes(clazz.signature.name))).toEqual([]); + expect(file.exportInfos.filter((info) => rejected.includes(info.exportName))).toEqual([]); + expect(file.classes.flatMap((clazz) => clazz.methods).some((method) => + method.signature.name === "constructor" + && rejected.includes(method.signature.declaringClass.name), + )).toBe(false); + expect(methodOf(classByName(file, "%dflt"), "refer").body!.cfg.blocks.flatMap((block) => block.stmts)) + .toContainEqual(expect.objectContaining({ _: "AssignStmt", right: expect.objectContaining({ _: "UnsupportedValue" }) })); + expect(classByName(file, "OrdinaryDerived").superClassName).toBe("OrdinaryBase"); + expect(diagnostics.messages.filter((message) => message.includes("unsupported statement: "))) + .toHaveLength(rejected.length); + }); + + it("rejects a derived declaration through an imported base alias", () => { + const { file, diagnostics } = lowerProject({ + "/base.ts": `export class Base { ["x"] = 1; }`, + "/consumer.ts": ` + import { Base as ImportedBase } from "./base"; + export class Derived extends ImportedBase {} + class Grandchild extends Derived {} + class OrdinaryBase {} + class OrdinaryDerived extends OrdinaryBase {} + `, + }, "/consumer.ts"); + const defaultBody = methodOf(classByName(file, "%dflt"), "%dflt").body!; + const rawClasses = defaultBody.cfg.blocks.flatMap((block) => block.stmts) + .map((stmt) => stmt as RawStmtDto) + .filter((stmt) => stmt._ === "UnsupportedStmt" && stmt.kindName === "ClassDeclaration"); + + expect(rawClasses).toHaveLength(2); + expect(file.classes.some((clazz) => clazz.signature.name === "Derived" || clazz.signature.name === "Grandchild")) + .toBe(false); + expect(file.exportInfos.some((info) => info.exportName === "Derived")).toBe(false); + expect(classByName(file, "OrdinaryDerived").superClassName).toBe("OrdinaryBase"); + expect(diagnostics.messages.filter((message) => message.includes("base class"))).toHaveLength(2); + }); + + it("keeps local value and mutable extends aliases raw instead of inventing a fixed base", () => { + const source = ` + class Base { ["x"] = 1; } + const Alias = Base; + export class Derived extends Alias {} + export class Grandchild extends Derived {} + let Mutable = Base; + export class ViaMutable extends Mutable {} + const Expression = class extends Base {}; + class OrdinaryBase {} + class OrdinaryDerived extends OrdinaryBase {} + `; + const { file, diagnostics } = lower(source); + const defaultBody = methodOf(classByName(file, "%dflt"), "%dflt").body!; + const rejected = ["Base", "Derived", "Grandchild", "ViaMutable"]; + const rawClasses = defaultBody.cfg.blocks.flatMap((block) => block.stmts) + .map((stmt) => stmt as RawStmtDto) + .filter((stmt) => stmt._ === "UnsupportedStmt" && stmt.kindName === "ClassDeclaration"); + const rawOrigins = defaultBody.stmtOrigins?.filter((origin) => origin.source.nodeKind === "ClassDeclaration") ?? []; + + expect(rawClasses).toHaveLength(rejected.length); + expect(rawOrigins.map((origin) => source.slice(origin.source.startOffset, origin.source.endOffset))) + .toEqual(rejected.map((name) => expect.stringContaining(`class ${name}`))); + expect(file.classes.filter((clazz) => rejected.includes(clazz.signature.name))).toEqual([]); + expect(file.exportInfos.filter((info) => rejected.includes(info.exportName))).toEqual([]); + expect(file.classes.some((clazz) => clazz.signature.name.startsWith("%ACExpr"))).toBe(false); + expect(defaultBody.cfg.blocks.flatMap((block) => block.stmts)) + .toContainEqual(expect.objectContaining({ _: "AssignStmt", right: expect.objectContaining({ _: "UnsupportedValue" }) })); + expect(classByName(file, "OrdinaryDerived").superClassName).toBe("OrdinaryBase"); + expect(diagnostics.messages.filter((message) => message.includes("unsupported statement: "))) + .toHaveLength(rejected.length); + expect(diagnostics.messages.some((message) => message.includes("class expression heritage"))).toBe(true); + }); + + it("keeps inferred literal field types when the checker retains them", () => { + const { file, diagnostics } = lower(` + class Kinds { + readonly fixed = "ok"; + pinned = "ok" as const; + mutable = "ok"; + readFixed() { return this.fixed; } + readPinned() { return this.pinned; } + readMutable() { return this.mutable; } + } + `); + const kinds = classByName(file, "Kinds"); + const fieldType = (name: string) => kinds.fields.find((field) => field.signature.name === name)?.signature.type; + const readType = (name: string) => { + const statements = methodOf(kinds, name).body!.cfg.blocks.flatMap((block) => block.stmts); + for (const statement of statements) { + if (statement._ === "AssignStmt" && statement.right._ === "InstanceFieldRef") { + return statement.right.field.type; + } + } + return undefined; + }; + + expect(fieldType("fixed")).toEqual({ _: "LiteralType", literal: "ok" }); + expect(fieldType("pinned")).toEqual({ _: "LiteralType", literal: "ok" }); + expect(fieldType("mutable")).toEqual({ _: "StringType" }); + expect(readType("readFixed")).toEqual(fieldType("fixed")); + expect(readType("readPinned")).toEqual(fieldType("pinned")); + expect(readType("readMutable")).toEqual(fieldType("mutable")); + expect(diagnostics.messages).toEqual([]); + }); + it("creates a fresh class expression value with constructor and field initializer", () => { const source = ` export function value(): number { From 97a9d75c2f2d9de8c4e5f2c7e41f54050dbe239f Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 04:29:44 +0300 Subject: [PATCH 54/63] fix(ts-frontend): forward inherited default constructor parameters --- jacodb-ets/ts-frontend/README.md | 5 ++ .../ts-frontend/src/lowering/classBuilder.ts | 37 ++++++++- jacodb-ets/ts-frontend/test/classes.spec.ts | 75 +++++++++++++++++++ 3 files changed, 115 insertions(+), 2 deletions(-) diff --git a/jacodb-ets/ts-frontend/README.md b/jacodb-ets/ts-frontend/README.md index 18691961c..33b6df679 100644 --- a/jacodb-ets/ts-frontend/README.md +++ b/jacodb-ets/ts-frontend/README.md @@ -114,6 +114,11 @@ keys or their runtime names. An `extends` value that does not resolve directly to a class declaration also uses this fallback; its runtime base cannot be fixed from the source name alone. Class expressions with heritage remain unsupported by the existing expression fallback. +Default derived constructors forward fixed parameters through constructor-free +ancestors. When an inherited constructor has a rest parameter, the synthetic +constructor emits an unsupported statement: the current call IR cannot forward +the received rest array as an arbitrary number of arguments without packing it +again. Explicit constructors retain their usual lowering. ## Development diff --git a/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts index a8e792662..854aadde3 100644 --- a/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts +++ b/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts @@ -110,7 +110,7 @@ export class ClassBuilder { methods.push( ctorDecl !== undefined ? this.buildConstructor(signature, ctorDecl, superClass?.signature) - : this.synthesizeDefaultConstructor(signature, superClass), + : this.synthesizeDefaultConstructor(signature, decl, superClass), ); const result: ClassDto = { @@ -428,6 +428,7 @@ export class ClassBuilder { private synthesizeDefaultConstructor( declaringClass: ClassSignatureDto, + decl: ts.ClassLikeDeclaration, superClass?: { signature: ClassSignatureDto; constructorDecl?: ts.ConstructorDeclaration }, ): MethodDto { const classType: ClassTypeDto = { _: "ClassType", signature: declaringClass }; @@ -435,6 +436,26 @@ export class ClassBuilder { const inherited = superClass?.constructorDecl !== undefined ? buildParameters(this.ctx, superClass.constructorDecl) : { parameters: [], prologueParams: [] }; + const hasRestParameter = inherited.parameters.some((parameter) => parameter.isRest === true); + if (hasRestParameter) { + // ParameterRef for a rest parameter is already a packed array. Passing + // it as one call argument would pack it again in the base constructor. + this.ctx.diagnostics.warn(decl, "unsupported default constructor forwarding of rest arguments"); + m.withOrigin(decl, () => m.cfg.emit(unsupportedStmt(decl))); + return { + signature: { + declaringClass, + name: CONSTRUCTOR_NAME, + parameters: inherited.parameters, + returnType: classType, + }, + modifiers: 0, + decorators: [], + isStrictMode: true, + body: m.build(), + }; + } + m.emitPrologue(inherited.prologueParams); if (superClass !== undefined) { const thisLocal = m.getOrCreateLocal("this", classType); @@ -497,6 +518,7 @@ export class ClassBuilder { private superClassInfo( decl: ts.ClassLikeDeclaration, + visitedClasses: Set = new Set(), ): { signature: ClassSignatureDto; constructorDecl?: ts.ConstructorDeclaration } | undefined { const extended = decl.heritageClauses ?.find((clause) => clause.token === ts.SyntaxKind.ExtendsKeyword) @@ -513,9 +535,20 @@ export class ClassBuilder { name: extended.expression.getText(), declaringFile: UNKNOWN_FILE_SIGNATURE, }; - const constructorDecl = classDeclaration?.members.find( + const ownConstructor = classDeclaration?.members.find( (member): member is ts.ConstructorDeclaration => ts.isConstructorDeclaration(member) && member.body !== undefined, ) ?? classDeclaration?.members.find(ts.isConstructorDeclaration); + // A default derived constructor forwards the arguments accepted by the + // first explicit constructor, even across other default constructors. + let constructorDecl = ownConstructor; + const canFollowBase = constructorDecl === undefined + && classDeclaration !== undefined + && !visitedClasses.has(classDeclaration); + if (canFollowBase && classDeclaration !== undefined) { + visitedClasses.add(classDeclaration); + constructorDecl = this.superClassInfo(classDeclaration, visitedClasses)?.constructorDecl; + } + return constructorDecl === undefined ? { signature } : { signature, constructorDecl }; } diff --git a/jacodb-ets/ts-frontend/test/classes.spec.ts b/jacodb-ets/ts-frontend/test/classes.spec.ts index 787b49ca4..2c8ff9b19 100644 --- a/jacodb-ets/ts-frontend/test/classes.spec.ts +++ b/jacodb-ets/ts-frontend/test/classes.spec.ts @@ -758,6 +758,81 @@ describe("class lowering", () => { }); }); + it("forwards constructor arguments through two default derived constructors", () => { + const { file } = lower(` + function mark(): number { return 7; } + class Grand { constructor(public value: number = mark(), label?: string) {} } + class Base extends Grand {} + class Derived extends Base {} + export function read(): number { return new Derived(9).value; } + `); + const expectedParameters = [ + { name: "value", type: { _: "NumberType" } }, + { name: "label", type: { _: "StringType" }, isOptional: true }, + ]; + + for (const [className, baseName] of [["Base", "Grand"], ["Derived", "Base"]]) { + const ctor = methodOf(classByName(file, className), "constructor"); + const stmts = singleBlockStmts(ctor); + const forwardedCall = stmts.map(constructorCallOf).find((call) => call !== undefined); + const parameterRefs = stmts.filter((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "ParameterRef"); + + expect(ctor.signature.parameters).toMatchObject(expectedParameters); + expect(parameterRefs.map((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "ParameterRef" + ? stmt.right.index : undefined)).toEqual([0, 1]); + expect(forwardedCall).toMatchObject({ + method: { declaringClass: { name: baseName }, parameters: expectedParameters }, + args: [{ name: "value" }, { name: "label" }], + }); + expect(stmts.some((stmt) => stmt._ === "AssignStmt" + && stmt.right._ === "PtrCallExpr" && stmt.right.method.name === "mark")).toBe(false); + } + + const grandCtor = methodOf(classByName(file, "Grand"), "constructor"); + const grandStmts = grandCtor.body!.cfg.blocks.flatMap((block) => block.stmts); + expect(grandStmts.some((stmt) => stmt._ === "AssignStmt" + && stmt.right._ === "PtrCallExpr" && stmt.right.method.name === "mark")).toBe(true); + }); + + it("marks default rest forwarding unsupported instead of passing a packed array as one argument", () => { + const { file, diagnostics } = lower(` + class Grand { constructor(...values: number[]) {} } + class Base extends Grand {} + class Derived extends Base {} + export function create(): Derived { return new Derived(1, 2); } + `); + + for (const className of ["Base", "Derived"]) { + const ctor = methodOf(classByName(file, className), "constructor"); + const stmts = ctor.body!.cfg.blocks.flatMap((block) => block.stmts); + + expect(ctor.signature.parameters).toMatchObject([{ name: "values", isRest: true }]); + expect(stmts).toContainEqual(expect.objectContaining({ _: "UnsupportedStmt" })); + expect(stmts.some((stmt) => constructorCallOf(stmt) !== undefined)).toBe(false); + } + + expect(diagnostics.messages.filter((message) => + message.includes("unsupported default constructor forwarding of rest arguments"), + )).toHaveLength(2); + }); + + it("uses an explicit intermediate constructor as the forwarding boundary", () => { + const { file } = lower(` + class Grand { constructor(value: number, label: string) {} } + class Base extends Grand { constructor(value: number) { super(value, "base"); } } + class Derived extends Base {} + `); + const ctor = methodOf(classByName(file, "Derived"), "constructor"); + const stmts = singleBlockStmts(ctor); + const forwardedCall = stmts.map(constructorCallOf).find((call) => call !== undefined); + + expect(ctor.signature.parameters).toMatchObject([{ name: "value", type: { _: "NumberType" } }]); + expect(forwardedCall).toMatchObject({ + method: { declaringClass: { name: "Base" }, parameters: [{ name: "value" }] }, + args: [{ name: "value" }], + }); + }); + it("places derived parameter properties after super and before instance fields", () => { const { file } = lower(` class Base { constructor() {} } From 22f853b8595e29706b5bb27a5b6c1282e7981896 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 04:40:15 +0300 Subject: [PATCH 55/63] fix(ts-frontend): preserve generic interface method binders --- .../ts-frontend/src/lowering/classBuilder.ts | 8 ++++++- jacodb-ets/ts-frontend/test/classes.spec.ts | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts index 854aadde3..7cb396f86 100644 --- a/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts +++ b/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts @@ -333,7 +333,7 @@ export class ClassBuilder { buildBodylessMethod(declaringClass: ClassSignatureDto, decl: ts.MethodSignature): MethodDto { const { parameters } = buildParameters(this.ctx, decl); - return { + const method: MethodDto = { signature: { declaringClass, name: memberName(decl.name), @@ -343,6 +343,12 @@ export class ClassBuilder { modifiers: modifiersOf(decl), decorators: [], }; + const typeParameters = this.ctx.converter.convertTypeParameters(decl.typeParameters); + if (typeParameters !== undefined) { + method.typeParameters = typeParameters; + } + + return method; } private buildConstructor( diff --git a/jacodb-ets/ts-frontend/test/classes.spec.ts b/jacodb-ets/ts-frontend/test/classes.spec.ts index 2c8ff9b19..6a358d719 100644 --- a/jacodb-ets/ts-frontend/test/classes.spec.ts +++ b/jacodb-ets/ts-frontend/test/classes.spec.ts @@ -933,6 +933,29 @@ describe("interface lowering", () => { expect(check.signature.returnType).toEqual({ _: "BooleanType" }); }); + it("preserves generic binders on bodyless interface methods", () => { + const { file, diagnostics } = lower(` + interface Identity { + identity(value: T): T; + } + `); + const identity = methodOf(classByName(file, "Identity"), "identity"); + + expect(identity.body).toBeUndefined(); + expect(identity.typeParameters).toEqual([{ + _: "GenericType", + name: "T", + constraint: { _: "StringType" }, + defaultType: { _: "LiteralType", literal: "fallback" }, + }]); + expect(identity.signature.parameters).toEqual([{ + name: "value", + type: { _: "GenericType", name: "T" }, + }]); + expect(identity.signature.returnType).toEqual({ _: "GenericType", name: "T" }); + expect(diagnostics.messages).toEqual([]); + }); + it("represents index signatures as typed fields", () => { const { file, diagnostics } = lower(` interface Graph { From c71377f5fa8422b8a1c0f8237316be5dc7e4cb59 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 04:42:15 +0300 Subject: [PATCH 56/63] fix(ts-frontend): preserve local export metadata --- .../ts-frontend/src/lowering/fileBuilder.ts | 24 ++++++++++-- jacodb-ets/ts-frontend/test/imports.spec.ts | 37 ++++++++++++++++++- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts index 73a82bbfe..5c121bde0 100644 --- a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts +++ b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts @@ -228,17 +228,22 @@ class FileBuilder { if ((modifiers & Modifier.EXPORT) !== 0) { const name = declarationName(statement) ?? anonymousDefaultExportBinding(statement); if (name !== undefined) { + const typeDeclarationName = ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement) + ? statement.name + : undefined; + const isTypeOnly = typeDeclarationName !== undefined + && this.isTypeOnlyLocalExport(typeDeclarationName); infos.push({ exportName: name, exportType: exportTypeOfDeclaration(statement), modifiers, - isTypeOnly: false, + isTypeOnly, }); } else if (ts.isVariableStatement(statement)) { for (const decl of statement.declarationList.declarations) { - if (ts.isIdentifier(decl.name)) { + for (const identifier of bindingIdentifiers(decl.name)) { infos.push({ - exportName: decl.name.text, + exportName: identifier.text, exportType: ExportType.LOCAL, modifiers, isTypeOnly: false, @@ -272,11 +277,14 @@ class FileBuilder { continue; } + const localName = element.propertyName ?? element.name; + const isTypeOnly = statement.isTypeOnly || element.isTypeOnly + || (exportFrom === undefined && this.isTypeOnlyLocalExport(localName)); const info: ExportInfoDto = { exportName: element.name.text, exportType: this.exportTypeOfSymbol(element.name), modifiers: 0, - isTypeOnly: statement.isTypeOnly || element.isTypeOnly, + isTypeOnly, }; if (element.propertyName !== undefined) info.nameBeforeAs = element.propertyName.text; if (exportFrom !== undefined) info.exportFrom = exportFrom; @@ -336,6 +344,14 @@ class FileBuilder { return decl !== undefined ? exportTypeOfDeclaration(decl) : ExportType.UNKNOWN; } + /** A local symbol can merge type and value declarations; only pure types lack a runtime export. */ + private isTypeOnlyLocalExport(node: ts.Node): boolean { + const symbol = this.ctx.converter.symbolOf(node); + return symbol !== undefined + && (symbol.flags & ts.SymbolFlags.Type) !== 0 + && (symbol.flags & ts.SymbolFlags.Value) === 0; + } + /** * Lower the statements of a scope (file or namespace body): * a `%dflt` class collecting loose statements + functions, plus diff --git a/jacodb-ets/ts-frontend/test/imports.spec.ts b/jacodb-ets/ts-frontend/test/imports.spec.ts index 5c729e619..7b3a6a3ac 100644 --- a/jacodb-ets/ts-frontend/test/imports.spec.ts +++ b/jacodb-ets/ts-frontend/test/imports.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { Modifier } from "../src/dto/constants"; +import { ExportType, Modifier } from "../src/dto/constants"; import { lower } from "./util"; describe("import infos", () => { @@ -50,6 +50,41 @@ describe("export infos", () => { expect(byName["C"].modifiers & Modifier.EXPORT).toBe(Modifier.EXPORT); }); + it("exports each object and array destructuring binding through its module field", () => { + const { file } = lower(` + export const { left: renamed, right } = { left: 1, right: 2 }; + export const [first, , third] = [3, 4, 5]; + `); + + const exportNames = file.exportInfos.map((info) => info.exportName); + const defaultClass = file.classes.find((clazz) => clazz.signature.name === "%dflt")!; + const fieldNames = defaultClass.fields.map((field) => field.signature.name); + + expect(exportNames).toEqual(["renamed", "right", "first", "third"]); + expect(fieldNames).toEqual(expect.arrayContaining(exportNames)); + expect(file.exportInfos.every((info) => info.exportType === ExportType.LOCAL && !info.isTypeOnly)).toBe(true); + }); + + it("marks pure type exports without hiding a merged runtime value", () => { + const { file } = lower(` + export interface I {} + export type T = number; + type Local = string; + export { Local }; + export class Hybrid {} + export interface Hybrid {} + export { Hybrid as Both }; + `); + + const byName = Object.fromEntries(file.exportInfos.map((info) => [info.exportName, info])); + + expect(byName["I"].isTypeOnly).toBe(true); + expect(byName["T"].isTypeOnly).toBe(true); + expect(byName["Local"].isTypeOnly).toBe(true); + expect(byName["Hybrid"].isTypeOnly).toBe(false); + expect(byName["Both"].isTypeOnly).toBe(false); + }); + it("builds infos for re-exports with nameBeforeAs and exportFrom", () => { const { file } = lower(` export { X, Y as Z } from "./other"; From 565f7b93546932444724272c78a06700dd4e1cc8 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 04:51:58 +0300 Subject: [PATCH 57/63] fix(ts-frontend): preserve dotted namespace containment --- .../ts-frontend/src/lowering/fileBuilder.ts | 34 +++++++++---------- jacodb-ets/ts-frontend/test/classes.spec.ts | 24 +++++++++++++ 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts index 5c121bde0..28b00726a 100644 --- a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts +++ b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts @@ -358,10 +358,10 @@ class FileBuilder { * declared classes/interfaces/enums and nested namespaces. */ private buildScope( - scope: ts.SourceFile | ts.ModuleBlock, + scope: ts.SourceFile | ts.ModuleBlock | ts.ModuleDeclaration, declaringNamespace: NamespaceSignatureDto | undefined, ): ScopeContents { - const statements = scope.statements; + const statements = ts.isModuleDeclaration(scope) ? [] : scope.statements; const classes: ClassDto[] = []; const namespaces: NamespaceDto[] = []; @@ -498,9 +498,8 @@ class FileBuilder { this.ctx.diagnostics.warn(decl, "string-named modules are not supported"); return undefined; } - // `namespace A.B {}` nests; resolve the innermost body. - let body = decl.body; - let signature: NamespaceSignatureDto = { + const body = decl.body; + const signature: NamespaceSignatureDto = { name: decl.name.text, declaringFile: this.fileSignature, }; @@ -509,20 +508,21 @@ class FileBuilder { signature.declaringNamespace = outer; } - while (body !== undefined && ts.isModuleDeclaration(body)) { - const innerSignature: NamespaceSignatureDto = { - name: ts.isIdentifier(body.name) ? body.name.text : "%unk", - declaringFile: this.fileSignature, - declaringNamespace: signature, - }; - signature = innerSignature; - body = body.body; - } - if (body === undefined || !ts.isModuleBlock(body)) { + if (body === undefined) { // Ambient namespace without a body. return { signature, classes: [], namespaces: [] }; } + if (ts.isModuleDeclaration(body)) { + // A dotted declaration has no block for its outer scope. + const contents = this.buildScope(decl, signature); + const inner = this.buildNamespace(body); + if (inner !== undefined) { + contents.namespaces.push(inner); + } + return { signature, classes: contents.classes, namespaces: contents.namespaces }; + } + const contents = this.buildScope(body, signature); return { signature, @@ -534,9 +534,9 @@ class FileBuilder { /** Loose scope statements -> `%dflt` method (impl continues below). */ private buildDefaultMethod( declaringClass: ClassSignatureDto, - scope: ts.SourceFile | ts.ModuleBlock, + scope: ts.SourceFile | ts.ModuleBlock | ts.ModuleDeclaration, ): MethodDto { - const statements = scope.statements; + const statements = ts.isModuleDeclaration(scope) ? [] : scope.statements; const m = new MethodContext(this.ctx, declaringClass, DEFAULT_ARK_METHOD_NAME); m.emitPrologue([]); diff --git a/jacodb-ets/ts-frontend/test/classes.spec.ts b/jacodb-ets/ts-frontend/test/classes.spec.ts index 6a358d719..3192811c4 100644 --- a/jacodb-ets/ts-frontend/test/classes.spec.ts +++ b/jacodb-ets/ts-frontend/test/classes.spec.ts @@ -1333,4 +1333,28 @@ describe("namespace lowering", () => { }); expect(nested.classes!.map((c) => c.signature.name)).toContain("E"); }); + + it("keeps every scope in a dotted namespace declaration", () => { + const { file, diagnostics } = lower("namespace A.B { export class C {} }"); + const explicit = lower("namespace A { namespace B { export class C {} } }").file; + const outer = file.namespaces[0]; + const inner = outer.namespaces![0]; + const outerDefault = outer.classes!.find((clazz) => clazz.signature.name === "%dflt")!; + const innerDefault = inner.classes!.find((clazz) => clazz.signature.name === "%dflt")!; + const declaredClass = inner.classes!.find((clazz) => clazz.signature.name === "C")!; + + expect(file.namespaces).toHaveLength(1); + expect(outer.signature).toEqual(explicit.namespaces[0].signature); + expect(outer.namespaces).toHaveLength(1); + expect(inner.signature).toEqual(explicit.namespaces[0].namespaces![0].signature); + expect(inner.namespaces).toHaveLength(0); + expect(outerDefault.signature.declaringNamespace).toEqual(outer.signature); + expect(innerDefault.signature.declaringNamespace).toEqual(inner.signature); + expect(outerDefault.methods.map((method) => method.signature.name)).toEqual(["%dflt"]); + expect(innerDefault.methods.map((method) => method.signature.name)).toEqual(["%dflt"]); + expect(declaredClass.signature).toEqual({ + name: "C", declaringFile: FILE_SIG, declaringNamespace: inner.signature, + }); + expect(diagnostics.messages).toEqual([]); + }); }); From ccb426512ed959eed496c3abb0d4ad7bf4e3cbed Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 05:02:27 +0300 Subject: [PATCH 58/63] fix(ts-frontend): preserve escaped CLI paths and emission status --- jacodb-ets/ts-frontend/src/index.ts | 24 ++++++++++++++++-------- jacodb-ets/ts-frontend/test/cli.spec.ts | 6 ++++++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/jacodb-ets/ts-frontend/src/index.ts b/jacodb-ets/ts-frontend/src/index.ts index 9b4109a5d..bb737feb6 100644 --- a/jacodb-ets/ts-frontend/src/index.ts +++ b/jacodb-ets/ts-frontend/src/index.ts @@ -55,12 +55,11 @@ interface CliArgs { /** * Hand-rolled arg parsing. * NOTE: the Kotlin side historically passes `-t N` as a SINGLE argv token `"-t N"`, - * so tokens containing spaces are pre-split here. + * so that option form is recognized while parsing options. * Everything after a `--` separator is treated as positional, which is the escape * hatch for paths starting with a dash. */ export function parseArgs(argv: string[]): CliArgs | string { - const tokens = argv.flatMap((a) => (a.startsWith("-") && a.includes(" ") ? a.split(/\s+/) : [a])); const positional: string[] = []; const args: CliArgs = { input: "", @@ -72,8 +71,8 @@ export function parseArgs(argv: string[]): CliArgs | string { verbose: false, }; let positionalOnly = false; - for (let i = 0; i < tokens.length; i++) { - const token = tokens[i]; + for (let i = 0; i < argv.length; i++) { + const token = argv[i]; if (positionalOnly) { positional.push(token); continue; @@ -82,6 +81,13 @@ export function parseArgs(argv: string[]): CliArgs | string { positionalOnly = true; continue; } + + const historicalTypeInference = /^-t\s+(\d+)$/.exec(token); + if (historicalTypeInference !== null) { + args.typeInference = parseInt(historicalTypeInference[1], 10); + continue; + } + switch (token) { case "-p": case "--project": @@ -101,7 +107,7 @@ export function parseArgs(argv: string[]): CliArgs | string { break; case "-t": case "--type-inference": { - const next = tokens[i + 1]; + const next = argv[i + 1]; if (next !== undefined && /^\d+$/.test(next)) { args.typeInference = parseInt(next, 10); i++; @@ -292,8 +298,8 @@ export function main(argv: string[]): number { path.relative(inputPath, path.resolve(sf.fileName)).split(path.sep).join("/"); const fileSignatureFor = (sf: ts.SourceFile) => { const relative = path.relative(inputPath, path.resolve(sf.fileName)); - const isProjectFile = isWithinDirectory(inputPath, sf.fileName) - && !relative.split(path.sep).includes("node_modules"); + const isDependency = relative.split(path.sep).includes("node_modules"); + const isProjectFile = isWithinDirectory(inputPath, sf.fileName) && !isDependency; if (isProjectFile) { return { projectName, fileName: relativeOf(sf) }; } @@ -318,7 +324,9 @@ export function main(argv: string[]): number { process.stderr.write(`invariant violation in ${relative}: ${violation}\n`); hadErrors = true; } - log(`emitted: ${outputFile}`); + if (result.violations.length === 0) { + log(`emitted: ${outputFile}`); + } } return hadErrors ? 1 : 0; } diff --git a/jacodb-ets/ts-frontend/test/cli.spec.ts b/jacodb-ets/ts-frontend/test/cli.spec.ts index 2d580872a..93370c61e 100644 --- a/jacodb-ets/ts-frontend/test/cli.spec.ts +++ b/jacodb-ets/ts-frontend/test/cli.spec.ts @@ -35,6 +35,12 @@ describe("parseArgs", () => { expect(args).toMatchObject({ typeInference: 1, input: "in", output: "out" }); }); + it("preserves dash-prefixed paths with spaces after --", () => { + const args = parseArgs(["--", "-my file.ts", "-output dir.json"]); + + expect(args).toMatchObject({ input: "-my file.ts", output: "-output dir.json" }); + }); + it("treats -t without numeric value as level 1", () => { const args = parseArgs(["in", "out", "-t"]); expect(args).toMatchObject({ typeInference: 1, input: "in", output: "out" }); From c2ac9aa6108a5a7fb0c35a1ec4e063348fbbab02 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 05:16:18 +0300 Subject: [PATCH 59/63] fix(ts-frontend-tests): resolve static methods by declaring class --- jacodb-ets/ts-frontend/test/execute.spec.ts | 27 ++++++++++++++ jacodb-ets/ts-frontend/test/execute.ts | 39 +++++++++++++++++---- 2 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 jacodb-ets/ts-frontend/test/execute.spec.ts diff --git a/jacodb-ets/ts-frontend/test/execute.spec.ts b/jacodb-ets/ts-frontend/test/execute.spec.ts new file mode 100644 index 000000000..76d2676e4 --- /dev/null +++ b/jacodb-ets/ts-frontend/test/execute.spec.ts @@ -0,0 +1,27 @@ +import * as ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { serializeEtsFile } from "../src/serialize"; +import { executor } from "./execute"; +import { lower } from "./util"; + +describe("concrete oracle method dispatch", () => { + it("calls the static method of its declaring class after JSON round trip", () => { + const source = ` + class A { static value(): number { return 1; } } + class B { static value(): number { return 2; } } + export function read(): number { return B.value(); } + `; + const { file, diagnostics } = lower(source); + const roundTrip = JSON.parse(serializeEtsFile(file)); + const javascript = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS }, + }).outputText; + + const nativeResult = new Function("exports", `${javascript}\nreturn exports.read();`)({}); + const oracleResult = executor(roundTrip).call("read"); + + expect(diagnostics.messages).toEqual([]); + expect(nativeResult).toBe(2); + expect(oracleResult).toBe(nativeResult); + }); +}); diff --git a/jacodb-ets/ts-frontend/test/execute.ts b/jacodb-ets/ts-frontend/test/execute.ts index 75bf484a9..df642dc26 100644 --- a/jacodb-ets/ts-frontend/test/execute.ts +++ b/jacodb-ets/ts-frontend/test/execute.ts @@ -1,7 +1,33 @@ import { NAMED_FUNCTION_REFERENCE_PREFIX } from "../src/dto/constants"; import { EtsFileDto, MethodDto } from "../src/dto/model"; +import { ClassSignatureDto, FileSignatureDto, MethodSignatureDto, NamespaceSignatureDto } from "../src/dto/signatures"; import { ValueDto } from "../src/dto/values"; +const MAX_CONCRETE_ORACLE_STEPS = 100_000; + +function sameFileSignature(left: FileSignatureDto, right: FileSignatureDto): boolean { + return left.projectName === right.projectName && left.fileName === right.fileName; +} + +function sameNamespaceSignature(left: NamespaceSignatureDto | undefined, right: NamespaceSignatureDto | undefined): boolean { + if (left === undefined || right === undefined) return left === right; + + return left.name === right.name + && sameFileSignature(left.declaringFile, right.declaringFile) + && sameNamespaceSignature(left.declaringNamespace, right.declaringNamespace); +} + +function sameClassSignature(left: ClassSignatureDto, right: ClassSignatureDto): boolean { + return left.name === right.name + && sameFileSignature(left.declaringFile, right.declaringFile) + && sameNamespaceSignature(left.declaringNamespace, right.declaringNamespace); +} + +function matchesMethod(candidate: MethodDto, signature: MethodSignatureDto): boolean { + return candidate.signature.name === signature.name + && sameClassSignature(candidate.signature.declaringClass, signature.declaringClass); +} + /** Small concrete oracle for this regression suite. Unknown IR fails loudly. * Calls consume raw arguments and pack isRest on entry, matching TsInterpreter. * JavaScript supplies built-in/iterator operations; this is not a USVM execution test. @@ -22,11 +48,12 @@ export function executor(file: EtsFileDto, externals: Record = {}) const value = (v: ValueDto): any => { switch (v._) { case "Local": { - if (v.name.startsWith(NAMED_FUNCTION_REFERENCE_PREFIX) && !locals.has(v.name) - && v.type._ === "FunctionType") { + const isUnboundNamedFunction = v.name.startsWith(NAMED_FUNCTION_REFERENCE_PREFIX) + && !locals.has(v.name) + && v.type._ === "FunctionType"; + if (isUnboundNamedFunction) { const signature = v.type.signature; - const target = methods.find((candidate) => candidate.signature.name === signature.name - && JSON.stringify(candidate.signature.declaringClass) === JSON.stringify(signature.declaringClass)); + const target = methods.find((candidate) => matchesMethod(candidate, signature)); if (target === undefined) throw new Error(`Unknown named function ${signature.name}`); locals.set(v.name, function (this: any, ...callArgs: any[]): any { return run(target, callArgs, this); @@ -94,7 +121,7 @@ export function executor(file: EtsFileDto, externals: Record = {}) } } case "StaticCallExpr": { - const target = methods.find((candidate) => candidate.signature.name === v.method.name); + const target = methods.find((candidate) => matchesMethod(candidate, v.method)); return target?.body !== undefined ? run(target, v.args.map(value)) : externals[v.method.name](...v.args.map(value)); @@ -125,7 +152,7 @@ export function executor(file: EtsFileDto, externals: Record = {}) }; let blockId = 0; - for (let steps = 0; steps < 100_000; steps++) { + for (let steps = 0; steps < MAX_CONCRETE_ORACLE_STEPS; steps++) { const block = method.body.cfg.blocks[blockId]; let next = block.successors[0]; for (let index = 0; index < block.stmts.length; index++) { From ee7dbc84b512c797b9d162f5558a22a50bd05b74 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 05:24:58 +0300 Subject: [PATCH 60/63] refactor(ts-frontend-tests): clarify and share concrete runtime helpers --- .../test/array-and-params-semantics.spec.ts | 14 +------------- .../ts-frontend/test/array-test-consumers.ts | 2 +- jacodb-ets/ts-frontend/test/object-runtime.ts | 8 ++++++-- 3 files changed, 8 insertions(+), 16 deletions(-) diff --git a/jacodb-ets/ts-frontend/test/array-and-params-semantics.spec.ts b/jacodb-ets/ts-frontend/test/array-and-params-semantics.spec.ts index 0455b3897..f217deb78 100644 --- a/jacodb-ets/ts-frontend/test/array-and-params-semantics.spec.ts +++ b/jacodb-ets/ts-frontend/test/array-and-params-semantics.spec.ts @@ -1,17 +1,5 @@ import { describe, expect, it } from "vitest"; -import * as ts from "typescript"; -import { serializeEtsFile } from "../src/serialize"; -import { executor } from "./execute"; -import { lower } from "./util"; - -function consumers(source: string, name: string, externals: Record = {}) { - const { file, diagnostics } = lower(source); - expect(diagnostics.messages).toEqual([]); - const ir = executor(JSON.parse(serializeEtsFile(file)), externals); - const js = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText; - const native = new Function(...Object.keys(externals), `${js}; return ${name};`)(...Object.values(externals)); - return { ir: (...args: any[]) => ir.call(name, ...args), native, initialize: () => ir.initialize() }; -} +import { consumers } from "./array-test-consumers"; describe("array iterator behavior after JSON round trip", () => { for (const source of [ diff --git a/jacodb-ets/ts-frontend/test/array-test-consumers.ts b/jacodb-ets/ts-frontend/test/array-test-consumers.ts index 8128b79cc..ebf5885d5 100644 --- a/jacodb-ets/ts-frontend/test/array-test-consumers.ts +++ b/jacodb-ets/ts-frontend/test/array-test-consumers.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { expect } from "vitest"; import * as ts from "typescript"; import { serializeEtsFile } from "../src/serialize"; import { executor } from "./execute"; diff --git a/jacodb-ets/ts-frontend/test/object-runtime.ts b/jacodb-ets/ts-frontend/test/object-runtime.ts index bd8e2169f..1141e0427 100644 --- a/jacodb-ets/ts-frontend/test/object-runtime.ts +++ b/jacodb-ets/ts-frontend/test/object-runtime.ts @@ -2,6 +2,8 @@ import { EtsFileDto, MethodDto } from "../src/dto/model"; import { LexicalEnvTypeDto } from "../src/dto/types"; import { ClosureFieldRefDto } from "../src/dto/values"; +const MAX_OBJECT_TEST_STEPS = 1_000; + /** * A deliberately small concrete consumer for object-lowering regressions. * Executes serialized EtsIR using JavaScript property operations; this is not @@ -40,7 +42,9 @@ export function executeObjectIr(file: EtsFileDto, name: string, args: unknown[] }; const captureEnvironment = (reference: ClosureFieldRefDto): Record => { const type = reference.base.type; - if (type._ !== "LexicalEnvType" || !type.closures.some((capture) => capture.name === reference.fieldName)) { + const isDeclaredCapture = type._ === "LexicalEnvType" + && type.closures.some((capture) => capture.name === reference.fieldName); + if (!isDeclaredCapture) { throw new Error(`undeclared test capture ${reference.fieldName}`); } @@ -144,7 +148,7 @@ export function executeObjectIr(file: EtsFileDto, name: string, args: unknown[] }; const blocks = method.body.cfg.blocks; let block = blocks[0]; - for (let steps = 0; block !== undefined && steps < 1000; steps++) { + for (let steps = 0; block !== undefined && steps < MAX_OBJECT_TEST_STEPS; steps++) { let next = block.successors[0]; for (const stmt of block.stmts) { switch (stmt._) { From c5cf5300ce2a396d68d7f7487a29dff965838721 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 10:23:28 +0300 Subject: [PATCH 61/63] Fix TypeScript narrowing in namespace and catch lowering --- jacodb-ets/ts-frontend/src/lowering/exprLowering.ts | 2 +- jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts | 5 +++++ jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts | 11 ++++++----- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index 2c6b52ac1..20876aa4f 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -37,7 +37,7 @@ import { CONSTRUCTOR_NAME, PATTERN_PARAMETER_PREFIX, } from "../dto/constants"; -import { FieldDto, MethodDto } from "../dto/model"; +import { FieldDto } from "../dto/model"; import { bindingIdentifier, buildParameters, BuiltParameters, classLikeDeclarationOf, memberName, modifiersOf, returnTypeOf, unsupportedClassDeclarationReason } from "./astUtils"; import { BinaryOp, RelationOp, UnaryOp } from "../dto/ops"; import { diff --git a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts index 28b00726a..85700e618 100644 --- a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts +++ b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts @@ -523,6 +523,11 @@ class FileBuilder { return { signature, classes: contents.classes, namespaces: contents.namespaces }; } + if (!ts.isModuleBlock(body)) { + this.ctx.diagnostics.warn(body, "namespace body is not supported"); + return { signature, classes: [], namespaces: [] }; + } + const contents = this.buildScope(body, signature); return { signature, diff --git a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts index a50ad3ddb..815d59c64 100644 --- a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts @@ -879,22 +879,23 @@ export class StmtLowerer { cfg.placeLabel(catchLabel); const decl = catchClause.variableDeclaration; const lowerCatch = (): void => { - if (decl !== undefined && ts.isIdentifier(decl.name)) { - const bindingIsReferenced = this.referencesCatchBinding(catchClause.block, decl.name); + const bindingName = decl?.name; + if (decl !== undefined && bindingName !== undefined && ts.isIdentifier(bindingName)) { + const bindingIsReferenced = this.referencesCatchBinding(catchClause.block, bindingName); if (bindingIsReferenced) { const caughtType = decl.type !== undefined ? this.m.converter.convertTypeNode(decl.type) : UNKNOWN_TYPE; - const caught = this.m.localForIdentifier(decl.name, caughtType); + const caught = this.m.localForIdentifier(bindingName, caughtType); cfg.emit({ _: "AssignStmt", left: caught, right: { _: "CaughtExceptionRef", type: caughtType }, }); } - } else if (decl !== undefined) { + } else if (bindingName !== undefined && !ts.isIdentifier(bindingName)) { const caught = this.m.newTemp(UNKNOWN_TYPE); cfg.emit({ _: "AssignStmt", left: caught, right: { _: "CaughtExceptionRef", type: UNKNOWN_TYPE } }); - this.lowerBindingPattern(decl.name, caught); + this.lowerBindingPattern(bindingName, caught); } this.lowerStatement(catchClause.block); From ec09fcf006ea95cbcc119ebbc262a5c6c1e4c1f8 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 10:55:22 +0300 Subject: [PATCH 62/63] Fix frontend regression fixtures and oracle assertions --- .../ts-frontend/test/async-iterator.spec.ts | 4 ++- jacodb-ets/ts-frontend/test/cfg.spec.ts | 11 +++++--- jacodb-ets/ts-frontend/test/classes.spec.ts | 1 + jacodb-ets/ts-frontend/test/corpus.spec.ts | 27 ++++++++++++++++++- .../ts-frontend/test/export-default.spec.ts | 2 +- .../ts-frontend/test/object-accessors.spec.ts | 4 +-- .../ts-frontend/test/regressions.spec.ts | 25 +++++++++++++++-- jacodb-ets/ts-frontend/test/try.spec.ts | 10 +++++-- 8 files changed, 71 insertions(+), 13 deletions(-) diff --git a/jacodb-ets/ts-frontend/test/async-iterator.spec.ts b/jacodb-ets/ts-frontend/test/async-iterator.spec.ts index b7619f775..e87a97554 100644 --- a/jacodb-ets/ts-frontend/test/async-iterator.spec.ts +++ b/jacodb-ets/ts-frontend/test/async-iterator.spec.ts @@ -119,9 +119,11 @@ function program(source: string) { expect(validateEtsFile(roundTripped)).toEqual([]); const method = methodByName(roundTripped, "run"); + const native = new Function("input", `${source}; return run(input);`) as (input: unknown) => unknown; + return { ir: (input: unknown) => execute(method, input), - native: new Function("input", `${source}; return run(input);`) as (input: unknown) => Promise, + native: async (input: unknown) => native(input), }; } diff --git a/jacodb-ets/ts-frontend/test/cfg.spec.ts b/jacodb-ets/ts-frontend/test/cfg.spec.ts index 37000a3e7..9ebcdb0cd 100644 --- a/jacodb-ets/ts-frontend/test/cfg.spec.ts +++ b/jacodb-ets/ts-frontend/test/cfg.spec.ts @@ -265,10 +265,13 @@ describe("control flow lowering", () => { `); const method = defaultMethod(file); const stmts = allStmts(method); - const calls = stmts.filter((s) => s._ === "AssignStmt" && s.right._ === "InstanceCallExpr"); - const methodNames = calls.map( - (s) => ((s as { right: { method: { name: string } } }).right.method.name), - ); + const methodNames = stmts.flatMap((statement) => { + if (statement._ !== "AssignStmt") return []; + if (statement.right._ !== "InstanceCallExpr" && statement.right._ !== "PtrCallExpr") return []; + + return [statement.right.method.name]; + }); + expect(methodNames).toContain("Symbol.iterator"); expect(methodNames).toContain("next"); const fieldReads = stmts diff --git a/jacodb-ets/ts-frontend/test/classes.spec.ts b/jacodb-ets/ts-frontend/test/classes.spec.ts index 3192811c4..ff18518c8 100644 --- a/jacodb-ets/ts-frontend/test/classes.spec.ts +++ b/jacodb-ets/ts-frontend/test/classes.spec.ts @@ -694,6 +694,7 @@ describe("class lowering", () => { it("initializes derived fields after super on every conditional branch", () => { const { file } = lower(` + class Base {} class Derived extends Base { value = 42; constructor(flag: boolean) { if (flag) super(); else super(); } diff --git a/jacodb-ets/ts-frontend/test/corpus.spec.ts b/jacodb-ets/ts-frontend/test/corpus.spec.ts index 083d0ceeb..507a96565 100644 --- a/jacodb-ets/ts-frontend/test/corpus.spec.ts +++ b/jacodb-ets/ts-frontend/test/corpus.spec.ts @@ -5,6 +5,10 @@ import { EtsFileDto } from "../src/dto/model"; import { lower } from "./util"; const FIXTURES_DIR = path.join(__dirname, "fixtures"); +const EXPECTED_RAW_FALLBACKS: Record = { + "geometry.ts": 6, + "events.js": 1, +}; /** Count raw fallback statements/values (any "Unsupported*" discriminator). */ function countRawFallbacks(file: EtsFileDto): number { @@ -38,7 +42,7 @@ describe("corpus: realistic TS/JS programs lower cleanly", () => { const source = fs.readFileSync(path.join(FIXTURES_DIR, fixture), "utf-8"); // lower() throws on any invariant violation. const { file, diagnostics } = lower(source, "corpus", fixture); - const expectedUnsupported = fixture === "geometry.ts" ? 6 : 0; + const expectedUnsupported = EXPECTED_RAW_FALLBACKS[fixture] ?? 0; expect(countRawFallbacks(file)).toBe(expectedUnsupported); expect(diagnostics.messages).toHaveLength(expectedUnsupported); @@ -61,6 +65,27 @@ describe("corpus: realistic TS/JS programs lower cleanly", () => { ]; }), ]; + + if (fixture === "events.js") { + const statements = allMethods.flatMap((method) => + method.body?.cfg.blocks.flatMap((block) => block.stmts) ?? [], + ); + + expect(statements).toContainEqual(expect.objectContaining({ + _: "AssignStmt", + right: expect.objectContaining({ + _: "UnsupportedValue", + kindName: "ObjectLiteralExpression", + text: expect.stringContaining("on(name, handler)"), + }), + })); + expect(diagnostics.messages).toEqual([ + expect.stringContaining( + "unsupported expression: object literal method needs a per-evaluation callable own property", + ), + ]); + } + for (const method of allMethods) { if (method.body !== undefined) { const stmts = method.body.cfg.blocks.flatMap((b) => b.stmts); diff --git a/jacodb-ets/ts-frontend/test/export-default.spec.ts b/jacodb-ets/ts-frontend/test/export-default.spec.ts index e47eb408b..2cc589ecc 100644 --- a/jacodb-ets/ts-frontend/test/export-default.spec.ts +++ b/jacodb-ets/ts-frontend/test/export-default.spec.ts @@ -79,7 +79,7 @@ describe("export default expressions", () => { })); expect(stmts).toContainEqual(expect.objectContaining({ _: "AssignStmt", - right: expect.objectContaining({ _: "UnsupportedValue", kindName: "ObjectLiteralExpression" }), + right: expect.objectContaining({ _: "UnsupportedValue" }), })); expect(diagnostics.messages).toEqual(expect.arrayContaining([ expect.stringContaining("object literal method needs a per-evaluation callable own property"), diff --git a/jacodb-ets/ts-frontend/test/object-accessors.spec.ts b/jacodb-ets/ts-frontend/test/object-accessors.spec.ts index 21a1464e6..5efe12a00 100644 --- a/jacodb-ets/ts-frontend/test/object-accessors.spec.ts +++ b/jacodb-ets/ts-frontend/test/object-accessors.spec.ts @@ -134,7 +134,7 @@ describe("object method materialization boundary", () => { expect(stmts).toContainEqual(expect.objectContaining({ _: "AssignStmt", - right: expect.objectContaining({ _: "UnsupportedValue", kindName: "ObjectLiteralExpression" }), + right: expect.objectContaining({ _: "UnsupportedValue" }), })); expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewExpr")).toBe(false); expect(stmts.some((stmt) => stmt._ === "DefineDataPropertyStmt" || stmt._ === "CopyDataPropertiesStmt")).toBe(false); @@ -153,7 +153,7 @@ describe("object method materialization boundary", () => { expect(diagnostics.messages).toEqual([]); expect(stmts).toContainEqual(expect.objectContaining({ _: "DefineDataPropertyStmt", - key: { _: "Constant", value: "method" }, + key: expect.objectContaining({ _: "Constant", value: "method" }), value: expect.objectContaining({ _: "Local", type: expect.objectContaining({ _: "FunctionType" }) }), })); expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue")).toBe(false); diff --git a/jacodb-ets/ts-frontend/test/regressions.spec.ts b/jacodb-ets/ts-frontend/test/regressions.spec.ts index 634dc857e..1a7ddb651 100644 --- a/jacodb-ets/ts-frontend/test/regressions.spec.ts +++ b/jacodb-ets/ts-frontend/test/regressions.spec.ts @@ -100,7 +100,6 @@ describe("unsupported loop bindings do not break the whole file", () => { const cases: Record = { "array pattern with rest": "declare const xs: any[]; for (const [[a], ...rest] of xs) { console.log(a); }", "member expression target": "declare const xs: any[]; const obj: any = {}; for (obj.x of xs) { console.log(obj.x); }", - "rest in for-in": "declare const o: any; for (const [[a], ...rest] in o) { console.log(a); }", }; for (const [name, source] of Object.entries(cases)) { @@ -114,6 +113,21 @@ describe("unsupported loop bindings do not break the whole file", () => { } }); +it("marks for-in enumeration unsupported before attempting its nested rest binding", () => { + const { file, diagnostics } = lower(` + declare const o: any; + for (const [[a], ...rest] in o) { console.log(a); } + `); + const stmts = file.classes.flatMap((clazz) => clazz.methods.flatMap((method) => allStmts(method))); + + expect(stmts).toContainEqual(expect.objectContaining({ + _: "UnsupportedStmt", kindName: "ForInStatement", + })); + expect(diagnostics.messages).toContainEqual(expect.stringContaining( + "unsupported for-in enumeration: inherited and changing keys cannot be represented", + )); +}); + it("finalizes a for initializer with an unsupported nested array rest binding", () => { const { file, diagnostics } = lower(` function f(input: any): void { @@ -778,7 +792,14 @@ describe("function hoisting", () => { const named = function self(value: typeof self): typeof self { return value; }; `); - expect(methodByName(file, "%AM0$run")).toBeDefined(); + expect(allStmts(methodByName(file, "run"))).toContainEqual(expect.objectContaining({ + _: "AssignStmt", + left: expect.objectContaining({ _: "Local", name: "fact" }), + right: expect.objectContaining({ + _: "Local", name: expect.stringMatching(/^%AM\d+\$run$/), + type: expect.objectContaining({ _: "FunctionType" }), + }), + })); expect(file.classes.flatMap((clazz) => clazz.methods).some((method) => method.signature.name.startsWith("%AM"))) .toBe(true); expect(diagnostics.messages.some((message) => message.includes("recursive lifted function binding"))).toBe(false); diff --git a/jacodb-ets/ts-frontend/test/try.spec.ts b/jacodb-ets/ts-frontend/test/try.spec.ts index 771e4cea4..0ff1ccbc8 100644 --- a/jacodb-ets/ts-frontend/test/try.spec.ts +++ b/jacodb-ets/ts-frontend/test/try.spec.ts @@ -180,8 +180,14 @@ describe("try/catch/finally lowering", () => { expect(diagnostics.messages).toEqual([]); expect(caught).toMatchObject({ left: { _: "Local", name: expect.stringMatching(/^%/) } }); expect(propertyReads).toEqual(expect.arrayContaining([ - expect.objectContaining({ left: { name: "message" }, right: { key: { value: "message" } } }), - expect.objectContaining({ left: { name: "unused" }, right: { key: { value: "unused" } } }), + expect.objectContaining({ + left: expect.objectContaining({ name: "message" }), + right: expect.objectContaining({ key: expect.objectContaining({ value: "message" }) }), + }), + expect.objectContaining({ + left: expect.objectContaining({ name: "unused" }), + right: expect.objectContaining({ key: expect.objectContaining({ value: "unused" }) }), + }), ])); expect(stmts).toContainEqual({ _: "ReturnStmt", arg: expect.objectContaining({ name: "message" }) }); }); From eecc7513f6b761b46700d1776adedab664eefadf Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 10:56:58 +0300 Subject: [PATCH 63/63] Preserve immediate constraints in inferred generic binders --- jacodb-ets/ts-frontend/src/types/convert.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/jacodb-ets/ts-frontend/src/types/convert.ts b/jacodb-ets/ts-frontend/src/types/convert.ts index caa320cdd..5ecde283f 100644 --- a/jacodb-ets/ts-frontend/src/types/convert.ts +++ b/jacodb-ets/ts-frontend/src/types/convert.ts @@ -720,12 +720,20 @@ export class TypeConverter { }; const typeParameters = signature.getTypeParameters(); if (typeParameters !== undefined && typeParameters.length > 0) { - result.typeParameters = typeParameters.map((typeParameter): GenericTypeDto => { + result.typeParameters = typeParameters.map((typeParameter, index): GenericTypeDto => { const binder: GenericTypeDto = { _: "GenericType", name: this.typeName(typeParameter) }; - const constraint = this.checker.getBaseConstraintOfType(typeParameter); - if (constraint !== undefined) { - binder.constraint = this.convertType(constraint, depth + 1); + const parameterDeclaration = declaration?.typeParameters?.[index]; + const isLiveDeclaration = parameterDeclaration !== undefined && + this.checker.getTypeAtLocation(parameterDeclaration.name) === typeParameter; + const declaredConstraint = isLiveDeclaration ? parameterDeclaration.constraint : undefined; + if (declaredConstraint !== undefined) { + binder.constraint = this.convertTypeNode(declaredConstraint, depth + 1); + } else { + const constraint = this.checker.getBaseConstraintOfType(typeParameter); + if (constraint !== undefined) { + binder.constraint = this.convertType(constraint, depth + 1); + } } const defaultType = this.checker.getDefaultFromTypeParameter(typeParameter);

Block #${block.id}