From 003097237e123f78930c48e4e8ad38766716aa56 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 02:59:05 +0300 Subject: [PATCH 01/97] Refactor Date model lookup preparation Move Date routing from the generic catalog to the dispatcher while preserving direct-target precedence and original-call completion. Add focused dispatcher contract tests. Runtime checks were not run at the user request. --- .../machine/call/TsUnknownCallModelCatalog.kt | 12 +- .../call/TsUnknownCallModelDispatcher.kt | 12 +- .../call/TsUnknownCallDateRoutingTest.kt | 127 ++++++++++++++++++ 3 files changed, 141 insertions(+), 10 deletions(-) create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDateRoutingTest.kt diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalog.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalog.kt index 50c4d5eb5b..206443e72d 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalog.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalog.kt @@ -2,7 +2,6 @@ package org.usvm.machine.call import org.jacodb.ets.model.EtsFile import org.jacodb.ets.model.EtsFileSignature -import org.usvm.machine.call.intrinsic.TsDateEtsIrModelFamily import org.usvm.machine.state.TsState import java.util.Collections import java.util.IdentityHashMap @@ -85,19 +84,14 @@ class TsUnknownCallModelCatalog( return enclosingClassName in candidates || (allowUnqualifiedTarget && null in candidates) } + /** Applies a model to prepared lookup metadata; family-specific call preparation belongs to the dispatcher. */ fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { - val directModel = select(call) - val modelCall = if (directModel == null) { - TsDateEtsIrModelFamily.canonicalModelCall(state, call) ?: call - } else { - call - } - val model = directModel ?: select(modelCall) ?: return TsUnknownCallModelApplication.NotApplicable + val model = select(call) ?: return TsUnknownCallModelApplication.NotApplicable if (state.isUnknownCallModelActive(model.id)) { return TsUnknownCallModelApplication.NotApplicable } - val execution = model.apply(state, modelCall) ?: return TsUnknownCallModelApplication.NotApplicable + val execution = model.apply(state, call) ?: return TsUnknownCallModelApplication.NotApplicable return TsUnknownCallModelApplication.Applied( modelId = model.id, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt index bb17d5f108..7cb6ca3a13 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt @@ -6,6 +6,7 @@ import org.usvm.api.makeFreshUnknownCallResult import org.usvm.api.mockMethodCall import org.usvm.api.setMockMethodCallResult import org.usvm.machine.TsInterpreterObserver +import org.usvm.machine.call.intrinsic.TsDateEtsIrModelFamily import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.state.TsMethodResult import org.usvm.machine.state.TsState @@ -35,7 +36,16 @@ class TsModelUnknownCallDispatcher( ) : TsUnknownCallModelDispatcher { override fun dispatch(scope: TsStepScope, call: TsUnknownCall): TsUnknownCallOutcome { val application = scope.calcOnState { - this@TsModelUnknownCallDispatcher.models.apply(this, call) + val catalog = this@TsModelUnknownCallDispatcher.models + val hasDirectTarget = catalog.select(call) != null + // A direct target retains precedence even when its model rejects the call or is already active. + val modelCall = if (hasDirectTarget) { + call + } else { + TsDateEtsIrModelFamily.canonicalModelCall(this, call) ?: call + } + + catalog.apply(this, modelCall) } return when (application) { diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDateRoutingTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDateRoutingTest.kt new file mode 100644 index 0000000000..222e9c08df --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDateRoutingTest.kt @@ -0,0 +1,127 @@ +package org.usvm.machine.call + +import io.mockk.every +import io.mockk.mockk +import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsFunctionType +import org.jacodb.ets.model.EtsLocal +import org.jacodb.ets.model.EtsMethodSignature +import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsStmt +import org.jacodb.ets.model.EtsStringType +import org.usvm.machine.interpreter.TsStepScope +import org.usvm.machine.state.TsState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class TsUnknownCallDateRoutingTest { + @Test + fun `a metadata miss prepares a genuine Date owner for one model application`() { + val call = dateAliasCall() + val dateModel = RecordingRejectingModel(id = "date-model", enclosingClassName = "Date") + val dispatcher = TsModelUnknownCallDispatcher( + models = TsUnknownCallModelCatalog(models = listOf(dateModel)), + fallback = TsResidualCallPolicy.STOP_PATH, + ) + val scope = scope(state = state()) + + val outcome = dispatcher.dispatch(scope, call) + + assertEquals(TsUnknownCallOutcome.PATH_STOPPED, outcome) + val preparedCall = dateModel.calls.single() + assertEquals(EtsClassSignature.UNKNOWN.copy(name = "Date"), preparedCall.callee.enclosingClass) + assertSame(call.receiver, preparedCall.receiver) + assertSame(call.callSite, preparedCall.callSite) + } + + @Test + fun `a rejected or active direct target never retries a Date target`() { + for (directModelActive in listOf(false, true)) { + val call = dateAliasCall() + val directModel = RecordingRejectingModel( + id = "direct-model", + enclosingClassName = call.callee.enclosingClass.name, + ) + val dateModel = RecordingRejectingModel(id = "date-model", enclosingClassName = "Date") + val dispatcher = TsModelUnknownCallDispatcher( + models = TsUnknownCallModelCatalog(models = listOf(directModel, dateModel)), + fallback = TsResidualCallPolicy.STOP_PATH, + ) + val activeModelId = if (directModelActive) directModel.id else null + val scope = scope(state = state(activeModelId = activeModelId)) + + val outcome = dispatcher.dispatch(scope, call) + + assertEquals(TsUnknownCallOutcome.PATH_STOPPED, outcome) + assertTrue(dateModel.calls.isEmpty()) + if (directModelActive) { + assertTrue(directModel.calls.isEmpty()) + } else { + assertSame(call, directModel.calls.single()) + } + } + } + + private fun dateAliasCall(): TsUnknownCall { + val dateGlobalType = EtsFunctionType( + signature = EtsMethodSignature( + enclosingClass = EtsClassSignature.UNKNOWN, + name = "", + parameters = emptyList(), + returnType = EtsStringType, + ) + ) + + return TsUnknownCall( + callee = EtsMethodSignature( + enclosingClass = EtsClassSignature.UNKNOWN.copy(name = "DateAlias"), + name = "now", + parameters = emptyList(), + returnType = EtsNumberType, + ), + receiver = TsUnknownCallValue( + source = EtsLocal(name = "Date", type = dateGlobalType), + resolved = null, + ), + arguments = emptyList(), + resultType = EtsNumberType, + callSite = mockk(relaxed = true), + failureReason = TsUnknownCallFailureReason.METHOD_BODY_UNAVAILABLE, + ) + } + + private fun state(activeModelId: String? = null): TsState { + val state = mockk() + every { state.isUnknownCallModelActive(any()) } answers { firstArg() == activeModelId } + every { state.ctx.falseExpr } returns mockk() + + return state + } + + private fun scope(state: TsState): TsStepScope { + val scope = mockk() + every { scope.calcOnState(any()) } answers { + val block = firstArg Any?>() + block(state) + } + every { scope.assert(any(), any()) } returns null + + return scope + } + + private class RecordingRejectingModel( + override val id: String, + enclosingClassName: String, + ) : TsUnknownCallModel { + override val target = TsUnknownCallTarget(methodName = "now", enclosingClassName = enclosingClassName) + val calls = mutableListOf() + + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution? { + calls += call + + return null + } + } +} From 032afa969f15f4b750cc35a43ea01c3ee74d3c0a Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 03:46:01 +0300 Subject: [PATCH 02/97] Separate unresolved value construction blocks Keep joined runtime-kind metadata separate from payload construction. No semantic change; verification was static only at the user request. --- .../src/main/kotlin/org/usvm/machine/types/TsUnresolvedValue.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsUnresolvedValue.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsUnresolvedValue.kt index b28b8fe3ee..06f32aa689 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsUnresolvedValue.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsUnresolvedValue.kt @@ -28,6 +28,7 @@ internal fun TsContext.iteUnresolvedValue( fpTypeExpr = mkIte(condition, trueValue.type.fpTypeExpr, falseValue.type.fpTypeExpr), refTypeExpr = mkIte(condition, trueValue.type.refTypeExpr, falseValue.type.refTypeExpr), ) + return TsUnresolvedValue( boolValue = mkIte(condition, trueValue.boolValue, falseValue.boolValue), fpValue = mkIte(condition, trueValue.fpValue, falseValue.fpValue), From 79f939038174a2eee8a98ddf0d7bbdb049e097e4 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 04:18:30 +0300 Subject: [PATCH 03/97] Fix TypeScript subtype relation and enumeration contracts Preserve array dimensions, literal reflexivity, structural class alternatives and auxiliary requirements. Add focused unexecuted regression source. Independent static patch verification only; no compiler, tests, JVM or containers were run. --- .../org/usvm/machine/types/TsTypeSystem.kt | 19 +- .../usvm/machine/types/TsTypeSystemTest.kt | 188 +++++++++++++++++- .../TypeGuardsReachabilityTest.kt | 18 ++ .../org/usvm/samples/types/TypeStream.kt | 2 + .../resources/samples/types/TypeStream.ts | 4 +- 5 files changed, 217 insertions(+), 14 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsTypeSystem.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsTypeSystem.kt index 34f428777a..f605a7a24a 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsTypeSystem.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsTypeSystem.kt @@ -33,7 +33,6 @@ import org.usvm.util.EtsFieldName import org.usvm.util.EtsHierarchy import org.usvm.util.EtsMethodName import org.usvm.util.componentType -import org.usvm.util.getAllFields import org.usvm.util.getAllProperties import org.usvm.util.getAllPropertiesCombined import org.usvm.util.type @@ -101,6 +100,8 @@ class TsTypeSystem( // Identical primitive types are mutually assignable (e.g., number to number). if (unwrappedSupertype is EtsPrimitiveType && unwrappedType is EtsPrimitiveType) { + if (unwrappedSupertype == unwrappedType) return true + if (unwrappedType is EtsLiteralType) { // Literal types are subtypes of their base primitive (e.g., 'foo' <: string). return when (unwrappedType) { @@ -174,7 +175,7 @@ class TsTypeSystem( // Class and structural types if (unwrappedSupertype is EtsAuxiliaryType && unwrappedType is EtsAuxiliaryType) { - return unwrappedType.properties.all { it in unwrappedSupertype.properties } + return unwrappedType.properties.containsAll(unwrappedSupertype.properties) } if (unwrappedSupertype is EtsAuxiliaryType) { @@ -193,7 +194,7 @@ class TsTypeSystem( return classes.any { cls -> val properties = cls.getAllPropertiesCombined(hierarchy) - return properties.containsAll(unwrappedSupertype.properties) + properties.containsAll(unwrappedSupertype.properties) } } @@ -206,7 +207,7 @@ class TsTypeSystem( return superClasses.any { cls -> val properties = cls.getAllPropertiesCombined(hierarchy) - return properties.containsAll(unwrappedType.properties) + properties.containsAll(unwrappedType.properties) } } @@ -296,9 +297,9 @@ class TsTypeSystem( scene.projectAndSdkClasses .asSequence() .filter { cls -> - cls.getAllFields(hierarchy) - .map { it.name } - .containsAll(t.properties) + val properties = cls.getAllPropertiesCombined(hierarchy) + + properties.containsAll(t.properties) } .map { it.type } } @@ -306,9 +307,9 @@ class TsTypeSystem( is EtsArrayType -> { findSubtypes(t.elementType).map { child -> if (child is EtsArrayType) { - EtsArrayType(child.elementType, child.dimensions + 1) + child.copy(dimensions = child.dimensions + t.dimensions) } else { - EtsArrayType(child, dimensions = 1) + EtsArrayType(child, dimensions = t.dimensions) } } } diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/types/TsTypeSystemTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/types/TsTypeSystemTest.kt index a44475a890..aab0ab76fd 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/types/TsTypeSystemTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/types/TsTypeSystemTest.kt @@ -1,35 +1,216 @@ package org.usvm.machine.types import io.mockk.mockk +import org.jacodb.ets.model.EtsAliasType import org.jacodb.ets.model.EtsAnyType import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsBooleanLiteralType import org.jacodb.ets.model.EtsBooleanType import org.jacodb.ets.model.EtsClassImpl import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsClassType import org.jacodb.ets.model.EtsFieldImpl import org.jacodb.ets.model.EtsFieldSignature import org.jacodb.ets.model.EtsFile import org.jacodb.ets.model.EtsFileSignature +import org.jacodb.ets.model.EtsLiteralType +import org.jacodb.ets.model.EtsLocalSignature +import org.jacodb.ets.model.EtsMethodImpl +import org.jacodb.ets.model.EtsMethodSignature import org.jacodb.ets.model.EtsNeverType import org.jacodb.ets.model.EtsNullType +import org.jacodb.ets.model.EtsNumberLiteralType import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsPrimitiveType import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.model.EtsStringLiteralType import org.jacodb.ets.model.EtsStringType import org.jacodb.ets.model.EtsTupleType import org.jacodb.ets.model.EtsUndefinedType import org.jacodb.ets.model.EtsUnknownType +import org.jacodb.ets.model.EtsVoidType import org.junit.jupiter.api.Test import org.usvm.machine.TsContext import org.usvm.machine.withCoreRuntimeSdk import org.usvm.types.TypesResult +import org.usvm.types.USingleTypeStream +import org.usvm.types.USupportTypeStream +import org.usvm.types.UTypeRegion import org.usvm.util.EtsHierarchy import org.usvm.util.type +import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds class TsTypeSystemTest { + @Test + fun `array subtype candidates preserve root dimensions and satisfy the root constraint`() { + val scene = EtsScene(projectFiles = emptyList()) + val typeSystem = TsTypeSystem(scene, typeOperationsTimeout = 1.seconds, hierarchy = EtsHierarchy(scene)) + val elementTypes = listOf(EtsNumberType, EtsBooleanType, EtsStringType) + + for (dimensions in listOf(1, 2, 4)) { + val root = EtsArrayType(elementType = EtsUnknownType, dimensions = dimensions) + val expectedTypes = elementTypes.map { EtsArrayType(elementType = it, dimensions = dimensions) }.toSet() + + val actualTypes = typeSystem.findSubtypes(root).toSet() + + assertEquals(expectedTypes, actualTypes) + actualTypes.forEach { candidate -> + assertTrue(typeSystem.isSupertype(supertype = root, type = candidate), candidate.typeName) + } + } + } + + @Test + fun `array subtype lifting accumulates dimensions supplied by an element alias`() { + val scene = EtsScene(projectFiles = emptyList()) + val typeSystem = TsTypeSystem(scene, typeOperationsTimeout = 1.seconds, hierarchy = EtsHierarchy(scene)) + val fileSignature = EtsFileSignature(projectName = "test", fileName = "types.ts") + val aliasSignature = EtsLocalSignature( + name = "Rows", + method = EtsMethodSignature( + enclosingClass = EtsClassSignature(name = "Types", file = fileSignature), + name = "useRows", + parameters = emptyList(), + returnType = EtsVoidType, + ), + ) + val alias = EtsAliasType( + name = "Rows", + originalType = EtsArrayType(elementType = EtsUnknownType, dimensions = 3), + signature = aliasSignature, + ) + val root = EtsArrayType(elementType = alias, dimensions = 2) + val expectedTypes = setOf( + EtsArrayType(elementType = EtsNumberType, dimensions = 5), + EtsArrayType(elementType = EtsBooleanType, dimensions = 5), + EtsArrayType(elementType = EtsStringType, dimensions = 5), + ) + + val actualTypes = typeSystem.findSubtypes(root).toSet() + + assertEquals(expectedTypes, actualTypes) + actualTypes.forEach { candidate -> + assertTrue(typeSystem.isSupertype(supertype = root, type = candidate), candidate.typeName) + } + } + + @Test + fun `literal subtyping is reflexive without accepting distinct literals or their base type`() { + val scene = EtsScene(projectFiles = emptyList()) + val typeSystem = TsTypeSystem(scene, typeOperationsTimeout = 1.seconds, hierarchy = EtsHierarchy(scene)) + val literalCases = listOf>( + Triple(EtsStringLiteralType(value = "a"), EtsStringType, EtsStringLiteralType(value = "b")), + Triple(EtsNumberLiteralType(value = 1.0), EtsNumberType, EtsNumberLiteralType(value = 2.0)), + Triple(EtsBooleanLiteralType(value = true), EtsBooleanType, EtsBooleanLiteralType(value = false)), + ) + + for ((literal, primitive, otherLiteral) in literalCases) { + val alias = EtsAliasType(name = "Literal", originalType = literal, signature = mockk()) + val array = EtsArrayType(elementType = literal, dimensions = 2) + + val filteredStream = USingleTypeStream(typeSystem, array).filterBySupertype(array) + + assertTrue(typeSystem.isSupertype(supertype = literal, type = literal)) + assertTrue(typeSystem.isSupertype(supertype = literal, type = alias)) + assertTrue(typeSystem.isSupertype(supertype = alias, type = literal)) + assertTrue(typeSystem.isSupertype(supertype = primitive, type = literal)) + assertFalse(typeSystem.isSupertype(supertype = literal, type = primitive)) + assertFalse(typeSystem.isSupertype(supertype = literal, type = otherLiteral)) + assertEquals(expected = false, actual = filteredStream.isEmpty) + } + } + + @Test + fun `auxiliary comparisons consider compatible class alternatives after the first candidate`() { + val files = listOf("first", "second").map { fieldName -> + val fileSignature = EtsFileSignature(projectName = "test", fileName = "$fieldName.ts") + val classSignature = EtsClassSignature(name = "Ambiguous", file = fileSignature) + val clazz = EtsClassImpl( + signature = classSignature, + fields = listOf( + EtsFieldImpl(EtsFieldSignature(enclosingClass = classSignature, name = fieldName, type = EtsNumberType)), + ), + methods = emptyList(), + ) + + EtsFile(signature = fileSignature, classes = listOf(clazz), namespaces = emptyList()) + } + val scene = EtsScene(projectFiles = files) + val hierarchy = EtsHierarchy(scene) + val typeSystem = TsTypeSystem(scene, typeOperationsTimeout = 1.seconds, hierarchy = hierarchy) + val ambiguousType = EtsClassType(EtsClassSignature(name = "Ambiguous", file = EtsFileSignature.UNKNOWN)) + val laterCandidate = hierarchy.classesForType(ambiguousType).last() + val auxiliaryType = EtsAuxiliaryType(properties = setOf(laterCandidate.fields.single().name)) + val absentPropertyType = EtsAuxiliaryType(properties = setOf("missing")) + + val acceptsLaterClass = typeSystem.isSupertype(supertype = auxiliaryType, type = ambiguousType) + val acceptsLaterShape = typeSystem.isSupertype(supertype = ambiguousType, type = auxiliaryType) + + assertTrue(acceptsLaterClass) + assertTrue(acceptsLaterShape) + assertFalse(typeSystem.isSupertype(supertype = absentPropertyType, type = ambiguousType)) + assertFalse(typeSystem.isSupertype(supertype = ambiguousType, type = absentPropertyType)) + } + + @Test + fun `stronger auxiliary requirements are subtypes and remain in the refined region`() { + val fileSignature = EtsFileSignature(projectName = "test", fileName = "types.ts") + val classSignature = EtsClassSignature(name = "WithTwoFields", file = fileSignature) + val clazz = EtsClassImpl( + signature = classSignature, + fields = listOf( + EtsFieldImpl(EtsFieldSignature(enclosingClass = classSignature, name = "a", type = EtsNumberType)), + EtsFieldImpl(EtsFieldSignature(enclosingClass = classSignature, name = "b", type = EtsNumberType)), + ), + methods = emptyList(), + ) + val file = EtsFile(signature = fileSignature, classes = listOf(clazz), namespaces = emptyList()) + val scene = EtsScene(projectFiles = listOf(file)) + val typeSystem = TsTypeSystem(scene, typeOperationsTimeout = 1.seconds, hierarchy = EtsHierarchy(scene)) + val weakerType = EtsAuxiliaryType(properties = setOf("a")) + val strongerType = EtsAuxiliaryType(properties = setOf("a", "b")) + val unrelatedType = EtsAuxiliaryType(properties = setOf("c")) + val region = UTypeRegion(typeSystem, USingleTypeStream(typeSystem, clazz.type)) + + val refinedRegion = region.addSupertype(weakerType).addSupertype(strongerType) + + assertTrue(typeSystem.isSupertype(supertype = weakerType, type = strongerType)) + assertFalse(typeSystem.isSupertype(supertype = strongerType, type = weakerType)) + assertFalse(typeSystem.isSupertype(supertype = weakerType, type = unrelatedType)) + assertEquals(setOf(strongerType), refinedRegion.supertypes.toSet()) + } + + @Test + fun `auxiliary type streams enumerate classes satisfying method-only requirements`() { + val fileSignature = EtsFileSignature(projectName = "test", fileName = "types.ts") + val classSignature = EtsClassSignature(name = "WithMethod", file = fileSignature) + val method = EtsMethodImpl( + signature = EtsMethodSignature( + enclosingClass = classSignature, + name = "virtualMethod", + parameters = emptyList(), + returnType = EtsNumberType, + ), + ) + val clazz = EtsClassImpl(signature = classSignature, fields = emptyList(), methods = listOf(method)) + val file = EtsFile(signature = fileSignature, classes = listOf(clazz), namespaces = emptyList()) + val scene = EtsScene(projectFiles = listOf(file)) + val typeSystem = TsTypeSystem(scene, typeOperationsTimeout = 1.seconds, hierarchy = EtsHierarchy(scene)) + val auxiliaryType = EtsAuxiliaryType(properties = setOf("virtualMethod")) + + val candidates = typeSystem.findSubtypes(auxiliaryType).toList() + val result = USupportTypeStream.from(typeSystem, auxiliaryType).take(n = 2) as TypesResult.SuccessfulTypesResult + + assertFalse(typeSystem.isInstantiable(auxiliaryType)) + assertTrue(typeSystem.isSupertype(supertype = auxiliaryType, type = clazz.type)) + assertEquals(listOf(clazz.type), candidates) + assertEquals(setOf(clazz.type), result.types.toSet()) + } + @Test fun `fake types are rejected in type constraints before subtype shortcuts`() { val scene = EtsScene(projectFiles = emptyList()) @@ -88,9 +269,10 @@ class TsTypeSystemTest { val typeSystem = TsTypeSystem(scene, typeOperationsTimeout = 1.seconds, hierarchy = EtsHierarchy(scene)) val prototypes = scene.sdkClasses.filter { it.name == "Function" || it.name == "Array" } + val result = typeSystem.topTypeStream().take(n = 20) as TypesResult.SuccessfulTypesResult + assertTrue(typeSystem.isInstantiable(EtsHierarchy.OBJECT_CLASS)) prototypes.forEach { assertFalse(typeSystem.isInstantiable(it.type), it.name) } - val result = typeSystem.topTypeStream().take(n = 20) as TypesResult.SuccessfulTypesResult prototypes.forEach { assertFalse(it.type in result.types, it.name) } } @@ -131,8 +313,8 @@ class TsTypeSystemTest { val clazz = EtsClassImpl( signature = classSignature, fields = listOf( - EtsFieldImpl(EtsFieldSignature(classSignature, "a", EtsNumberType)), - EtsFieldImpl(EtsFieldSignature(classSignature, "b", EtsNumberType)), + EtsFieldImpl(EtsFieldSignature(classSignature, name = "a", EtsNumberType)), + EtsFieldImpl(EtsFieldSignature(classSignature, name = "b", EtsNumberType)), ), methods = emptyList(), ) diff --git a/usvm-ts/src/test/kotlin/org/usvm/reachability/TypeGuardsReachabilityTest.kt b/usvm-ts/src/test/kotlin/org/usvm/reachability/TypeGuardsReachabilityTest.kt index c8d00c1245..3f3aae1a2d 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/reachability/TypeGuardsReachabilityTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/reachability/TypeGuardsReachabilityTest.kt @@ -70,12 +70,14 @@ class TypeGuardsReachabilityTest { target.addChild(TsReachabilityTarget.FinalPoint(returnStmt)) val results = machine.analyze(listOf(method), listOf(initialTarget)) + assertTrue( results.isNotEmpty(), "Expected at least one result for typeof guard reachability" ) val reachedStatements = results.flatMap { it.pathNode.allStatements }.toSet() + assertTrue( returnStmt in reachedStatements, "Expected return statement to be reached when value is exactly 'hello'" @@ -108,12 +110,14 @@ class TypeGuardsReachabilityTest { target.addChild(TsReachabilityTarget.FinalPoint(returnStmt)) val results = machine.analyze(listOf(method), listOf(initialTarget)) + assertTrue( results.isNotEmpty(), "Expected at least one result for instanceof guard reachability" ) val reachedStatements = results.flatMap { it.pathNode.allStatements }.toSet() + assertTrue( returnStmt in reachedStatements, "Expected return statement to be reached when obj is array with length 3" @@ -146,12 +150,14 @@ class TypeGuardsReachabilityTest { target.addChild(TsReachabilityTarget.FinalPoint(returnStmt)) val results = machine.analyze(listOf(method), listOf(initialTarget)) + assertTrue( results.isNotEmpty(), "Expected at least one result for user-defined guard reachability" ) val reachedStatements = results.flatMap { it.pathNode.allStatements }.toSet() + assertTrue( returnStmt in reachedStatements, "Expected return statement to be reached when input is exactly 'test'" @@ -179,12 +185,14 @@ class TypeGuardsReachabilityTest { target.addChild(TsReachabilityTarget.FinalPoint(returnStmt)) val results = machine.analyze(listOf(method), listOf(initialTarget)) + assertTrue( results.isNotEmpty(), "Expected at least one result for type assertion reachability" ) val reachedStatements = results.flatMap { it.pathNode.allStatements }.toSet() + assertTrue( returnStmt in reachedStatements, "Expected return statement to be reached when value is exactly 'A'" @@ -217,12 +225,14 @@ class TypeGuardsReachabilityTest { target.addChild(TsReachabilityTarget.FinalPoint(returnStmt)) val results = machine.analyze(listOf(method), listOf(initialTarget)) + assertTrue( results.isNotEmpty(), "Expected at least one result for non-null assertion reachability" ) val reachedStatements = results.flatMap { it.pathNode.allStatements }.toSet() + assertTrue( returnStmt in reachedStatements, "Expected return statement to be reached when value is non-null 'nonempty'" @@ -259,12 +269,14 @@ class TypeGuardsReachabilityTest { target.addChild(TsReachabilityTarget.FinalPoint(returnStmt)) val results = machine.analyze(listOf(method), listOf(initialTarget)) + assertTrue( results.isNotEmpty(), "Expected at least one result for numeric type guard reachability" ) val reachedStatements = results.flatMap { it.pathNode.allStatements }.toSet() + assertTrue( returnStmt in reachedStatements, "Expected return statement to be reached for numbers between 10 and 20" @@ -296,12 +308,14 @@ class TypeGuardsReachabilityTest { target.addChild(TsReachabilityTarget.FinalPoint(returnStmt)) val results = machine.analyze(listOf(method), listOf(initialTarget)) + assertTrue( results.isNotEmpty(), "Expected at least one result for boolean type guard reachability" ) val reachedStatements = results.flatMap { it.pathNode.allStatements }.toSet() + assertTrue( returnStmt in reachedStatements, "Expected return statement to be reached when value is boolean true" @@ -329,12 +343,14 @@ class TypeGuardsReachabilityTest { target.addChild(TsReachabilityTarget.FinalPoint(returnStmt)) val results = machine.analyze(listOf(method), listOf(initialTarget)) + assertTrue( results.isNotEmpty(), "Expected at least one result for object type guard reachability" ) val reachedStatements = results.flatMap { it.pathNode.allStatements }.toSet() + assertTrue( returnStmt in reachedStatements, "Expected return statement to be reached for non-null objects" @@ -371,12 +387,14 @@ class TypeGuardsReachabilityTest { target.addChild(TsReachabilityTarget.FinalPoint(returnStmt)) val results = machine.analyze(listOf(method), listOf(initialTarget)) + assertTrue( results.isNotEmpty(), "Expected at least one result for complex type guard reachability" ) val reachedStatements = results.flatMap { it.pathNode.allStatements }.toSet() + assertTrue( returnStmt in reachedStatements, "Expected return statement to be reached for dates after 2020" diff --git a/usvm-ts/src/test/kotlin/org/usvm/samples/types/TypeStream.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/types/TypeStream.kt index 63abaf7f31..82da1c2846 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/samples/types/TypeStream.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/samples/types/TypeStream.kt @@ -17,6 +17,7 @@ class TypeStream : TsMethodTestRunner() { @Test fun `test ancestor instanceof`() { val method = getMethod("instanceOf") + discoverProperties( method = method, { x, r -> @@ -43,6 +44,7 @@ class TypeStream : TsMethodTestRunner() { @Test fun `test virtual invoke on an ancestor`() { val method = getMethod("virtualInvokeOnAncestor") + discoverProperties( method = method, { x, r -> diff --git a/usvm-ts/src/test/resources/samples/types/TypeStream.ts b/usvm-ts/src/test/resources/samples/types/TypeStream.ts index 37e3d06a2a..a750f0363e 100644 --- a/usvm-ts/src/test/resources/samples/types/TypeStream.ts +++ b/usvm-ts/src/test/resources/samples/types/TypeStream.ts @@ -32,9 +32,9 @@ class TypeStream { if (virtualInvokeResult == 100) { return 1; } else if (virtualInvokeResult == 200) { - return 2; // unreachable + return 2; } else if (virtualInvokeResult == 300) { - return 3; // unreachable + return 3; } return -1; // unreachable } From 521c529baabc9a5b96fd0d0af512ad06762acac0 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 05:39:18 +0300 Subject: [PATCH 04/97] Preserve runtime kinds when extracting conditional values Decompose result ITE payloads and guards from current memory. Add five unexecuted API regression contracts and clarify existing test phases and numeric comment. Independent static verification and targeted fixture repair only; no compiler, tests, JVM or containers were run. --- .../org/usvm/machine/types/FakeExprUtil.kt | 15 ++ .../usvm/machine/types/FakeExprUtilTest.kt | 162 ++++++++++++++++++ .../org/usvm/samples/lang/TypeCoercion.kt | 7 + .../resources/samples/lang/TypeCoercion.ts | 1 - 4 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/types/FakeExprUtilTest.kt diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/types/FakeExprUtil.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/types/FakeExprUtil.kt index 412b0f8f44..e4cf268059 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/types/FakeExprUtil.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/types/FakeExprUtil.kt @@ -134,6 +134,8 @@ fun TsState.extractValue( sort: T, extractIntermediateLValue: (Int) -> ULValue<*, T>, ): Pair?, UBoolExpr> = with(ctx) { + val hasFakeResultBranches = value.sort == addressSort && value.asExpr(addressSort).hasFakeValueBranch() + when { value.isFakeObject() -> { val lValue = extractIntermediateLValue(value.address) @@ -147,6 +149,19 @@ fun TsState.extractValue( memory.read(lValue) to typeCondition } + value is UIteExpr<*> && hasFakeResultBranches -> { + val (trueValue, trueTypeCondition) = extractValue(value.trueBranch, sort, extractIntermediateLValue) + val (falseValue, falseTypeCondition) = extractValue(value.falseBranch, sort, extractIntermediateLValue) + val typeCondition = mkIte(value.condition, trueTypeCondition, falseTypeCondition) + val payload = when { + trueValue != null && falseValue != null -> mkIte(value.condition, trueValue, falseValue) + trueValue != null -> trueValue + else -> falseValue + } + + payload to typeCondition + } + value.sort == sort -> { value.asExpr(sort) to trueExpr } diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/types/FakeExprUtilTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/types/FakeExprUtilTest.kt new file mode 100644 index 0000000000..76354377dd --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/types/FakeExprUtilTest.kt @@ -0,0 +1,162 @@ +package org.usvm.machine.types + +import io.mockk.mockk +import org.jacodb.ets.model.EtsScene +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.usvm.UIteExpr +import org.usvm.UMachineOptions +import org.usvm.collections.immutable.internal.MutabilityOwnership +import org.usvm.machine.TsComponents +import org.usvm.machine.TsContext +import org.usvm.machine.state.TsState +import org.usvm.util.EtsHierarchy +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.seconds + +/** Prepared-state extraction contracts; source reachability and replay are separate concerns. */ +class FakeExprUtilTest { + private val scene = EtsScene(projectFiles = emptyList()) + private val typeSystem = TsTypeSystem(scene, typeOperationsTimeout = 1.seconds, hierarchy = EtsHierarchy(scene)) + private val components = TsComponents(typeSystem, options = UMachineOptions()) + private val ctx = TsContext(scene = scene, components = components) + private val state = TsState( + ctx = ctx, + ownership = MutabilityOwnership(), + entrypoint = mockk(), + maxStringLength = 0, + ) + + @AfterEach + fun closeContext() { + components.close() + ctx.close() + } + + @Test + fun `conditional number wrappers retain numeric payloads and kind`() = with(ctx) { + val selected = mkRegisterReading(idx = 0, sort = boolSort) + val trueNumber = mkFp64(7.0) + val falseNumber = mkFp64(11.0) + val trueWrapper = state.mkFakeValue( + scope = null, + fpValue = trueNumber, + valueType = EtsFakeType.mkFp(ctx), + ) + val falseWrapper = state.mkFakeValue( + scope = null, + fpValue = falseNumber, + valueType = EtsFakeType.mkFp(ctx), + ) + val conditional = mkIte(selected, trueWrapper, falseWrapper) + + val (number, numberKind) = state.extractValue(conditional, fp64Sort, ::getIntermediateFpLValue) + val (_, referenceKind) = state.extractValue(conditional, addressSort, ::getIntermediateRefLValue) + + assertEquals(mkIte(selected, trueNumber, falseNumber), number) + assertEquals(trueExpr, numberKind) + assertEquals(falseExpr, referenceKind) + } + + @Test + fun `mixed wrappers retain complementary kinds without wrapper reference payloads`() = with(ctx) { + val selected = mkRegisterReading(idx = 0, sort = boolSort) + val numberWrapper = state.mkFakeValue( + scope = null, + fpValue = mkFp64(7.0), + valueType = EtsFakeType.mkFp(ctx), + ) + val reference = mkTsNullValue() + val referenceWrapper = state.mkFakeValue( + scope = null, + refValue = reference, + valueType = EtsFakeType.mkRef(ctx), + ) + val conditional = mkIte(selected, numberWrapper, referenceWrapper) + + val (_, numberKind) = state.extractValue(conditional, fp64Sort, ::getIntermediateFpLValue) + val (ref, referenceKind) = state.extractValue(conditional, addressSort, ::getIntermediateRefLValue) + + assertEquals(selected, numberKind) + assertEquals(mkNot(selected), referenceKind) + assertFalse(assertNotNull(ref).hasFakeValueBranch()) + } + + @Test + fun `one available payload remains protected by the selected branch guard`() = with(ctx) { + val selected = mkRegisterReading(idx = 0, sort = boolSort) + val number = mkFp64(7.0) + val wrapper = state.mkFakeValue( + scope = null, + fpValue = number, + valueType = EtsFakeType.mkFp(ctx), + ) + val reference = mkTsNullValue() + val trueNumber = mkIte(selected, wrapper, reference) + val falseNumber = mkIte(selected, reference, wrapper) + + val (truePayload, trueKind) = state.extractValue(trueNumber, fp64Sort, ::getIntermediateFpLValue) + val (falsePayload, falseKind) = state.extractValue(falseNumber, fp64Sort, ::getIntermediateFpLValue) + val (_, boolKind) = state.extractValue(trueNumber, boolSort, ::getIntermediateBoolLValue) + + assertEquals(number, truePayload) + assertEquals(selected, trueKind) + assertEquals(number, falsePayload) + assertEquals(mkNot(selected), falseKind) + assertEquals(falseExpr, boolKind) + } + + @Test + fun `nested conditional wrappers preserve every selector condition`() = with(ctx) { + val outer = mkRegisterReading(idx = 0, sort = boolSort) + val inner = mkRegisterReading(idx = 1, sort = boolSort) + val number = mkFp64(7.0) + val numberWrapper = state.mkFakeValue( + scope = null, + fpValue = number, + valueType = EtsFakeType.mkFp(ctx), + ) + val boolWrapper = state.mkFakeValue( + scope = null, + boolValue = trueExpr, + valueType = EtsFakeType.mkBool(ctx), + ) + val reference = mkTsNullValue() + val conditional = mkIte(outer, mkIte(inner, numberWrapper, boolWrapper), reference) + + val (_, numberKind) = state.extractValue(conditional, fp64Sort, ::getIntermediateFpLValue) + val (_, boolKind) = state.extractValue(conditional, boolSort, ::getIntermediateBoolLValue) + val (ref, referenceKind) = state.extractValue(conditional, addressSort, ::getIntermediateRefLValue) + + assertEquals(mkIte(outer, inner, falseExpr), numberKind) + assertEquals(mkIte(outer, mkNot(inner), falseExpr), boolKind) + assertEquals(mkNot(outer), referenceKind) + assertFalse(assertNotNull(ref).hasFakeValueBranch()) + } + + @Test + fun `ordinary reference alternatives ignore wrappers used only by the guard`() = with(ctx) { + val wrapper = state.mkFakeValue( + scope = null, + fpValue = mkFp64(7.0), + valueType = EtsFakeType.mkFp(ctx), + ) + val guardPredicateName = "wrapperGuard" + val guardPredicate = mkFuncDecl(guardPredicateName, boolSort, listOf(addressSort)) + val selected = mkApp(guardPredicate, listOf(wrapper)) + val conditional = mkIte(selected, mkTsNullValue(), mkUndefinedValue()) + assertIs>(conditional) + + val (ref, referenceKind) = state.extractValue(conditional, addressSort, ::getIntermediateRefLValue) + val (number, numberKind) = state.extractValue(conditional, fp64Sort, ::getIntermediateFpLValue) + + assertEquals(conditional, ref) + assertEquals(trueExpr, referenceKind) + assertNull(number) + assertEquals(falseExpr, numberKind) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/samples/lang/TypeCoercion.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/lang/TypeCoercion.kt index 9d42558ebc..aab7e5c3b6 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/samples/lang/TypeCoercion.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/samples/lang/TypeCoercion.kt @@ -18,6 +18,7 @@ class TypeCoercion : TsMethodTestRunner() { @Test fun `test argWithConst`() { val method = getMethod("argWithConst") + discoverProperties( method, { a, r -> (a.number == 1.0) && (r.number == 1.0) }, @@ -28,6 +29,7 @@ class TypeCoercion : TsMethodTestRunner() { @Test fun `test dualBoolean`() { val method = getMethod("dualBoolean") + discoverProperties( method, { a, r -> (a.number == 0.0) && (r.number == -1.0) }, @@ -43,6 +45,7 @@ class TypeCoercion : TsMethodTestRunner() { @Disabled("Unsupported string") fun `test dualBooleanWithoutTypes`() { val method = getMethod("dualBooleanWithoutTypes") + discoverProperties( method, ) @@ -51,6 +54,7 @@ class TypeCoercion : TsMethodTestRunner() { @Test fun `test argWithArg`() { val method = getMethod("argWithArg") + discoverProperties( method, { a, b, r -> (a.number + b.number == 10.0) && (r.number == 1.0) }, @@ -61,6 +65,7 @@ class TypeCoercion : TsMethodTestRunner() { @Test fun `test unreachableByType`() { val method = getMethod("unreachableByType") + discoverProperties( method, { a, b, r -> (a.number != b.number) && (r.number == 2.0) }, @@ -75,6 +80,7 @@ class TypeCoercion : TsMethodTestRunner() { @Disabled("Wrong IR, incorrect handling of NaN value") fun `test transitiveCoercion`() { val method = getMethod("transitiveCoercion") + discoverProperties( method, { a, b, c, r -> (a.number == b.number) && (b.number == c.number) && (r.number == 1.0) }, @@ -86,6 +92,7 @@ class TypeCoercion : TsMethodTestRunner() { @Test fun `test transitiveCoercionNoTypes`() { val method = getMethod("transitiveCoercionNoTypes") + discoverProperties( method, // Too complicated to write property matchers, examine run log to verify the test. diff --git a/usvm-ts/src/test/resources/samples/lang/TypeCoercion.ts b/usvm-ts/src/test/resources/samples/lang/TypeCoercion.ts index a9bef91624..79deb9040a 100644 --- a/usvm-ts/src/test/resources/samples/lang/TypeCoercion.ts +++ b/usvm-ts/src/test/resources/samples/lang/TypeCoercion.ts @@ -29,7 +29,6 @@ class TypeCoercion { return 3; } - // a == 0 return -1; } From eb00e46010b148de1774988059779bf470165145 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 06:44:31 +0300 Subject: [PATCH 05/97] Preserve conditional payloads and primitive array storage descriptors Correct wrapper-bearing conditional boxing and primitive literal, alias, and intersection array descriptors. Add focused regression source and separate existing test phases. Independently reviewed and statically verified; compilation and runtime checks were not run under the user restriction. --- .../main/kotlin/org/usvm/machine/TsContext.kt | 36 +- .../org/usvm/machine/TsContextFactoryTest.kt | 327 ++++++++++++++++++ .../kotlin/org/usvm/machine/TsContextTest.kt | 16 +- 3 files changed, 370 insertions(+), 9 deletions(-) create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/TsContextFactoryTest.kt diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt index 9c260c84d0..db29cdd20c 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt @@ -53,6 +53,7 @@ import org.usvm.machine.expr.TsVoidSort import org.usvm.machine.expr.TsVoidValue import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.types.EtsFakeType +import org.usvm.machine.types.iteWriteIntoFakeObject import org.usvm.memory.UReadOnlyMemory import org.usvm.types.single import org.usvm.util.componentType @@ -196,12 +197,24 @@ class TsContext( } fun arrayDescriptorOf(type: EtsArrayType): EtsArrayType { - val storageElement = type.componentType - return when (storageElement) { - is EtsBooleanType -> EtsArrayType(EtsBooleanType, dimensions = 1) - is EtsNumberType -> EtsArrayType(EtsNumberType, dimensions = 1) - else -> EtsArrayType(EtsUnknownType, dimensions = 1) + val storageElement = arrayStorageElementType(type.componentType) + return EtsArrayType(elementType = storageElement, dimensions = 1) + } + + // Descriptor-only operations remain available when the element's expression sort is unsupported. + private fun arrayStorageElementType(type: EtsType): EtsType = when (type) { + is EtsBooleanType, is EtsBooleanLiteralType -> EtsBooleanType + is EtsNumberType, is EtsNumberLiteralType -> EtsNumberType + is EtsAliasType -> arrayStorageElementType(type.originalType) + is EtsIntersectionType -> { + val storageElements = type.types.map(::arrayStorageElementType) + val commonStorageElement = storageElements.firstOrNull() ?: EtsUnknownType + val hasCommonStorageElement = storageElements.all { it == commonStorageElement } + + if (hasCommonStorageElement) commonStorageElement else EtsUnknownType } + + else -> EtsUnknownType } fun UConcreteHeapRef.getFakeType(memory: UReadOnlyMemory<*>): EtsFakeType { @@ -243,6 +256,19 @@ class TsContext( return this } + val hasFakeResultBranches = sort == addressSort && asExpr(addressSort).hasFakeValueBranch() + if (this is UIteExpr<*> && hasFakeResultBranches) { + val trueValue = trueBranch.asExpr(addressSort) + val falseValue = falseBranch.asExpr(addressSort) + + return iteWriteIntoFakeObject( + scope = scope, + condition = condition, + trueBranchValue = trueValue, + falseBranchValue = falseValue, + ) + } + val ref = createFakeObjectRef() scope.doWithState { diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/TsContextFactoryTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/TsContextFactoryTest.kt new file mode 100644 index 0000000000..06f90b5297 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/TsContextFactoryTest.kt @@ -0,0 +1,327 @@ +package org.usvm.machine + +import io.mockk.mockk +import org.jacodb.ets.model.EtsAliasType +import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsBooleanLiteralType +import org.jacodb.ets.model.EtsBooleanType +import org.jacodb.ets.model.EtsGenericType +import org.jacodb.ets.model.EtsIntersectionType +import org.jacodb.ets.model.EtsNumberLiteralType +import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsRawType +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.model.EtsStringType +import org.jacodb.ets.model.EtsType +import org.jacodb.ets.model.EtsUnknownType +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.usvm.UIteExpr +import org.usvm.UMachineOptions +import org.usvm.api.initializeArrayLength +import org.usvm.collections.immutable.internal.MutabilityOwnership +import org.usvm.machine.interpreter.TsStepScope +import org.usvm.machine.state.TsState +import org.usvm.machine.types.EtsFakeType +import org.usvm.machine.types.TsTypeSystem +import org.usvm.machine.types.mkFakeValue +import org.usvm.sizeSort +import org.usvm.util.EtsHierarchy +import org.usvm.util.copyArrayElements +import org.usvm.util.mkArrayIndexLValue +import org.usvm.util.mkArrayLengthLValue +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.time.Duration.Companion.seconds + +/** Prepared factory/storage contracts; source reachability and replay are separate concerns. */ +class TsContextFactoryTest { + private val scene = EtsScene(projectFiles = emptyList()) + private val typeSystem = TsTypeSystem(scene, typeOperationsTimeout = 1.seconds, hierarchy = EtsHierarchy(scene)) + private val components = TsComponents(typeSystem, options = UMachineOptions(useSolverForForks = false)) + private val ctx = TsContext(scene = scene, components = components) + private val state = TsState( + ctx = ctx, + ownership = MutabilityOwnership(), + entrypoint = mockk(), + maxStringLength = 0, + ) + private val scope = TsStepScope(originalState = state, forkBlackList = mockk()) + + @AfterEach + fun closeContext() { + components.close() + ctx.close() + } + + @Test + fun `boxing conditional number wrappers retains numeric payload and kind`() = with(ctx) { + val selected = mkRegisterReading(idx = 0, sort = boolSort) + val trueNumber = mkFp64(7.0) + val falseNumber = mkFp64(11.0) + val trueWrapper = state.mkFakeValue(scope = null, fpValue = trueNumber, valueType = EtsFakeType.mkFp(ctx)) + val falseWrapper = state.mkFakeValue(scope = null, fpValue = falseNumber, valueType = EtsFakeType.mkFp(ctx)) + val conditional = mkIte(selected, trueWrapper, falseWrapper) + assertIs>(conditional) + + val boxed = conditional.toFakeObject(scope) + val kind = boxed.getFakeType(state.memory) + val payload = boxed.extractFp(state.memory) + + assertEquals(mkIte(selected, trueNumber, falseNumber), payload) + assertEquals(trueExpr, kind.fpTypeExpr) + assertEquals(falseExpr, kind.boolTypeExpr) + assertEquals(falseExpr, kind.refTypeExpr) + } + + @Test + fun `boxing mixed wrappers retains complementary kinds and represented reference`() = with(ctx) { + val selected = mkRegisterReading(idx = 0, sort = boolSort) + val number = mkFp64(7.0) + val zero = mkFp64(0.0) + val undefined = mkUndefinedValue() + val reference = mkTsNullValue() + val numberWrapper = state.mkFakeValue( + scope = null, + boolValue = falseExpr, + fpValue = number, + refValue = undefined, + valueType = EtsFakeType.mkFp(ctx), + ) + val referenceWrapper = state.mkFakeValue( + scope = null, + boolValue = falseExpr, + fpValue = zero, + refValue = reference, + valueType = EtsFakeType.mkRef(ctx), + ) + val conditional = mkIte(selected, numberWrapper, referenceWrapper) + assertIs>(conditional) + + val boxed = conditional.toFakeObject(scope) + val kind = boxed.getFakeType(state.memory) + val numberPayload = boxed.extractFp(state.memory) + val referencePayload = boxed.extractRef(state.memory) + + assertEquals(selected, kind.fpTypeExpr) + assertEquals(mkNot(selected), kind.refTypeExpr) + assertEquals(falseExpr, kind.boolTypeExpr) + assertEquals(mkIte(selected, number, zero), numberPayload) + assertEquals(mkIte(selected, undefined, reference), referencePayload) + assertFalse(referencePayload.hasFakeValueBranch()) + } + + @Test + fun `boxing nested wrappers preserves one-sided payload guards`() = with(ctx) { + val outer = mkRegisterReading(idx = 0, sort = boolSort) + val inner = mkRegisterReading(idx = 1, sort = boolSort) + val number = mkFp64(7.0) + val zero = mkFp64(0.0) + val undefined = mkUndefinedValue() + val reference = mkTsNullValue() + val numberWrapper = state.mkFakeValue( + scope = null, + boolValue = falseExpr, + fpValue = number, + refValue = undefined, + valueType = EtsFakeType.mkFp(ctx), + ) + val boolWrapper = state.mkFakeValue( + scope = null, + boolValue = trueExpr, + fpValue = zero, + refValue = undefined, + valueType = EtsFakeType.mkBool(ctx), + ) + val conditional = mkIte(outer, mkIte(inner, numberWrapper, boolWrapper), reference) + assertIs>(conditional) + + val boxed = conditional.toFakeObject(scope) + val kind = boxed.getFakeType(state.memory) + val numberPayload = boxed.extractFp(state.memory) + val referencePayload = boxed.extractRef(state.memory) + + assertEquals(mkIte(outer, inner, falseExpr), kind.fpTypeExpr) + assertEquals(mkIte(outer, mkNot(inner), falseExpr), kind.boolTypeExpr) + assertEquals(mkNot(outer), kind.refTypeExpr) + assertEquals(mkIte(inner, number, zero), numberPayload) + assertEquals(mkIte(outer, undefined, reference), referencePayload) + assertFalse(referencePayload.hasFakeValueBranch()) + } + + @Test + fun `boxing a direct wrapper preserves its identity`() = with(ctx) { + val wrapper = state.mkFakeValue( + scope = null, + fpValue = mkFp64(7.0), + valueType = EtsFakeType.mkFp(ctx), + ) + + val boxed = wrapper.toFakeObject(scope) + + assertSame(wrapper, boxed) + } + + @Test + fun `boxing ordinary references ignores wrappers used only in the condition`() = with(ctx) { + val wrapper = state.mkFakeValue( + scope = null, + fpValue = mkFp64(7.0), + valueType = EtsFakeType.mkFp(ctx), + ) + val guardPredicateName = "wrapperGuard" + val guardPredicate = mkFuncDecl(guardPredicateName, boolSort, listOf(addressSort)) + val selected = mkApp(guardPredicate, listOf(wrapper)) + val conditional = mkIte(selected, mkTsNullValue(), mkUndefinedValue()) + assertIs>(conditional) + + val boxed = conditional.toFakeObject(scope) + val kind = boxed.getFakeType(state.memory) + val payload = boxed.extractRef(state.memory) + + assertEquals(conditional, payload) + assertEquals(trueExpr, kind.refTypeExpr) + assertEquals(falseExpr, kind.fpTypeExpr) + assertEquals(falseExpr, kind.boolTypeExpr) + } + + @Test + fun `literal and alias arrays use their primitive physical descriptors`() { + val numberLiteral = EtsNumberLiteralType(value = 7.0) + val numberAlias = alias(name = "Numeric", originalType = numberLiteral) + val nestedNumberAlias = alias(name = "WrappedNumeric", originalType = numberAlias) + val booleanAlias = alias(name = "Boolean", originalType = EtsBooleanType) + val elementTypes = listOf( + EtsBooleanType, + EtsNumberType, + EtsBooleanLiteralType(value = true), + numberLiteral, + booleanAlias, + nestedNumberAlias, + ) + val booleanDescriptor = EtsArrayType(elementType = EtsBooleanType, dimensions = 1) + val numberDescriptor = EtsArrayType(elementType = EtsNumberType, dimensions = 1) + val expectedDescriptors = listOf( + booleanDescriptor, + numberDescriptor, + booleanDescriptor, + numberDescriptor, + booleanDescriptor, + numberDescriptor, + ) + + val descriptors = elementTypes.map { component -> + ctx.arrayDescriptorOf(EtsArrayType(elementType = component, dimensions = 1)) + } + + assertEquals(expectedDescriptors, descriptors) + } + + @Test + fun `primitive intersections share storage and mixed or empty intersections stay unresolved`() { + val numberIntersection = EtsIntersectionType(types = listOf(EtsNumberType, EtsNumberLiteralType(value = 7.0))) + val booleanIntersection = EtsIntersectionType(types = listOf(EtsBooleanType, EtsBooleanLiteralType(value = true))) + val elementTypes = listOf( + numberIntersection, + alias(name = "Intersection", originalType = numberIntersection), + booleanIntersection, + EtsIntersectionType(types = listOf(EtsNumberType, EtsBooleanType)), + EtsIntersectionType(types = emptyList()), + ) + val numberDescriptor = EtsArrayType(elementType = EtsNumberType, dimensions = 1) + val booleanDescriptor = EtsArrayType(elementType = EtsBooleanType, dimensions = 1) + val referenceDescriptor = EtsArrayType(elementType = EtsUnknownType, dimensions = 1) + val expectedDescriptors = listOf( + numberDescriptor, + numberDescriptor, + booleanDescriptor, + referenceDescriptor, + referenceDescriptor, + ) + + val descriptors = elementTypes.map { component -> + ctx.arrayDescriptorOf(EtsArrayType(elementType = component, dimensions = 1)) + } + + assertEquals(expectedDescriptors, descriptors) + } + + @Test + fun `unsupported and reference components retain nonthrowing reference descriptors`() { + val raw = EtsRawType(kind = "unsupported") + val constrained = EtsGenericType(typeName = "Constrained", constraint = EtsNumberType) + val defaulted = EtsGenericType(typeName = "Defaulted", defaultType = EtsBooleanType) + val elementTypes = listOf( + raw, + constrained, + defaulted, + alias(name = "Raw", originalType = raw), + alias(name = "Constrained", originalType = constrained), + alias(name = "Defaulted", originalType = defaulted), + EtsIntersectionType(types = listOf(EtsNumberType, raw)), + EtsIntersectionType(types = listOf(EtsBooleanType, defaulted)), + EtsStringType, + EtsUnknownType, + ) + val expected = EtsArrayType(elementType = EtsUnknownType, dimensions = 1) + + val descriptors = elementTypes.map { component -> + ctx.arrayDescriptorOf(EtsArrayType(elementType = component, dimensions = 1)) + } + + assertEquals(List(elementTypes.size) { expected }, descriptors) + } + + @Test + fun `logical array dimensions do not become physical payload dimensions`() { + val rows = alias( + name = "Rows", + originalType = EtsArrayType(elementType = EtsNumberType, dimensions = 2), + ) + val arrays = listOf( + EtsArrayType(elementType = EtsNumberType, dimensions = 3), + EtsArrayType(elementType = EtsBooleanLiteralType(value = true), dimensions = 2), + EtsArrayType(elementType = rows, dimensions = 1), + ) + val expected = EtsArrayType(elementType = EtsUnknownType, dimensions = 1) + + val descriptors = arrays.map(ctx::arrayDescriptorOf) + + assertEquals(List(arrays.size) { expected }, descriptors) + } + + @Test + fun `literal array copies use the base numeric payload and length regions`() = with(ctx) { + val literalType = EtsArrayType(elementType = EtsNumberLiteralType(value = 7.0), dimensions = 1) + val numberType = EtsArrayType(elementType = EtsNumberType, dimensions = 1) + val source = state.memory.allocConcrete(literalType) + val destination = state.memory.allocConcrete(numberType) + val sourceIndex = mkBv(0) + val destinationIndex = mkBv(1) + val length = mkBv(1) + val value = mkFp64(7.0) + val sourceElement = mkArrayIndexLValue(fp64Sort, source, sourceIndex, literalType) + state.memory.write(sourceElement, value, guard = trueExpr) + state.memory.initializeArrayLength(source, arrayDescriptorOf(literalType), sizeSort, length) + + state.copyArrayElements( + srcRef = source, + dstRef = destination, + arrayType = literalType, + fromSrc = sourceIndex, + fromDst = destinationIndex, + length = length, + ) + val copiedElement = mkArrayIndexLValue(fp64Sort, destination, destinationIndex, numberType) + val copiedValue = state.memory.read(copiedElement) + val sourceLength = state.memory.read(mkArrayLengthLValue(source, numberType)) + + assertEquals(value, copiedValue) + assertEquals(length, sourceLength) + } + + private fun alias(name: String, originalType: EtsType): EtsAliasType = + EtsAliasType(name = name, originalType = originalType, signature = mockk()) +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/TsContextTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/TsContextTest.kt index 35e523467f..27433e9b8e 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/TsContextTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/TsContextTest.kt @@ -11,10 +11,18 @@ import kotlin.test.assertSame class TsContextTest { @Test fun `literal types use their base type sorts`() { - TsContext(EtsScene(emptyList()), mockk()).use { context -> - assertSame(context.boolSort, context.typeToSort(EtsBooleanLiteralType(true))) - assertSame(context.fp64Sort, context.typeToSort(EtsNumberLiteralType(1.0))) - assertSame(context.addressSort, context.typeToSort(EtsStringLiteralType("value"))) + TsContext(scene = EtsScene(projectFiles = emptyList()), components = mockk()).use { context -> + val booleanType = EtsBooleanLiteralType(value = true) + val numberType = EtsNumberLiteralType(value = 1.0) + val stringType = EtsStringLiteralType(value = "value") + + val booleanSort = context.typeToSort(booleanType) + val numberSort = context.typeToSort(numberType) + val stringSort = context.typeToSort(stringType) + + assertSame(context.boolSort, booleanSort) + assertSame(context.fp64Sort, numberSort) + assertSame(context.addressSort, stringSort) } } } From 54dc43a4f587742aae0616cdea63c5f8acbc25a4 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 07:23:55 +0300 Subject: [PATCH 06/97] Isolate artificial locals when cloning TypeScript states Copy the mutable artificial-local registry for each cloned state. Add a focused bidirectional add/remove isolation regression source and clean local comments and test phases. Independent static review and patch verification passed; compilation, tests, and runtime checks were not run. --- .../kotlin/org/usvm/machine/state/TsState.kt | 15 +---- .../org/usvm/machine/TsCatchRoutingTest.kt | 3 + .../org/usvm/machine/TsSharedAnalysisTest.kt | 1 + .../org/usvm/machine/TsStateCloneTest.kt | 63 +++++++++++++++++++ 4 files changed, 70 insertions(+), 12 deletions(-) create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/TsStateCloneTest.kt diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/state/TsState.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/state/TsState.kt index 39874af0cd..11ceaa7621 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/state/TsState.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/state/TsState.kt @@ -272,8 +272,6 @@ class TsState( fun getDfltObject(file: EtsFile): UConcreteHeapRef { val (updated, result) = dfltObject.getOrPut(file.signature, ownership) { - // val dfltClass = file.getDfltClass() - // memory.allocConcrete(dfltClass.type) ctx.allocateConcreteRef() } dfltObject = updated @@ -296,23 +294,16 @@ class TsState( } /** - * Initializes and returns a fully constructed string constant in this state's memory. - * This function handles both heap reference allocation (via context) and memory initialization. - * Use this when you need a complete, usable string object in the current state. + * Initializes the reference interned by the context and its backing once in this state's memory. */ fun mkInitializedStringConstant( value: String, ): UConcreteHeapRef = with(ctx) { - // Get the shared reference from context val ref = mkStringConstantRef(value) - // Check if we've already initialized this string constant in our state val (updated, result) = stringConstantAllocatedRefs.getOrPut(value, ownership) { - // Initialize the string constant only if not already done in this state - // Allocate type information for this ref in this state's memory memory.types.allocate(ref.address, EtsStringType) - // Initialize char array val charArray = memory.allocConcrete(EtsNumberType) memory.initializeArray( arrayHeapRef = charArray, @@ -357,7 +348,7 @@ class TsState( targets = targets.clone(), localToSortStack = localToSortStack.toMutableList(), staticStorage = staticStorage, - addedArtificialLocals = addedArtificialLocals, + addedArtificialLocals = addedArtificialLocals.toMutableSet(), lValuesToAllocatedFakeObjects = lValuesToAllocatedFakeObjects.toMutableList(), discoveredCallees = discoveredCallees, promiseState = promiseState, @@ -397,7 +388,7 @@ class TsState( /** Snapshot of the regions that establish dense Calls input-array contents. */ data class TsDenseInputArray( val type: EtsArrayType, - val length: UExpr, + val length: UExpr, val lengthRegion: UReadOnlyMemoryRegion<*, *>, val elementRegion: UReadOnlyMemoryRegion<*, *>, ) diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/TsCatchRoutingTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/TsCatchRoutingTest.kt index da171b11a3..f71bf58ea9 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/TsCatchRoutingTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/TsCatchRoutingTest.kt @@ -42,6 +42,7 @@ class TsCatchRoutingTest : TsMethodTestRunner() { @Test fun `catch paths retain the thrown value and replay in Node`() { val conditionalCatch = getMethod(methodName = "conditionalCatch", className = "Exceptions") + discoverProperties( method = conditionalCatch, { input, result -> input.number == 0.0 && result.number == 2.0 }, @@ -51,6 +52,7 @@ class TsCatchRoutingTest : TsMethodTestRunner() { for (name in listOf("caughtValue", "nestedCatch", "catchesCall", "rethrowToOuter")) { val method = getMethod(methodName = name, className = "Exceptions") + discoverProperties( method = method, { input, result -> result.number == input.number + 1.0 }, @@ -117,6 +119,7 @@ class TsCatchRoutingTest : TsMethodTestRunner() { } } } + assertNodeReplay( source = script, directory = directory, diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/TsSharedAnalysisTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/TsSharedAnalysisTest.kt index fa09cad3fc..a0f1672f4e 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/TsSharedAnalysisTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/TsSharedAnalysisTest.kt @@ -138,6 +138,7 @@ class TsSharedAnalysisTest { assignments++ val value = stmt.lhv.accept(simpleValueResolver) val expected = scope.calcOnState { ctx.mkFp(7.0, ctx.fp64Sort) } + assertEquals(expected, value) } } diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/TsStateCloneTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/TsStateCloneTest.kt new file mode 100644 index 0000000000..c9af535003 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/TsStateCloneTest.kt @@ -0,0 +1,63 @@ +package org.usvm.machine + +import io.mockk.mockk +import org.jacodb.ets.model.EtsScene +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.usvm.UMachineOptions +import org.usvm.collections.immutable.internal.MutabilityOwnership +import org.usvm.machine.state.TsState +import org.usvm.machine.types.TsTypeSystem +import org.usvm.util.EtsHierarchy +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.seconds + +/** Prepared clone ownership contract; source reachability and replay are separate concerns. */ +class TsStateCloneTest { + private val scene = EtsScene(projectFiles = emptyList()) + private val typeSystem = TsTypeSystem( + scene = scene, + typeOperationsTimeout = 1.seconds, + hierarchy = EtsHierarchy(scene), + ) + private val components = TsComponents(typeSystem = typeSystem, options = UMachineOptions(useSolverForForks = false)) + private val ctx = TsContext(scene = scene, components = components) + private val state = TsState( + ctx = ctx, + ownership = MutabilityOwnership(), + entrypoint = mockk(), + maxStringLength = 0, + ) + + @AfterEach + fun closeContext() { + components.close() + ctx.close() + } + + @Test + fun `cloned states isolate additions and removals of artificial locals`() { + val retainedInClone = "retained-in-clone" + val retainedInSource = "retained-in-source" + val cloneOnlyLocal = "clone-only" + val sourceOnlyLocal = "source-only" + val initialLocals = setOf(retainedInClone, retainedInSource) + state.addedArtificialLocals.addAll(initialLocals) + + val cloned = state.clone() + + assertEquals(initialLocals, cloned.addedArtificialLocals) + + cloned.addedArtificialLocals += cloneOnlyLocal + cloned.addedArtificialLocals.remove(retainedInSource) + + assertEquals(initialLocals, state.addedArtificialLocals) + assertEquals(setOf(retainedInClone, cloneOnlyLocal), cloned.addedArtificialLocals) + + state.addedArtificialLocals += sourceOnlyLocal + state.addedArtificialLocals.remove(retainedInClone) + + assertEquals(setOf(retainedInSource, sourceOnlyLocal), state.addedArtificialLocals) + assertEquals(setOf(retainedInClone, cloneOnlyLocal), cloned.addedArtificialLocals) + } +} From 2e3b3a56e015a5d3dd7c936b061e5bbca3fb41f1 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 08:07:13 +0300 Subject: [PATCH 07/97] Share guarded UTF-16 storage helpers Reuse guarded code-unit population and the existing private length reader while preserving distinct backing allocation and state mutation order. Name the input register index and separate test phases. Independently reviewed and statically verified; compilation, tests, solver, and replay were not run. --- .../kotlin/org/usvm/util/StringStorage.kt | 22 +++++++++++++------ .../kotlin/org/usvm/util/TsStringValue.kt | 14 ++---------- .../org/usvm/machine/StringEngineTest.kt | 5 ++++- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/StringStorage.kt b/usvm-ts/src/main/kotlin/org/usvm/util/StringStorage.kt index 84b518474b..69003e86b9 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/StringStorage.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/StringStorage.kt @@ -69,6 +69,20 @@ internal fun TsState.stringFromUtf16CodeUnit(codeUnit: UExpr): UConcr result } +internal fun TsState.writeStringCodeUnits( + characters: UHeapRef, + length: UExpr, + codeUnits: List>, +): Unit = with(ctx) { + codeUnits.forEachIndexed { index, codeUnit -> + val position = mkBv(index) + val liveIndex = mkBvSignedLessExpr(position, length) + val element = mkStringBackingElementLValue(ref = characters, index = position) + + memory.write(element, codeUnit, guard = liveIndex) + } +} + /** Initialize the private UTF-16 backing of one bounded symbolic string input. */ fun TsState.allocateBoundedString( length: UExpr, @@ -79,13 +93,7 @@ fun TsState.allocateBoundedString( val boundedLength = mkBvSignedLessOrEqualExpr(length, mkBv(maxLength)) pathConstraints += mkAnd(nonnegativeLength, boundedLength) val (string, characters) = allocateString(length = length, maxLength = maxLength) - codeUnits.forEachIndexed { index, codeUnit -> - val position = mkBv(index) - val liveIndex = mkBvSignedLessExpr(position, length) - val element = mkStringBackingElementLValue(ref = characters, index = position) - - memory.write(element, codeUnit, guard = liveIndex) - } + writeStringCodeUnits(characters = characters, length = length, codeUnits = codeUnits) string } diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/TsStringValue.kt b/usvm-ts/src/main/kotlin/org/usvm/util/TsStringValue.kt index eb792d1c2b..e9e104e506 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/TsStringValue.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/TsStringValue.kt @@ -41,14 +41,7 @@ fun TsState.mkStringFromCodeUnits( val withinCapacity = mkBvSignedLessOrEqualExpr(length, mkBv(codeUnits.size)) memory.initializeArrayLength(characters, stringBackingArrayDescriptor, sizeSort, length) - codeUnits.forEachIndexed { index, codeUnit -> - val position = mkBv(index) - memory.write( - mkStringBackingElementLValue(characters, position), - codeUnit, - guard = mkBvSignedLessExpr(position, length), - ) - } + writeStringCodeUnits(characters = characters, length = length, codeUnits = codeUnits) memory.write(mkStringBackingLValue(string), characters, guard = trueExpr) pathConstraints += mkAnd(nonnegativeLength, withinCapacity) boundedStringBackingRefs += string to codeUnits.size @@ -57,10 +50,7 @@ fun TsState.mkStringFromCodeUnits( } /** Reads the same private length region used by ordinary TypeScript string operations. */ -fun TsState.readStringLength(string: UHeapRef): UExpr { - val characters = memory.read(mkStringBackingLValue(string)) - return memory.read(mkStringBackingLengthLValue(characters)) -} +fun TsState.readStringLength(string: UHeapRef): UExpr = stringLength(receiver = string) /** Resolves a solver-selected witness from this state's memory without changing its input snapshot. */ fun TsState.resolveStringValue(string: UHeapRef, model: UModelBase, maxLength: Int): String = with(ctx) { diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/StringEngineTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/StringEngineTest.kt index ace942d088..af6f2e9e4e 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/StringEngineTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/StringEngineTest.kt @@ -63,7 +63,7 @@ class StringEngineTest : TsMethodTestRunner() { state.pathConstraints += mkEq(unit, mkBv(0xd800, bv16Sort)) input = state.mkStringFromCodeUnits(length = length, codeUnits = listOf(unit)) state.memory.write( - mkRegisterStackLValue(addressSort, 1), + mkRegisterStackLValue(addressSort, idx = 1), input.asExpr(addressSort), guard = trueExpr, ) @@ -72,8 +72,11 @@ class StringEngineTest : TsMethodTestRunner() { ) val state = states.single() + assertEquals("\ud800", state.resolveStringValue(input, model = state.models.single(), maxLength = 1)) + val decoded = TsTestResolver().resolve(method, state).returnValue + assertEquals("\ud800", assertIs(decoded).value) } } From 0ea8295c01db8e06bd57900dac04fa7018391a8f Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 11:03:37 +0300 Subject: [PATCH 08/97] Preserve selected guards in numeric conversion Reject unsupported active numeric conversions and retain represented wrapper alternatives. Add regression source; verification is static only and no tests were executed. --- .../kotlin/org/usvm/machine/expr/ExprUtil.kt | 143 ++++++-- .../usvm/machine/operator/TsBinaryOperator.kt | 138 +++++-- .../expr/ExprUtilConditionalValueTest.kt | 344 ++++++++++++++++++ .../expr/RuntimeLimitationBoundariesTest.kt | 51 +++ .../models/RuntimeLimitationBoundaries.ts | 28 ++ 5 files changed, 645 insertions(+), 59 deletions(-) create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/expr/ExprUtilConditionalValueTest.kt diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ExprUtil.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ExprUtil.kt index 1335278ac1..458b764ef0 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ExprUtil.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ExprUtil.kt @@ -138,23 +138,31 @@ private fun TsState.referenceTruthy(ref: UHeapRef): UBoolExpr = with(ctx) { mkAnd(nonNullish, mkImplies(isString, stringIsNonEmpty)) } -/** Validate the execution path separately from constructing its boolean expression. */ -fun TsStepScope.ensureTruthinessSupported(expr: UExpr): Unit? { - val unsupportedString = calcOnState { - with(ctx) { - val (ref, activeGuard) = when { - expr.isFakeObject() -> { - val type = expr.getFakeType(memory) - memory.read(getIntermediateRefLValue(expr.address)) to type.refTypeExpr - } - - expr.sort == addressSort -> expr.asExpr(addressSort) to trueExpr - else -> return@calcOnState falseExpr - } +private fun TsState.unsupportedTruthinessCondition(expr: UExpr): UBoolExpr = with(ctx) { + val hasFakeResultBranches = expr.sort == addressSort && expr.asExpr(addressSort).hasFakeValueBranch() + if (expr is UIteExpr<*> && hasFakeResultBranches) { + val trueUnsupported = unsupportedTruthinessCondition(expr.trueBranch) + val falseUnsupported = unsupportedTruthinessCondition(expr.falseBranch) - mkAnd(activeGuard, mkNotNullOrUndefined(ref), stringTypeCondition(ref), mkNot(hasStringBacking(ref))) + return@with mkIte(expr.condition, trueUnsupported, falseUnsupported) + } + + val (ref, activeGuard) = when { + expr.isFakeObject() -> { + val type = expr.getFakeType(memory) + memory.read(getIntermediateRefLValue(expr.address)) to type.refTypeExpr } + + expr.sort == addressSort -> expr.asExpr(addressSort) to trueExpr + else -> return@with falseExpr } + + mkAnd(activeGuard, mkNotNullOrUndefined(ref), stringTypeCondition(ref), mkNot(hasStringBacking(ref))) +} + +/** Validate the execution path separately from constructing its boolean expression. */ +fun TsStepScope.ensureTruthinessSupported(expr: UExpr): Unit? { + val unsupportedString = calcOnState { unsupportedTruthinessCondition(expr) } if (unsupportedString.isFalse) return Unit val supported = calcOnState { ctx.mkNot(unsupportedString) } @@ -168,6 +176,14 @@ fun TsContext.mkTruthyExpr( expr: UExpr, scope: TsStepScope, ): UBoolExpr = scope.calcOnState { + val hasFakeResultBranches = expr.sort == addressSort && expr.asExpr(addressSort).hasFakeValueBranch() + if (expr is UIteExpr<*> && hasFakeResultBranches) { + val trueTruthy = mkTruthyExpr(expr.trueBranch, scope) + val falseTruthy = mkTruthyExpr(expr.falseBranch, scope) + + return@calcOnState mkIte(expr.condition, trueTruthy, falseTruthy) + } + if (expr.isFakeObject()) { val falseBranchGround = makeSymbolicPrimitive(boolSort) @@ -227,9 +243,78 @@ fun TsContext.mkTruthyExpr( } } +private fun TsState.numericConversionSupported(expr: UExpr): UBoolExpr = with(ctx) { + if (expr.isFakeObject()) { + val type = expr.getFakeType(memory) + val reference = expr.extractRef(memory) + val referenceSupported = numericConversionSupported(reference) + val supportedReferenceKind = mkAnd(type.refTypeExpr, referenceSupported) + + return@with mkOr(type.fpTypeExpr, type.boolTypeExpr, supportedReferenceKind) + } + + if (expr.sort == fp64Sort || expr.sort == boolSort) { + return@with trueExpr + } + + if (expr is UIteExpr<*>) { + val trueSupported = numericConversionSupported(expr.trueBranch) + val falseSupported = numericConversionSupported(expr.falseBranch) + + return@with mkIte(expr.condition, trueSupported, falseSupported) + } + + val reference = expr.asExpr(addressSort) + val lengthBound = boundedStringBackingRefs[reference] + val hasModeledStringConversion = lengthBound != null && lengthBound in 0..1 + if (hasModeledStringConversion) { + return@with trueExpr + } + + mkIsNullOrUndefined(reference) +} + +/** Converts modeled primitives and bounded strings, retaining unmodeled alternatives as unsupported paths. */ fun TsContext.mkNumericExpr( expr: UExpr, scope: TsStepScope, +): UExpr = mkNumericExprWithGuard(expr = expr, scope = scope, activeGuard = trueExpr) + +/** Keeps unsupported outcomes local to the numeric alternative being constructed by a caller. */ +internal fun TsContext.mkNumericExprWithGuard( + expr: UExpr, + scope: TsStepScope, + activeGuard: UBoolExpr, +): UExpr { + // Preserve the existing error boundary for sorts outside TypeScript runtime values. + when (expr.sort) { + boolSort, fp64Sort, addressSort -> Unit + else -> return mkNumericExprUnchecked(expr, scope) + } + + val supported = scope.calcOnState { + val valueSupported = numericConversionSupported(expr) + + mkOr(mkNot(activeGuard), valueSupported) + } + if (supported.isTrue) { + return mkNumericExprUnchecked(expr, scope) + } + + val unsupportedReason = "Numeric conversion needs a modeled StringToNumber or ToPrimitive conversion" + val currentSupported = scope.fork(supported, blockOnFalseState = { + terminateAsUnsupported(reason = unsupportedReason) + }) + if (currentSupported == null) { + throw UnsupportedOperationException(unsupportedReason) + } + + return mkNumericExprUnchecked(expr, scope) +} + +private fun TsContext.mkNumericExprUnchecked( + expr: UExpr, + scope: TsStepScope, ): UExpr { if (expr.isFakeObject()) { val type = expr.getFakeType(scope) @@ -238,8 +323,8 @@ fun TsContext.mkNumericExpr( trueBranch = expr.extractFp(scope), falseBranch = mkIte( condition = type.boolTypeExpr, - trueBranch = mkNumericExpr(expr.extractBool(scope), scope), - falseBranch = mkNumericExpr(expr.extractRef(scope), scope) + trueBranch = mkNumericExprUnchecked(expr.extractBool(scope), scope), + falseBranch = mkNumericExprUnchecked(expr.extractRef(scope), scope) ) ) } @@ -274,8 +359,8 @@ fun TsContext.mkNumericExpr( } if (expr is UIteExpr<*>) { - val trueNumber = mkNumericExpr(expr.trueBranch, scope) - val falseNumber = mkNumericExpr(expr.falseBranch, scope) + val trueNumber = mkNumericExprUnchecked(expr.trueBranch, scope) + val falseNumber = mkNumericExprUnchecked(expr.falseBranch, scope) return mkIte(expr.condition, trueNumber, falseNumber) } @@ -285,19 +370,12 @@ fun TsContext.mkNumericExpr( return codeUnitNumber } - // TODO: ToPrimitive, then ToNumber again - // TODO: probably we need to implement Object (Ref/Fake) -> Number conversion here directly, without ToPrimitive - - // TODO incorrect implementation, returns some number that is not equal to 0 and NaN - // https://github.com/UnitTestBot/usvm/issues/280 + // TODO: Model general StringToNumber and ToPrimitive conversion (https://github.com/UnitTestBot/usvm/issues/280). + // The public support guard excludes unmodeled reference alternatives from the selected result. return mkIte( condition = mkEq(expr.asExpr(addressSort), mkTsNullValue()), trueBranch = mkFp(0.0, fp64Sort), - falseBranch = mkIte( - mkEq(expr.asExpr(addressSort), mkUndefinedValue()), - mkFp64NaN(), - mkFp64NaN() - ) + falseBranch = mkFp64NaN(), ) } @@ -305,6 +383,14 @@ fun TsContext.mkNullishExpr( expr: UExpr, scope: TsStepScope, ): UBoolExpr { + val hasFakeResultBranches = expr.sort == addressSort && expr.asExpr(addressSort).hasFakeValueBranch() + if (expr is UIteExpr<*> && hasFakeResultBranches) { + val trueNullish = mkNullishExpr(expr.trueBranch, scope) + val falseNullish = mkNullishExpr(expr.falseBranch, scope) + + return mkIte(expr.condition, trueNullish, falseNullish) + } + // Handle fake objects specially if (expr.isFakeObject()) { val fakeType = expr.getFakeType(scope) @@ -324,7 +410,6 @@ fun TsContext.mkNullishExpr( return mkIsNullOrUndefined(ref) } - // Non-reference types (numbers, booleans, strings) are never nullish return mkFalse() } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt index bb2a01a457..0ce8594301 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt @@ -21,6 +21,7 @@ import org.usvm.machine.TsSizeSort import org.usvm.machine.expr.ensureTruthinessSupported import org.usvm.machine.expr.mkIsNullOrUndefined import org.usvm.machine.expr.mkNumericExpr +import org.usvm.machine.expr.mkNumericExprWithGuard import org.usvm.machine.expr.mkTruthyExpr import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.types.ExprWithTypeConstraint @@ -925,6 +926,32 @@ sealed interface TsBinaryOperator { } data object Add : TsBinaryOperator { + override fun TsContext.resolve( + lhs: UExpr<*>, + rhs: UExpr<*>, + scope: TsStepScope, + activeGuard: UBoolExpr, + ): UExpr { + val left = mkNumericExprWithGuard(expr = lhs, scope = scope, activeGuard = activeGuard) + val right = mkNumericExprWithGuard(expr = rhs, scope = scope, activeGuard = activeGuard) + + return onFp(left, right, scope) + } + + override fun TsContext.onRefWithGuard( + lhs: UHeapRef, + rhs: UHeapRef, + scope: TsStepScope, + activeGuard: UBoolExpr, + ): UExpr = resolve(lhs, rhs, scope, activeGuard) + + override fun TsContext.resolveFakeObjectWithGuard( + lhs: UExpr<*>, + rhs: UExpr<*>, + scope: TsStepScope, + activeGuard: UBoolExpr, + ): UExpr = resolve(lhs, rhs, scope, activeGuard) + override fun TsContext.onBool( lhs: UBoolExpr, rhs: UBoolExpr, @@ -962,16 +989,7 @@ sealed interface TsBinaryOperator { rhs: UExpr<*>, scope: TsStepScope, ): UExpr { - return commonResolveFakeObject( - lhs, - rhs, - scope, - fp64Sort - ) { conjuncts -> - conjuncts.foldRight(mkFp(0.0, fp64Sort).asExpr(fp64Sort)) { value, acc -> - mkIte(value.constraint, value.expr, acc) - } - } ?: error("Should not be null") + return resolve(lhs, rhs, scope, activeGuard = trueExpr) } override fun TsContext.internalResolve( @@ -1169,6 +1187,32 @@ sealed interface TsBinaryOperator { } data object Lt : TsBinaryOperator { + override fun TsContext.resolve( + lhs: UExpr<*>, + rhs: UExpr<*>, + scope: TsStepScope, + activeGuard: UBoolExpr, + ): UBoolExpr { + val left = mkNumericExprWithGuard(expr = lhs, scope = scope, activeGuard = activeGuard) + val right = mkNumericExprWithGuard(expr = rhs, scope = scope, activeGuard = activeGuard) + + return onFp(left, right, scope) + } + + override fun TsContext.onRefWithGuard( + lhs: UHeapRef, + rhs: UHeapRef, + scope: TsStepScope, + activeGuard: UBoolExpr, + ): UBoolExpr = resolve(lhs, rhs, scope, activeGuard) + + override fun TsContext.resolveFakeObjectWithGuard( + lhs: UExpr<*>, + rhs: UExpr<*>, + scope: TsStepScope, + activeGuard: UBoolExpr, + ): UBoolExpr = resolve(lhs, rhs, scope, activeGuard) + override fun TsContext.onBool( lhs: UBoolExpr, rhs: UBoolExpr, @@ -1200,16 +1244,7 @@ sealed interface TsBinaryOperator { rhs: UExpr<*>, scope: TsStepScope, ): UBoolExpr { - return commonResolveFakeObject( - lhs, - rhs, - scope, - boolSort - ) { conjuncts -> - conjuncts.foldRight(mkFalse().asExpr(boolSort)) { value, acc -> - mkIte(value.constraint, value.expr, acc) - } - } ?: error("Should not be null") + return resolve(lhs, rhs, scope, activeGuard = trueExpr) } override fun TsContext.internalResolve( @@ -1227,6 +1262,32 @@ sealed interface TsBinaryOperator { } data object Gt : TsBinaryOperator { + override fun TsContext.resolve( + lhs: UExpr<*>, + rhs: UExpr<*>, + scope: TsStepScope, + activeGuard: UBoolExpr, + ): UBoolExpr { + val left = mkNumericExprWithGuard(expr = lhs, scope = scope, activeGuard = activeGuard) + val right = mkNumericExprWithGuard(expr = rhs, scope = scope, activeGuard = activeGuard) + + return onFp(left, right, scope) + } + + override fun TsContext.onRefWithGuard( + lhs: UHeapRef, + rhs: UHeapRef, + scope: TsStepScope, + activeGuard: UBoolExpr, + ): UBoolExpr = resolve(lhs, rhs, scope, activeGuard) + + override fun TsContext.resolveFakeObjectWithGuard( + lhs: UExpr<*>, + rhs: UExpr<*>, + scope: TsStepScope, + activeGuard: UBoolExpr, + ): UBoolExpr = resolve(lhs, rhs, scope, activeGuard) + override fun TsContext.onBool( lhs: UBoolExpr, rhs: UBoolExpr, @@ -1258,16 +1319,7 @@ sealed interface TsBinaryOperator { rhs: UExpr<*>, scope: TsStepScope, ): UBoolExpr { - return commonResolveFakeObject( - lhs, - rhs, - scope, - boolSort - ) { conjuncts -> - conjuncts.foldRight(falseExpr.asExpr(boolSort)) { value, acc -> - mkIte(value.constraint, value.expr, acc) - } - } ?: error("Should not be null") + return resolve(lhs, rhs, scope, activeGuard = trueExpr) } override fun TsContext.internalResolve( @@ -1287,6 +1339,32 @@ sealed interface TsBinaryOperator { rhs: UExpr, ): UExpr + override fun TsContext.resolve( + lhs: UExpr<*>, + rhs: UExpr<*>, + scope: TsStepScope, + activeGuard: UBoolExpr, + ): UExpr { + val left = mkNumericExprWithGuard(expr = lhs, scope = scope, activeGuard = activeGuard) + val right = mkNumericExprWithGuard(expr = rhs, scope = scope, activeGuard = activeGuard) + + return apply(left, right) + } + + override fun TsContext.onRefWithGuard( + lhs: UHeapRef, + rhs: UHeapRef, + scope: TsStepScope, + activeGuard: UBoolExpr, + ): UExpr = resolve(lhs, rhs, scope, activeGuard) + + override fun TsContext.resolveFakeObjectWithGuard( + lhs: UExpr<*>, + rhs: UExpr<*>, + scope: TsStepScope, + activeGuard: UBoolExpr, + ): UExpr = resolve(lhs, rhs, scope, activeGuard) + override fun TsContext.onBool( lhs: UBoolExpr, rhs: UBoolExpr, diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/expr/ExprUtilConditionalValueTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/ExprUtilConditionalValueTest.kt new file mode 100644 index 0000000000..a4db34a190 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/ExprUtilConditionalValueTest.kt @@ -0,0 +1,344 @@ +package org.usvm.machine.expr + +import io.mockk.mockk +import org.jacodb.ets.model.EtsNullType +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.model.EtsType +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.usvm.UIteExpr +import org.usvm.UExpr +import org.usvm.UMachineOptions +import org.usvm.USort +import org.usvm.collections.immutable.internal.MutabilityOwnership +import org.usvm.machine.TsComponents +import org.usvm.machine.TsContext +import org.usvm.machine.interpreter.TsStepScope +import org.usvm.machine.operator.TsBinaryOperator +import org.usvm.machine.operator.TsUnaryOperator +import org.usvm.machine.state.TsState +import org.usvm.machine.types.EtsFakeType +import org.usvm.machine.types.TsTypeSystem +import org.usvm.machine.types.mkFakeValue +import org.usvm.memory.UReadOnlyMemory +import org.usvm.memory.URegistersStack +import org.usvm.util.EtsHierarchy +import org.usvm.util.boolToFp +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.seconds + +/** Prepared symbolic API contracts; source reachability and replay are separate concerns. */ +class ExprUtilConditionalValueTest { + private val scene = EtsScene(projectFiles = emptyList()) + private val typeSystem = TsTypeSystem(scene, typeOperationsTimeout = 1.seconds, hierarchy = EtsHierarchy(scene)) + private val components = TsComponents(typeSystem, options = UMachineOptions(useSolverForForks = false)) + private val ctx = TsContext(scene = scene, components = components) + private val state = TsState( + ctx = ctx, + ownership = MutabilityOwnership(), + entrypoint = mockk(), + maxStringLength = 2, + ) + + init { + state.memory.types.allocate(ctx.mkTsNullValue().address, EtsNullType) + } + + private val scope = TsStepScope(originalState = state, forkBlackList = mockk()) + + @AfterEach + fun closeContext() { + components.close() + ctx.close() + } + + @Test + fun `joined operator wrappers preserve false and zero truthiness`() = with(ctx) { + val selected = mkRegisterReading(idx = 0, sort = boolSort) + val numeric = boolToFp(selected) + + val joined = with(TsBinaryOperator.And) { ctx.resolve(numeric, falseExpr, scope) } + val conditional = assertNotNull(joined) + assertIs>(conditional) + + val supported = scope.ensureTruthinessSupported(conditional) + val truthy = mkTruthyExpr(conditional, scope) + val negated = with(TsUnaryOperator.Not) { ctx.resolve(conditional, scope) } + + assertNotNull(supported) + assertEquals(falseExpr, truthy) + assertEquals(trueExpr, negated) + } + + @Test + fun `joined operator wrappers compare represented null instead of storage identity`() = with(ctx) { + val selected = mkRegisterReading(idx = 0, sort = boolSort) + val numeric = boolToFp(selected) + + val joined = with(TsBinaryOperator.Or) { ctx.resolve(numeric, mkTsNullValue(), scope) } + val conditional = assertNotNull(joined) + assertIs>(conditional) + + val nullish = mkNullishExpr(conditional, scope) + + assertEquals(mkIte(selected, falseExpr, trueExpr), nullish) + } + + @Test + fun `nested wrapper results retain truthiness nullish and numeric selection`() = with(ctx) { + val outer = mkRegisterReading(idx = 0, sort = boolSort) + val inner = mkRegisterReading(idx = 1, sort = boolSort) + val booleanWrapper = state.mkFakeValue( + scope = null, + boolValue = trueExpr, + valueType = EtsFakeType.mkBool(ctx), + ) + val numberWrapper = state.mkFakeValue( + scope = null, + fpValue = mkFp64(0.0), + valueType = EtsFakeType.mkFp(ctx), + ) + val conditional = mkIte(outer, mkIte(inner, booleanWrapper, numberWrapper), mkTsNullValue()) + assertIs>(conditional) + + val supported = scope.ensureTruthinessSupported(conditional) + val truthy = mkTruthyExpr(conditional, scope) + val nullish = mkNullishExpr(conditional, scope) + val numeric = mkNumericExpr(conditional, scope) + + assertNotNull(supported) + assertEquals(mkIte(outer, mkIte(inner, trueExpr, falseExpr), falseExpr), truthy) + assertEquals(mkIte(outer, falseExpr, trueExpr), nullish) + assertEquals(mkIte(outer, mkIte(inner, mkFp64(1.0), mkFp64(0.0)), mkFp64(0.0)), numeric) + } + + @Test + fun `wrappers used only by a guard are not conditional result wrappers`() = with(ctx) { + val wrapper = state.mkFakeValue( + scope = null, + fpValue = mkFp64(7.0), + valueType = EtsFakeType.mkFp(ctx), + ) + val guardPredicateName = "wrapperDependency" + val guardPredicate = mkFuncDecl(guardPredicateName, boolSort, listOf(addressSort)) + val selected = mkApp(guardPredicate, listOf(wrapper)) + val conditional = mkIte(selected, mkTsNullValue(), mkUndefinedValue()) + assertIs>(conditional) + + val supported = scope.ensureTruthinessSupported(conditional) + val truthy = mkTruthyExpr(conditional, scope) + val nullish = mkNullishExpr(conditional, scope) + val numeric = mkNumericExpr(conditional, scope) + + assertFalse(conditional.hasFakeValueBranch()) + assertNotNull(supported) + assertEquals(falseExpr, truthy) + assertEquals(trueExpr, nullish) + assertEquals(mkIte(selected, mkFp64(0.0), mkFp64NaN()), numeric) + } + + @Test + fun `inactive unmodeled reference payload does not reject a numeric wrapper`() = with(ctx) { + val number = mkFp64(7.0) + val unmodeledString = state.mkInitializedStringConstant(value = "42") + val wrapper = state.mkFakeValue( + scope = null, + fpValue = number, + refValue = unmodeledString, + valueType = EtsFakeType.mkFp(ctx), + ) + + val numeric = mkNumericExpr(wrapper, scope) + + assertEquals(number, numeric) + assertNull(state.unsupportedReason) + assertEquals(emptyList(), scope.stepResult().forkedStates.toList()) + } + + @Test + fun `numeric conversion retains a supported conditional wrapper and records its unsupported sibling`() = with(ctx) { + val selected = mkRegisterReading(idx = 0, sort = boolSort) + val number = mkFp64(7.0) + val unmodeledString = state.mkInitializedStringConstant(value = "42") + val numberWrapper = state.mkFakeValue(scope = null, fpValue = number, valueType = EtsFakeType.mkFp(ctx)) + val stringWrapper = state.mkFakeValue( + scope = null, + refValue = unmodeledString, + valueType = EtsFakeType.mkRef(ctx), + ) + val conditional = mkIte(selected, numberWrapper, stringWrapper) + assertIs>(conditional) + + val numeric = mkNumericExpr(conditional, scope) + val unsupported = scope.stepResult().forkedStates.single() + + assertEquals(mkIte(selected, number, mkFp64NaN()), numeric) + assertNull(state.unsupportedReason) + assertEquals( + "Numeric conversion needs a modeled StringToNumber or ToPrimitive conversion", + unsupported.unsupportedReason, + ) + } + + @Test + fun `binary arithmetic retains the numeric result of joined operator wrappers`() = with(ctx) { + val selected = mkRegisterReading(idx = 0, sort = boolSort) + val unmodeledString = state.mkInitializedStringConstant(value = "42") + val joined = with(TsBinaryOperator.Or) { ctx.resolve(boolToFp(selected), unmodeledString, scope) } + val conditional = assertNotNull(joined) + assertIs>(conditional) + + val resolved = with(TsBinaryOperator.Sub) { ctx.resolve(conditional, mkFp64(1.0), scope) } + val result = assertNotNull(resolved) + val unsupported = scope.stepResult().forkedStates.single() + + assertEquals(mkFp64(0.0), evaluateSelection(expr = result, selectedValue = true)) + assertNull(state.unsupportedReason) + assertEquals( + "Numeric conversion needs a modeled StringToNumber or ToPrimitive conversion", + unsupported.unsupportedReason, + ) + } + + @Test + fun `binary arithmetic retains a numeric conditional right operand`() = with(ctx) { + val selected = mkRegisterReading(idx = 0, sort = boolSort) + val unmodeledString = state.mkInitializedStringConstant(value = "42") + val joined = with(TsBinaryOperator.Or) { ctx.resolve(boolToFp(selected), unmodeledString, scope) } + val conditional = assertNotNull(joined) + assertIs>(conditional) + + val resolved = with(TsBinaryOperator.Sub) { ctx.resolve(mkFp64(10.0), conditional, scope) } + val result = assertNotNull(resolved) + val unsupported = scope.stepResult().forkedStates.single() + + assertEquals(mkFp64(9.0), evaluateSelection(expr = result, selectedValue = true)) + assertNull(state.unsupportedReason) + assertEquals( + "Numeric conversion needs a modeled StringToNumber or ToPrimitive conversion", + unsupported.unsupportedReason, + ) + } + + @Test + fun `numeric binary operators ignore inactive populated reference payloads on either side`() = with(ctx) { + val selected = mkRegisterReading(idx = 0, sort = boolSort) + val unmodeledString = state.mkInitializedStringConstant(value = "42") + val kinds = EtsFakeType( + boolTypeExpr = selected, + fpTypeExpr = mkNot(selected), + refTypeExpr = falseExpr, + ) + val wrapper = state.mkFakeValue( + scope = null, + boolValue = trueExpr, + fpValue = mkFp64(7.0), + refValue = unmodeledString, + valueType = kinds, + ) + val operators = listOf(TsBinaryOperator.Add, TsBinaryOperator.Lt, TsBinaryOperator.Gt) + + operators.forEach { operator -> + val leftResult = with(operator) { ctx.resolve(wrapper, mkFp64(10.0), scope) } + val rightResult = with(operator) { ctx.resolve(mkFp64(10.0), wrapper, scope) } + val left = assertNotNull(leftResult) + val right = assertNotNull(rightResult) + + val leftBooleanExpected = when (operator) { + TsBinaryOperator.Add -> mkFp64(11.0) + TsBinaryOperator.Lt -> trueExpr + TsBinaryOperator.Gt -> falseExpr + else -> error("Unexpected test operator") + } + val leftNumberExpected = when (operator) { + TsBinaryOperator.Add -> mkFp64(17.0) + TsBinaryOperator.Lt -> trueExpr + TsBinaryOperator.Gt -> falseExpr + else -> error("Unexpected test operator") + } + val rightBooleanExpected = when (operator) { + TsBinaryOperator.Add -> mkFp64(11.0) + TsBinaryOperator.Lt -> falseExpr + TsBinaryOperator.Gt -> trueExpr + else -> error("Unexpected test operator") + } + val rightNumberExpected = when (operator) { + TsBinaryOperator.Add -> mkFp64(17.0) + TsBinaryOperator.Lt -> falseExpr + TsBinaryOperator.Gt -> trueExpr + else -> error("Unexpected test operator") + } + + assertEquals(leftBooleanExpected, evaluateSelection(expr = left, selectedValue = true)) + assertEquals(leftNumberExpected, evaluateSelection(expr = left, selectedValue = false)) + assertEquals(rightBooleanExpected, evaluateSelection(expr = right, selectedValue = true)) + assertEquals(rightNumberExpected, evaluateSelection(expr = right, selectedValue = false)) + } + + assertNull(state.unsupportedReason) + assertEquals(emptyList(), scope.stepResult().forkedStates.toList()) + } + + @Test + fun `addition retains symbolic numeric kinds while rejecting the selected reference kind`() = with(ctx) { + val selected = mkRegisterReading(idx = 0, sort = boolSort) + val unmodeledString = state.mkInitializedStringConstant(value = "42") + val kinds = EtsFakeType( + boolTypeExpr = falseExpr, + fpTypeExpr = selected, + refTypeExpr = mkNot(selected), + ) + val wrapper = state.mkFakeValue( + scope = null, + fpValue = mkFp64(7.0), + refValue = unmodeledString, + valueType = kinds, + ) + + val resolved = with(TsBinaryOperator.Add) { ctx.resolve(wrapper, mkFp64(1.0), scope) } + val result = assertNotNull(resolved) + val unsupported = scope.stepResult().forkedStates.single() + + assertEquals(mkFp64(8.0), evaluateSelection(expr = result, selectedValue = true)) + assertNull(state.unsupportedReason) + assertEquals( + "Numeric conversion needs a modeled StringToNumber or ToPrimitive conversion", + unsupported.unsupportedReason, + ) + } + + @Test + fun `numeric binary support failures stay under the enclosing active guard`() = with(ctx) { + val selected = mkRegisterReading(idx = 0, sort = boolSort) + val unmodeledString = state.mkInitializedStringConstant(value = "42") + + val resolved = with(TsBinaryOperator.Add) { + ctx.resolve(unmodeledString, mkFp64(1.0), scope, activeGuard = selected) + } + val result = assertNotNull(resolved) + val unsupported = scope.stepResult().forkedStates.single() + + assertEquals(mkFp64NaN(), evaluateSelection(expr = result, selectedValue = false)) + assertNull(state.unsupportedReason) + assertEquals( + "Numeric conversion needs a modeled StringToNumber or ToPrimitive conversion", + unsupported.unsupportedReason, + ) + } + + private fun evaluateSelection(expr: UExpr, selectedValue: Boolean): UExpr { + val registers = URegistersStack() + registers.push(registersCount = 1) + registers.writeRegister(index = 0, value = if (selectedValue) ctx.trueExpr else ctx.falseExpr) + val evaluationMemory = object : UReadOnlyMemory by state.memory { + override val stack = registers + } + val composer = ctx.composer(memory = evaluationMemory, ownership = MutabilityOwnership()) + + return composer.compose(expr) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/expr/RuntimeLimitationBoundariesTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/RuntimeLimitationBoundariesTest.kt index e05c0f4975..6a335dafff 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/expr/RuntimeLimitationBoundariesTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/RuntimeLimitationBoundariesTest.kt @@ -4,7 +4,9 @@ import org.jacodb.ets.model.EtsMethod import org.jacodb.ets.model.EtsScene import org.jacodb.ets.utils.EtsIrProvider import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.usvm.PathSelectionStrategy import org.usvm.SolverType +import org.usvm.StateCollectionStrategy import org.usvm.UMachineOptions import org.usvm.api.TsTestValue import org.usvm.machine.TsAnalysisStopReason @@ -20,6 +22,9 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue +private const val UNMODELED_NUMERIC_CONVERSION_REASON = + "Numeric conversion needs a modeled StringToNumber or ToPrimitive conversion" + class RuntimeLimitationBoundariesTest : TsMethodTestRunner() { override val scene = EtsScene( listOf( @@ -109,6 +114,52 @@ class RuntimeLimitationBoundariesTest : TsMethodTestRunner() { ) } + @Test + fun `unmodeled numeric conversion stops while a stored numeric sibling returns`() { + assertUnsupportedNumericSibling(methodName = "unsupportedNumericConversionOrSupportedSibling", expected = 7.0) + } + + @Test + fun `unmodeled binary numeric conversion stops while a stored numeric sibling returns`() { + assertUnsupportedNumericSibling(methodName = "unsupportedNumericBinaryConversionOrSupportedSibling", expected = 6.0) + } + + private fun assertUnsupportedNumericSibling(methodName: String, expected: Double) { + val target = method(name = methodName) + val options = UMachineOptions( + pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), + solverType = SolverType.YICES, + stateCollectionStrategy = StateCollectionStrategy.ALL, + throwExceptionOnStepFailure = false, + stepsFromLastCovered = 128L, + ) + + TsMachine(scene = scene, options = options, tsOptions = TsOptions()).use { machine -> + val outcome = machine.analyzeWithOutcome(listOf(target)) + val witnesses = outcome.states.map { state -> TsTestResolver().resolve(target, state) } + + assertEquals(TsAnalysisStopReason.EXHAUSTED, outcome.stopReason) + assertTrue(UNMODELED_NUMERIC_CONVERSION_REASON in outcome.unsupportedPaths) + assertTrue(witnesses.isNotEmpty()) + witnesses.forEach { witness -> + val useString = witness.before.parameters.single() as TsTestValue.TsBoolean + val result = witness.returnValue as TsTestValue.TsNumber + + assertTrue(!useString.value) + assertEquals(expected, result.number) + } + } + } + + @Test + fun `stored primitives and zero or one code unit strings keep numeric conversions`() { + discoverProperties( + method = method(name = "modeledNumericConversions"), + { result -> result.value }, + invariants = arrayOf({ result -> result.value }), + ) + } + private fun method(name: String): EtsMethod = getMethod( methodName = name, className = "RuntimeLimitationBoundaries", diff --git a/usvm-ts/src/test/resources/models/RuntimeLimitationBoundaries.ts b/usvm-ts/src/test/resources/models/RuntimeLimitationBoundaries.ts index 8c734dbc94..d38dd3b08f 100644 --- a/usvm-ts/src/test/resources/models/RuntimeLimitationBoundaries.ts +++ b/usvm-ts/src/test/resources/models/RuntimeLimitationBoundaries.ts @@ -17,4 +17,32 @@ export class RuntimeLimitationBoundaries { const record = { length: 17 }; return record.length; } + + static unsupportedNumericConversionOrSupportedSibling(useString: boolean): number { + const values: unknown[] = [7]; + if (useString) values[0] = "42"; + + return +values[0]; + } + + static modeledNumericConversions(): boolean { + const values: unknown[] = [true, false, 7, null, undefined]; + + return +values[0] === 1 + && +values[1] === 0 + && +values[2] === 7 + && +values[3] === 0 + && Number.isNaN(+values[4]) + && +"" === 0 + && +"7" === 7 + && +" " === 0 + && Number.isNaN(+"x"); + } + + static unsupportedNumericBinaryConversionOrSupportedSibling(useString: boolean): number { + const values: unknown[] = [7]; + if (useString) values[0] = "42"; + + return values[0] - 1; + } } From 72fca1dd8194632ac2f6ca0536d3dd175cd9c4ac Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 11:21:24 +0300 Subject: [PATCH 09/97] Correct fake equality operands and clarify value encoding Use the selected right payload in mixed-kind equality and add a focused regression source case. Apply named arguments and staged predicates in PBT value encoding. Verification is static only. --- .../org/usvm/ts/pbt/model/JsConcreteValue.kt | 18 +++++----- .../kotlin/org/usvm/ts/pbt/model/Utf16Json.kt | 6 +++- .../usvm/machine/operator/TsBinaryOperator.kt | 4 +-- .../expr/ExprUtilConditionalValueTest.kt | 33 +++++++++++++++++++ 4 files changed, 48 insertions(+), 13 deletions(-) diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsConcreteValue.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsConcreteValue.kt index 09a9b01049..5e9d9c7432 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsConcreteValue.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsConcreteValue.kt @@ -49,20 +49,18 @@ data class JsNumber( /** Whether this tag can represent exactly the encoded ECMAScript binary64 value. */ fun hasValidEncoding(): Boolean = when (value) { JsNumberKind.FINITE -> { - bits?.takeIf { it.matches(FINITE_NUMBER_BITS_REGEX) } - ?.let { Double.fromBits(it.toULong(JS_NUMBER_HEX_RADIX).toLong()).isFinite() } - ?: false + val finiteBits = bits?.takeIf { it.matches(FINITE_NUMBER_BITS_REGEX) } + finiteBits?.let { Double.fromBits(it.toULong(radix = JS_NUMBER_HEX_RADIX).toLong()).isFinite() } ?: false } else -> bits == null } fun toDouble(): Double = when (value) { - JsNumberKind.FINITE -> Double.fromBits( - requireNotNull(bits) { "A finite JavaScript number requires IEEE-754 bits" } - .toULong(JS_NUMBER_HEX_RADIX) - .toLong(), - ) + JsNumberKind.FINITE -> { + val finiteBits = requireNotNull(bits) { "A finite JavaScript number requires IEEE-754 bits" } + Double.fromBits(finiteBits.toULong(radix = JS_NUMBER_HEX_RADIX).toLong()) + } JsNumberKind.NAN -> Double.NaN JsNumberKind.POSITIVE_INFINITY -> Double.POSITIVE_INFINITY @@ -78,8 +76,8 @@ data class JsNumber( bits = value .toRawBits() .toULong() - .toString(JS_NUMBER_HEX_RADIX) - .padStart(JS_NUMBER_HEX_DIGITS, '0'), + .toString(radix = JS_NUMBER_HEX_RADIX) + .padStart(length = JS_NUMBER_HEX_DIGITS, padChar = '0'), ) } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/Utf16Json.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/Utf16Json.kt index d75c5beee5..3e75f3933b 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/Utf16Json.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/Utf16Json.kt @@ -13,7 +13,11 @@ inline fun Json.encodeToUtf8SafeString(value: T): String = * Keep this at the text boundary: JsonElement string contents must retain their original UTF-16 value. */ fun escapeJsonSurrogates(json: String): String { - if (json.none { it.isSurrogate() }) return json + val hasNoSurrogates = json.none { it.isSurrogate() } + + if (hasNoSurrogates) { + return json + } return buildString { json.forEach { unit -> diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt index 0ce8594301..b61c6de560 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt @@ -312,14 +312,14 @@ sealed interface TsBinaryOperator { ) // fake(bool) + fake(fp) - val boolFpExpr = internalResolve(lhsBool, lhsFp, scope)?.asExpr(resultSort) ?: return null + val boolFpExpr = internalResolve(lhsBool, rhsFp, scope)?.asExpr(resultSort) ?: return null conjuncts += ExprWithTypeConstraint( constraint = mkAnd(lhsType.boolTypeExpr, rhsType.fpTypeExpr), expr = boolFpExpr ) // fake(bool) + fake(ref) - val boolRefExpr = internalResolve(lhsBool, lhsRef, scope)?.asExpr(resultSort) ?: return null + val boolRefExpr = internalResolve(lhsBool, rhsRef, scope)?.asExpr(resultSort) ?: return null conjuncts += ExprWithTypeConstraint( constraint = mkAnd(lhsType.boolTypeExpr, rhsType.refTypeExpr), expr = boolRefExpr diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/expr/ExprUtilConditionalValueTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/ExprUtilConditionalValueTest.kt index a4db34a190..6fd8094bc7 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/expr/ExprUtilConditionalValueTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/ExprUtilConditionalValueTest.kt @@ -311,6 +311,39 @@ class ExprUtilConditionalValueTest { ) } + @Test + fun `loose equality compares the selected right numeric payload`() = with(ctx) { + val leftBooleanSelected = mkRegisterReading(idx = 0, sort = boolSort) + val leftKinds = EtsFakeType( + boolTypeExpr = leftBooleanSelected, + fpTypeExpr = mkNot(leftBooleanSelected), + refTypeExpr = falseExpr, + ) + val rightKinds = EtsFakeType( + boolTypeExpr = mkNot(leftBooleanSelected), + fpTypeExpr = leftBooleanSelected, + refTypeExpr = falseExpr, + ) + val left = state.mkFakeValue( + scope = null, + boolValue = trueExpr, + fpValue = mkFp64(2.0), + valueType = leftKinds, + ) + val right = state.mkFakeValue( + scope = null, + boolValue = falseExpr, + fpValue = mkFp64(1.0), + valueType = rightKinds, + ) + + val resolved = with(TsBinaryOperator.Eq) { ctx.resolve(left, right, scope) } + val result = assertNotNull(resolved) + + assertEquals(trueExpr, evaluateSelection(expr = result, selectedValue = true)) + assertEquals(falseExpr, evaluateSelection(expr = result, selectedValue = false)) + } + @Test fun `numeric binary support failures stay under the enclosing active guard`() = with(ctx) { val selected = mkRegisterReading(idx = 0, sort = boolSort) From f3ec3011c80dea6446c53d53b7414db1dc98eca5 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 11:44:26 +0300 Subject: [PATCH 10/97] Preserve runtime limitation outcomes when observers fail Isolate runtime limitation callbacks like unknown-call observation. Add a regression source scenario asserting callback invocation and preserved metadata. Independent static verification only; no tests executed. --- .../org/usvm/machine/TsInterpreterObserver.kt | 13 ++++++- .../org/usvm/machine/expr/TsExprResolver.kt | 3 +- .../expr/RuntimeLimitationBoundariesTest.kt | 35 +++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt index 566d040c00..49b9919a77 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt @@ -1,5 +1,6 @@ package org.usvm.machine +import mu.KotlinLogging import org.jacodb.ets.model.EtsAssignStmt import org.jacodb.ets.model.EtsCallExpr import org.jacodb.ets.model.EtsCallStmt @@ -14,6 +15,8 @@ import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.state.TsState import org.usvm.statistics.UInterpreterObserver +private val logger = KotlinLogging.logger {} + @Suppress("unused") interface TsInterpreterObserver : UInterpreterObserver { /** Each completed selector turn, including the final partial turn and repeated turns of one entry point. */ @@ -42,7 +45,10 @@ interface TsInterpreterObserver : UInterpreterObserver { // default empty implementation } - /** Reports a satisfiable path stopped at an explicit runtime modeling boundary. */ + /** + * Reports a satisfiable path stopped at an explicit runtime modeling boundary. + * Callback failures are logged without changing the path outcome. + */ fun onRuntimeFeatureLimitation(event: TsRuntimeFeatureLimitationEvent) { // default empty implementation } @@ -119,6 +125,11 @@ interface TsInterpreterObserver : UInterpreterObserver { } } +internal fun TsInterpreterObserver.onRuntimeFeatureLimitationSafely(event: TsRuntimeFeatureLimitationEvent) { + runCatching { onRuntimeFeatureLimitation(event) } + .onFailure { error -> logger.warn(error) { "Runtime feature limitation observer failed while recording an event" } } +} + /** A feasible execution path stopped because the runtime model deliberately omits [reason]. */ data class TsRuntimeFeatureLimitationEvent( val statement: EtsStmt, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt index 3bc38202a8..3149784a60 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt @@ -117,6 +117,7 @@ import org.usvm.machine.TsInterpreterObserver import org.usvm.machine.TsOptions import org.usvm.machine.TsRuntimeFeatureLimitationEvent import org.usvm.machine.TsRuntimeFeatureLimitationReason +import org.usvm.machine.onRuntimeFeatureLimitationSafely import org.usvm.machine.call.TsUnknownCallDispatcher import org.usvm.machine.call.TsUnknownCallFailureReason import org.usvm.machine.call.dispatch @@ -212,7 +213,7 @@ class TsExprResolver( internal fun reportRuntimeFeatureLimitation(reason: TsRuntimeFeatureLimitationReason, detail: String) { val statement = scope.calcOnState { lastStmt } - observer?.onRuntimeFeatureLimitation( + observer?.onRuntimeFeatureLimitationSafely( TsRuntimeFeatureLimitationEvent(statement = statement, reason = reason, detail = detail), ) } diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/expr/RuntimeLimitationBoundariesTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/RuntimeLimitationBoundariesTest.kt index 6a335dafff..3aa8e3a641 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/expr/RuntimeLimitationBoundariesTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/RuntimeLimitationBoundariesTest.kt @@ -20,6 +20,7 @@ import org.usvm.util.TsTestResolver import org.usvm.util.getResourcePath import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue private const val UNMODELED_NUMERIC_CONVERSION_REASON = @@ -62,6 +63,40 @@ class RuntimeLimitationBoundariesTest : TsMethodTestRunner() { assertEquals(listOf(TsRuntimeFeatureLimitationReason.REGULAR_EXPRESSION_LITERAL), observer.reasons) } + @Test + fun `throwing limitation observer preserves unsupported path metadata`() { + var callbackCount = 0 + val observer = object : TsInterpreterObserver { + override fun onRuntimeFeatureLimitation(event: TsRuntimeFeatureLimitationEvent) { + callbackCount++ + error("Injected runtime limitation observer failure") + } + } + val target = method("regularExpressionLiteral") + + val result = TsMachine( + scene = scene, + options = UMachineOptions( + solverType = SolverType.YICES, + throwExceptionOnStepFailure = true, + stepsFromLastCovered = 128L, + ), + tsOptions = TsOptions(), + observer = observer, + ).use { machine -> + machine.analyzeWithMetadata(methods = listOf(target)) + } + + assertEquals(1, callbackCount) + assertTrue(result.states.isEmpty()) + assertTrue(result.runtimeLimited) + assertFalse(result.engineFailed) + assertTrue( + result.unsupportedPaths.any { "REGULAR_EXPRESSION_LITERAL" in it }, + result.unsupportedPaths.toString(), + ) + } + @Test fun `unsupported operand conversion terminates while a supported sibling still returns`() { val observer = RecordingObserver() From 4bcd3c7aa66c9c0b5b804b6d97efebc8e7a25457 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 11:56:24 +0300 Subject: [PATCH 11/97] Select Calls witness project by function identity Preserve manifests with repeated project IDs by selecting the project that owns the globally unique function. Add focused regression source and clarify manifest validation and build diagnostics. Independent static verification only. --- .../org/usvm/ts/calls/CallsExperiment.kt | 26 +++++--- .../org/usvm/ts/calls/CallsWitnessReplay.kt | 9 ++- .../org/usvm/ts/calls/CallsExperimentTest.kt | 65 +++++++++++++++++++ 3 files changed, 89 insertions(+), 11 deletions(-) diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt index 99ada8b646..47f1b4b97d 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt @@ -89,22 +89,28 @@ internal data class CallsExperimentManifest( "Tool revision must identify a clean Git commit" } require(toolRevision == modelSet.toolRevision) { "Tool and model-set revisions must match" } + val functions = projects.flatMap(CallsProjectCase::functions) - require(functions.map(CallsFunctionCase::functionId).distinct().size == functions.size) { + val uniqueFunctionIds = functions.map(CallsFunctionCase::functionId).distinct() + require(uniqueFunctionIds.size == functions.size) { "Function IDs must be unique" } - require(functions.all { function -> function.sourceFile == function.entryPoint.module }) { + val sourceFilesMatchEntryPoints = functions.all { function -> + function.sourceFile == function.entryPoint.module + } + require(sourceFilesMatchEntryPoints) { "Function source files must match their replay entry-point modules" } + val targets = functions.flatMap(CallsFunctionCase::targets) - require(targets.map(CallsSourceTarget::targetId).distinct().size == targets.size) { + val uniqueTargetIds = targets.map(CallsSourceTarget::targetId).distinct() + require(uniqueTargetIds.size == targets.size) { "Target IDs must be unique" } - require( - functions.all { function -> - function.targets.all { target -> target.sourcePath == function.sourceFile } - }, - ) { + val targetsBelongToFunctionSourceFiles = functions.all { function -> + function.targets.all { target -> target.sourcePath == function.sourceFile } + } + require(targetsBelongToFunctionSourceFiles) { "Every target must belong to its function source file" } } @@ -267,12 +273,12 @@ internal object CallsBuildIdentity { } val toolRevision: String by lazy { - checkNotNull(properties.getProperty("tool.revision")).takeIf(String::isNotBlank) + properties.getProperty("tool.revision")?.takeIf(String::isNotBlank) ?: error("Missing tool revision in calls build identity") } val nativeFrontendRevision: String by lazy { - checkNotNull(properties.getProperty("native.frontend.revision")).takeIf(String::isNotBlank) + properties.getProperty("native.frontend.revision")?.takeIf(String::isNotBlank) ?: error("Missing native frontend revision in calls build identity") } } diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsWitnessReplay.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsWitnessReplay.kt index 631ef895b3..101b412064 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsWitnessReplay.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsWitnessReplay.kt @@ -64,7 +64,14 @@ internal class CallsWitnessReplayer( "single-witness replay requires the same clean tool revision" } - val project = manifest.projects.single { project -> project.projectId == selector.projectId } + val project = manifest.projects.single { project -> + val sameProjectId = project.projectId == selector.projectId + val containsSelectedFunction = project.functions.any { function -> + function.functionId == selector.functionId + } + + sameProjectId && containsSelectedFunction + } require(row.revision == project.revision && row.development == project.development) { "Selected raw row project identity does not match the frozen manifest" } diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt index 371417f10e..3bb7032ee4 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt @@ -334,6 +334,71 @@ class CallsExperimentTest { assertEquals(directory.toRealPath(), verifiedCheckout) } + @Test + fun `single witness replay selects function when projects share an ID`(@TempDir directory: Path) { + val baseManifest = manifest(sourceRoot = ".", seeds = listOf(19L)) + val firstProject = baseManifest.projects.single() + val firstFunction = firstProject.functions.single() + val secondTarget = firstFunction.targets.single().copy( + targetId = "fixture.ts::other/1#return", + siteId = "fixture.ts:2:1-2:12::other/1", + ) + val secondFunction = firstFunction.copy( + functionId = "fixture.ts::other/1", + targets = listOf(secondTarget), + ) + val secondProject = firstProject.copy( + revision = "second-project-revision", + functions = listOf(secondFunction), + ) + val frozenManifest = baseManifest.copy(projects = listOf(firstProject, secondProject)) + val rawOutput = directory.resolve("results.jsonl") + val selectedWitness = CallsWitnessSelector( + projectId = secondProject.projectId, + functionId = secondFunction.functionId, + targetId = secondTarget.targetId, + profile = CallsExperimentProfile.FROZEN_STOP, + seed = 19L, + ) + + CallsExperimentRunner( + symbolicEngine = CallsSymbolicEngine { + result( + status = CallsSymbolicStatus.REACHED, + inputs = listOf(JsConcreteValue.Boolean(true)), + ) + }, + targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> + CallsSourceReplayResult(status = CallsReplayStatus.CONFIRMED) + }, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + ).run( + manifest = frozenManifest, + manifestDirectory = directory, + rawOutput = rawOutput, + ) + + var verifiedRevision: String? = null + var replayedTarget: CallsSourceTarget? = null + val replay = CallsWitnessReplayer( + targetReplayer = CallsTargetReplayer { _, _, _, target, _ -> + replayedTarget = target + CallsSourceReplayResult(status = CallsReplayStatus.CONFIRMED) + }, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + verifyProjectCheckout = { _, revision -> verifiedRevision = revision }, + ).replay( + manifest = frozenManifest, + manifestDirectory = directory, + rawInput = rawOutput, + selector = selectedWitness, + ) + + assertEquals(CallsReplayStatus.CONFIRMED, replay.status) + assertEquals("second-project-revision", verifiedRevision) + assertEquals(secondTarget, replayedTarget) + } + @Test fun `historical extracted row without stored witness reports replay unavailable first`(@TempDir directory: Path) { val rawOutput = directory.resolve("results.jsonl") From 6cff81a274efc590d90d47ecafa4474656f03777 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 12:04:18 +0300 Subject: [PATCH 12/97] Reject malformed number encodings in numeric domains Check the existing encoding contract before NaN and bound membership handling. Add direct predicate regression source for malformed tags and noncanonical finite bits. Independent static verification only. --- .../kotlin/org/usvm/ts/pbt/model/PropertyDomain.kt | 2 ++ .../org/usvm/ts/pbt/model/PropertyDomainTest.kt | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDomain.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDomain.kt index 3f18a4c3b5..600ce9e0e9 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDomain.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDomain.kt @@ -113,6 +113,8 @@ private fun JsConcreteValue.Number.isIntegerIn(domain: IntegerDomain): Boolean { } private fun JsConcreteValue.Number.isNumberIn(domain: NumberDomain): Boolean { + if (!number.hasValidEncoding()) return false + if (number.value == JsNumberKind.NAN) return domain.allowNaN val value = validDoubleOrNull() ?: return false diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/PropertyDomainTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/PropertyDomainTest.kt index 5afa81257b..3031e7772b 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/PropertyDomainTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/PropertyDomainTest.kt @@ -54,8 +54,20 @@ class PropertyDomainTest { val invalidNumber = JsConcreteValue.Number( number = JsNumber(value = JsNumberKind.FINITE, bits = "invalid"), ) + val malformedFiniteNumber = JsConcreteValue.Number( + number = JsNumber(value = JsNumberKind.FINITE, bits = "000000000000000"), + ) + val malformedSpecialNumbers = listOf( + JsNumberKind.NAN, + JsNumberKind.NEGATIVE_INFINITY, + JsNumberKind.POSITIVE_INFINITY, + ).map { kind -> JsConcreteValue.Number(JsNumber(value = kind, bits = "invalid")) } assertFalse(invalidNumber in IntegerDomain()) assertFalse(invalidNumber in NumberDomain()) + assertFalse(malformedFiniteNumber in NumberDomain()) + malformedSpecialNumbers.forEach { number -> + assertFalse(number in NumberDomain()) + } } } From aae1102422150d6f8993e99ce25f633eb528f329 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 12:32:27 +0300 Subject: [PATCH 13/97] Resolve module defaults without confusing re-export aliases Select public default exports and direct named declarations marked DEFAULT while preserving source-origin metadata. Add ordinary alias and source-backed named-function regression cases. Independent static verification only; no tests executed. --- .../main/kotlin/org/usvm/util/TsImports.kt | 6 +++- .../org/usvm/samples/imports/Imports.kt | 29 +++++++++++++++++++ .../test/resources/samples/imports/Imports.ts | 6 ++++ .../imports/defaultAliasBeforeExport.ts | 6 ++++ .../samples/imports/namedDefaultFunction.ts | 5 ++++ 5 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 usvm-ts/src/test/resources/samples/imports/defaultAliasBeforeExport.ts create mode 100644 usvm-ts/src/test/resources/samples/imports/namedDefaultFunction.ts diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/TsImports.kt b/usvm-ts/src/main/kotlin/org/usvm/util/TsImports.kt index 51bc7f56ab..c7406207d2 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/TsImports.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/TsImports.kt @@ -130,7 +130,11 @@ private fun resolveSymbolInFile( return when (importType) { EtsImportType.DEFAULT -> { - val defaultExport = exports.find { it.isDefaultExport } + val defaultExport = exports.find { export -> + val hasPublicDefaultName = export.name == "default" + val isDirectDefaultDeclaration = !export.isReExport && export.isDefault + hasPublicDefaultName || isDirectDefaultDeclaration + } if (defaultExport != null) { SymbolResolutionResult.Success(targetFile, defaultExport) } else { diff --git a/usvm-ts/src/test/kotlin/org/usvm/samples/imports/Imports.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/imports/Imports.kt index adab612c65..4a068ceeec 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/samples/imports/Imports.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/samples/imports/Imports.kt @@ -5,9 +5,13 @@ import org.jacodb.ets.utils.loadEtsProjectAutoConvert import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test import org.usvm.api.TsTestValue +import org.usvm.util.SymbolResolutionResult import org.usvm.util.TsMethodTestRunner import org.usvm.util.eq import org.usvm.util.getResourcePath +import org.usvm.util.resolveImportInfo +import kotlin.test.assertEquals +import kotlin.test.assertIs class Imports : TsMethodTestRunner() { private val tsPath = "/samples/imports" @@ -125,6 +129,31 @@ class Imports : TsMethodTestRunner() { ) } + @Test + fun `default import selects public default after renamed source default`() { + val method = getMethod("getDefaultAfterRenamedReExport") + + discoverProperties( + method = method, + { result -> result eq 42 }, + invariants = arrayOf( + { result -> result eq 42 } + ) + ) + } + + @Test + fun `default import retains named default declaration resolution`() { + val method = getMethod("getDefaultValue") + val sourceFile = method.enclosingClass!!.declaringFile!! + val importInfo = sourceFile.importInfos.single { it.name == "namedDefaultFunction" } + + val result = scene.resolveImportInfo(sourceFile, importInfo) + + assertIs(result) + assertEquals("namedDefaultFunction", result.exportInfo.name) + } + @Test fun `test get renamed value`() { val method = getMethod("getRenamedValue") diff --git a/usvm-ts/src/test/resources/samples/imports/Imports.ts b/usvm-ts/src/test/resources/samples/imports/Imports.ts index 11a0b3b281..9447c218f4 100644 --- a/usvm-ts/src/test/resources/samples/imports/Imports.ts +++ b/usvm-ts/src/test/resources/samples/imports/Imports.ts @@ -18,6 +18,8 @@ import { } from './namedExports'; import defaultValue from './defaultExport'; +import defaultAfterRenamedReExport from './defaultAliasBeforeExport'; +import namedDefaultFunction from './namedDefaultFunction'; import { renamedValue as aliasedValue, @@ -94,6 +96,10 @@ class Imports { return defaultValue; } + getDefaultAfterRenamedReExport(): number { + return defaultAfterRenamedReExport; + } + getRenamedValue(): number { return aliasedValue; } diff --git a/usvm-ts/src/test/resources/samples/imports/defaultAliasBeforeExport.ts b/usvm-ts/src/test/resources/samples/imports/defaultAliasBeforeExport.ts new file mode 100644 index 0000000000..bc6d1efda3 --- /dev/null +++ b/usvm-ts/src/test/resources/samples/imports/defaultAliasBeforeExport.ts @@ -0,0 +1,6 @@ +// @ts-nocheck + +export { default as RenamedDefault } from './defaultExport'; + +const actualDefault = 42; +export { actualDefault as default }; diff --git a/usvm-ts/src/test/resources/samples/imports/namedDefaultFunction.ts b/usvm-ts/src/test/resources/samples/imports/namedDefaultFunction.ts new file mode 100644 index 0000000000..e04243404d --- /dev/null +++ b/usvm-ts/src/test/resources/samples/imports/namedDefaultFunction.ts @@ -0,0 +1,5 @@ +// @ts-nocheck + +export default function namedDefaultFunction(): number { + return 17; +} From 34654064fb989dc2bcb85eb34b5e1342734db854 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 13:35:12 +0300 Subject: [PATCH 14/97] Validate identifier, numeric domain, and coverage string boundaries Reject identifier-ignorable controls and malformed integer encodings. Enforce string primitive types in Istanbul reports while preserving nullable path fallback. Add focused regression source and clarify validation code. Independent static verification only. --- .../usvm/ts/pbt/coverage/IstanbulJsonValue.kt | 38 +++++++--- .../org/usvm/ts/pbt/model/PropertyDomain.kt | 2 + .../ts/pbt/validation/PropertyValidation.kt | 65 +++++++++------- .../coverage/IstanbulCoverageReportTest.kt | 75 +++++++++++++++++++ .../usvm/ts/pbt/model/PropertyDomainTest.kt | 1 + .../pbt/validation/PropertyValidationTest.kt | 30 ++++++++ 6 files changed, 171 insertions(+), 40 deletions(-) diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulJsonValue.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulJsonValue.kt index 6f84a5dac3..49e6851c9f 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulJsonValue.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulJsonValue.kt @@ -48,34 +48,48 @@ internal class IstanbulJsonValue( message = "Expected a string", path = childPath, ) + val content = primitive.contentOrNull - return primitive.contentOrNull - ?: throw invalidReport( + if (!primitive.isString || content == null) { + throw invalidReport( message = "Expected a string", path = childPath, ) + } + + return content } fun optionalString(name: String): String? { - val primitive = asObject()[name] as? JsonPrimitive + val child = asObject()[name] ?: return null + val childPath = "$path.$name" + val primitive = child as? JsonPrimitive + ?: throw invalidReport( + message = "Expected a string", + path = childPath, + ) + val content = primitive.contentOrNull ?: return null + + if (!primitive.isString) { + throw invalidReport( + message = "Expected a string", + path = childPath, + ) + } - return primitive?.contentOrNull + return content } fun asRange(): SourceRange { - val range = asObject() - val rangeJson = IstanbulJsonValue(range, path) - val start = rangeJson.required(name = "start").asPosition() - val end = rangeJson.required(name = "end").asPosition() + val start = required(name = "start").asPosition() + val end = required(name = "end").asPosition() return SourceRange(start = start, end = end) } fun asPosition(): SourcePosition { - val position = asObject() - val positionJson = IstanbulJsonValue(position, path) - val line = positionJson.requiredInt(name = "line") - val column = positionJson.requiredInt(name = "column") + val line = requiredInt(name = "line") + val column = requiredInt(name = "column") return SourcePosition(line = line, column = column) } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDomain.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDomain.kt index 600ce9e0e9..94d4588344 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDomain.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDomain.kt @@ -104,6 +104,8 @@ operator fun PropertyDomain.contains(value: JsConcreteValue): Boolean = when (th } private fun JsConcreteValue.Number.isIntegerIn(domain: IntegerDomain): Boolean { + if (!number.hasValidEncoding()) return false + val value = validDoubleOrNull() ?: return false // fc.integer can generate positive zero but never the distinct binary64 value negative zero. diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/validation/PropertyValidation.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/validation/PropertyValidation.kt index 68b4579618..739204e55b 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/validation/PropertyValidation.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/validation/PropertyValidation.kt @@ -119,11 +119,12 @@ private fun validateProperty( path = path, ) } - if (input.generatorId != null && !isCanonicalPropertyId(input.generatorId)) { + val generatorIdIsInvalid = input.generatorId != null && !isCanonicalPropertyId(input.generatorId) + if (generatorIdIsInvalid) { diagnostics += diagnostic( - PbtDiagnosticCode.PROPERTY_GENERATOR_INVALID, - "Invalid generator ID", - "$path.generatorId", + code = PbtDiagnosticCode.PROPERTY_GENERATOR_INVALID, + message = "Invalid generator ID", + path = "$path.generatorId", ) } validateDomain(input.domain, "$path.domain", diagnostics) @@ -144,11 +145,12 @@ private fun validateAssertions( val seen = mutableSetOf() assertions.forEachIndexed { index, assertion -> val path = "assertions[$index]" - if (!isCanonicalPropertyId(assertion.id) || !seen.add(assertion.id)) { + val assertionIdIsInvalidOrRepeated = !isCanonicalPropertyId(assertion.id) || !seen.add(assertion.id) + if (assertionIdIsInvalidOrRepeated) { diagnostics += diagnostic( - PbtDiagnosticCode.PROPERTY_ASSERTION_INVALID, - "Assertion ID is invalid or repeated", - "$path.id", + code = PbtDiagnosticCode.PROPERTY_ASSERTION_INVALID, + message = "Assertion ID is invalid or repeated", + path = "$path.id", ) } @@ -157,11 +159,12 @@ private fun validateAssertions( val operands = mutableSetOf() assertion.operands.forEachIndexed { operandIndex, operand -> val operandPath = "$path.operands[$operandIndex]" - if (!isCanonicalPropertyId(operand.id) || !operands.add(operand.id)) { + val operandIdIsInvalidOrRepeated = !isCanonicalPropertyId(operand.id) || !operands.add(operand.id) + if (operandIdIsInvalidOrRepeated) { diagnostics += diagnostic( - PbtDiagnosticCode.PROPERTY_ASSERTION_INVALID, - "Operand ID is invalid or repeated", - "$operandPath.id", + code = PbtDiagnosticCode.PROPERTY_ASSERTION_INVALID, + message = "Operand ID is invalid or repeated", + path = "$operandPath.id", ) } validateSourcePoint(operand.source, "$operandPath.source", diagnostics) @@ -174,8 +177,13 @@ private fun validateSourcePoint( path: String, diagnostics: MutableList, ) { - if (!isProjectRelativePosixPath(point.module) || point.line < 1 || point.column < 1) { - diagnostics += diagnostic(PbtDiagnosticCode.PROPERTY_SOURCE_INVALID, "Invalid source point", path) + val sourcePointIsInvalid = !isProjectRelativePosixPath(point.module) || point.line < 1 || point.column < 1 + if (sourcePointIsInvalid) { + diagnostics += diagnostic( + code = PbtDiagnosticCode.PROPERTY_SOURCE_INVALID, + message = "Invalid source point", + path = path, + ) } } @@ -186,15 +194,16 @@ private fun validateGenerator( ) { val array = inputs.getOrNull(generator.arrayInputIndex)?.domain as? ArrayDomain val index = inputs.getOrNull(generator.indexInputIndex)?.domain as? IntegerDomain - val valid = generator.kind == "array-index" && isCanonicalPropertyId(generator.id) && - inputs.size == 2 && generator.arrayInputIndex == 0 && generator.indexInputIndex == 1 && - array != null && array.minLength >= 1 && array.maxLength <= MAX_DIRECT_ARRAY_LENGTH && - index != null && index.min == 0 && index.max == array.maxLength - 1 - if (!valid) { + val hasExpectedInputPositions = inputs.size == 2 && generator.arrayInputIndex == 0 && generator.indexInputIndex == 1 + val hasSupportedArray = array != null && array.minLength >= 1 && array.maxLength <= MAX_DIRECT_ARRAY_LENGTH + val hasMatchingIndex = array != null && index != null && index.min == 0 && index.max == array.maxLength - 1 + val generatorIsValid = generator.kind == "array-index" && isCanonicalPropertyId(generator.id) && + hasExpectedInputPositions && hasSupportedArray && hasMatchingIndex + if (!generatorIsValid) { diagnostics += diagnostic( - PbtDiagnosticCode.PROPERTY_GENERATOR_INVALID, - "Array-index construction requires a nonempty bounded array followed by its valid index range", - "generator", + code = PbtDiagnosticCode.PROPERTY_GENERATOR_INVALID, + message = "Array-index construction requires a nonempty bounded array followed by its valid index range", + path = "generator", ) } } @@ -203,13 +212,13 @@ private fun validateSourceIdentity( identity: PropertySourceIdentity, diagnostics: MutableList, ) { - if (!identity.sourceSha256.matches(SHA256_REGEX) || + val sourceIdentityIsInvalid = !identity.sourceSha256.matches(SHA256_REGEX) || !identity.buildSha256.matches(SHA256_REGEX) || identity.buildScope.isBlank() - ) { + if (sourceIdentityIsInvalid) { diagnostics += diagnostic( - PbtDiagnosticCode.PROPERTY_SOURCE_INVALID, - "Expected lowercase SHA-256 hashes", - "sourceIdentity", + code = PbtDiagnosticCode.PROPERTY_SOURCE_INVALID, + message = "Expected lowercase SHA-256 hashes", + path = "sourceIdentity", ) } } @@ -481,7 +490,7 @@ private fun isJavaScriptIdentifierPart(codePoint: Int): Boolean = isJavaScriptIdentifierStart(codePoint) || codePoint == ZERO_WIDTH_NON_JOINER_CODE_POINT || codePoint == ZERO_WIDTH_JOINER_CODE_POINT || - Character.isUnicodeIdentifierPart(codePoint) + (Character.isUnicodeIdentifierPart(codePoint) && !Character.isIdentifierIgnorable(codePoint)) private fun MutableList.toResult(): PropertyValidationResult { val orderedDiagnostics = sortedWith(compareBy(ValidationDiagnostic::path, ValidationDiagnostic::code)) diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportTest.kt index 0ccfe14bdc..16b9baf23d 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportTest.kt @@ -115,6 +115,81 @@ class IstanbulCoverageReportTest { } } + @Test + fun `numeric source path is rejected instead of silently omitting coverage`() { + val report = createTempFile(suffix = ".json") + try { + val original = Files.readString(goldenReport()) + val malformed = original.replace( + oldValue = "\"path\": \"/workspace/src/math.ts\"", + newValue = "\"path\": 123", + ) + assertTrue(malformed != original) + report.writeText(malformed) + + val error = assertFailsWith { + decodeIstanbulCoverageReport( + reportPath = report, + context = context(), + ) + } + + assertEquals("coverage.report.invalid", error.diagnostic.code) + assertEquals("coverage[/workspace/src/math.ts].path", error.diagnostic.path) + } finally { + report.deleteIfExists() + } + } + + @Test + fun `boolean branch type is rejected instead of being converted to text`() { + val report = createTempFile(suffix = ".json") + try { + val original = Files.readString(goldenReport()) + val malformed = original.replace( + oldValue = "\"type\": \"if\"", + newValue = "\"type\": false", + ) + assertTrue(malformed != original) + report.writeText(malformed) + + val error = assertFailsWith { + decodeIstanbulCoverageReport( + reportPath = report, + context = context(), + ) + } + + assertEquals("coverage.report.invalid", error.diagnostic.code) + assertEquals("coverage[/workspace/src/math.ts].branchMap[0].type", error.diagnostic.path) + } finally { + report.deleteIfExists() + } + } + + @Test + fun `null source path falls back to the report key`() { + val report = createTempFile(suffix = ".json") + try { + val original = Files.readString(goldenReport()) + val withNullPath = original.replace( + oldValue = "\"path\": \"/workspace/src/math.ts\"", + newValue = "\"path\": null", + ) + assertTrue(withNullPath != original) + report.writeText(withNullPath) + + val artifact = decodeIstanbulCoverageReport( + reportPath = report, + context = context(), + ) + + assertEquals("/workspace/src/math.ts", artifact.files.single().path) + } finally { + report.deleteIfExists() + } + } + @Test fun `report larger than the decoder limit is rejected before parsing`() { val oversizedReport = createTempFile(suffix = ".json") diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/PropertyDomainTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/PropertyDomainTest.kt index 3031e7772b..d85f356c53 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/PropertyDomainTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/PropertyDomainTest.kt @@ -65,6 +65,7 @@ class PropertyDomainTest { assertFalse(invalidNumber in IntegerDomain()) assertFalse(invalidNumber in NumberDomain()) + assertFalse(malformedFiniteNumber in IntegerDomain()) assertFalse(malformedFiniteNumber in NumberDomain()) malformedSpecialNumbers.forEach { number -> assertFalse(number in NumberDomain()) diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/validation/PropertyValidationTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/validation/PropertyValidationTest.kt index 41ff6f7104..5cff8ad11c 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/validation/PropertyValidationTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/validation/PropertyValidationTest.kt @@ -140,6 +140,36 @@ class PropertyValidationTest { assertTrue(validatePropertyDefinition(validDefinition(IntegerDomain(-5, 5))).isValid) } + @Test + fun `identifier ignorable control characters are invalid in input and export names`() { + val valid = validDefinition(IntegerDomain()) + val invalidInput = valid.copy(inputs = listOf(PropertyInput(name = "a\u0001b", domain = IntegerDomain()))) + val invalidExport = valid.copy(predicate = valid.predicate.copy(exportName = "a\u0001b")) + + assertEquals( + listOf("input.name.invalid"), + validatePropertyDefinition(invalidInput).diagnostics.map { it.code }, + ) + assertEquals( + listOf("entrypoint.export.invalid"), + validatePropertyDefinition(invalidExport).diagnostics.map { it.code }, + ) + } + + @Test + fun `JavaScript identifier joiners remain valid after the first character`() { + val valid = validDefinition(IntegerDomain()) + + listOf("a\u200Cb", "a\u200Db").forEach { name -> + val withJoiner = valid.copy( + inputs = listOf(PropertyInput(name = name, domain = IntegerDomain())), + predicate = valid.predicate.copy(exportName = name), + ) + + assertTrue(validatePropertyDefinition(withJoiner).isValid) + } + } + private fun validDefinition(domain: PropertyDomain) = PropertyDefinition( id = PropertyId("valid.id"), inputs = listOf(PropertyInput("value", domain)), From 136d68cfb516edb9c87496260a101247d83178aa Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 13:49:16 +0300 Subject: [PATCH 15/97] Preserve original Istanbul coverage map keys --- .../pbt/coverage/IstanbulSourceFileDecoder.kt | 73 ++++++++++--------- .../coverage/IstanbulCoverageReportTest.kt | 64 ++++++++++++++++ 2 files changed, 104 insertions(+), 33 deletions(-) diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulSourceFileDecoder.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulSourceFileDecoder.kt index 4dab7dd44e..9953119cc8 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulSourceFileDecoder.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulSourceFileDecoder.kt @@ -47,13 +47,13 @@ internal class IstanbulSourceFileDecoder( hitsName = "s", ) - return coverageMap.ids.map { statementId -> + return coverageMap.entries.map { entry -> checkBudget() - val location = coverageMap.location(statementId).asRange() - val hitCount = coverageMap.hitCount(statementId).asHitCount() + val location = coverageMap.location(entry).asRange() + val hitCount = coverageMap.hitCount(entry).asHitCount() StatementCoverage( - statementId = statementId, + statementId = entry.id, location = location, hits = hitCount, ) @@ -66,29 +66,29 @@ internal class IstanbulSourceFileDecoder( hitsName = "f", ) - return coverageMap.ids.map { functionId -> + return coverageMap.entries.map { entry -> checkBudget() decodeFunction( - functionId = functionId, + entry = entry, coverageMap = coverageMap, ) } } private fun decodeFunction( - functionId: Int, + entry: CoverageEntry, coverageMap: CoverageMap, ): FunctionCoverage { - val functionJson = coverageMap.location(functionId) + val functionJson = coverageMap.location(entry) val functionName = functionJson.optionalString(name = "name") ?.ifBlank { ANONYMOUS_FUNCTION_NAME } ?: ANONYMOUS_FUNCTION_NAME val declaration = functionJson.required(name = "decl").asRange() val body = functionJson.required(name = "loc").asRange() - val hitCount = coverageMap.hitCount(functionId).asHitCount() + val hitCount = coverageMap.hitCount(entry).asHitCount() return FunctionCoverage( - functionId = functionId, + functionId = entry.id, name = functionName, declaration = declaration, body = body, @@ -102,21 +102,21 @@ internal class IstanbulSourceFileDecoder( hitsName = "b", ) - return coverageMap.ids.map { branchId -> + return coverageMap.entries.map { entry -> checkBudget() decodeBranch( - branchId = branchId, + entry = entry, coverageMap = coverageMap, ) } } private fun decodeBranch( - branchId: Int, + entry: CoverageEntry, coverageMap: CoverageMap, ): BranchCoverage { - val branchJson = coverageMap.location(branchId) - val branchHitsJson = coverageMap.hitCount(branchId) + val branchJson = coverageMap.location(entry) + val branchHitsJson = coverageMap.hitCount(entry) val branchArms = decodeBranchArms(branchJson, branchHitsJson) val type = branchJson.requiredString(name = "type") @@ -130,7 +130,7 @@ internal class IstanbulSourceFileDecoder( } return BranchCoverage( - branchId = branchId, + branchId = entry.id, type = type, location = location, arms = arms, @@ -176,13 +176,22 @@ internal class IstanbulSourceFileDecoder( ) } - val ids = locations.keys.map { id -> + val seenIds = mutableSetOf() + val entries = locations.keys.map { key -> checkBudget() - parseCoverageId(id) - }.sorted() + val id = parseCoverageId(value = key, locationsPath = locationsJson.path) + if (!seenIds.add(id)) { + throw IstanbulJsonValue.invalidReport( + message = "Duplicate numeric coverage ID $id", + path = "${locationsJson.path}[$key]", + ) + } + + CoverageEntry(id = id, key = key) + }.sortedBy { entry -> entry.id } return CoverageMap( - ids = ids, + entries = entries, locations = locations, hits = hits, locationsPath = locationsJson.path, @@ -190,38 +199,36 @@ internal class IstanbulSourceFileDecoder( ) } - private fun parseCoverageId(value: String): Int { + private fun parseCoverageId(value: String, locationsPath: String): Int { val coverageId = value.toIntOrNull() return coverageId?.takeIf { id -> id >= 0 } ?: throw IstanbulJsonValue.invalidReport( message = "Coverage ID must be a non-negative integer", - path = value, + path = "$locationsPath[$value]", ) } + private data class CoverageEntry(val id: Int, val key: String) + private data class CoverageMap( - val ids: List, + val entries: List, val locations: JsonObject, val hits: JsonObject, val locationsPath: String, val hitsPath: String, ) { - fun location(id: Int): IstanbulJsonValue { - val key = id.toString() - + fun location(entry: CoverageEntry): IstanbulJsonValue { return IstanbulJsonValue( - value = locations.getValue(key), - path = "$locationsPath[$key]", + value = locations.getValue(entry.key), + path = "$locationsPath[${entry.key}]", ) } - fun hitCount(id: Int): IstanbulJsonValue { - val key = id.toString() - + fun hitCount(entry: CoverageEntry): IstanbulJsonValue { return IstanbulJsonValue( - value = hits.getValue(key), - path = "$hitsPath[$key]", + value = hits.getValue(entry.key), + path = "$hitsPath[${entry.key}]", ) } } diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportTest.kt index 16b9baf23d..7b6e28d971 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportTest.kt @@ -1,5 +1,6 @@ package org.usvm.ts.pbt.coverage +import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject import org.junit.jupiter.api.Test import org.usvm.ts.pbt.backend.CoverageCollectorIdentity @@ -33,6 +34,7 @@ class IstanbulCoverageReportTest { val firstArm = branch.arms.first() assertEquals("/workspace/src/math.ts", file.path) + assertEquals(listOf(0, 1), file.statements.map { statement -> statement.statementId }) assertEquals(listOf(2L, 2L), file.statements.map { statement -> statement.hits }) assertEquals("classify", file.functions.single().name) assertEquals(2L, file.functions.single().hits) @@ -167,6 +169,45 @@ class IstanbulCoverageReportTest { } } + @Test + fun `alternate coverage key retains its location and hits in numeric order`() { + val decoder = sourceFileDecoder( + statementMap = "\"02\": $TEST_STATEMENT_LOCATION, \"1\": $TEST_STATEMENT_LOCATION", + hits = "\"02\": 7, \"1\": 3", + ) + + val statements = decoder.decode().statements + + assertEquals(listOf(1, 2), statements.map { statement -> statement.statementId }) + assertEquals(listOf(3L, 7L), statements.map { statement -> statement.hits }) + } + + @Test + fun `numeric aliases in a coverage map are rejected at the second key`() { + val decoder = sourceFileDecoder( + statementMap = "\"1\": $TEST_STATEMENT_LOCATION, \"01\": $TEST_STATEMENT_LOCATION", + hits = "\"1\": 3, \"01\": 7", + ) + + val error = assertFailsWith { decoder.decode() } + + assertEquals("coverage.report.invalid", error.diagnostic.code) + assertEquals("coverage[/workspace/src/math.ts].statementMap[01]", error.diagnostic.path) + } + + @Test + fun `invalid coverage ID reports its full map path`() { + val decoder = sourceFileDecoder( + statementMap = "\"abc\": $TEST_STATEMENT_LOCATION", + hits = "\"abc\": 1", + ) + + val error = assertFailsWith { decoder.decode() } + + assertEquals("coverage.report.invalid", error.diagnostic.code) + assertEquals("coverage[/workspace/src/math.ts].statementMap[abc]", error.diagnostic.path) + } + @Test fun `null source path falls back to the report key`() { val report = createTempFile(suffix = ".json") @@ -334,12 +375,35 @@ class IstanbulCoverageReportTest { "usvm-ts-pbt/src/test/resources/coverage/istanbul/statement-branch.json", ) + private fun sourceFileDecoder(statementMap: String, hits: String): IstanbulSourceFileDecoder { + val file = Json.parseToJsonElement( + """ + { + "statementMap": {$statementMap}, + "fnMap": {}, + "branchMap": {}, + "s": {$hits}, + "f": {}, + "b": {} + } + """.trimIndent(), + ).jsonObject + + return IstanbulSourceFileDecoder( + file = file, + path = "/workspace/src/math.ts", + reportKey = "/workspace/src/math.ts", + ) + } + private fun locateFile(vararg candidates: String): Path = candidates .map { candidate -> Path.of(candidate).absolute() } .singleOrNull(Files::isRegularFile) ?: error("Cannot locate file; checked ${candidates.toList()}") private companion object { + const val TEST_STATEMENT_LOCATION = + """{"start":{"line":1,"column":0},"end":{"line":1,"column":1}}""" const val TEST_COVERAGE_REPORT_LIMIT_BYTES = 64L * 1024 * 1024 const val STRICT_REPORT_LIMIT_BYTES = 4L * 1024 * 1024 } From 32eb5380466be8f85697e55ce7f12213d860f234 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 14:00:33 +0300 Subject: [PATCH 16/97] Model primitive string coercion in loose equality --- .../usvm/machine/operator/TsBinaryOperator.kt | 109 ++++++++++++++---- .../org/usvm/machine/TsStringEqualityTest.kt | 19 +++ .../test/resources/models/StringEquality.ts | 23 ++++ 3 files changed, 128 insertions(+), 23 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt index b61c6de560..6547403b7c 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt @@ -222,6 +222,13 @@ sealed interface TsBinaryOperator { scope: TsStepScope, ): UExpr<*>? + fun TsContext.internalResolveWithGuard( + lhs: UExpr<*>, + rhs: UExpr<*>, + scope: TsStepScope, + activeGuard: UBoolExpr, + ): UExpr<*>? = internalResolve(lhs, rhs, scope) + fun TsContext.resolve( lhs: UExpr<*>, rhs: UExpr<*>, @@ -274,7 +281,7 @@ sealed interface TsBinaryOperator { } } - return internalResolve(lhsValue, rhsValue, scope) + return internalResolveWithGuard(lhsValue, rhsValue, scope, activeGuard) } @Suppress("LongMethod") @@ -319,7 +326,9 @@ sealed interface TsBinaryOperator { ) // fake(bool) + fake(ref) - val boolRefExpr = internalResolve(lhsBool, rhsRef, scope)?.asExpr(resultSort) ?: return null + val boolRefGuard = mkAnd(activeGuard, lhsType.boolTypeExpr, rhsType.refTypeExpr) + val boolRefExpr = internalResolveWithGuard(lhsBool, rhsRef, scope, boolRefGuard)?.asExpr(resultSort) + ?: return null conjuncts += ExprWithTypeConstraint( constraint = mkAnd(lhsType.boolTypeExpr, rhsType.refTypeExpr), expr = boolRefExpr @@ -340,21 +349,27 @@ sealed interface TsBinaryOperator { ) // fake(fp) + fake(ref) - val fpRefExpr = internalResolve(lhsFp, rhsRef, scope)?.asExpr(resultSort) ?: return null + val fpRefGuard = mkAnd(activeGuard, lhsType.fpTypeExpr, rhsType.refTypeExpr) + val fpRefExpr = internalResolveWithGuard(lhsFp, rhsRef, scope, fpRefGuard)?.asExpr(resultSort) + ?: return null conjuncts += ExprWithTypeConstraint( constraint = mkAnd(lhsType.fpTypeExpr, rhsType.refTypeExpr), expr = fpRefExpr ) // fake(ref) + fake(bool) - val refBoolExpr = internalResolve(lhsRef, rhsBool, scope)?.asExpr(resultSort) ?: return null + val refBoolGuard = mkAnd(activeGuard, lhsType.refTypeExpr, rhsType.boolTypeExpr) + val refBoolExpr = internalResolveWithGuard(lhsRef, rhsBool, scope, refBoolGuard)?.asExpr(resultSort) + ?: return null conjuncts += ExprWithTypeConstraint( constraint = mkAnd(lhsType.refTypeExpr, rhsType.boolTypeExpr), expr = refBoolExpr ) // fake(ref) + fake(fp) - val refFpExpr = internalResolve(lhsRef, rhsFp, scope)?.asExpr(resultSort) ?: return null + val refFpGuard = mkAnd(activeGuard, lhsType.refTypeExpr, rhsType.fpTypeExpr) + val refFpExpr = internalResolveWithGuard(lhsRef, rhsFp, scope, refFpGuard)?.asExpr(resultSort) + ?: return null conjuncts += ExprWithTypeConstraint( constraint = mkAnd(lhsType.refTypeExpr, rhsType.fpTypeExpr), expr = refFpExpr @@ -393,7 +408,9 @@ sealed interface TsBinaryOperator { ) // fake(ref) + bool - val refBoolExpr = internalResolve(lhsRef, rhsBool, scope)?.asExpr(resultSort) ?: return null + val refBoolGuard = mkAnd(activeGuard, lhsType.refTypeExpr) + val refBoolExpr = internalResolveWithGuard(lhsRef, rhsBool, scope, refBoolGuard) + ?.asExpr(resultSort) ?: return null conjuncts += ExprWithTypeConstraint( constraint = lhsType.refTypeExpr, expr = refBoolExpr @@ -417,7 +434,9 @@ sealed interface TsBinaryOperator { ) // fake(ref) + fp - val refFpExpr = internalResolve(lhsRef, rhsFpExpr, scope)?.asExpr(resultSort) ?: return null + val refFpGuard = mkAnd(activeGuard, lhsType.refTypeExpr) + val refFpExpr = internalResolveWithGuard(lhsRef, rhsFpExpr, scope, refFpGuard) + ?.asExpr(resultSort) ?: return null conjuncts += ExprWithTypeConstraint( constraint = lhsType.refTypeExpr, expr = refFpExpr @@ -427,14 +446,18 @@ sealed interface TsBinaryOperator { is UAddressSort -> { // fake(bool) + ref val rhsRef = rhs.asExpr(addressSort) - val boolRefExpr = internalResolve(lhsBool, rhsRef, scope)?.asExpr(resultSort) ?: return null + val boolRefGuard = mkAnd(activeGuard, lhsType.boolTypeExpr) + val boolRefExpr = internalResolveWithGuard(lhsBool, rhsRef, scope, boolRefGuard) + ?.asExpr(resultSort) ?: return null conjuncts += ExprWithTypeConstraint( constraint = lhsType.boolTypeExpr, expr = boolRefExpr ) // fake(fp) + ref - val fpRefExpr = internalResolve(lhsFp, rhsRef, scope)?.asExpr(resultSort) ?: return null + val fpRefGuard = mkAnd(activeGuard, lhsType.fpTypeExpr) + val fpRefExpr = internalResolveWithGuard(lhsFp, rhsRef, scope, fpRefGuard) + ?.asExpr(resultSort) ?: return null conjuncts += ExprWithTypeConstraint( constraint = lhsType.fpTypeExpr, expr = fpRefExpr @@ -483,7 +506,9 @@ sealed interface TsBinaryOperator { ) // bool + fake(ref) - val boolRefExpr = internalResolve(lhsBool, rhsRef, scope)?.asExpr(resultSort) ?: return null + val boolRefGuard = mkAnd(activeGuard, rhsType.refTypeExpr) + val boolRefExpr = internalResolveWithGuard(lhsBool, rhsRef, scope, boolRefGuard) + ?.asExpr(resultSort) ?: return null conjuncts += ExprWithTypeConstraint( constraint = rhsType.refTypeExpr, expr = boolRefExpr @@ -507,7 +532,9 @@ sealed interface TsBinaryOperator { ) // fp + fake(ref) - val fpRefExpr = internalResolve(lhsFp, rhsRef, scope)?.asExpr(resultSort) ?: return null + val fpRefGuard = mkAnd(activeGuard, rhsType.refTypeExpr) + val fpRefExpr = internalResolveWithGuard(lhsFp, rhsRef, scope, fpRefGuard) + ?.asExpr(resultSort) ?: return null conjuncts += ExprWithTypeConstraint( constraint = rhsType.refTypeExpr, expr = fpRefExpr @@ -517,14 +544,18 @@ sealed interface TsBinaryOperator { is UAddressSort -> { // ref + fake(bool) val lhsRef = lhs.asExpr(addressSort) - val refBoolExpr = internalResolve(lhsRef, rhsBool, scope)?.asExpr(resultSort) ?: return null + val refBoolGuard = mkAnd(activeGuard, rhsType.boolTypeExpr) + val refBoolExpr = internalResolveWithGuard(lhsRef, rhsBool, scope, refBoolGuard) + ?.asExpr(resultSort) ?: return null conjuncts += ExprWithTypeConstraint( constraint = rhsType.boolTypeExpr, expr = refBoolExpr ) // ref + fake(fp) - val refFpExpr = internalResolve(lhsRef, rhsFp, scope)?.asExpr(resultSort) ?: return null + val refFpGuard = mkAnd(activeGuard, rhsType.fpTypeExpr) + val refFpExpr = internalResolveWithGuard(lhsRef, rhsFp, scope, refFpGuard) + ?.asExpr(resultSort) ?: return null conjuncts += ExprWithTypeConstraint( constraint = rhsType.fpTypeExpr, expr = refFpExpr @@ -555,6 +586,26 @@ sealed interface TsBinaryOperator { data object Eq : TsBinaryOperator { + private fun TsContext.primitiveReferenceEquals( + primitive: UExpr, + reference: UHeapRef, + scope: TsStepScope, + activeGuard: UBoolExpr, + ): UBoolExpr? { + val isNullish = mkIsNullOrUndefined(reference) + val isString = scope.calcOnState { memory.types.evalTypeEquals(reference, EtsStringType) } + val supportedType = mkOr(mkNot(activeGuard), isNullish, isString) + scope.fork(supportedType, blockOnFalseState = { + terminateAsUnsupported(reason = "Loose equality needs a modeled ToPrimitive conversion for objects") + }) ?: return null + + val stringGuard = mkAnd(activeGuard, isString, mkNot(isNullish)) + val converted = mkNumericExprWithGuard(expr = reference, scope = scope, activeGuard = stringGuard) + val equalValue = mkFpEqualExpr(primitive, converted) + + return mkAnd(isString, mkNot(isNullish), equalValue) + } + override fun TsContext.onBool( lhs: UBoolExpr, rhs: UBoolExpr, @@ -621,6 +672,13 @@ sealed interface TsBinaryOperator { lhs: UExpr<*>, rhs: UExpr<*>, scope: TsStepScope, + ): UBoolExpr? = internalResolveWithGuard(lhs, rhs, scope, trueExpr) + + override fun TsContext.internalResolveWithGuard( + lhs: UExpr<*>, + rhs: UExpr<*>, + scope: TsStepScope, + activeGuard: UBoolExpr, ): UBoolExpr? { check(!lhs.isFakeObject() && !rhs.isFakeObject()) @@ -661,30 +719,28 @@ sealed interface TsBinaryOperator { // bool == ref if (lhs.sort == boolSort && rhs.sort == addressSort) { - return mkFalse() + val primitive = boolToFp(lhs.asExpr(boolSort)) + return primitiveReferenceEquals(primitive, rhs.asExpr(addressSort), scope, activeGuard) } // ref == bool if (lhs.sort == addressSort && rhs.sort == boolSort) { - return mkFalse() + val primitive = boolToFp(rhs.asExpr(boolSort)) + return primitiveReferenceEquals(primitive, lhs.asExpr(addressSort), scope, activeGuard) } // fp == ref if (lhs.sort == fp64Sort && rhs.sort == addressSort) { - // TODO: the correct impl is to convert ref to primitive, - // and then compare fp and this primitive. - return mkFalse() + return primitiveReferenceEquals(lhs.asExpr(fp64Sort), rhs.asExpr(addressSort), scope, activeGuard) } // ref == fp if (lhs.sort == addressSort && rhs.sort == fp64Sort) { - // TODO: the correct impl is to convert ref to primitive, - // and then compare this primitive to fp - return mkFalse() + return primitiveReferenceEquals(rhs.asExpr(fp64Sort), lhs.asExpr(addressSort), scope, activeGuard) } // TODO: support bigint - // TODO: support string + // TODO: support general string and object coercion beyond the bounded string conversion above TODO("Support equality for sorts: ${lhs.sort} == ${rhs.sort}") } @@ -753,9 +809,16 @@ sealed interface TsBinaryOperator { lhs: UExpr<*>, rhs: UExpr<*>, scope: TsStepScope, + ): UExpr<*>? = internalResolveWithGuard(lhs, rhs, scope, trueExpr) + + override fun TsContext.internalResolveWithGuard( + lhs: UExpr<*>, + rhs: UExpr<*>, + scope: TsStepScope, + activeGuard: UBoolExpr, ): UExpr<*>? { return with(Eq) { - internalResolve(lhs, rhs, scope)?.not() + internalResolveWithGuard(lhs, rhs, scope, activeGuard)?.not() } } } diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/TsStringEqualityTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/TsStringEqualityTest.kt index 28a45cdf66..5824480498 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/TsStringEqualityTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/TsStringEqualityTest.kt @@ -125,6 +125,25 @@ class TsStringEqualityTest : TsMethodTestRunner() { replay(tests) } + @Test + fun `loose equality converts primitive strings and preserves nullish comparisons`() { + val methodNames = listOf( + "loosePrimitiveStringEquals", + "loosePrimitiveStringNotEquals", + "loosePrimitiveNullishControls", + ) + + methodNames.forEach { name -> + val method = getMethod(methodName = name, className = "StringEquality") + + discoverProperties( + method = method, + { result -> result.number == 1.0 }, + invariants = arrayOf({ result -> result.number == 1.0 }), + ) + } + } + @Test fun `default string bound can compare two symbolic inputs`() { discoverProperties( diff --git a/usvm-ts/src/test/resources/models/StringEquality.ts b/usvm-ts/src/test/resources/models/StringEquality.ts index b545014e41..c15e252329 100644 --- a/usvm-ts/src/test/resources/models/StringEquality.ts +++ b/usvm-ts/src/test/resources/models/StringEquality.ts @@ -103,4 +103,27 @@ class StringEquality { nullAndUndefined(): number { return null === undefined ? 1 : null == undefined ? 2 : 3; } + + loosePrimitiveStringEquals(): number { + const one: any = "1"; + + if (true == one && one == true && 1 == one && one == 1) return 1; + return 2; + } + + loosePrimitiveStringNotEquals(): number { + const one: any = "1"; + + if (true != one || one != true || 1 != one || one != 1) return 2; + return 1; + } + + loosePrimitiveNullishControls(): number { + const nil: any = null; + const missing: any = undefined; + + if (true == nil || nil == true || 1 == missing || missing == 1) return 2; + if (true != nil && nil != true && 1 != missing && missing != 1) return 1; + return 2; + } } From 2b2b1418e7245f33858066f2e590b249dbd5eb56 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 14:08:45 +0300 Subject: [PATCH 17/97] Report unmodeled object string loose equality as unsupported --- .../usvm/machine/operator/TsBinaryOperator.kt | 41 ++++++++++++++++ .../org/usvm/machine/TsStringEqualityTest.kt | 47 +++++++++++++++++++ .../test/resources/models/StringEquality.ts | 34 ++++++++++++++ 3 files changed, 122 insertions(+) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt index 6547403b7c..b7ea738b83 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt @@ -586,6 +586,45 @@ sealed interface TsBinaryOperator { data object Eq : TsBinaryOperator { + private fun TsContext.ensureReferencePairCoercionSupported( + lhs: UHeapRef, + rhs: UHeapRef, + scope: TsStepScope, + activeGuard: UBoolExpr, + ): Unit? { + if (lhs is UIteExpr<*>) { + val trueGuard = mkAnd(activeGuard, lhs.condition) + val falseGuard = mkAnd(activeGuard, mkNot(lhs.condition)) + ensureReferencePairCoercionSupported(lhs.trueBranch.asExpr(addressSort), rhs, scope, trueGuard) + ?: return null + return ensureReferencePairCoercionSupported(lhs.falseBranch.asExpr(addressSort), rhs, scope, falseGuard) + } + + if (rhs is UIteExpr<*>) { + return ensureReferencePairCoercionSupported(rhs, lhs, scope, activeGuard) + } + + val sameReference = mkHeapRefEq(lhs, rhs) + val lhsNullish = mkIsNullOrUndefined(lhs) + val rhsNullish = mkIsNullOrUndefined(rhs) + val (lhsString, rhsString) = scope.calcOnState { + memory.types.evalTypeEquals(lhs, EtsStringType) to memory.types.evalTypeEquals(rhs, EtsStringType) + } + val samePrimitiveStringKind = mkEq(lhsString, rhsString) + val supported = mkOr( + mkNot(activeGuard), + sameReference, + lhsNullish, + rhsNullish, + samePrimitiveStringKind, + ) + scope.fork(supported, blockOnFalseState = { + terminateAsUnsupported(reason = "Loose equality needs a modeled ToPrimitive conversion for object/string pairs") + }) ?: return null + + return Unit + } + private fun TsContext.primitiveReferenceEquals( primitive: UExpr, reference: UHeapRef, @@ -639,6 +678,8 @@ sealed interface TsBinaryOperator { val rhsIsNull = mkEq(rhs, mkTsNullValue()) val lhsIsUndefined = mkEq(lhs, mkUndefinedValue()) val rhsIsUndefined = mkEq(rhs, mkUndefinedValue()) + ensureReferencePairCoercionSupported(lhs, rhs, scope, activeGuard) ?: return null + val referenceEquality = referenceOrStringValueEquals(lhs, rhs, scope, activeGuard) ?: return null return mkOr( mkAnd(lhsIsUndefined, rhsIsNull), diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/TsStringEqualityTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/TsStringEqualityTest.kt index 5824480498..eb66184a70 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/TsStringEqualityTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/TsStringEqualityTest.kt @@ -144,6 +144,53 @@ class TsStringEqualityTest : TsMethodTestRunner() { } } + @Test + fun `loose object string equality records unsupported coercion and keeps supported alternatives`() { + val unsupportedNames = listOf( + "looseArrayEqualsEmptyString", + "looseEmptyStringEqualsArray", + "looseArrayNotEqualsEmptyString", + ) + + unsupportedNames.forEach { name -> + val method = getMethod(methodName = name, className = "StringEquality") + val analysis = TsMachine( + scene, + options = analysisOptions.copy(stateCollectionStrategy = StateCollectionStrategy.ALL), + ).use { machine -> + machine.analyzeWithOutcome(listOf(method)) + } + + assertEquals(TsAnalysisStopReason.EXHAUSTED, analysis.stopReason, name) + assertTrue(analysis.states.isEmpty(), name) + assertTrue(analysis.unsupportedPaths.any { "ToPrimitive conversion for object/string pairs" in it }, name) + } + + val controls = getMethod(methodName = "looseReferenceControls", className = "StringEquality") + discoverProperties( + method = controls, + { result -> result.number == 1.0 }, + invariants = arrayOf({ result -> result.number == 1.0 }), + ) + + val conditional = getMethod(methodName = "looseStringOrArray", className = "StringEquality") + val conditionalAnalysis = TsMachine( + scene, + options = analysisOptions.copy(stateCollectionStrategy = StateCollectionStrategy.ALL), + ).use { machine -> + machine.analyzeWithOutcome(listOf(conditional)) + } + val surviving = conditionalAnalysis.states.map { state -> TsTestResolver().resolve(conditional, state) } + + assertEquals(TsAnalysisStopReason.EXHAUSTED, conditionalAnalysis.stopReason) + assertTrue(conditionalAnalysis.unsupportedPaths.any { "ToPrimitive conversion for object/string pairs" in it }) + assertTrue(surviving.isNotEmpty()) + surviving.forEach { test -> + assertTrue(assertIs(test.before.parameters.single()).value) + assertEquals(1, resultNumber(test)) + } + } + @Test fun `default string bound can compare two symbolic inputs`() { discoverProperties( diff --git a/usvm-ts/src/test/resources/models/StringEquality.ts b/usvm-ts/src/test/resources/models/StringEquality.ts index c15e252329..3d905396f7 100644 --- a/usvm-ts/src/test/resources/models/StringEquality.ts +++ b/usvm-ts/src/test/resources/models/StringEquality.ts @@ -126,4 +126,38 @@ class StringEquality { if (true != nil && nil != true && 1 != missing && missing != 1) return 1; return 2; } + + looseArrayEqualsEmptyString(): number { + const array: any = []; + + return array == "" ? 1 : 2; + } + + looseEmptyStringEqualsArray(): number { + const array: any = []; + + return "" == array ? 1 : 2; + } + + looseArrayNotEqualsEmptyString(): number { + const array: any = []; + + return array != "" ? 2 : 1; + } + + looseStringOrArray(flag: boolean): number { + const value: any = flag ? "" : []; + + return value == "" ? 1 : 2; + } + + looseReferenceControls(): number { + const left: any = []; + const right: any = []; + const text: any = ""; + const nil: any = null; + + if (left != right && left == left && text == "" && nil == undefined) return 1; + return 2; + } } From f53b70a785e40eca80b5de97195a631144ecf694 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 14:09:00 +0300 Subject: [PATCH 18/97] Convert invalid Istanbul source paths into typed diagnostics --- .../pbt/coverage/IstanbulCoverageDecoder.kt | 16 ++++++- .../coverage/IstanbulCoverageReportTest.kt | 45 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageDecoder.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageDecoder.kt index 8f1a2430a1..2db9edb928 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageDecoder.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageDecoder.kt @@ -2,6 +2,7 @@ package org.usvm.ts.pbt.coverage import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import org.usvm.ts.pbt.PbtDiagnosticCode import org.usvm.ts.pbt.backend.CoverageArtifactKind import org.usvm.ts.pbt.backend.CoverageDiagnostic import org.usvm.ts.pbt.backend.CoverageProvenance @@ -9,6 +10,7 @@ import org.usvm.ts.pbt.backend.CoverageScope import org.usvm.ts.pbt.backend.PropertyCoverageArtifact import org.usvm.ts.pbt.backend.SourceFileCoverage import java.nio.file.Files +import java.nio.file.InvalidPathException import java.nio.file.Path /** Selects relevant report entries and assembles the coverage artifact for one property run. */ @@ -57,7 +59,19 @@ internal class IstanbulCoverageDecoder( val fileJson = IstanbulJsonValue(reportEntry, coveragePath) val fileObject = fileJson.asObject() val reportedPath = fileJson.optionalString(name = "path") - val path = normalizeCoveragePath(reportedPath ?: reportKey) + val rawPath = reportedPath ?: reportKey + val path = try { + normalizeCoveragePath(rawPath) + } catch (error: InvalidPathException) { + val invalidPathLocation = if (reportedPath != null) "$coveragePath.path" else coveragePath + + throw CoverageArtifactException.create( + code = PbtDiagnosticCode.COVERAGE_REPORT_INVALID, + message = "Istanbul coverage entry has an invalid source path", + path = invalidPathLocation, + cause = error, + ) + } if (isGeneratedJavaScriptBelowSourceRoot(path)) { diagnostics += sourceMapDiagnostic(path) diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportTest.kt index 7b6e28d971..1f1bb89bca 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportTest.kt @@ -143,6 +143,51 @@ class IstanbulCoverageReportTest { } } + @Test + fun `invalid source path reports its field location`() { + val report = createTempFile(suffix = ".json") + val invalidPath = "/workspace/src/math.ts\\u0000" + + try { + report.writeText("""{"/workspace/src/math.ts":{"path":"$invalidPath"}}""") + + val error = assertFailsWith { + decodeIstanbulCoverageReport( + reportPath = report, + context = context(), + ) + } + + assertEquals("coverage.report.invalid", error.diagnostic.code) + assertEquals("coverage[/workspace/src/math.ts].path", error.diagnostic.path) + } finally { + report.deleteIfExists() + } + } + + @Test + fun `invalid report key reports its entry location when path is absent`() { + val report = createTempFile(suffix = ".json") + val invalidReportKey = "/workspace/src/math.ts\\u0000" + val decodedReportKey = "/workspace/src/math.ts" + '\u0000' + + try { + report.writeText("""{"$invalidReportKey":{}}""") + + val error = assertFailsWith { + decodeIstanbulCoverageReport( + reportPath = report, + context = context(), + ) + } + + assertEquals("coverage.report.invalid", error.diagnostic.code) + assertEquals("coverage[$decodedReportKey]", error.diagnostic.path) + } finally { + report.deleteIfExists() + } + } + @Test fun `boolean branch type is rejected instead of being converted to text`() { val report = createTempFile(suffix = ".json") From 295e0ce01738eb31e957d12c8a593b4b9725221e Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 14:33:21 +0300 Subject: [PATCH 19/97] Bound hierarchy traversal and preserve method call kinds --- .../org/usvm/machine/expr/CallStatic.kt | 12 +++-- .../usvm/machine/interpreter/TsInterpreter.kt | 10 ++-- .../main/kotlin/org/usvm/util/EtsHierarchy.kt | 4 +- .../org/usvm/samples/lang/StaticOverloads.kt | 22 +++++++++ .../kotlin/org/usvm/util/EtsHierarchyTest.kt | 46 +++++++++++++++++++ .../resources/samples/lang/StaticOverloads.ts | 22 +++++++++ 6 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 usvm-ts/src/test/kotlin/org/usvm/util/EtsHierarchyTest.kt diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallStatic.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallStatic.kt index 154d9555ac..8a39388230 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallStatic.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallStatic.kt @@ -5,6 +5,7 @@ import org.jacodb.ets.model.EtsClassType import org.jacodb.ets.model.EtsMethod import org.jacodb.ets.model.EtsMethodSignature import org.jacodb.ets.model.EtsStaticCallExpr +import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME import org.jacodb.ets.utils.UNKNOWN_CLASS_NAME import org.usvm.UExpr import org.usvm.machine.Constants @@ -80,7 +81,7 @@ private fun TsExprResolver.resolveStaticMethod( val classes = hierarchy.classesForType(EtsClassType(method.enclosingClass)) if (classes.size > 1) { val methods = classes - .flatMap { clazz -> clazz.methods.filter { it.name == method.name } } + .flatMap { clazz -> clazz.methods.filter { it.isStaticCallTarget(method.name) } } .canonicalizeExecutableOverloads() return TsResolutionResult.create(methods) } @@ -89,7 +90,7 @@ private fun TsExprResolver.resolveStaticMethod( val clazz = classes.single() val methods = clazz.methods - .filter { it.name == method.name } + .filter { it.isStaticCallTarget(method.name) } .canonicalizeExecutableOverloads() return TsResolutionResult.create(methods) } @@ -97,12 +98,17 @@ private fun TsExprResolver.resolveStaticMethod( // Unknown signature: val methods = ctx.applicationAndSdkClasses .flatMap { it.methods } - .filter { it.name == method.name } + .filter { it.isStaticCallTarget(method.name) } .canonicalizeExecutableOverloads() return TsResolutionResult.create(methods) } +private fun EtsMethod.isStaticCallTarget(methodName: String): Boolean { + val isTopLevelFunction = enclosingClass?.name == DEFAULT_ARK_CLASS_NAME + return name == methodName && (isStatic || isTopLevelFunction) +} + private fun TsExprResolver.processAmbiguousStaticMethod( resolved: TsResolutionResult.Ambiguous, expr: EtsStaticCallExpr, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt index aa82ca3837..eaabec428f 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt @@ -271,12 +271,12 @@ class TsInterpreter( // scope.assert(falseExpr) // return for (cls in classes) { - val suitableMethods = cls.methods.filter { it.name == callee.name } + val suitableMethods = cls.methods.filter { !it.isStatic && it.name == callee.name } concreteMethods += suitableMethods } } else { val cls = classes.single() - val suitableMethods = cls.methods.filter { it.name == callee.name } + val suitableMethods = cls.methods.filter { !it.isStatic && it.name == callee.name } concreteMethods += suitableMethods } } else { @@ -287,7 +287,7 @@ class TsInterpreter( return } } else { - val methods = resolveEtsMethods(callee) + val methods = resolveEtsMethods(callee).filterNot { it.isStatic } if (methods.isEmpty()) { if (callee.name !in listOf("then")) { logger.warn { "Could not resolve method: $callee" } @@ -333,10 +333,10 @@ class TsInterpreter( .map { candidate -> graph.hierarchy.getAncestors(candidate) } .mapNotNull { ancestors -> ancestors.firstOrNull { ancestor -> - ancestor.methods.any { method -> method.name == callee.name } + ancestor.methods.any { method -> !method.isStatic && method.name == callee.name } } } - .map { ancestor -> clazz to ancestor.methods.first { method -> method.name == callee.name } } + .map { ancestor -> clazz to ancestor.methods.first { method -> !method.isStatic && method.name == callee.name } } }.toList().take(10) // TODO check it // logger.info { diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/EtsHierarchy.kt b/usvm-ts/src/main/kotlin/org/usvm/util/EtsHierarchy.kt index 4de206a4f2..a4a9ed9dcf 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/EtsHierarchy.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/EtsHierarchy.kt @@ -32,6 +32,7 @@ class EtsHierarchy(private val scene: EtsScene) { val time = measureTimeMillis { result = scene.projectAndSdkClasses.associateWith { start -> + val visited = mutableSetOf(start) generateSequence(listOf(start)) { classes -> if (classes.isEmpty()) return@generateSequence null @@ -44,7 +45,8 @@ class EtsHierarchy(private val scene: EtsScene) { superClasses.toMutableSet() + resolvedInterfaces // TODO optimize } - result.takeIf { it.isNotEmpty() } + val newAncestors = result.filter { visited.add(it) } + newAncestors.takeIf { it.isNotEmpty() } }.flatten().toMutableSet() + scene.sdkClasses.filter { it.name == "Object" } } } diff --git a/usvm-ts/src/test/kotlin/org/usvm/samples/lang/StaticOverloads.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/lang/StaticOverloads.kt index 3255d41d5f..812e042748 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/samples/lang/StaticOverloads.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/samples/lang/StaticOverloads.kt @@ -25,6 +25,28 @@ class StaticOverloads : TsMethodTestRunner() { ) } + @Test + fun `static call selects static body when instance body shares its name`() { + val method = getMethod("callStaticWithSameNameInstance") + + discoverProperties( + method = method, + { result -> result eq 42 }, + invariants = arrayOf({ result -> result eq 42 }), + ) + } + + @Test + fun `super call selects instance body when static body shares its name`() { + val method = getMethod("callSuperWithSameNameStatic") + + discoverProperties( + method = method, + { result -> result eq 11 }, + invariants = arrayOf({ result -> result eq 11 }), + ) + } + @Test fun `test static overload group does not fork equivalent states`() { var forkedStates = 0 diff --git a/usvm-ts/src/test/kotlin/org/usvm/util/EtsHierarchyTest.kt b/usvm-ts/src/test/kotlin/org/usvm/util/EtsHierarchyTest.kt new file mode 100644 index 0000000000..6e60159968 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/util/EtsHierarchyTest.kt @@ -0,0 +1,46 @@ +package org.usvm.util + +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.EtsScene +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +class EtsHierarchyTest { + @Test + fun `cyclic ancestry terminates and keeps first encounter order`() { + val fileSignature = EtsFileSignature(projectName = "test", fileName = "cycle.ts") + val aSignature = EtsClassSignature(name = "A", file = fileSignature) + val bSignature = EtsClassSignature(name = "B", file = fileSignature) + val unrelatedSignature = EtsClassSignature(name = "Unrelated", file = fileSignature) + val a = EtsClassImpl( + signature = aSignature, + fields = emptyList(), + methods = emptyList(), + superClass = bSignature, + ) + val b = EtsClassImpl( + signature = bSignature, + fields = emptyList(), + methods = emptyList(), + superClass = aSignature, + ) + val unrelated = EtsClassImpl( + signature = unrelatedSignature, + fields = emptyList(), + methods = emptyList(), + ) + val file = EtsFile(signature = fileSignature, classes = listOf(a, b, unrelated), namespaces = emptyList()) + val hierarchy = EtsHierarchy(EtsScene(projectFiles = listOf(file))) + + val unrelatedAncestors = hierarchy.getAncestors(unrelated) + val aAncestors = hierarchy.getAncestors(a) + val bAncestors = hierarchy.getAncestors(b) + + assertEquals(listOf(unrelated), unrelatedAncestors.toList()) + assertEquals(listOf(a, b), aAncestors.toList()) + assertEquals(listOf(b, a), bAncestors.toList()) + } +} diff --git a/usvm-ts/src/test/resources/samples/lang/StaticOverloads.ts b/usvm-ts/src/test/resources/samples/lang/StaticOverloads.ts index 8cec979a1d..4ce1f340bd 100644 --- a/usvm-ts/src/test/resources/samples/lang/StaticOverloads.ts +++ b/usvm-ts/src/test/resources/samples/lang/StaticOverloads.ts @@ -7,8 +7,30 @@ function overloaded(value: number | string): number | string { return typeof value === "number" ? value + 1 : value; } +class InstanceDeclarationFirst { + method(): number { return 7; } + static method(): number { return 42; } +} + +class StaticDeclarationFirst { + static method(): number { return 99; } + method(): number { return 11; } +} + +class MethodKindsChild extends StaticDeclarationFirst { + callParentMethod(): number { return super.method(); } +} + class StaticOverloads { callOverloaded(): number { return overloaded(41); } + + callStaticWithSameNameInstance(): number { + return InstanceDeclarationFirst.method(); + } + + callSuperWithSameNameStatic(): number { + return new MethodKindsChild().callParentMethod(); + } } From 2808dd1c65ef325c99da2dcfbf3caf5d6b56a55b Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 14:39:59 +0300 Subject: [PATCH 20/97] Track fake reference provenance without address offsets --- .../main/kotlin/org/usvm/machine/TsContext.kt | 24 ++++++++++++------ .../org/usvm/machine/TsContextFactoryTest.kt | 25 +++++++++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt index db29cdd20c..b59c0b9987 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt @@ -46,7 +46,6 @@ import org.usvm.api.allocateStaticRef import org.usvm.api.typeStreamOf import org.usvm.collection.field.UFieldLValue import org.usvm.isTrue -import org.usvm.machine.Constants.Companion.MAGIC_OFFSET import org.usvm.machine.expr.TsUndefinedSort import org.usvm.machine.expr.TsUnresolvedSort import org.usvm.machine.expr.TsVoidSort @@ -56,6 +55,7 @@ import org.usvm.machine.types.EtsFakeType import org.usvm.machine.types.iteWriteIntoFakeObject import org.usvm.memory.UReadOnlyMemory import org.usvm.types.single +import org.usvm.uctx import org.usvm.util.componentType import org.usvm.util.mkFieldLValue import kotlin.contracts.ExperimentalContracts @@ -237,7 +237,10 @@ class TsContext( returns(true) implies (this@isFakeObject is UConcreteHeapRef) } - return sort == addressSort && this is UConcreteHeapRef && address > MAGIC_OFFSET + return sort == addressSort && + this is UConcreteHeapRef && + uctx === this@TsContext && + address in fakeObjectAddresses } /** @@ -334,23 +337,27 @@ class TsContext( return this } + private val fakeObjectAddresses = mutableSetOf() + fun createFakeObjectRef(): UConcreteHeapRef { - val address = addressCounter.freshAllocatedAddress() + MAGIC_OFFSET - return mkConcreteHeapRef(address) + val address = addressCounter.freshAllocatedAddress() + val ref = mkConcreteHeapRef(address) + fakeObjectAddresses += address + return ref } fun getIntermediateBoolLValue(addr: Int): UFieldLValue { - require(addr > MAGIC_OFFSET) + require(addr in fakeObjectAddresses) { "Address $addr is not a fake-value wrapper" } return mkFieldLValue(boolSort, mkConcreteHeapRef(addr), IntermediateLValueField.BOOL) } fun getIntermediateFpLValue(addr: Int): UFieldLValue { - require(addr > MAGIC_OFFSET) + require(addr in fakeObjectAddresses) { "Address $addr is not a fake-value wrapper" } return mkFieldLValue(fp64Sort, mkConcreteHeapRef(addr), IntermediateLValueField.FP) } fun getIntermediateRefLValue(addr: Int): UFieldLValue { - require(addr > MAGIC_OFFSET) + require(addr in fakeObjectAddresses) { "Address $addr is not a fake-value wrapper" } return mkFieldLValue(addressSort, mkConcreteHeapRef(addr), IntermediateLValueField.REF) } @@ -403,6 +410,9 @@ class TsContext( class Constants { companion object { const val STATIC_METHODS_FORK_LIMIT = 5 + + /** Obsolete fake-address offset retained for source and binary compatibility. */ + @Deprecated("Fake values now use context-owned address provenance") const val MAGIC_OFFSET = 1000000 } } diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/TsContextFactoryTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/TsContextFactoryTest.kt index 06f90b5297..ddd510eba0 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/TsContextFactoryTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/TsContextFactoryTest.kt @@ -31,9 +31,12 @@ import org.usvm.util.copyArrayElements import org.usvm.util.mkArrayIndexLValue import org.usvm.util.mkArrayLengthLValue import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertIs +import kotlin.test.assertNotEquals import kotlin.test.assertSame +import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds /** Prepared factory/storage contracts; source reachability and replay are separate concerns. */ @@ -56,6 +59,28 @@ class TsContextFactoryTest { ctx.close() } + @Test + fun `fake provenance is separate from concrete address magnitude`() = with(ctx) { + val firstAddressAboveFormerThreshold = 1_000_001 + val highOrdinaryRef = mkConcreteHeapRef(firstAddressAboveFormerThreshold) + state.memory.types.allocate(highOrdinaryRef.address, EtsNumberType) + + val payload = mkFp64(7.0) + val fakeRef = state.mkFakeValue(scope = null, fpValue = payload) + val nextOrdinaryRef = state.memory.allocConcrete(EtsNumberType) + + assertFalse(highOrdinaryRef.isFakeObject()) + assertFalse(nextOrdinaryRef.isFakeObject()) + assertTrue(fakeRef.isFakeObject()) + assertNotEquals(fakeRef, nextOrdinaryRef) + assertEquals(payload, fakeRef.extractFp(state.memory)) + assertEquals(trueExpr, fakeRef.getFakeType(state.memory).fpTypeExpr) + + assertFailsWith { getIntermediateBoolLValue(highOrdinaryRef.address) } + assertFailsWith { getIntermediateFpLValue(highOrdinaryRef.address) } + assertFailsWith { getIntermediateRefLValue(highOrdinaryRef.address) } + } + @Test fun `boxing conditional number wrappers retains numeric payload and kind`() = with(ctx) { val selected = mkRegisterReading(idx = 0, sort = boolSort) From 58b82c90bb75280ff29e90b22d49eb8c9b1e403e Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 14:48:55 +0300 Subject: [PATCH 21/97] Resolve absolute imports by exact file signatures --- .../main/kotlin/org/usvm/util/TsImports.kt | 11 ++--- .../usvm/machine/ImportExportResolution.kt | 40 +++++++++++++++++++ 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/TsImports.kt b/usvm-ts/src/main/kotlin/org/usvm/util/TsImports.kt index c7406207d2..051fb6b152 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/TsImports.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/TsImports.kt @@ -69,17 +69,12 @@ private fun EtsScene.resolveSystemLibrary(importPath: String): ImportResolutionR private fun EtsScene.resolveAbsolutePath(importPath: String): ImportResolutionResult { val normalizedPath = importPath.removePrefix("/") + val relativeCandidates = listOf(normalizedPath, "$normalizedPath.ts", "$normalizedPath.ets", "$normalizedPath.d.ts") + val absoluteCandidates = listOf(importPath, "$importPath.ts", "$importPath.ets", "$importPath.d.ts") val foundFile = (projectFiles + sdkFiles).find { file -> val fileName = file.signature.fileName - fileName == normalizedPath || - fileName == "$normalizedPath.ts" || - fileName == "$normalizedPath.ets" || - fileName == "$normalizedPath.d.ts" || - fileName.endsWith("/$normalizedPath") || - fileName.endsWith("/$normalizedPath.ts") || - fileName.endsWith("/$normalizedPath.ets") || - fileName.endsWith("/$normalizedPath.d.ts") + fileName in relativeCandidates || fileName in absoluteCandidates } return if (foundFile != null) { diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/ImportExportResolution.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/ImportExportResolution.kt index 0ac4570dca..41ff1ba51b 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/ImportExportResolution.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/ImportExportResolution.kt @@ -129,6 +129,46 @@ class ImportExportResolutionTest { assertEquals("src/types/index.ets", result.file.signature.fileName) } + @Test + fun `absolute import chooses project-root path over an earlier nested suffix`() { + val nestedFile = createMockFile( + signature = EtsFileSignature(projectName = "TestProject", fileName = "app/src/types/index.ts"), + exports = emptyList(), + ) + val rootFile = createMockFile( + signature = EtsFileSignature(projectName = "TestProject", fileName = "src/types/index.ts"), + exports = emptyList(), + ) + val collisionScene = EtsScene( + projectFiles = listOf(currentFile, nestedFile, rootFile), + sdkFiles = emptyList(), + projectName = "TestProject", + ) + + val result = collisionScene.resolveImport(currentFile, "/src/types/index") + + assertIs(result) + assertEquals(rootFile, result.file) + } + + @Test + fun `absolute import accepts a fully absolute file signature`() { + val absoluteFile = createMockFile( + signature = EtsFileSignature(projectName = "TestProject", fileName = "/src/types/index.ts"), + exports = emptyList(), + ) + val absoluteScene = EtsScene( + projectFiles = listOf(currentFile, absoluteFile), + sdkFiles = emptyList(), + projectName = "TestProject", + ) + + val result = absoluteScene.resolveImport(currentFile, "/src/types/index") + + assertIs(result) + assertEquals(absoluteFile, result.file) + } + @Test @DisplayName("Test import resolution failure") fun testImportNotFound() { From aa504d03139e1dd5d008de0979f8f38fc20596a7 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 14:53:56 +0300 Subject: [PATCH 22/97] Compare primitive strings by UTF-16 lexical order --- .../usvm/machine/operator/TsBinaryOperator.kt | 198 ++++++++++++++++-- .../usvm/machine/TsStringRelationalTest.kt | 78 +++++++ .../test/resources/models/StringRelational.ts | 27 +++ 3 files changed, 280 insertions(+), 23 deletions(-) create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/TsStringRelationalTest.kt create mode 100644 usvm-ts/src/test/resources/models/StringRelational.ts diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt index b7ea738b83..7ccfd14a65 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt @@ -31,6 +31,175 @@ import org.usvm.util.mkStringBackingElementLValue import org.usvm.util.mkStringBackingLValue import org.usvm.util.mkStringBackingLengthLValue +private fun TsContext.stringReferenceKind( + reference: UHeapRef, + scope: TsStepScope, +): UBoolExpr { + if (reference is UIteExpr<*>) { + val trueReference = reference.trueBranch.asExpr(addressSort) + val falseReference = reference.falseBranch.asExpr(addressSort) + val trueIsString = stringReferenceKind(trueReference, scope) + val falseIsString = stringReferenceKind(falseReference, scope) + + return mkIte(reference.condition, trueIsString, falseIsString) + } + + return scope.calcOnState { memory.types.evalTypeEquals(reference, EtsStringType) } +} + +private fun TsContext.stringReferenceAndKind( + value: UExpr<*>, + scope: TsStepScope, +): Pair { + val (reference, referenceKind) = when { + value.isFakeObject() -> value.extractRef(scope) to value.getFakeType(scope).refTypeExpr + value.sort == addressSort -> value.asExpr(addressSort) to trueExpr + else -> return null to falseExpr + } + + val stringKind = stringReferenceKind(reference, scope) + + return reference to mkAnd(referenceKind, stringKind) +} + +/** Compare backed primitive strings by the first differing UTF-16 code unit, then by length. */ +private fun TsContext.stringValueLessThan( + lhs: UHeapRef, + rhs: UHeapRef, + scope: TsStepScope, + activeGuard: UBoolExpr, +): UBoolExpr { + if (lhs is UIteExpr<*>) { + val trueGuard = mkAnd(activeGuard, lhs.condition) + val falseGuard = mkAnd(activeGuard, mkNot(lhs.condition)) + val trueResult = stringValueLessThan(lhs.trueBranch.asExpr(addressSort), rhs, scope, trueGuard) + val falseResult = stringValueLessThan(lhs.falseBranch.asExpr(addressSort), rhs, scope, falseGuard) + + return mkIte(lhs.condition, trueResult, falseResult) + } + + if (rhs is UIteExpr<*>) { + val trueGuard = mkAnd(activeGuard, rhs.condition) + val falseGuard = mkAnd(activeGuard, mkNot(rhs.condition)) + val trueResult = stringValueLessThan(lhs, rhs.trueBranch.asExpr(addressSort), scope, trueGuard) + val falseResult = stringValueLessThan(lhs, rhs.falseBranch.asExpr(addressSort), scope, falseGuard) + + return mkIte(rhs.condition, trueResult, falseResult) + } + + val lhsConstant = (lhs as? UConcreteHeapRef)?.let(::getStringConstantValue) + val rhsConstant = (rhs as? UConcreteHeapRef)?.let(::getStringConstantValue) + if (lhsConstant != null && rhsConstant != null) { + return if (lhsConstant < rhsConstant) trueExpr else falseExpr + } + + val (lhsBound, rhsBound, machineBound) = scope.calcOnState { + Triple(boundedStringBackingRefs[lhs], boundedStringBackingRefs[rhs], maxStringLength) + } + val backedWithinLimit = lhsBound != null && rhsBound != null && + lhsBound >= 0 && lhsBound <= machineBound && + rhsBound >= 0 && rhsBound <= machineBound + val unsupportedReason = "Relational string comparison needs bounded primitive string backings" + if (!backedWithinLimit) { + val inactive = scope.fork(mkNot(activeGuard), blockOnFalseState = { + terminateAsUnsupported(reason = unsupportedReason) + }) + if (inactive == null) throw UnsupportedOperationException(unsupportedReason) + + return falseExpr + } + + val leftBound = requireNotNull(lhsBound) + val rightBound = requireNotNull(rhsBound) + val comparison = scope.calcOnState { + val leftChars = memory.read(mkStringBackingLValue(lhs)) + val rightChars = memory.read(mkStringBackingLValue(rhs)) + + StringComparisonData( + lhsChars = leftChars, + rhsChars = rightChars, + lhsLength = memory.read(mkStringBackingLengthLValue(leftChars)), + rhsLength = memory.read(mkStringBackingLengthLValue(rightChars)), + maxLength = maxOf(leftBound, rightBound), + ) + } + val zero = mkBv(0) + val lengthsWithinBounds = mkAnd( + mkBvSignedGreaterOrEqualExpr(comparison.lhsLength, zero), + mkBvSignedLessOrEqualExpr(comparison.lhsLength, mkBv(leftBound)), + mkBvSignedGreaterOrEqualExpr(comparison.rhsLength, zero), + mkBvSignedLessOrEqualExpr(comparison.rhsLength, mkBv(rightBound)), + ) + val supported = mkOr(mkNot(activeGuard), lengthsWithinBounds) + val currentSupported = scope.fork(supported, blockOnFalseState = { + terminateAsUnsupported(reason = unsupportedReason) + }) + if (currentSupported == null) throw UnsupportedOperationException(unsupportedReason) + + var lessThan = mkBvSignedLessExpr(comparison.lhsLength, comparison.rhsLength) + for (index in (0 until comparison.maxLength).reversed()) { + val position = mkBv(index) + val bothHaveCodeUnit = mkAnd( + mkBvSignedLessExpr(position, comparison.lhsLength), + mkBvSignedLessExpr(position, comparison.rhsLength), + ) + val (leftCodeUnit, rightCodeUnit) = scope.calcOnState { + memory.read(mkStringBackingElementLValue(comparison.lhsChars, position)) to + memory.read(mkStringBackingElementLValue(comparison.rhsChars, position)) + } + val differingCodeUnits = mkAnd(bothHaveCodeUnit, mkNot(mkEq(leftCodeUnit, rightCodeUnit))) + + lessThan = mkIte(differingCodeUnits, mkBvUnsignedLessExpr(leftCodeUnit, rightCodeUnit), lessThan) + } + + return lessThan +} + +private fun TsContext.relationalComparison( + lhs: UExpr<*>, + rhs: UExpr<*>, + scope: TsStepScope, + activeGuard: UBoolExpr, + reversed: Boolean, +): UBoolExpr { + if (lhs is UIteExpr<*>) { + val trueGuard = mkAnd(activeGuard, lhs.condition) + val falseGuard = mkAnd(activeGuard, mkNot(lhs.condition)) + val trueResult = relationalComparison(lhs.trueBranch, rhs, scope, trueGuard, reversed) + val falseResult = relationalComparison(lhs.falseBranch, rhs, scope, falseGuard, reversed) + + return mkIte(lhs.condition, trueResult, falseResult) + } + + if (rhs is UIteExpr<*>) { + val trueGuard = mkAnd(activeGuard, rhs.condition) + val falseGuard = mkAnd(activeGuard, mkNot(rhs.condition)) + val trueResult = relationalComparison(lhs, rhs.trueBranch, scope, trueGuard, reversed) + val falseResult = relationalComparison(lhs, rhs.falseBranch, scope, falseGuard, reversed) + + return mkIte(rhs.condition, trueResult, falseResult) + } + + val (lhsReference, lhsString) = stringReferenceAndKind(lhs, scope) + val (rhsReference, rhsString) = stringReferenceAndKind(rhs, scope) + val bothStrings = mkAnd(lhsString, rhsString) + val stringGuard = mkAnd(activeGuard, bothStrings) + val numericGuard = mkAnd(activeGuard, mkNot(bothStrings)) + + val leftNumber = mkNumericExprWithGuard(expr = lhs, scope = scope, activeGuard = numericGuard) + val rightNumber = mkNumericExprWithGuard(expr = rhs, scope = scope, activeGuard = numericGuard) + val numericResult = if (reversed) mkFpGreaterExpr(leftNumber, rightNumber) else mkFpLessExpr(leftNumber, rightNumber) + if (lhsReference == null || rhsReference == null) return numericResult + + val stringResult = if (reversed) { + stringValueLessThan(rhsReference, lhsReference, scope, stringGuard) + } else { + stringValueLessThan(lhsReference, rhsReference, scope, stringGuard) + } + + return mkIte(bothStrings, stringResult, numericResult) +} + private val logger = KotlinLogging.logger {} /** Strings are primitive values even though the TS heap stores their UTF-16 contents behind references. */ @@ -1297,10 +1466,7 @@ sealed interface TsBinaryOperator { scope: TsStepScope, activeGuard: UBoolExpr, ): UBoolExpr { - val left = mkNumericExprWithGuard(expr = lhs, scope = scope, activeGuard = activeGuard) - val right = mkNumericExprWithGuard(expr = rhs, scope = scope, activeGuard = activeGuard) - - return onFp(left, right, scope) + return relationalComparison(lhs, rhs, scope, activeGuard, reversed = false) } override fun TsContext.onRefWithGuard( @@ -1338,9 +1504,7 @@ sealed interface TsBinaryOperator { rhs: UHeapRef, scope: TsStepScope, ): UBoolExpr { - val lhsNumeric = mkNumericExpr(lhs, scope) - val rhsNumeric = mkNumericExpr(rhs, scope) - return mkFpLessExpr(lhsNumeric, rhsNumeric) + return resolve(lhs, rhs, scope, activeGuard = trueExpr) } override fun TsContext.resolveFakeObject( @@ -1356,12 +1520,7 @@ sealed interface TsBinaryOperator { rhs: UExpr<*>, scope: TsStepScope, ): UBoolExpr { - // TODO: the immediate conversion to numbers is not correct, - // we first need to try to convert arguments to primitive values, - // which might become strings, for which LT has different semantics. - val lhsNumeric = mkNumericExpr(lhs, scope) - val rhsNumeric = mkNumericExpr(rhs, scope) - return mkFpLessExpr(lhsNumeric, rhsNumeric) + return resolve(lhs, rhs, scope, activeGuard = trueExpr) } } @@ -1372,10 +1531,7 @@ sealed interface TsBinaryOperator { scope: TsStepScope, activeGuard: UBoolExpr, ): UBoolExpr { - val left = mkNumericExprWithGuard(expr = lhs, scope = scope, activeGuard = activeGuard) - val right = mkNumericExprWithGuard(expr = rhs, scope = scope, activeGuard = activeGuard) - - return onFp(left, right, scope) + return relationalComparison(lhs, rhs, scope, activeGuard, reversed = true) } override fun TsContext.onRefWithGuard( @@ -1413,9 +1569,7 @@ sealed interface TsBinaryOperator { rhs: UHeapRef, scope: TsStepScope, ): UBoolExpr { - val lhsNumeric = mkNumericExpr(lhs, scope) - val rhsNumeric = mkNumericExpr(rhs, scope) - return mkFpGreaterExpr(lhsNumeric, rhsNumeric) + return resolve(lhs, rhs, scope, activeGuard = trueExpr) } override fun TsContext.resolveFakeObject( @@ -1431,9 +1585,7 @@ sealed interface TsBinaryOperator { rhs: UExpr<*>, scope: TsStepScope, ): UBoolExpr { - val lhsNumeric = mkNumericExpr(lhs, scope) - val rhsNumeric = mkNumericExpr(rhs, scope) - return mkFpGreaterExpr(lhsNumeric, rhsNumeric) + return resolve(lhs, rhs, scope, activeGuard = trueExpr) } } diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/TsStringRelationalTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/TsStringRelationalTest.kt new file mode 100644 index 0000000000..5ae79deb46 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/TsStringRelationalTest.kt @@ -0,0 +1,78 @@ +package org.usvm.machine + +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.junit.jupiter.api.Test +import org.usvm.api.TsTestValue +import org.usvm.util.TsMethodTestRunner +import org.usvm.util.getResourcePath + +class TsStringRelationalTest : TsMethodTestRunner() { + override val scene = EtsScene( + listOf( + loadEtsFileAutoConvert( + getResourcePath("/models/StringRelational.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ), + ), + ) + override val tsOptions = TsOptions(maxArraySize = 2) + + @Test + fun `literal strings use lexicographic UTF-16 ordering`() { + for (name in listOf("literalLess", "unsignedCodeUnitOrder", "lengthOrder")) { + val method = getMethod(methodName = name, className = "StringRelational") + + discoverProperties( + method = method, + { result -> result.number == 1.0 }, + invariants = arrayOf({ result -> result.number == 1.0 }), + ) + } + } + + @Test + fun `mixed string and number comparisons keep numeric coercion`() { + val method = getMethod(methodName = "mixedNumberAndString", className = "StringRelational") + + discoverProperties( + method = method, + { result -> result.number == 1.0 }, + invariants = arrayOf({ result -> result.number == 1.0 }), + ) + } + + @Test + fun `symbolic string pairs compare their source values`() { + val method = getMethod(methodName = "symbolicLess", className = "StringRelational") + + discoverProperties( + method = method, + { left, right, result -> left.value < right.value && result.number == 1.0 }, + { left, right, result -> left.value >= right.value && result.number == 2.0 }, + invariants = arrayOf({ left, right, result -> + val expected = if (left.value < right.value) 1.0 else 2.0 + + result.number == expected + }), + ) + } + + @Test + fun `conditional string alternative retains numeric sibling`() { + val method = getMethod(methodName = "conditionalLeft", className = "StringRelational") + + discoverProperties( + method = method, + { useString, value, result -> useString.value && value.value < "b" && result.number == 1.0 }, + { useString, value, result -> useString.value && value.value >= "b" && result.number == 2.0 }, + { useString, _, result -> !useString.value && result.number == 2.0 }, + invariants = arrayOf({ useString, value, result -> + val expected = if (useString.value && value.value < "b") 1.0 else 2.0 + + result.number == expected + }), + ) + } +} diff --git a/usvm-ts/src/test/resources/models/StringRelational.ts b/usvm-ts/src/test/resources/models/StringRelational.ts new file mode 100644 index 0000000000..526f564b10 --- /dev/null +++ b/usvm-ts/src/test/resources/models/StringRelational.ts @@ -0,0 +1,27 @@ +// @ts-nocheck +class StringRelational { + literalLess(): number { + return "a" < "b" && "b" > "a" ? 1 : 0; + } + + unsignedCodeUnitOrder(): number { + return "\u8000" > "\u7fff" && "\u8000" < "\uffff" ? 1 : 0; + } + + lengthOrder(): number { + return "a" < "aa" && "aa" > "a" ? 1 : 0; + } + + mixedNumberAndString(): number { + return "2" < 10 && 10 > "2" && !("2" < 1) ? 1 : 0; + } + + symbolicLess(left: string, right: string): number { + return left < right ? 1 : 2; + } + + conditionalLeft(useString: boolean, value: string): number { + const left: string | number = useString ? value : 1; + return left < "b" ? 1 : 2; + } +} From 8f820d1cd57824c218771355751b1ca8a2c5103b Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 17:36:14 +0300 Subject: [PATCH 23/97] Treat generic defaults independently from constructor bounds --- .../machine/types/TsConstructorInputGuard.kt | 2 +- .../samples/types/RuntimeInstanceofTest.kt | 26 +++++++++++++++++++ .../samples/types/RuntimeInstanceof.ts | 4 +++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsConstructorInputGuard.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsConstructorInputGuard.kt index 0ee059c218..833fa78125 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsConstructorInputGuard.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsConstructorInputGuard.kt @@ -43,7 +43,7 @@ internal fun EtsType.requiresConstructorWitness( false } else { val declaration = genericParameters[typeName] - listOfNotNull(constraint, defaultType, declaration?.constraint, declaration?.defaultType).any { + listOfNotNull(constraint, declaration?.constraint).any { it.requiresConstructorWitness(genericParameters, visitedGenerics + typeName) } } diff --git a/usvm-ts/src/test/kotlin/org/usvm/samples/types/RuntimeInstanceofTest.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/types/RuntimeInstanceofTest.kt index 76d3dcd6de..acbddaf752 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/samples/types/RuntimeInstanceofTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/samples/types/RuntimeInstanceofTest.kt @@ -497,6 +497,32 @@ class RuntimeInstanceofTest : TsMethodTestRunner() { )) } + @Test + fun `constructor generic default permits inferred number input`() { + val methodName = "genericDefaultConstructorParameter" + val method = getMethod(methodName = methodName, className = "RuntimeInstanceof") + + discoverProperties( + method = method, + { input, result -> input is TsTestValue.TsNumber && result.number == 1.0 }, + invariants = arrayOf({ input, result -> + val expected = if (input is TsTestValue.TsNumber) 1.0 else 0.0 + result.number == expected + }), + ) + + val outcome = analyze(methodName) + + assertEquals(TsAnalysisStopReason.EXHAUSTED, outcome.stopReason) + assertTrue(outcome.unsupportedPaths.isEmpty(), "${outcome.unsupportedPaths}") + assertTrue(outcome.states.isNotEmpty()) + + replay(listOf( + "if (new RuntimeInstanceof().genericDefaultConstructorParameter(7) !== 1) " + + "throw Error('inferred number despite constructor default');", + )) + } + @Test fun `constructor arrays have an explicit unsupported outcome`() { val methods = listOf("constructorArrayParameter", "constructorNestedArrayParameter") diff --git a/usvm-ts/src/test/resources/samples/types/RuntimeInstanceof.ts b/usvm-ts/src/test/resources/samples/types/RuntimeInstanceof.ts index a701d1e5e0..4e85307170 100644 --- a/usvm-ts/src/test/resources/samples/types/RuntimeInstanceof.ts +++ b/usvm-ts/src/test/resources/samples/types/RuntimeInstanceof.ts @@ -203,6 +203,10 @@ class RuntimeInstanceof { return typeof ctor === "function"; } + genericDefaultConstructorParameter(value: T): number { + return typeof value === "number" ? 1 : 0; + } + constructorTupleParameter(ctors: [typeof InstanceA]): boolean { return new InstanceA() instanceof ctors[0]; } From 2d58256e4203b76cd30cb513aee92e2492dae182 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 17:49:14 +0300 Subject: [PATCH 24/97] Preserve runtime addition boundaries and JavaScript remainder semantics --- .../usvm/machine/operator/TsBinaryOperator.kt | 28 +++++++++++++++++++ .../expr/RuntimeLimitationBoundariesTest.kt | 19 +++++++++++-- .../org/usvm/samples/operators/Remainder.kt | 23 +++++++++++++++ .../models/RuntimeLimitationBoundaries.ts | 7 +++++ .../resources/samples/operators/Remainder.ts | 23 +++++++++++++++ 5 files changed, 98 insertions(+), 2 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt index 7ccfd14a65..307bad7366 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt @@ -62,6 +62,20 @@ private fun TsContext.stringReferenceAndKind( return reference to mkAnd(referenceKind, stringKind) } +private fun TsContext.primitiveStringKind( + value: UExpr<*>, + scope: TsStepScope, +): UBoolExpr { + if (value is UIteExpr<*>) { + val trueIsString = primitiveStringKind(value.trueBranch, scope) + val falseIsString = primitiveStringKind(value.falseBranch, scope) + + return mkIte(value.condition, trueIsString, falseIsString) + } + + return stringReferenceAndKind(value, scope).second +} + /** Compare backed primitive strings by the first differing UTF-16 code unit, then by length. */ private fun TsContext.stringValueLessThan( lhs: UHeapRef, @@ -1205,6 +1219,20 @@ sealed interface TsBinaryOperator { scope: TsStepScope, activeGuard: UBoolExpr, ): UExpr { + val lhsIsString = primitiveStringKind(lhs, scope) + val rhsIsString = primitiveStringKind(rhs, scope) + val hasStringOperand = mkOr(lhsIsString, rhsIsString) + val numericAlternative = mkOr(mkNot(activeGuard), mkNot(hasStringOperand)) + if (!numericAlternative.isTrue) { + val unsupportedReason = "Dynamic string addition needs a modeled mixed-kind result" + val currentNumericAlternative = scope.fork(numericAlternative, blockOnFalseState = { + terminateAsUnsupported(reason = unsupportedReason) + }) + if (currentNumericAlternative == null) { + throw UnsupportedOperationException(unsupportedReason) + } + } + val left = mkNumericExprWithGuard(expr = lhs, scope = scope, activeGuard = activeGuard) val right = mkNumericExprWithGuard(expr = rhs, scope = scope, activeGuard = activeGuard) diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/expr/RuntimeLimitationBoundariesTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/RuntimeLimitationBoundariesTest.kt index 3aa8e3a641..57fe9ed894 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/expr/RuntimeLimitationBoundariesTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/RuntimeLimitationBoundariesTest.kt @@ -25,6 +25,8 @@ import kotlin.test.assertTrue private const val UNMODELED_NUMERIC_CONVERSION_REASON = "Numeric conversion needs a modeled StringToNumber or ToPrimitive conversion" +private const val DYNAMIC_STRING_ADDITION_REASON = + "Dynamic string addition needs a modeled mixed-kind result" class RuntimeLimitationBoundariesTest : TsMethodTestRunner() { override val scene = EtsScene( @@ -159,7 +161,20 @@ class RuntimeLimitationBoundariesTest : TsMethodTestRunner() { assertUnsupportedNumericSibling(methodName = "unsupportedNumericBinaryConversionOrSupportedSibling", expected = 6.0) } - private fun assertUnsupportedNumericSibling(methodName: String, expected: Double) { + @Test + fun `dynamic string addition stops while numeric sibling returns`() { + assertUnsupportedNumericSibling( + methodName = "dynamicStringAdditionOrSupportedNumericSibling", + expected = 9.0, + unsupportedReason = DYNAMIC_STRING_ADDITION_REASON, + ) + } + + private fun assertUnsupportedNumericSibling( + methodName: String, + expected: Double, + unsupportedReason: String = UNMODELED_NUMERIC_CONVERSION_REASON, + ) { val target = method(name = methodName) val options = UMachineOptions( pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), @@ -174,7 +189,7 @@ class RuntimeLimitationBoundariesTest : TsMethodTestRunner() { val witnesses = outcome.states.map { state -> TsTestResolver().resolve(target, state) } assertEquals(TsAnalysisStopReason.EXHAUSTED, outcome.stopReason) - assertTrue(UNMODELED_NUMERIC_CONVERSION_REASON in outcome.unsupportedPaths) + assertTrue(unsupportedReason in outcome.unsupportedPaths) assertTrue(witnesses.isNotEmpty()) witnesses.forEach { witness -> val useString = witness.before.parameters.single() as TsTestValue.TsBoolean diff --git a/usvm-ts/src/test/kotlin/org/usvm/samples/operators/Remainder.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/operators/Remainder.kt index 4e2e657c3a..6cc4396781 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/samples/operators/Remainder.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/samples/operators/Remainder.kt @@ -13,6 +13,29 @@ class Remainder : TsMethodTestRunner() { override val scene = loadScene(tsPath) + @Test + fun `remainder uses a truncating quotient`() { + assertRemainderProperty(methodName = "remTruncationExamples") + } + + @Test + fun `remainder preserves exceptional and signed zero cases`() { + assertRemainderProperty(methodName = "remExceptionalValues") + } + + @Test + fun `remainder handles very large and tiny finite operands`() { + assertRemainderProperty(methodName = "remFiniteExtremes") + } + + private fun assertRemainderProperty(methodName: String) { + discoverProperties( + method = getMethod(methodName), + { result -> result.value }, + invariants = arrayOf({ result -> result.value }), + ) + } + @Test fun testRemNumberAndNumber() { val method = getMethod("remNumberAndNumber") diff --git a/usvm-ts/src/test/resources/models/RuntimeLimitationBoundaries.ts b/usvm-ts/src/test/resources/models/RuntimeLimitationBoundaries.ts index d38dd3b08f..9bd4240338 100644 --- a/usvm-ts/src/test/resources/models/RuntimeLimitationBoundaries.ts +++ b/usvm-ts/src/test/resources/models/RuntimeLimitationBoundaries.ts @@ -45,4 +45,11 @@ export class RuntimeLimitationBoundaries { return values[0] - 1; } + + static dynamicStringAdditionOrSupportedNumericSibling(useString: boolean): number { + const values: any[] = [7]; + if (useString) values[0] = "1"; + + return values[0] + 2; + } } diff --git a/usvm-ts/src/test/resources/samples/operators/Remainder.ts b/usvm-ts/src/test/resources/samples/operators/Remainder.ts index 7227ea905d..2df0f5d23f 100644 --- a/usvm-ts/src/test/resources/samples/operators/Remainder.ts +++ b/usvm-ts/src/test/resources/samples/operators/Remainder.ts @@ -2,6 +2,29 @@ // noinspection JSUnusedGlobalSymbols class Remainder { + remTruncationExamples(): boolean { + return 7 % 4 === 3 + && 7 % -4 === 3 + && -7 % 4 === -3 + && -7 % -4 === -3; + } + + remExceptionalValues(): boolean { + return 1 / (0 % 4) === Infinity + && 1 / (-0 % 4) === -Infinity + && 5 % Infinity === 5 + && Number.isNaN(5 % 0) + && Number.isNaN(Infinity % 3) + && Number.isNaN((0 / 0) % 3); + } + + remFiniteExtremes(): boolean { + return 1e308 % 1.5e308 === 1e308 + && -1e308 % 1.5e308 === -1e308 + && 1e308 % 5e-324 === 0 + && 5e-324 % 1e308 === 5e-324; + } + // number % number remNumberAndNumber(a: number, b: number): number { let res = a % b; From fc12da24261fc04451ba0548b3ec6b917c565a16 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 17:55:01 +0300 Subject: [PATCH 25/97] Clarify runtime storage predicates and field type lookup --- .../main/kotlin/org/usvm/machine/expr/RuntimeFieldSort.kt | 6 +++--- usvm-ts/src/main/kotlin/org/usvm/util/LValueUtil.kt | 7 +++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/RuntimeFieldSort.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/RuntimeFieldSort.kt index 57864a672c..0d92bf38ae 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/RuntimeFieldSort.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/RuntimeFieldSort.kt @@ -24,9 +24,9 @@ internal fun TsContext.resolveAmbiguousFieldSort( } val runtimeType = scope.calcOnState { memory.typeStreamOf(instance).singleOrNull() } - val runtimeClass = (runtimeType as? EtsClassType)?.let { type -> - scene.projectAndSdkClasses.singleOrNull { it.signature == type.signature } - } ?: return unresolvedSort + val runtimeClassType = runtimeType as? EtsClassType ?: return unresolvedSort + val runtimeClass = scene.projectAndSdkClasses.singleOrNull { it.signature == runtimeClassType.signature } + ?: return unresolvedSort val ancestors = hierarchy.getAncestors(runtimeClass) // Scene-wide resolution repeats a declaration for every class inheriting it. diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/LValueUtil.kt b/usvm-ts/src/main/kotlin/org/usvm/util/LValueUtil.kt index b391903cf8..8821bd93c4 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/LValueUtil.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/LValueUtil.kt @@ -30,7 +30,10 @@ internal fun TsState.arrayStorageType(ref: UHeapRef, staticType: EtsType): EtsTy if (ref !is UConcreteHeapRef && ref !is USymbolicHeapRef) return staticType val memoryType = memory.typeStreamOf(ref).singleOrNull() - return if (memoryType is EtsArrayType || memoryType == EtsStringType || isAllocatedConcreteHeapRef(ref)) { + val usesRuntimeStorage = memoryType is EtsArrayType || memoryType == EtsStringType || + isAllocatedConcreteHeapRef(ref) + + return if (usesRuntimeStorage) { memoryType ?: staticType } else { staticType @@ -76,7 +79,7 @@ fun mkArrayLengthLValue( type: EtsArrayType, ): UArrayLengthLValue = with(ref.tctx) { val descriptor = arrayDescriptorOf(type) - return UArrayLengthLValue(ref, descriptor, sizeSort) + UArrayLengthLValue(ref, descriptor, sizeSort) } internal fun mkStringBackingLengthLValue( From 5243f4d117cbfc7da05cbafee2ba4cfddb3591ca Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 18:02:35 +0300 Subject: [PATCH 26/97] Name the modeled string backing condition --- .../src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt index 3149784a60..13eee9cf95 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt @@ -799,6 +799,7 @@ class TsExprResolver( } } } + return resolveBinaryOperator(TsBinaryOperator.Add, expr) } @@ -829,7 +830,8 @@ class TsExprResolver( // Resolve a conditional field payload through the same forker used for property and array receivers. val ref = resolveHeapRef(scope, operand.asExpr(addressSort)) ?: return null concreteStringValue(ref)?.let { return mkStringConstant(it, scope) } - if (scope.calcOnState { ref !in boundedStringBackingRefs }) { + val hasModeledStringBacking = scope.calcOnState { ref in boundedStringBackingRefs } + if (!hasModeledStringBacking) { stopRuntimeFeatureLimitation( reason = TsRuntimeFeatureLimitationReason.STRING_CONCAT_OPERAND_CONVERSION, detail = "String concatenation needs a modeled string backing for $ref", From c2dc5ade30d451c93c89b14203ab1742d9793924 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 18:38:52 +0300 Subject: [PATCH 27/97] Name projection capability case arguments --- .../pbt/usvm/UsvmProjectionCapabilityTest.kt | 47 ++++++++++++++----- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/usvm/UsvmProjectionCapabilityTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/usvm/UsvmProjectionCapabilityTest.kt index 0c41d6d2b1..49a8553cff 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/usvm/UsvmProjectionCapabilityTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/usvm/UsvmProjectionCapabilityTest.kt @@ -32,17 +32,42 @@ class UsvmProjectionCapabilityTest { @Test fun `reports scalar fidelity and type mismatches`() { val cases = listOf( - Case(BooleanDomain, "acceptsBoolean", ProjectionLevel.EXACT, emptyList()), - Case(IntegerDomain(min = -2, max = 3), "acceptsNumber", ProjectionLevel.EXACT, emptyList()), - Case(NumberDomain(allowNaN = false), "acceptsNumber", ProjectionLevel.EXACT, emptyList()), - Case(StringDomain(maxLength = 4), "acceptsString", ProjectionLevel.EXACT, emptyList()), Case( - ConstantDomain(JsConcreteValue.Boolean(value = true)), - "acceptsBoolean", - ProjectionLevel.EXACT, - emptyList(), + domain = BooleanDomain, + exportName = "acceptsBoolean", + level = ProjectionLevel.EXACT, + diagnosticPaths = emptyList(), + ), + Case( + domain = IntegerDomain(min = -2, max = 3), + exportName = "acceptsNumber", + level = ProjectionLevel.EXACT, + diagnosticPaths = emptyList(), + ), + Case( + domain = NumberDomain(allowNaN = false), + exportName = "acceptsNumber", + level = ProjectionLevel.EXACT, + diagnosticPaths = emptyList(), + ), + Case( + domain = StringDomain(maxLength = 4), + exportName = "acceptsString", + level = ProjectionLevel.EXACT, + diagnosticPaths = emptyList(), + ), + Case( + domain = ConstantDomain(JsConcreteValue.Boolean(value = true)), + exportName = "acceptsBoolean", + level = ProjectionLevel.EXACT, + diagnosticPaths = emptyList(), + ), + Case( + domain = IntegerDomain(), + exportName = "acceptsString", + level = ProjectionLevel.UNSUPPORTED, + diagnosticPaths = listOf("inputs[0].domain"), ), - Case(IntegerDomain(), "acceptsString", ProjectionLevel.UNSUPPORTED, listOf("inputs[0].domain")), ) cases.forEach { case -> @@ -110,8 +135,8 @@ class UsvmProjectionCapabilityTest { domain = OptionalDomain(ArrayDomain(IntegerDomain(), minLength = 1, maxLength = 1)), exportName = "acceptsOptionalNumberArray", ) - val caughtDirect = resolve(IntegerDomain(), "catchesDirectThrow") - val caughtHelper = resolve(IntegerDomain(), "catchesHelperThrow") + val caughtDirect = resolve(domain = IntegerDomain(), exportName = "catchesDirectThrow") + val caughtHelper = resolve(domain = IntegerDomain(), exportName = "catchesHelperThrow") val preconditionManifest = manifest( domain = IntegerDomain(), predicateExport = "acceptsNumber", From b30dd375213706ffd257276bf6e9d0ed2755c58f Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 19:01:08 +0300 Subject: [PATCH 28/97] Name precondition projection arguments --- .../usvm/ts/pbt/usvm/UsvmProjectionCapabilityResolver.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/usvm/UsvmProjectionCapabilityResolver.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/usvm/UsvmProjectionCapabilityResolver.kt index 4664380a34..74605c71b7 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/usvm/UsvmProjectionCapabilityResolver.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/usvm/UsvmProjectionCapabilityResolver.kt @@ -161,7 +161,12 @@ class UsvmProjectionCapabilityResolver(scene: EtsScene? = null) { path = "precondition", ) } else { - domainCapabilityForProjector(input.domain, parameterType, "inputs[$index].domain", options) + domainCapabilityForProjector( + domain = input.domain, + etsType = parameterType, + path = "inputs[$index].domain", + options = options, + ) } } From d7f57107b98d7a84c7852c0008cb0a0b564f9187 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 19:06:25 +0300 Subject: [PATCH 29/97] Name coverage mapping branch predicates --- .../usvm/ts/pbt/mapping/EtsCoverageMapper.kt | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsCoverageMapper.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsCoverageMapper.kt index 6de2704d94..24fcbb9bf7 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsCoverageMapper.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsCoverageMapper.kt @@ -112,7 +112,9 @@ internal class EtsCoverageMapper( val expectedBranchArmCount = branchSuccessorOrder.armCount val requiredBranchShape = "an if branch with exactly $expectedBranchArmCount ordered coverage arms" - if (coverage.type != ISTANBUL_IF_BRANCH_TYPE || coverage.arms.size != expectedBranchArmCount) { + val hasUnsupportedBranchShape = + coverage.type != ISTANBUL_IF_BRANCH_TYPE || coverage.arms.size != expectedBranchArmCount + if (hasUnsupportedBranchShape) { val diagnostic = EtsMappingDiagnostic( code = PbtDiagnosticCode.MAPPING_BRANCH_SHAPE_UNSUPPORTED, message = "EtsIR branch mapping requires $requiredBranchShape", @@ -133,7 +135,9 @@ internal class EtsCoverageMapper( .filter { candidate -> location.path in candidate.canonicalPaths } .map { candidate -> candidate.statements.filterIsInstance() } val conditionsInSourceFile = conditionGroupsInSourceFile.flatten() - if (conditionsInSourceFile.isNotEmpty() && conditionsInSourceFile.none { it.location.origin != null }) { + val conditionsLackSourceOrigins = + conditionsInSourceFile.isNotEmpty() && conditionsInSourceFile.none { it.location.origin != null } + if (conditionsLackSourceOrigins) { val diagnostic = EtsMappingDiagnostic( code = PbtDiagnosticCode.MAPPING_SOURCE_ORIGINS_UNSUPPORTED, message = "EtsIR conditions for the covered source file have no source origins", @@ -153,7 +157,9 @@ internal class EtsCoverageMapper( .map { statements -> statements.filter { statement -> statement.hasOriginWithin(location) } } .filter { statements -> statements.isNotEmpty() } val conditions = conditionGroups.flatten() - if (conditions.any { statement -> statement.successorCount() != expectedBranchArmCount }) { + val hasUnsupportedCfgSuccessors = + conditions.any { statement -> statement.successorCount() != expectedBranchArmCount } + if (hasUnsupportedCfgSuccessors) { val diagnostic = EtsMappingDiagnostic( code = PbtDiagnosticCode.MAPPING_BRANCH_CFG_UNSUPPORTED, message = "EtsIR branch mapping requires exactly $expectedBranchArmCount ordered CFG successors", @@ -326,7 +332,9 @@ internal class EtsCoverageMapper( .filter { candidate -> location.path in candidate.canonicalPaths } .map { candidate -> candidate.statements } val statementsInSourceFile = statementGroupsInSourceFile.flatten() - if (statementsInSourceFile.isNotEmpty() && statementsInSourceFile.none { it.location.origin != null }) { + val statementsLackSourceOrigins = + statementsInSourceFile.isNotEmpty() && statementsInSourceFile.none { it.location.origin != null } + if (statementsLackSourceOrigins) { val diagnostic = EtsMappingDiagnostic( code = PbtDiagnosticCode.MAPPING_SOURCE_ORIGINS_UNSUPPORTED, message = "EtsIR statements for the covered source file have no source origins", @@ -353,8 +361,9 @@ internal class EtsCoverageMapper( .mapNotNull { statement -> statement.location.origin } .distinct() val containedTargets = containedStatements.map(::EtsStatementTarget) + val hasAmbiguousSourceFiles = statementGroupsInSourceFile.size > 1 && containedStatements.isNotEmpty() val mapping = when { - statementGroupsInSourceFile.size > 1 && containedStatements.isNotEmpty() -> { + hasAmbiguousSourceFiles -> { ambiguousStatementMapping(location, containedStatements) } From fd373509eb9ffe5b1247938ec75eb5581d6a4b5a Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 22:36:22 +0300 Subject: [PATCH 30/97] Extract compatible runtime payloads for typed static field writes --- .../org/usvm/machine/expr/WriteField.kt | 74 ++++++++++++++----- .../samples/lang/StaticFieldTypedWrite.kt | 45 +++++++++++ .../samples/lang/StaticFieldTypedWrite.ts | 27 +++++++ 3 files changed, 126 insertions(+), 20 deletions(-) create mode 100644 usvm-ts/src/test/kotlin/org/usvm/samples/lang/StaticFieldTypedWrite.kt create mode 100644 usvm-ts/src/test/resources/samples/lang/StaticFieldTypedWrite.ts diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteField.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteField.kt index 048b0b3b53..babe7ae426 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteField.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteField.kt @@ -12,6 +12,7 @@ import org.jacodb.ets.model.EtsStringType import org.jacodb.ets.model.EtsType import org.usvm.UExpr import org.usvm.UHeapRef +import org.usvm.USort import org.usvm.isAllocatedConcreteHeapRef import org.usvm.machine.TsContext import org.usvm.machine.TsRuntimeFeatureLimitationReason @@ -243,25 +244,8 @@ fun TsContext.assignToInstanceField( } } - if (sort !is TsUnresolvedSort && expr.isFakeObject()) { - val fakeType = scope.calcOnState { expr.getFakeType(scope) } - val compatibleType = when (sort) { - boolSort -> fakeType.boolTypeExpr - fp64Sort -> fakeType.fpTypeExpr - addressSort -> fakeType.refTypeExpr - else -> error("Unsupported field sort: $sort") - } - - scope.fork( - compatibleType, - blockOnFalseState = { - terminateAsUnsupported(reason = "Assignment changes runtime sort of field '${field.name}'") - }, - ) ?: return null - } - - if (sort !is TsUnresolvedSort && expr.sort != sort && !expr.isFakeObject()) { - throw UnsupportedOperationException("Assignment changes runtime sort of field '${field.name}'") + if (sort !is TsUnresolvedSort) { + ensureCompatibleFieldWriteKind(scope, field.name, sort, expr) ?: return null } // If the field type is unknown, we create a fake object for the expr and assign it. @@ -298,6 +282,37 @@ fun TsContext.assignToInstanceField( } } +private fun TsContext.ensureCompatibleFieldWriteKind( + scope: TsStepScope, + fieldName: String, + sort: USort, + expr: UExpr<*>, +): Unit? { + if (expr.isFakeObject()) { + val fakeType = scope.calcOnState { expr.getFakeType(scope) } + val compatibleType = when (sort) { + boolSort -> fakeType.boolTypeExpr + fp64Sort -> fakeType.fpTypeExpr + addressSort -> fakeType.refTypeExpr + else -> error("Unsupported field sort: $sort") + } + + scope.fork( + compatibleType, + blockOnFalseState = { + terminateAsUnsupported(reason = "Assignment changes runtime sort of field '$fieldName'") + }, + ) ?: return null + return Unit + } + + if (expr.sort != sort) { + throw UnsupportedOperationException("Assignment changes runtime sort of field '$fieldName'") + } + + return Unit +} + internal fun TsExprResolver.handleAssignToStaticField( lhv: EtsStaticFieldRef, expr: UExpr<*>, @@ -343,8 +358,27 @@ fun TsContext.assignToStaticField( } } else { val lValue = mkFieldLValue(sort, instance, field.name) + // A conditional reference can contain a fake value on only one branch. + val mayContainFakeValue = expr.sort == addressSort && expr.asExpr(addressSort).hasFakeValueBranch() + val value = if (mayContainFakeValue) { + expr.toFakeObject(scope) + } else { + expr + } + ensureCompatibleFieldWriteKind(scope, field.name, sort, value) ?: return null + + val payload = if (value.isFakeObject()) { + when (sort) { + boolSort -> value.extractBool(scope) + fp64Sort -> value.extractFp(scope) + addressSort -> value.extractRef(scope) + else -> error("Unsupported field sort: $sort") + } + } else { + value + } scope.doWithState { - memory.write(lValue, expr.asExpr(lValue.sort), guard = trueExpr) + memory.write(lValue, payload.asExpr(lValue.sort), guard = trueExpr) } } } diff --git a/usvm-ts/src/test/kotlin/org/usvm/samples/lang/StaticFieldTypedWrite.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/lang/StaticFieldTypedWrite.kt new file mode 100644 index 0000000000..745853758e --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/samples/lang/StaticFieldTypedWrite.kt @@ -0,0 +1,45 @@ +package org.usvm.samples.lang + +import org.jacodb.ets.model.EtsScene +import org.usvm.api.TsTestValue +import org.usvm.util.TsMethodTestRunner +import org.usvm.util.eq +import kotlin.test.Test + +class StaticFieldTypedWrite : TsMethodTestRunner() { + override val scene: EtsScene = loadScene("/samples/lang/StaticFieldTypedWrite.ts") + + @Test + fun `numeric any value is written to typed static field`() { + val method = getMethod("copy", className = "TypedStaticNumberWrite") + + discoverProperties( + method = method, + { result -> result eq 7 }, + invariants = arrayOf({ result -> result eq 7 }), + ) + } + + @Test + fun `boolean any value is written to typed static field`() { + val method = getMethod("copy", className = "TypedStaticBooleanWrite") + + discoverProperties( + method = method, + { result -> result.value }, + invariants = arrayOf({ result -> result.value }), + ) + } + + @Test + fun `conditional source branch keeps compatible numeric static write`() { + // The frontend lowers the conditional to separate CFG branches. + val method = getMethod("copyConditionalBranch", className = "TypedStaticNumberWrite") + + discoverProperties( + method = method, + { useNumber, result -> useNumber.value && (result eq 9) }, + invariants = arrayOf({ useNumber, result -> useNumber.value && (result eq 9) }), + ) + } +} diff --git a/usvm-ts/src/test/resources/samples/lang/StaticFieldTypedWrite.ts b/usvm-ts/src/test/resources/samples/lang/StaticFieldTypedWrite.ts new file mode 100644 index 0000000000..2bca130c7e --- /dev/null +++ b/usvm-ts/src/test/resources/samples/lang/StaticFieldTypedWrite.ts @@ -0,0 +1,27 @@ +// noinspection JSUnusedGlobalSymbols + +class TypedStaticNumberWrite { + static source: any = 7; + static destination: number = 0; + + static copy(): number { + this.destination = this.source; + return this.destination; + } + + static copyConditionalBranch(useNumber: boolean): number { + const value: any = useNumber ? 9 : false; + this.destination = value; + return this.destination; + } +} + +class TypedStaticBooleanWrite { + static source: any = true; + static destination: boolean = false; + + static copy(): boolean { + this.destination = this.source; + return this.destination; + } +} From 1f57da6fd2ba0e346ede6584f2158ecbaec46bf9 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 22:36:27 +0300 Subject: [PATCH 31/97] Assert clean reachable states in domain projection tests --- .../usvm/ts/pbt/usvm/UsvmDomainProjector.kt | 3 +- .../usvm/UsvmCollectionDomainProjectorTest.kt | 36 ++++++++++++------- .../pbt/usvm/UsvmScalarDomainProjectorTest.kt | 27 +++++++++++--- 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/usvm/UsvmDomainProjector.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/usvm/UsvmDomainProjector.kt index fd168e327b..21463bce69 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/usvm/UsvmDomainProjector.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/usvm/UsvmDomainProjector.kt @@ -21,6 +21,7 @@ import org.usvm.machine.expr.TsUnresolvedSort import org.usvm.machine.state.TsState import org.usvm.machine.types.mkFakeValue import org.usvm.sizeSort +import org.usvm.ts.pbt.backend.ProjectionLevel import org.usvm.ts.pbt.mapping.EtsInputBinding import org.usvm.ts.pbt.model.ArrayDomain import org.usvm.ts.pbt.model.ArrayIndexGenerator @@ -87,7 +88,7 @@ class UsvmDomainProjector( path = path, options = options, ) - require(capability.level != org.usvm.ts.pbt.backend.ProjectionLevel.UNSUPPORTED) { + require(capability.level != ProjectionLevel.UNSUPPORTED) { capability.diagnostics.joinToString { diagnostic -> diagnostic.message } } } diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/usvm/UsvmCollectionDomainProjectorTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/usvm/UsvmCollectionDomainProjectorTest.kt index 8c2ca6d260..9f14f6e243 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/usvm/UsvmCollectionDomainProjectorTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/usvm/UsvmCollectionDomainProjectorTest.kt @@ -13,8 +13,10 @@ import org.usvm.SolverType import org.usvm.StateCollectionStrategy import org.usvm.UConcreteHeapRef import org.usvm.UMachineOptions +import org.usvm.machine.TsAnalysisStopReason import org.usvm.machine.TsMachine import org.usvm.machine.TsOptions +import org.usvm.machine.state.TsMethodResult import org.usvm.machine.state.TsState import org.usvm.ts.pbt.manifest.PropertyManifest import org.usvm.ts.pbt.mapping.PropertyEtsMapper @@ -29,6 +31,7 @@ import org.usvm.ts.pbt.model.TypeScriptEntryPoint import org.usvm.ts.pbt.testResourcePath import org.usvm.util.mkArrayIndexLValue import org.usvm.util.mkArrayLengthLValue +import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -73,7 +76,7 @@ class UsvmCollectionDomainProjectorTest { ) val target = mapper.map(manifest).predicate.targets.single() - fun accepts(length: Int, index: Int) = runCatchingAnalyze(target.method) { state -> + fun accepts(length: Int, index: Int) = analyze(target.method) { state -> val projection = projector.configure( state = state, inputs = manifest.inputs, @@ -119,7 +122,7 @@ class UsvmCollectionDomainProjectorTest { val mapping = mapper.map(manifest) val target = mapping.predicate.targets.single() - return runCatchingAnalyze(target.method) { state -> + return analyze(target.method) { state -> val projection = projector.configure(state, manifest.inputs, target.bindings.inputs) with(state.ctx) { @@ -147,7 +150,7 @@ class UsvmCollectionDomainProjectorTest { val mapping = mapper.map(manifest) val target = mapping.predicate.targets.single() - return runCatchingAnalyze(target.method) { state -> + return analyze(target.method) { state -> val projection = projector.configure(state, manifest.inputs, target.bindings.inputs) with(state.ctx) { @@ -177,28 +180,37 @@ class UsvmCollectionDomainProjectorTest { ), ) - private fun runCatchingAnalyze( - method: org.jacodb.ets.model.EtsMethod, - configure: (TsState) -> Unit, - ): Boolean = runCatching { analyze(method, configure) }.isSuccess - private fun analyze( method: org.jacodb.ets.model.EtsMethod, configure: (TsState) -> Unit, - ) { - TsMachine( + ): Boolean { + val analysis = TsMachine( scene = scene, options = UMachineOptions( solverType = SolverType.YICES, - stateCollectionStrategy = StateCollectionStrategy.ALL + stateCollectionStrategy = StateCollectionStrategy.ALL, + stopOnCoverage = 0, + removeCoveredEntryPoints = false, ), tsOptions = TsOptions(), ).use { machine -> - machine.analyze( + machine.analyzeWithMetadata( methods = listOf(method), configureInitialState = { _, state -> configure(state) }, ) } + + assertEquals(TsAnalysisStopReason.EXHAUSTED, analysis.stopReason) + assertFalse(analysis.timedOut) + assertFalse(analysis.engineFailed) + assertFalse(analysis.unsupportedCall) + assertTrue(analysis.unsupportedPaths.isEmpty(), analysis.unsupportedPaths.toString()) + assertTrue(analysis.states.all { state -> + val result = state.methodResult as? TsMethodResult.Success.RegularCall + result?.method == method + }) + + return analysis.states.isNotEmpty() } companion object { diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/usvm/UsvmScalarDomainProjectorTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/usvm/UsvmScalarDomainProjectorTest.kt index 9e05b77b05..89b1957baf 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/usvm/UsvmScalarDomainProjectorTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/usvm/UsvmScalarDomainProjectorTest.kt @@ -11,8 +11,10 @@ import org.usvm.StateCollectionStrategy import org.usvm.UBoolExpr import org.usvm.UConcreteHeapRef import org.usvm.UMachineOptions +import org.usvm.machine.TsAnalysisStopReason import org.usvm.machine.TsMachine import org.usvm.machine.TsOptions +import org.usvm.machine.state.TsMethodResult import org.usvm.machine.state.TsState import org.usvm.ts.pbt.manifest.PropertyManifest import org.usvm.ts.pbt.mapping.PropertyEtsMapper @@ -29,6 +31,7 @@ import org.usvm.ts.pbt.model.TypeScriptEntryPoint import org.usvm.ts.pbt.testResourcePath import org.usvm.util.mkRegisterStackLValue import org.usvm.util.readStringLength +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -204,21 +207,35 @@ class UsvmScalarDomainProjectorTest { private fun analyze( method: org.jacodb.ets.model.EtsMethod, configure: (TsState) -> Unit, - ): Boolean = runCatching { - TsMachine( + ): Boolean { + val analysis = TsMachine( scene = scene, options = UMachineOptions( solverType = SolverType.YICES, - stateCollectionStrategy = StateCollectionStrategy.ALL + stateCollectionStrategy = StateCollectionStrategy.ALL, + stopOnCoverage = 0, + removeCoveredEntryPoints = false, ), tsOptions = TsOptions(), ).use { machine -> - machine.analyze( + machine.analyzeWithMetadata( methods = listOf(method), configureInitialState = { _, state -> configure(state) }, ) } - }.isSuccess + + assertEquals(TsAnalysisStopReason.EXHAUSTED, analysis.stopReason) + assertFalse(analysis.timedOut) + assertFalse(analysis.engineFailed) + assertFalse(analysis.unsupportedCall) + assertTrue(analysis.unsupportedPaths.isEmpty(), analysis.unsupportedPaths.toString()) + assertTrue(analysis.states.all { state -> + val result = state.methodResult as? TsMethodResult.Success.RegularCall + result?.method == method + }) + + return analysis.states.isNotEmpty() + } private fun manifest(domain: PropertyDomain, exportName: String) = PropertyManifest( propertyId = "usvm.scalar.$exportName", From 97db16b4c164945f25d7077db118228c98aa2c91 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 22:42:20 +0300 Subject: [PATCH 32/97] Validate object intersection input fields across declaring members --- .../ts/calls/CallsInputTypeCompatibility.kt | 98 +++++++++++++++++-- .../usvm/ts/calls/CallsObjectInputsTest.kt | 56 +++++++++++ .../org/usvm/util/InputPropertyDeclaration.kt | 37 +++++-- 3 files changed, 177 insertions(+), 14 deletions(-) diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputTypeCompatibility.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputTypeCompatibility.kt index efb979aea2..90dc66964e 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputTypeCompatibility.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputTypeCompatibility.kt @@ -34,8 +34,10 @@ import org.usvm.ts.pbt.model.PropertyDomain import org.usvm.ts.pbt.model.PropertyInput import org.usvm.ts.pbt.model.StringDomain import org.usvm.util.EtsHierarchy +import org.usvm.util.InputPropertyResolution import org.usvm.util.componentType import org.usvm.util.declaredInstanceProperty +import org.usvm.util.resolveInstanceProperty /** Rejects bindings whose frozen domain would replace a source parameter with an incompatible runtime kind. */ internal fun callsInputTypePreflight( @@ -99,8 +101,11 @@ private fun PropertyDomain.inputTypeLimitation( } } is EtsStringLiteralType -> { - (this is StringDomain && minLength == 0 && maxLength == 0 && type.value.isEmpty()) || - (constant is JsConcreteValue.String && constant.value == type.value) + val matchesEmptyStringDomain = this is StringDomain && + minLength == 0 && maxLength == 0 && type.value.isEmpty() + val matchesConstant = constant is JsConcreteValue.String && constant.value == type.value + + matchesEmptyStringDomain || matchesConstant } is EtsBooleanLiteralType -> { constant is JsConcreteValue.Boolean && constant.value == type.value @@ -156,11 +161,13 @@ private fun PropertyDomain.inputTypeLimitation( } } is EtsIntersectionType -> { - for (member in type.types) { - val limitation = inputTypeLimitation(member, genericParameters, path, hierarchy, visitedGenerics) - if (limitation != null) return limitation - } - true + return intersectionTypeLimitation( + type = type, + genericParameters = genericParameters, + path = path, + hierarchy = hierarchy, + visitedGenerics = visitedGenerics, + ) } else -> { false @@ -170,6 +177,83 @@ private fun PropertyDomain.inputTypeLimitation( return if (compatible) null else "$path: frozen domain $this is not supported for declared type $type" } +private fun PropertyDomain.intersectionTypeLimitation( + type: EtsIntersectionType, + genericParameters: Map, + path: String, + hierarchy: EtsHierarchy, + visitedGenerics: Set, +): String? { + val members = type.types.flatMap { member -> + member.intersectionMembers(genericParameters, visitedGenerics) + } + if (this !is ObjectDomain) { + for (member in members) { + val limitation = inputTypeLimitation(member, genericParameters, path, hierarchy, visitedGenerics) + if (limitation != null) return limitation + } + + return null + } + + val objectMembers = members.filter { member -> member is EtsClassType || member is EtsUnclearRefType } + val otherMembers = members - objectMembers.toSet() + for (member in otherMembers) { + val limitation = inputTypeLimitation(member, genericParameters, path, hierarchy, visitedGenerics) + if (limitation != null) return limitation + } + + for ((name, domain) in fields) { + val fieldPath = "$path.$name" + val resolutions = objectMembers.map { member -> + resolveInstanceProperty(member, name, hierarchy) + } + val hasUnresolvedDeclaration = resolutions.any { resolution -> + resolution == InputPropertyResolution.Unresolved + } + if (hasUnresolvedDeclaration) { + return "$fieldPath: cannot resolve the declared field type in $type" + } + + val declarations = resolutions.filterIsInstance() + if (objectMembers.isNotEmpty() && declarations.isEmpty()) { + return "$fieldPath: cannot resolve the declared field type in $type" + } + + for (declaration in declarations) { + val limitation = domain.inputTypeLimitation( + type = declaration.property.type, + genericParameters = genericParameters, + path = fieldPath, + hierarchy = hierarchy, + visitedGenerics = visitedGenerics, + ) + if (limitation != null) return limitation + } + } + + return null +} + +private fun EtsType.intersectionMembers( + genericParameters: Map, + visitedGenerics: Set, +): List = when (this) { + is EtsIntersectionType -> types.flatMap { member -> + member.intersectionMembers(genericParameters, visitedGenerics) + } + is EtsAliasType -> originalType.intersectionMembers(genericParameters, visitedGenerics) + is EtsGenericType -> { + if (typeName in visitedGenerics) { + listOf(this) + } else { + val upperBound = constraint ?: genericParameters[typeName]?.constraint ?: EtsUnknownType + upperBound.intersectionMembers(genericParameters, visitedGenerics + typeName) + } + } + else -> listOf(this) +} + private fun PropertyDomain.objectFieldTypeLimitation( type: EtsRefType, genericParameters: Map, diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsObjectInputsTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsObjectInputsTest.kt index 077e66ee0d..2141b1ffef 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsObjectInputsTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsObjectInputsTest.kt @@ -54,6 +54,62 @@ internal class CallsObjectInputsTest : CurrentTsCallsSymbolicEngineTestSupport() fixture.assertReplayConfirmed(inputs) } + @Test + fun `plain object satisfies fields from separate intersection members`() { + val fixture = fixture( + source = """ + export function accepts(input: { a: number } & { b: number }): boolean { + if (input.a === 7 && input.b === 11) return true; + return false; + } + """.trimIndent(), + exportName = "accepts", + inputs = listOf( + PropertyInput( + name = "input", + domain = ObjectDomain( + mapOf( + "a" to IntegerDomain(min = 6, max = 8), + "b" to IntegerDomain(min = 10, max = 12), + ), + ), + ), + ), + targetStatement = "return true;", + ) + + val preflight = fixture.preflight() + val result = fixture.search(modelIds = emptySet()) + + assertEquals(CallsSymbolicPreflightStatus.ELIGIBLE, preflight.status, preflight.toString()) + assertEquals(CallsSymbolicStatus.REACHED, result.status, result.toString()) + val inputs = assertNotNull(result.inputs, result.toString()) + val input = assertIs(inputs.single()) + assertEquals(JsConcreteValue.number(7.0), input.fields.getValue("a")) + assertEquals(JsConcreteValue.number(11.0), input.fields.getValue("b")) + fixture.assertReplayConfirmed(inputs) + } + + @Test + fun `intersection field must satisfy every declaring member`() { + val fixture = fixture( + source = """ + export function accepts(input: { value: number } & { value: boolean }): boolean { + return true; + } + """.trimIndent(), + exportName = "accepts", + inputs = listOf(PropertyInput(name = "input", domain = ObjectDomain(mapOf("value" to NumberDomain()))), + targetStatement = "return true;", + ) + + val preflight = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, preflight.status, preflight.toString()) + assertEquals(CallsSymbolicPreflightReasonCode.INPUT_DOMAIN_UNSUPPORTED, preflight.reasonCode) + assertTrue(preflight.diagnostic.orEmpty().contains("inputs.input.value"), preflight.toString()) + } + @Test fun `plain object keeps data fields of a class annotation without becoming that class`() { val fixture = fixture( diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/InputPropertyDeclaration.kt b/usvm-ts/src/main/kotlin/org/usvm/util/InputPropertyDeclaration.kt index 07dbcd5890..b3302d7473 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/InputPropertyDeclaration.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/InputPropertyDeclaration.kt @@ -9,13 +9,20 @@ import org.jacodb.ets.model.EtsUnclearRefType /** Resolved own or inherited data-field metadata for a declared receiver type. */ data class InputPropertyDeclaration(val type: EtsType, val optional: Boolean) +/** Keep absent fields distinct from declarations that cannot be resolved consistently. */ +sealed interface InputPropertyResolution { + data class Declared(val property: InputPropertyDeclaration) : InputPropertyResolution + data object Absent : InputPropertyResolution + data object Unresolved : InputPropertyResolution +} + /** Resolve receiver-local metadata; independent declarations must agree on type and optionality. */ -fun declaredInstanceProperty( +fun resolveInstanceProperty( type: EtsType?, name: String, hierarchy: EtsHierarchy, -): InputPropertyDeclaration? { - if (type !is EtsClassType && type !is EtsUnclearRefType) return null +): InputPropertyResolution { + if (type !is EtsClassType && type !is EtsUnclearRefType) return InputPropertyResolution.Unresolved val declarations = hierarchy.classesForType(type).map { receiver -> val fieldOwners = hierarchy.getAncestors(receiver).filter { owner -> @@ -29,13 +36,29 @@ fun declaredInstanceProperty( val nearestOwners = owners.filter { owner -> owners.none { descendant -> owner != descendant && owner in hierarchy.getAncestors(descendant) } } - nearestOwners.flatMap { owner -> owner.fields.filter { !it.isStatic && it.name == name } } + val nearestFields = nearestOwners.flatMap { owner -> + owner.fields.filter { field -> !field.isStatic && field.name == name } + } + val nearestDeclarations = nearestFields .map { field -> InputPropertyDeclaration(field.type, optional = (field as? EtsFieldImpl)?.isOptional == true) } .distinct() - .singleOrNull() + when (nearestDeclarations.size) { + 0 -> InputPropertyResolution.Absent + 1 -> InputPropertyResolution.Declared(nearestDeclarations.single()) + else -> InputPropertyResolution.Unresolved + } } - // An unresolved receiver may denote several classes. Keep metadata only when every candidate agrees. - return declarations.distinct().singleOrNull() + // An unresolved receiver may denote several classes. Every candidate must agree on one outcome. + return declarations.distinct().singleOrNull() ?: InputPropertyResolution.Unresolved +} + +fun declaredInstanceProperty( + type: EtsType?, + name: String, + hierarchy: EtsHierarchy, +): InputPropertyDeclaration? = when (val resolution = resolveInstanceProperty(type, name, hierarchy)) { + is InputPropertyResolution.Declared -> resolution.property + InputPropertyResolution.Absent, InputPropertyResolution.Unresolved -> null } From 01c95578cbfcf031905435112d5ce70f56a60ee9 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 22:44:13 +0300 Subject: [PATCH 33/97] Use imported union type in candidate input resolution --- .../kotlin/org/usvm/ts/pbt/usvm/UsvmCandidateInputResolver.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/usvm/UsvmCandidateInputResolver.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/usvm/UsvmCandidateInputResolver.kt index 48caffa173..bdd58c0a1e 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/usvm/UsvmCandidateInputResolver.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/usvm/UsvmCandidateInputResolver.kt @@ -4,6 +4,7 @@ import io.ksmt.utils.asExpr import org.jacodb.ets.model.EtsArrayType import org.jacodb.ets.model.EtsTupleType import org.jacodb.ets.model.EtsType +import org.jacodb.ets.model.EtsUnionType import org.jacodb.ets.model.EtsUnknownType import org.usvm.UConcreteHeapRef import org.usvm.UExpr @@ -139,7 +140,7 @@ internal class UsvmCandidateInputResolver(private val objectShapes: PlainObjectI } } - val nestedType = (etsType as org.jacodb.ets.model.EtsUnionType).types.first { type -> + val nestedType = (etsType as EtsUnionType).types.first { type -> UsvmProjectionCapabilityResolver(state.ctx.scene).domainCompatibilityForProjector(domain.value, type) } From d2c577ba0bc553a3a2eaf9d2576489594cdae383 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 22:59:09 +0300 Subject: [PATCH 34/97] Report call graph resolution failures during Calls preflight --- .../usvm/ts/calls/CallsIrReadinessIssue.kt | 12 +-------- .../calls/CallsReadinessAndTelemetryTest.kt | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsIrReadinessIssue.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsIrReadinessIssue.kt index 0cd776ddb5..a7c6bf9adc 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsIrReadinessIssue.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsIrReadinessIssue.kt @@ -53,7 +53,7 @@ private fun EtsMethod.projectCallees( sourceFiles: Set, ): Sequence = cfg.stmts.asSequence() .flatMap { statement -> - val directCallees = runCatching { graph.callees(statement).toList() }.getOrDefault(emptyList()) + val directCallees = graph.callees(statement).toList() val pointerCallees = statement.walkEntities().filterIsInstance().flatMap { call -> val signature = (call.ptr.type as? EtsFunctionType)?.signature ?: return@flatMap emptyList() graph.cp.projectClasses.filter { owner -> owner.signature == signature.enclosingClass } @@ -81,16 +81,6 @@ private fun EtsMethod.projectStaticInitializers( ?.singleOrNull { it.name == DEFAULT_ARK_METHOD_NAME } } -internal fun callsIrReadinessIssue( - method: EtsMethod, - graph: TsGraph, - source: String, -): CallsIrReadinessIssue? = callsIrReadinessIssue( - method = method, - graph = graph, - sourceByFile = mapOf(method.signature.enclosingClass.file to source), -) - private fun EtsMethod.irReadinessIssue( source: String, graph: TsGraph, diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsReadinessAndTelemetryTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsReadinessAndTelemetryTest.kt index 59ecb8bde0..0d63707464 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsReadinessAndTelemetryTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsReadinessAndTelemetryTest.kt @@ -150,6 +150,31 @@ internal class CallsReadinessAndTelemetryTest : CurrentTsCallsSymbolicEngineTest assertEquals(CallsSymbolicPreflightReasonCode.REGEX_LITERAL_UNSUPPORTED, result.reasonCode, result.toString()) } + @Test + fun `preflight reports a call graph error for a merged class and interface`() { + val fixture = fixture( + source = """ + class Merged { + static containsA(value: string): boolean { return /a/.test(value); } + } + interface Merged {} + + export function callsMerged(value: string): boolean { + return Merged.containsA(value); + } + """.trimIndent(), + exportName = "callsMerged", + inputs = listOf(PropertyInput(name = "value", domain = StringDomain(maxLength = 3))), + targetStatement = "return Merged.containsA(value);", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.TOOL_ERROR, result.status, result.toString()) + assertEquals(CallsSymbolicPreflightReasonCode.FRONTEND_OR_PREFLIGHT_ERROR, result.reasonCode, result.toString()) + assertTrue(result.diagnostic.orEmpty().contains("Multiple classes with the same signature")) + } + @Test fun `preflight ignores regex allocation in an unreachable same-file helper`() { val fixture = fixture( From c78c171eaa2c12c876b9bf1faa01fd119e81c795 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 23:04:27 +0300 Subject: [PATCH 35/97] Resolve instance call arguments in explicit ordered blocks --- usvm-ts/src/main/kotlin/org/usvm/machine/expr/Call.kt | 6 +++++- .../org/usvm/machine/expr/TsCapturedInstanceCall.kt | 6 +++++- .../main/kotlin/org/usvm/machine/expr/TsExprResolver.kt | 8 +++++--- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/Call.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/Call.kt index 530cede137..319c24c05b 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/Call.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/Call.kt @@ -48,7 +48,11 @@ internal fun TsExprResolver.handleInstanceCall( val instance = resolve(expr.instance) ?: return null // Resolve arguments. - val args = expr.args.map { resolve(it) ?: return null } + val args = mutableListOf>() + for (argument in expr.args) { + val resolved = resolve(argument) ?: return null + args += resolved + } // Call. callInstanceMethod(scope, expr, instance, args) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsCapturedInstanceCall.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsCapturedInstanceCall.kt index b7169cb3ab..c7da5d879a 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsCapturedInstanceCall.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsCapturedInstanceCall.kt @@ -121,7 +121,11 @@ internal fun TsExprResolver.handleCapturedInstanceCall( TsExprApproximationResult.NoApproximation -> {} } - val args = call.args.map { resolve(it) ?: return null } + val args = mutableListOf>() + for (argument in call.args) { + val resolved = resolve(argument) ?: return null + args += resolved + } callInstanceMethod( scope = scope, call = instanceCall, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt index 13eee9cf95..b112512c5f 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt @@ -1336,9 +1336,11 @@ class TsExprResolver( ) return null } - val resolvedArgs = buildList { - callee.closure?.let(::add) - expr.args.mapTo(this) { resolve(it) ?: return null } + val resolvedArgs = mutableListOf>() + callee.closure?.let(resolvedArgs::add) + for (argument in expr.args) { + val resolved = resolve(argument) ?: return null + resolvedArgs += resolved } when (val approximation = tryApproximateResolvedFunctionCall(callee.method)) { From a0225d14a2d2a3ab382aed6f7e44fb1ec0a0bd40 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 23:12:34 +0300 Subject: [PATCH 36/97] Clarify Calls source root containment checks --- .../src/main/kotlin/org/usvm/ts/calls/CallsSourceProject.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceProject.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceProject.kt index 04dbb39303..6c51710a45 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceProject.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceProject.kt @@ -47,9 +47,11 @@ internal fun loadCallsSourceProject(sourceRoot: Path, source: Path): CallsSource ) } + val realSourceRoot = sourceRoot.toRealPath() val sources = closure.files.associateWith { relative -> val path = sourceRoot.resolve(relative).normalize() - require(path.startsWith(sourceRoot) && path.toRealPath().startsWith(sourceRoot.toRealPath())) { + val isWithinFrozenRoot = path.startsWith(sourceRoot) && path.toRealPath().startsWith(realSourceRoot) + require(isWithinFrozenRoot) { "Dependency source escapes its frozen root: $relative" } Files.readString(path) From 45ea7cfda044f17db8aef7576d5a3ceea9adee42 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 23:30:54 +0300 Subject: [PATCH 37/97] Revoke dense array proofs after own-slot deletion --- .../org/usvm/util/ArrayIndexPresence.kt | 8 ++++ .../main/kotlin/org/usvm/util/ArrayStorage.kt | 2 +- .../machine/call/TsGapModelsSourceTest.kt | 39 +++++++++++++++++++ .../test/resources/models/GapModelSource.ts | 20 ++++++++++ 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/ArrayIndexPresence.kt b/usvm-ts/src/main/kotlin/org/usvm/util/ArrayIndexPresence.kt index 03450edb83..cf9b1dfb61 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/ArrayIndexPresence.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/ArrayIndexPresence.kt @@ -34,6 +34,14 @@ internal fun TsState.writtenArrayIndexPresence(array: UHeapRef, index: UExpr) = with(ctx) { + // Deletion can create a hole without changing length or payload storage. + if (array is UConcreteHeapRef) { + denseInputArrays.remove(array) + } else { + // A symbolic receiver can alias any recorded dense array. + denseInputArrays.clear() + } + val initialized = initializedArrayIndices[array].orEmpty() initializedArrayIndices += array to initialized.mapValues { (writtenIndex, present) -> mkAnd(present, mkNot(mkEq(index, writtenIndex))) diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/ArrayStorage.kt b/usvm-ts/src/main/kotlin/org/usvm/util/ArrayStorage.kt index 54ea54872d..94660d13e4 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/ArrayStorage.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/ArrayStorage.kt @@ -124,7 +124,7 @@ internal fun TsState.isUnmodifiedDenseInputArray( memory.getRegion(elementRegionId) === snapshot.elementRegion } -/** In-range payload writes preserve density; unsupported deletions stop before stale values can escape. */ +/** In-range payload writes preserve density; own-slot deletion revokes this proof. */ internal fun TsState.hasDenseArrayShape(array: UConcreteHeapRef, type: EtsArrayType): Boolean = with(ctx) { val snapshot = denseInputArrays[array] ?: return false if (snapshot.type != type) return false diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsGapModelsSourceTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsGapModelsSourceTest.kt index 3310f542df..f726b1e785 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsGapModelsSourceTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsGapModelsSourceTest.kt @@ -291,6 +291,45 @@ class TsGapModelsSourceTest : TsMethodTestRunner() { assertTrue(limitations.any { it.reason == TsRuntimeFeatureLimitationReason.ARRAY_ELEMENT_DELETE }) } + @Test + fun `own slot deletion revokes dense reduction and slice models`() { + discoverProperties( + method = method("splitReverseKeepsDenseValues"), + { result -> result.value }, + invariants = arrayOf({ result -> result.value }), + ) + + for (name in listOf("reduceAfterDenseSlotDeletion", "sliceAfterDenseSlotDeletion")) { + val events = mutableListOf() + val limitations = mutableListOf() + val observer = object : TsInterpreterObserver { + override fun onUnknownCall(event: TsUnknownCallEvent) { + events += event + } + + override fun onRuntimeFeatureLimitation(event: TsRuntimeFeatureLimitationEvent) { + limitations += event + } + } + + val analysis = TsMachine(scene = scene, options = options, tsOptions = tsOptions, observer = observer) + .use { machine -> machine.analyzeWithOutcome(listOf(method(name))) } + val residualMethod = if (name == "reduceAfterDenseSlotDeletion") "reduce" else "slice" + val residual = events.any { event -> + event.callee.name == residualMethod && event.outcome == TsUnknownCallOutcome.PATH_STOPPED + } + val reversed = events.any { event -> + event.callee.name == "reverse" && event.decision is TsUnknownCallDecision.ModelApplied + } + + assertEquals(TsAnalysisStopReason.EXHAUSTED, analysis.stopReason, name) + assertTrue(analysis.states.isEmpty(), name) + assertTrue(reversed, "$name: $events") + assertTrue(residual, "$name: $events") + assertTrue(limitations.isEmpty(), "$name: $limitations") + } + } + @Test fun `generated model witnesses replay in JavaScript`() { val methodNames = listOf( diff --git a/usvm-ts/src/test/resources/models/GapModelSource.ts b/usvm-ts/src/test/resources/models/GapModelSource.ts index 03a01572dd..8e922596ca 100644 --- a/usvm-ts/src/test/resources/models/GapModelSource.ts +++ b/usvm-ts/src/test/resources/models/GapModelSource.ts @@ -213,4 +213,24 @@ export class GapModelSource { throw array[0]; }, 0); } + + splitReverseKeepsDenseValues(): boolean { + const values = "a,b".split(","); + values.reverse(); + return values[0] === "b" && values[1] === "a"; + } + + reduceAfterDenseSlotDeletion(): number { + const values = "a,b".split(","); + values.reverse(); + delete values[1]; + return values.reduce((count, _value) => count + 1, 0); + } + + sliceAfterDenseSlotDeletion(): string[] { + const values = "a,b".split(","); + values.reverse(); + delete values[1]; + return values.slice(); + } } From 7d3e773a0dd87ba4a5b00177828912681208162c Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 23:38:10 +0300 Subject: [PATCH 38/97] Name Calls coverage replay and universe predicates --- .../main/kotlin/org/usvm/ts/calls/CallsCoverageCell.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsCoverageCell.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsCoverageCell.kt index 80e427551e..7150445f27 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsCoverageCell.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsCoverageCell.kt @@ -170,8 +170,8 @@ internal fun runCoverageCell( search = search, replayErrors = events.count { it.error != null }, completionMismatches = events.indices.count { index -> - events[index].replay?.completion != null && - events[index].replay?.completion != search.candidates[index].completion + val replayCompletion = events[index].replay?.completion + replayCompletion != null && replayCompletion != search.candidates[index].completion }, replayElapsedMillis = events.sumOf { event -> event.replay?.replayElapsedMillis ?: 0L }, cellElapsedMillis = cellStarted.elapsedNow().inWholeMilliseconds, @@ -235,11 +235,12 @@ private fun parseCoverageCellRequest( ) require(universeRecord.identity == expectedUniverseIdentity) { "Coverage universe identity differs" } val universe = universeRecord.universe - require(source.function.sourceFile in universe.sourceFiles && universe.supportedStatementKeys.isNotEmpty()) { + val includesEntrySource = source.function.sourceFile in universe.sourceFiles + val hasSupportedStatements = universe.supportedStatementKeys.isNotEmpty() + require(includesEntrySource && hasSupportedStatements) { "Coverage universe does not include the entry source statements" } - val identity = CallsCoverageCellIdentity( schemaVersion = 3, toolRevision = runtimeToolRevision, From cfe6b2d490888f4b08fcd3edc786206a3fc08528 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 00:01:11 +0300 Subject: [PATCH 39/97] Dispatch project instance methods through their runtime hierarchy --- .../usvm/machine/expr/CallApproximations.kt | 43 ++++++++++- .../usvm/machine/interpreter/TsInterpreter.kt | 52 +++++++------- .../main/kotlin/org/usvm/util/EtsHierarchy.kt | 60 ++++++++++++++++ .../machine/call/BuiltinOwnerBoundaryTest.kt | 35 +++++++++ .../resources/models/BuiltinOwnerBoundary.ts | 71 +++++++++++++++++++ 5 files changed, 234 insertions(+), 27 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index 120776dd90..ad61f3fc74 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -5,9 +5,12 @@ import io.ksmt.utils.asExpr import mu.KotlinLogging import org.jacodb.ets.model.EtsArrayType import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsClassType import org.jacodb.ets.model.EtsInstanceCallExpr import org.jacodb.ets.model.EtsMethodSignature +import org.jacodb.ets.model.EtsRefType import org.jacodb.ets.model.EtsStringType +import org.jacodb.ets.model.EtsUnclearRefType import org.jacodb.ets.model.EtsUnknownType import org.jacodb.ets.utils.CONSTRUCTOR_NAME import org.usvm.UBoolExpr @@ -19,6 +22,7 @@ import org.usvm.api.initializeArray import org.usvm.api.makeSymbolicPrimitive import org.usvm.api.memcpy import org.usvm.api.readArrayIndex +import org.usvm.api.typeStreamOf import org.usvm.getIntValue import org.usvm.isAllocatedConcreteHeapRef import org.usvm.machine.TsRuntimeFeatureLimitationReason @@ -39,6 +43,7 @@ import org.usvm.machine.state.lastStmt import org.usvm.machine.state.newStmt import org.usvm.machine.types.TsUnresolvedArrayKind import org.usvm.sizeSort +import org.usvm.types.TypesResult import org.usvm.util.arrayStorageType import org.usvm.util.componentType import org.usvm.util.copyArrayElements @@ -142,9 +147,11 @@ internal fun TsExprResolver.tryApproximateInstanceCall( val iterator = tryApproximateBuiltinIteratorCall(stmt) if (iterator !is TsExprApproximationResult.NoApproximation) return iterator - // Mock `.toString()` method calls + // The model catalog has no toString target; project methods still need normal dispatch. if (expr.callee.name == "toString") { if (unknownCallDispatcher is TsUnknownCallModelDispatcher) { + if (hasProjectToStringTarget(stmt)) return TsExprApproximationResult.NoApproximation + dispatchLegacyInstanceCall(stmt) return TsExprApproximationResult.ResolveFailure } @@ -266,6 +273,40 @@ internal fun TsExprResolver.tryApproximateInstanceCall( return TsExprApproximationResult.NoApproximation } +private fun TsExprResolver.hasProjectToStringTarget(stmt: TsVirtualMethodCallStmt): Boolean = with(ctx) { + if (stmt.instance.sort != addressSort) return false + + val receiver = stmt.instance.asExpr(addressSort) + val concreteReceiver = isAllocatedConcreteHeapRef(receiver) + val candidateOwners = resolveEtsMethods(stmt.call.callee) + .filterNot { it.isStatic } + .mapNotNull { it.enclosingClass } + .filterTo(mutableSetOf()) { it in scene.projectClasses } + if (!concreteReceiver && !stmt.call.isSuperCall && candidateOwners.isEmpty()) return false + + val possibleTypes = scope.calcOnState { + memory.typeStreamOf(receiver).take(scene.projectAndSdkClasses.size + 1) + } + if (possibleTypes !is TypesResult.SuccessfulTypesResult) return false + + val referenceTypes = possibleTypes.types.filterIsInstance() + .filter { it is EtsClassType || it is EtsUnclearRefType } + referenceTypes.any { type -> + hierarchy.classesForType(type).any { receiverClass -> + val selectedMethod = if (stmt.call.isSuperCall) { + hierarchy.explicitSuperMethod(receiverClass, stmt.call.callee) + } else { + hierarchy.nearestInstanceMethod(receiverClass, stmt.call.callee.name) + } + val declaringClass = selectedMethod?.enclosingClass + val projectMethod = declaringClass?.let { it in scene.projectClasses } == true + val eligibleOwner = stmt.call.isSuperCall || concreteReceiver || + declaringClass?.let(candidateOwners::contains) == true + projectMethod && eligibleOwner + } + } +} + private fun TsExprResolver.handleArrayIndexSearchCall( stmt: TsVirtualMethodCallStmt, instanceType: EtsArrayType, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt index eaabec428f..a437105718 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt @@ -7,6 +7,7 @@ import org.jacodb.ets.model.EtsArrayAccess import org.jacodb.ets.model.EtsArrayType import org.jacodb.ets.model.EtsAssignStmt import org.jacodb.ets.model.EtsCallStmt +import org.jacodb.ets.model.EtsClass import org.jacodb.ets.model.EtsClassType import org.jacodb.ets.model.EtsClosureFieldRef import org.jacodb.ets.model.EtsCopyDataPropertiesStmt @@ -199,6 +200,12 @@ class TsInterpreter( val instance = stmt.instance val callee = stmt.call.callee + fun selectedMethod(receiverClass: EtsClass): EtsMethod? = if (stmt.call.isSuperCall) { + graph.hierarchy.explicitSuperMethod(receiverClass, callee) + } else { + graph.hierarchy.nearestInstanceMethod(receiverClass, callee.name) + } + if (instance.sort == addressSort) { checkUndefinedOrNullPropertyRead(scope, instance.asExpr(addressSort), callee.name) ?: return } @@ -227,7 +234,11 @@ class TsInterpreter( if (isAllocatedConcreteHeapRef(receiver)) { val field = mkFieldLValue(addressSort, receiver, callee.name) - val functionRef = if (stmt.targetCaptured) null else scope.calcOnState { memory.read(field) } + val functionRef = if (stmt.targetCaptured || stmt.call.isSuperCall) { + null + } else { + scope.calcOnState { memory.read(field) } + } val function = if (functionRef != null && isAllocatedConcreteHeapRef(functionRef)) { scope.calcOnState { associatedFunction[functionRef] } } else { @@ -270,15 +281,8 @@ class TsInterpreter( logger.warn { "Multiple (${classes.size}) classes with name '${type.typeName}'" } // scope.assert(falseExpr) // return - for (cls in classes) { - val suitableMethods = cls.methods.filter { !it.isStatic && it.name == callee.name } - concreteMethods += suitableMethods - } - } else { - val cls = classes.single() - val suitableMethods = cls.methods.filter { !it.isStatic && it.name == callee.name } - concreteMethods += suitableMethods } + concreteMethods += classes.mapNotNull(::selectedMethod) } else { logger.warn { "Could not resolve method: $callee on type: $type" @@ -287,7 +291,12 @@ class TsInterpreter( return } } else { - val methods = resolveEtsMethods(callee).filterNot { it.isStatic } + val methods = if (stmt.call.isSuperCall) { + graph.hierarchy.classesForType(EtsClassType(callee.enclosingClass)) + .mapNotNull { graph.hierarchy.nearestInstanceMethod(it, callee.name) } + } else { + resolveEtsMethods(callee).filterNot { it.isStatic } + } if (methods.isEmpty()) { if (callee.name !in listOf("then")) { logger.warn { "Could not resolve method: $callee" } @@ -327,17 +336,10 @@ class TsInterpreter( // val chosenMethods = intersectedTypes.flatMap { clazz -> - graph.hierarchy.classesForType(clazz as EtsRefType) - .asSequence() - // TODO wrong order, ancestors are unordered - .map { candidate -> graph.hierarchy.getAncestors(candidate) } - .mapNotNull { ancestors -> - ancestors.firstOrNull { ancestor -> - ancestor.methods.any { method -> !method.isStatic && method.name == callee.name } - } - } - .map { ancestor -> clazz to ancestor.methods.first { method -> !method.isStatic && method.name == callee.name } } - }.toList().take(10) // TODO check it + graph.hierarchy.classesForType(clazz as EtsRefType).mapNotNull { candidate -> + selectedMethod(candidate)?.let { clazz to it } + } + }.take(10) // TODO check it // logger.info { // "Preparing to fork on ${limitedConcreteMethods.size} methods out of ${concreteMethods.size}: ${ @@ -345,17 +347,15 @@ class TsInterpreter( // }" // } - val conditionsWithBlocks = chosenMethods.mapIndexed { i, (clazz, method) -> + val conditionsWithBlocks = chosenMethods.map { (clazz, method) -> val concreteCall = stmt.toConcrete(method) val block = { state: TsState -> state.newStmt(concreteCall) } - val type = requireNotNull(method.enclosingClass).type val constraint = scope.calcOnState { - // TODO mistake, should be separated into several hierarchies - // or evalTypeEqual with several concrete types + // Select one runtime class even when the chosen method is declared by an ancestor. mkAnd( memory.types.evalIsSubtype(receiver, clazz), - memory.types.evalIsSupertype(receiver, type) + memory.types.evalIsSupertype(receiver, clazz), ) } constraint to block diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/EtsHierarchy.kt b/usvm-ts/src/main/kotlin/org/usvm/util/EtsHierarchy.kt index a4a9ed9dcf..408f781dc9 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/EtsHierarchy.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/EtsHierarchy.kt @@ -6,6 +6,8 @@ import org.jacodb.ets.model.EtsClass import org.jacodb.ets.model.EtsClassSignature import org.jacodb.ets.model.EtsClassType import org.jacodb.ets.model.EtsFileSignature +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsMethodSignature import org.jacodb.ets.model.EtsRefType import org.jacodb.ets.model.EtsScene import org.jacodb.ets.model.EtsUnclearRefType @@ -92,6 +94,64 @@ class EtsHierarchy(private val scene: EtsScene) { } } + /** Finds the closest instance declaration, preferring the class chain over interface declarations. */ + fun nearestInstanceMethod(clazz: EtsClass, methodName: String): EtsMethod? { + val classes = ArrayDeque() + val interfaces = ArrayDeque() + val visited = mutableSetOf(clazz) + classes.addLast(clazz) + + fun enqueue(target: ArrayDeque, candidates: Collection) { + for (candidate in candidates.sortedBy { it.signature.toString() }) { + if (visited.add(candidate)) target.addLast(candidate) + } + } + + while (classes.isNotEmpty()) { + val current = classes.removeFirst() + val method = current.methods.firstOrNull { !it.isStatic && it.name == methodName } + if (method != null) return method + + val superClasses = current.superClass?.let(::resolveClassesBySignature).orEmpty() + enqueue(classes, superClasses) + + val implementedInterfaces = current.implementedInterfaces.flatMap(::resolveClassesBySignature) + enqueue(interfaces, implementedInterfaces) + } + + val objectMethod = scene.sdkClasses.asSequence() + .filter { it.name == "Object" } + .sortedBy { it.signature.toString() } + .mapNotNull { sdkObject -> + sdkObject.methods.firstOrNull { !it.isStatic && it.name == methodName } + } + .firstOrNull() + if (objectMethod != null) return objectMethod + + while (interfaces.isNotEmpty()) { + val current = interfaces.removeFirst() + val method = current.methods.firstOrNull { !it.isStatic && it.name == methodName } + if (method != null) return method + + val parents = current.implementedInterfaces.flatMap(::resolveClassesBySignature) + enqueue(interfaces, parents) + } + + return null + } + + /** Explicit `super.m()` starts lookup at the declared superclass, not the receiver's override. */ + fun explicitSuperMethod(receiverClass: EtsClass, callee: EtsMethodSignature): EtsMethod? { + val ancestors = getAncestors(receiverClass) - receiverClass + val declaredSuperclasses = classesForType(EtsClassType(callee.enclosingClass)) + + return declaredSuperclasses.asSequence() + .filter(ancestors::contains) + .sortedBy { it.signature.toString() } + .mapNotNull { nearestInstanceMethod(it, callee.name) } + .firstOrNull() + } + fun getInheritors(clazz: EtsClass): Set { return inheritors[clazz] ?: run { error("TODO") diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/BuiltinOwnerBoundaryTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/BuiltinOwnerBoundaryTest.kt index eed868523c..00e3d1b264 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/BuiltinOwnerBoundaryTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/BuiltinOwnerBoundaryTest.kt @@ -57,4 +57,39 @@ class BuiltinOwnerBoundaryTest : TsMethodTestRunner() { invariants = arrayOf({ result -> !result.value }), ) } + + @Test + fun `project toString methods execute through direct inherited overridden and any references`() { + val methods = listOf("declaredToString", "inheritedToString", "overriddenToString", "anyReferenceToString") + + methods.forEach { methodName -> + discoverProperties( + method = getMethod(methodName = methodName, className = "BuiltinOwnerBoundary"), + { result -> result.number == 1.0 }, + invariants = arrayOf({ result -> result.number == 1.0 }), + ) + } + } + + @Test + fun `ordinary inherited instance method executes the base body`() { + discoverProperties( + method = getMethod(methodName = "inheritedOrdinaryMethod", className = "BuiltinOwnerBoundary"), + { result -> result.number == 7.0 }, + invariants = arrayOf({ result -> result.number == 7.0 }), + ) + } + + @Test + fun `explicit super calls use the base body while base typed virtual calls use the override`() { + val methods = listOf("explicitSuperCall", "baseTypedVirtualCall") + + methods.forEach { methodName -> + discoverProperties( + method = getMethod(methodName = methodName, className = "BuiltinOwnerBoundary"), + { result -> result.number == 12.0 }, + invariants = arrayOf({ result -> result.number == 12.0 }), + ) + } + } } diff --git a/usvm-ts/src/test/resources/models/BuiltinOwnerBoundary.ts b/usvm-ts/src/test/resources/models/BuiltinOwnerBoundary.ts index 67af2e6f3e..25870a99e5 100644 --- a/usvm-ts/src/test/resources/models/BuiltinOwnerBoundary.ts +++ b/usvm-ts/src/test/resources/models/BuiltinOwnerBoundary.ts @@ -22,4 +22,75 @@ export class BuiltinOwnerBoundary { return numeric.isInteger(1.5); } + + declaredToString(): number { + const receiver = new DeclaredStringifier(); + + return receiver.toString() === "declared" ? 1 : 0; + } + + inheritedToString(): number { + const receiver = new InheritedStringifier(); + + return receiver.toString() === "declared" ? 1 : 0; + } + + overriddenToString(): number { + const receiver = new OverrideStringifier(); + + return receiver.toString() === "override" ? 1 : 0; + } + + anyReferenceToString(): number { + const receiver: any = new DeclaredStringifier(); + + return receiver.toString() === "declared" ? 1 : 0; + } + + inheritedOrdinaryMethod(): number { + const receiver = new InheritedStringifier(); + + return receiver.baseValue(); + } + + explicitSuperCall(): number { + return new SuperDispatchChild().value(); + } + + baseTypedVirtualCall(): number { + const receiver: SuperDispatchBase = new SuperDispatchChild(); + + return receiver.value(); + } +} + +class DeclaredStringifier { + toString(): string { + return "declared"; + } + + baseValue(): number { + return 7; + } +} + +class InheritedStringifier extends DeclaredStringifier { +} + +class OverrideStringifier extends DeclaredStringifier { + toString(): string { + return "override"; + } +} + +class SuperDispatchBase { + value(): number { + return 5; + } +} + +class SuperDispatchChild extends SuperDispatchBase { + value(): number { + return super.value() + 7; + } } From baef1e55fba93b7b7007fac551d9b79527ae940d Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 00:05:02 +0300 Subject: [PATCH 40/97] Respect lexical owners when resolving static calls --- .../org/usvm/machine/expr/CallStatic.kt | 24 ++++-- .../machine/expr/CallStaticApproximations.kt | 23 ++++-- .../usvm/baseline/CallFallbackBaselineTest.kt | 77 ++++++++++++++++++- .../org/usvm/samples/lang/StaticOverloads.kt | 33 ++++++++ .../baseline/CallFallbackBaseline.ts | 21 +++++ .../resources/samples/lang/StaticOverloads.ts | 24 ++++++ 6 files changed, 188 insertions(+), 14 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallStatic.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallStatic.kt index 8a39388230..ed31e5b655 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallStatic.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallStatic.kt @@ -95,9 +95,13 @@ private fun TsExprResolver.resolveStaticMethod( return TsResolutionResult.create(methods) } - // Unknown signature: - val methods = ctx.applicationAndSdkClasses - .flatMap { it.methods } + // Legacy unknown-owner calls may still name top-level functions in the source file. + // A fully unresolved bare call has no binding to an SDK file or namespace. + val currentFile = scope.calcOnState { lastEnteredMethod.enclosingClass?.declaringFile } + val sourceDefaultClass = currentFile?.classes?.firstOrNull { + it.name == DEFAULT_ARK_CLASS_NAME && it.signature.namespace == null + } + val methods = sourceDefaultClass?.methods.orEmpty() .filter { it.isStaticCallTarget(method.name) } .canonicalizeExecutableOverloads() @@ -113,7 +117,12 @@ private fun TsExprResolver.processAmbiguousStaticMethod( resolved: TsResolutionResult.Ambiguous, expr: EtsStaticCallExpr, ) { - val args = expr.args.map { resolve(it) ?: return } + val args = mutableListOf>() + for (argument in expr.args) { + val resolvedArgument = resolve(argument) ?: return + args += resolvedArgument + } + val staticProperties = resolved.properties.take(Constants.STATIC_METHODS_FORK_LIMIT) val staticInstances = scope.calcOnState { staticProperties.map { getStaticInstance(it.enclosingClass!!) } @@ -142,7 +151,12 @@ private fun TsExprResolver.processUniqueStaticMethod( val instance = scope.calcOnState { getStaticInstance(resolved.property.enclosingClass!!) } - val args = expr.args.map { resolve(it) ?: return } + val args = mutableListOf>() + for (argument in expr.args) { + val resolvedArgument = resolve(argument) ?: return + args += resolvedArgument + } + val concreteCall = TsConcreteMethodCallStmt( callee = resolved.property, call = expr, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallStaticApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallStaticApproximations.kt index 93bf34af1f..f673f934a2 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallStaticApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallStaticApproximations.kt @@ -3,24 +3,28 @@ package org.usvm.machine.expr import org.jacodb.ets.model.EtsMethod import org.jacodb.ets.model.EtsStaticCallExpr import org.jacodb.ets.model.EtsValue +import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME +import org.jacodb.ets.utils.UNKNOWN_CLASS_NAME import org.usvm.UExpr import org.usvm.machine.expr.TsExprApproximationResult.Companion.from internal fun TsExprResolver.tryApproximateStaticCall( expr: EtsStaticCallExpr, ): TsExprApproximationResult { - // Mock `$r` calls - if (expr.callee.name == "\$r") { + val owner = expr.callee.enclosingClass + val ownerName = owner.name + val isUnresolvedGlobal = ownerName == UNKNOWN_CLASS_NAME + val isTopLevelFunction = ownerName == DEFAULT_ARK_CLASS_NAME && owner.namespace == null + + if (expr.callee.name == "\$r" && (isUnresolvedGlobal || isTopLevelFunction)) { return from(handleR()) } - // Handle `Number(...)` calls - if (expr.callee.name == "Number") { + if (expr.callee.name == "Number" && isUnresolvedGlobal) { return from(handleNumberConverter(expr.args)) } - // Handle `Boolean(...)` calls - if (expr.callee.name == "Boolean") { + if (expr.callee.name == "Boolean" && isUnresolvedGlobal) { return from(handleBooleanConverter(expr)) } @@ -28,8 +32,11 @@ internal fun TsExprResolver.tryApproximateStaticCall( } internal fun TsExprResolver.tryApproximateResolvedFunctionCall(method: EtsMethod): TsExprApproximationResult { - // Match the selected function, not its mutable source binding or alias name. - if (method.name == "\$r") { + // Match the selected top-level function, not its mutable source binding or alias name. + val owner = method.enclosingClass?.signature + val isTopLevelFunction = owner?.name == DEFAULT_ARK_CLASS_NAME && owner?.namespace == null + val isResourceLookup = method.name == "\$r" && isTopLevelFunction + if (isResourceLookup) { return from(handleR()) } diff --git a/usvm-ts/src/test/kotlin/org/usvm/baseline/CallFallbackBaselineTest.kt b/usvm-ts/src/test/kotlin/org/usvm/baseline/CallFallbackBaselineTest.kt index b5353e7a8b..ceeef3f3da 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/baseline/CallFallbackBaselineTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/baseline/CallFallbackBaselineTest.kt @@ -6,8 +6,10 @@ import org.jacodb.ets.model.EtsMethod import org.jacodb.ets.model.EtsPtrCallExpr import org.jacodb.ets.model.EtsReturnStmt import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.model.EtsStaticCallExpr import org.jacodb.ets.model.EtsStmt import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME import org.jacodb.ets.utils.callExpr import org.jacodb.ets.utils.loadEtsFileAutoConvert import org.junit.jupiter.api.Test @@ -19,7 +21,11 @@ import org.usvm.api.targets.TsReachabilityTarget import org.usvm.machine.TsMachine import org.usvm.machine.TsOptions import org.usvm.machine.call.TsCompatibilityUnknownCallDispatcher +import org.usvm.machine.call.TsUnknownCall +import org.usvm.machine.call.TsUnknownCallDispatcher +import org.usvm.machine.call.TsUnknownCallFailureReason import org.usvm.util.getResourcePath +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNotNull @@ -54,6 +60,56 @@ class CallFallbackBaselineTest { assertFalse(reachesReturn("unresolvedStaticCallPrunes", scene)) } + @Test + fun `an unresolved bare call does not execute a same named class static`() { + val call = callExpression("unresolvedBareCallDoesNotUseClassStatic") + val classReturn = returnStatement(fullScene, "missingBareCall", className = "UnrelatedStatic") + val reached = assertUnknownStaticDispatch("unresolvedBareCallDoesNotUseClassStatic") + + assertIs(call) + assertFalse(classReturn in reached) + assertFalse(returnStatement(fullScene, "unresolvedBareCallDoesNotUseClassStatic") in reached) + } + + @Test + fun `an unresolved bare call does not execute a same named SDK namespace function`() { + val sdkFile = loadEtsFileAutoConvert( + getResourcePath("/samples/lang/StaticOverloads.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + val scene = EtsScene(projectFiles = listOf(sourceFile), sdkFiles = listOf(sdkFile)) + val call = callExpression("unresolvedBareCallDoesNotUseSdkNamespace") + val namespaceMethods = sdkFile.namespaces.flatMap { namespace -> + namespace.allClasses.flatMap { clazz -> clazz.methods } + } + + val reached = assertUnknownStaticDispatch("unresolvedBareCallDoesNotUseSdkNamespace", scene) + + assertTrue(namespaceMethods.any { it.name == "missingNamespaceCall" }) + assertIs(call) + assertFalse(returnStatement(scene, "unresolvedBareCallDoesNotUseSdkNamespace") in reached) + } + + @Test + fun `an unresolved bare call does not execute an SDK module export`() { + val sdkFile = loadEtsFileAutoConvert( + getResourcePath("/samples/lang/StaticOverloads.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + val scene = EtsScene(projectFiles = listOf(sourceFile), sdkFiles = listOf(sdkFile)) + val call = callExpression("unresolvedBareCallDoesNotUseSdkModule") + val rootMethods = sdkFile.classes + .single { it.name == DEFAULT_ARK_CLASS_NAME } + .methods + + val reached = assertUnknownStaticDispatch("unresolvedBareCallDoesNotUseSdkModule", scene) + + assertTrue(sdkFile.exportInfos.any { it.name == "missingModuleCall" }) + assertTrue(rootMethods.any { it.name == "missingModuleCall" }) + assertIs(call) + assertFalse(returnStatement(scene, "unresolvedBareCallDoesNotUseSdkModule") in reached) + } + @Test fun `an unresolved virtual call prunes the state`() { val scene = sceneWithout("ExternalReceiver") @@ -190,6 +246,7 @@ class CallFallbackBaselineTest { methodName: String, scene: EtsScene = fullScene, tsOptions: TsOptions = TsOptions(), + unknownCallDispatcher: TsUnknownCallDispatcher = TsCompatibilityUnknownCallDispatcher, ): Set { val method = method(scene, methodName) val returnStatement = returnStatement(scene, methodName) @@ -201,13 +258,31 @@ class CallFallbackBaselineTest { options = machineOptions, tsOptions = tsOptions, machineObserver = ReachabilityObserver(), - unknownCallDispatcher = TsCompatibilityUnknownCallDispatcher, + unknownCallDispatcher = unknownCallDispatcher, ).use { machine -> machine.analyze(listOf(method), listOf(initialTarget)) .flatMapTo(mutableSetOf()) { state -> state.pathNode.allStatements } } } + private fun assertUnknownStaticDispatch( + methodName: String, + scene: EtsScene = fullScene, + ): Set { + val calls = mutableListOf() + val dispatcher = TsUnknownCallDispatcher { scope, call -> + calls += call + TsCompatibilityUnknownCallDispatcher.dispatch(scope, call) + } + + val reached = reachedStatements(methodName, scene, unknownCallDispatcher = dispatcher) + + val dispatched = calls.single() + assertEquals(TsUnknownCallFailureReason.STATIC_METHOD_NOT_FOUND, dispatched.failureReason) + assertEquals(callExpression(methodName), dispatched.callSite.callExpr) + return reached + } + private fun returnStatement( scene: EtsScene, methodName: String, diff --git a/usvm-ts/src/test/kotlin/org/usvm/samples/lang/StaticOverloads.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/lang/StaticOverloads.kt index 812e042748..fef288afdf 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/samples/lang/StaticOverloads.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/samples/lang/StaticOverloads.kt @@ -47,6 +47,39 @@ class StaticOverloads : TsMethodTestRunner() { ) } + @Test + fun `class static Number executes its own body`() { + val method = getMethod(methodName = "callStaticNamedNumber") + + discoverProperties( + method = method, + { result -> result eq 42 }, + invariants = arrayOf({ result -> result eq 42 }), + ) + } + + @Test + fun `class static Boolean executes its own body`() { + val method = getMethod(methodName = "callStaticNamedBoolean") + + discoverProperties( + method = method, + { result -> result eq 53 }, + invariants = arrayOf({ result -> result eq 53 }), + ) + } + + @Test + fun `class static resource name executes its own body`() { + val method = getMethod(methodName = "callStaticNamedResourceLookup") + + discoverProperties( + method = method, + { result -> result eq 64 }, + invariants = arrayOf({ result -> result eq 64 }), + ) + } + @Test fun `test static overload group does not fork equivalent states`() { var forkedStates = 0 diff --git a/usvm-ts/src/test/resources/baseline/CallFallbackBaseline.ts b/usvm-ts/src/test/resources/baseline/CallFallbackBaseline.ts index f0d6d4ba48..c61140c87c 100644 --- a/usvm-ts/src/test/resources/baseline/CallFallbackBaseline.ts +++ b/usvm-ts/src/test/resources/baseline/CallFallbackBaseline.ts @@ -9,6 +9,12 @@ declare class ExternalStatic { static external(): void; } +class UnrelatedStatic { + static missingBareCall(): number { + return 127; + } +} + declare class ExternalOverloads { static convert(value: number): number; static convert(value: string): string; @@ -136,6 +142,21 @@ class CallFallbackBaseline { return 104; } + unresolvedBareCallDoesNotUseClassStatic(): number { + missingBareCall(); + return 128; + } + + unresolvedBareCallDoesNotUseSdkNamespace(): number { + missingNamespaceCall(); + return 136; + } + + unresolvedBareCallDoesNotUseSdkModule(): number { + missingModuleCall(); + return 138; + } + unresolvedVirtualCallPrunes(receiver: ExternalReceiver): number { receiver.external(); return 105; diff --git a/usvm-ts/src/test/resources/samples/lang/StaticOverloads.ts b/usvm-ts/src/test/resources/samples/lang/StaticOverloads.ts index 4ce1f340bd..cfaf265f76 100644 --- a/usvm-ts/src/test/resources/samples/lang/StaticOverloads.ts +++ b/usvm-ts/src/test/resources/samples/lang/StaticOverloads.ts @@ -21,6 +21,18 @@ class MethodKindsChild extends StaticDeclarationFirst { callParentMethod(): number { return super.method(); } } +class NamedBuiltinStatics { + static Number(value: number): number { return value + 40; } + static Boolean(value: number): number { return value + 50; } + static $r(value: number): number { return value + 60; } +} + +namespace SdkNamespaceCollision { + export function missingNamespaceCall(): number { return 135; } +} + +export function missingModuleCall(): number { return 137; } + class StaticOverloads { callOverloaded(): number { return overloaded(41); @@ -33,4 +45,16 @@ class StaticOverloads { callSuperWithSameNameStatic(): number { return new MethodKindsChild().callParentMethod(); } + + callStaticNamedNumber(): number { + return NamedBuiltinStatics.Number(2); + } + + callStaticNamedBoolean(): number { + return NamedBuiltinStatics.Boolean(3); + } + + callStaticNamedResourceLookup(): number { + return NamedBuiltinStatics.$r(4); + } } From bc9376a0c93368034e48e204858fb64665e823ca Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 00:16:32 +0300 Subject: [PATCH 41/97] Simplify global and local register operations --- .../src/main/kotlin/org/usvm/machine/expr/ReadGlobal.kt | 4 ---- .../src/main/kotlin/org/usvm/machine/expr/WriteGlobal.kt | 4 ---- .../src/main/kotlin/org/usvm/machine/expr/WriteLocal.kt | 7 ++----- 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadGlobal.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadGlobal.kt index ea37d294c5..f2c3aa40e5 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadGlobal.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadGlobal.kt @@ -15,10 +15,8 @@ fun TsContext.readGlobal( file: EtsFile, name: String, ): UExpr<*>? = scope.calcOnState { - // Initialize globals in `file` if necessary ensureGlobalsInitialized(scope, file) ?: return@calcOnState null - // Restore the sort of the requested global variable val savedSort = getSortForDfltObjectField(file, name) if (savedSort == null) { // No saved sort means this variable was never assigned to, which is an error to read. @@ -27,10 +25,8 @@ fun TsContext.readGlobal( return@calcOnState null } - // Get the globals container object val dfltObject = getDfltObject(file) - // Read the global variable as a field of the globals container object val lValue = mkFieldLValue(savedSort, dfltObject, name) memory.read(lValue) } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteGlobal.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteGlobal.kt index 4a97d68828..886b425874 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteGlobal.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteGlobal.kt @@ -14,16 +14,12 @@ fun TsContext.writeGlobal( name: String, expr: UExpr<*>, ): Unit? = scope.calcOnState { - // Initialize globals in `file` if necessary ensureGlobalsInitialized(scope, file) ?: return@calcOnState null - // Get the globals container object val dfltObject = getDfltObject(file) - // Write the global variable as a field of the globals container object val lValue = mkFieldLValue(expr.sort, dfltObject, name) memory.write(lValue, expr.cast(), guard = trueExpr) - // Save the sort of the global variable for future reads saveSortForDfltObjectField(file, name, expr.sort) } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteLocal.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteLocal.kt index 6b07e6fca8..5f60aeb395 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteLocal.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteLocal.kt @@ -13,9 +13,7 @@ private val logger = KotlinLogging.logger {} internal fun TsExprResolver.handleAssignToLocal( local: EtsLocal, expr: UExpr<*>, -): Unit? = with(ctx) { - return assignToLocal(scope, local, expr) -} +): Unit? = ctx.assignToLocal(scope, local, expr) fun TsContext.assignToLocal( scope: TsStepScope, @@ -26,16 +24,15 @@ fun TsContext.assignToLocal( val idx = getLocalIdx(local, currentMethod) - // If local is found in the current method: if (idx != null) { return scope.doWithState { saveSortForLocal(idx, expr.sort) + val lValue = mkRegisterStackLValue(expr.sort, idx) memory.write(lValue, expr.cast(), guard = trueExpr) } } - // Local not found, probably a global val file = currentMethod.enclosingClass!!.declaringFile!! logger.warn { "Assigning to a global variable: ${local.name} in $file" From 9779301be68a67a6c68e92b33aff568153758dc7 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 00:19:19 +0300 Subject: [PATCH 42/97] Verify frozen source checkouts throughout legacy Calls runs --- .../org/usvm/ts/calls/CallsExperiment.kt | 16 +++- .../ts/calls/CurrentTsCallsSymbolicEngine.kt | 10 ++- .../org/usvm/ts/calls/CallsExperimentTest.kt | 82 +++++++++++++++++++ 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt index 47f1b4b97d..4f608c5b3f 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt @@ -288,6 +288,7 @@ internal class CallsExperimentRunner( private val targetReplayer: CallsTargetReplayer, private val runtimeToolRevision: String = CallsBuildIdentity.toolRevision, private val eventRecordWriter: ((Path, CallsRawRecord) -> Unit)? = null, + private val sourceCheckoutVerifier: (Path, String) -> Unit = ::verifyCallsGitCheckout, ) { fun run( manifest: CallsExperimentManifest, @@ -354,6 +355,12 @@ internal class CallsExperimentRunner( resultRows = resultRows, ), ) + + manifest.projects.forEach { project -> + val sourceRoot = manifestDirectory.resolve(project.sourceRoot).normalize().toRealPath() + sourceCheckoutVerifier(sourceRoot, project.revision) + } + Files.move( partialOutput, rawOutput, @@ -421,6 +428,8 @@ internal class CallsExperimentRunner( profile: CallsExperimentProfile, appendUnknownCall: (CallsRawRecord) -> Unit, ): CallsTargetResult { + sourceCheckoutVerifier(sourceRoot, project.revision) + val request = CallsSymbolicSearchRequest( sourceRoot = sourceRoot, project = project, @@ -449,14 +458,19 @@ internal class CallsExperimentRunner( ), ) eventWrites.requireComplete() + sourceCheckoutVerifier(sourceRoot, project.revision) + val replay = symbolic.inputs?.let { inputs -> - targetReplayer.replay( + val replayResult = targetReplayer.replay( sourceRoots = listOf(sourceRoot), entryPoint = function.entryPoint, inputs = inputs, target = target, timeoutMillis = manifest.perTargetBudgetMillis, ) + sourceCheckoutVerifier(sourceRoot, project.revision) + + replayResult } return CallsTargetResult( diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt index 1e1375a164..9fbf3d7c67 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt @@ -305,11 +305,12 @@ internal class CurrentTsCallsSymbolicEngine( @Suppress("LongMethod") private fun prepareFunctionSupported(request: CallsFunctionPreflightRequest): CallsFunctionPreparation { - callsSymbolicInputPreflight(request.function.inputs)?.let { diagnostic -> + val inputDomainDiagnostic = callsSymbolicInputPreflight(request.function.inputs) + if (inputDomainDiagnostic != null) { return CallsFunctionPreparation.Rejected( status = CallsSymbolicStatus.UNSUPPORTED, reasonCode = CallsSymbolicPreflightReasonCode.INPUT_DOMAIN_UNSUPPORTED, - diagnostic = diagnostic, + diagnostic = inputDomainDiagnostic, ) } @@ -441,11 +442,12 @@ internal class CurrentTsCallsSymbolicEngine( val method = function.method val source = request.sourceRoot.resolve(request.function.sourceFile).normalize() val sourceText = java.nio.file.Files.readString(source) - callsTargetCoordinateDiagnostic(source = sourceText, target = request.target)?.let { diagnostic -> + val targetCoordinateDiagnostic = callsTargetCoordinateDiagnostic(source = sourceText, target = request.target) + if (targetCoordinateDiagnostic != null) { return CallsTargetPreparation.Rejected( status = CallsSymbolicStatus.UNMAPPED, reasonCode = CallsSymbolicPreflightReasonCode.TARGET_ORIGIN_UNMAPPED, - diagnostic = diagnostic, + diagnostic = targetCoordinateDiagnostic, ) } diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt index 3bb7032ee4..1cde9e56ba 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt @@ -24,6 +24,64 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class CallsExperimentTest { + @Test + fun `runner rejects a source edit during symbolic search before replay or publication`(@TempDir directory: Path) { + val frozenManifest = frozenGitManifest(directory) + val sourceFile = directory.resolve("source/fixture.ts") + val rawOutput = directory.resolve("results.jsonl") + val engine = CallsSymbolicEngine { + Files.writeString(sourceFile, "export function predicate(value: boolean): boolean { return !value; }") + result(status = CallsSymbolicStatus.UNREACHED) + } + + val failure = assertFailsWith { + CallsExperimentRunner( + symbolicEngine = engine, + targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> error("Replay must not run") }, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + ).run( + manifest = frozenManifest, + manifestDirectory = directory, + rawOutput = rawOutput, + ) + } + + assertTrue(failure.message.orEmpty().contains("Git diff --quiet failed")) + assertFalse(Files.exists(rawOutput)) + } + + @Test + fun `runner rejects a source edit during replay before publication`(@TempDir directory: Path) { + val frozenManifest = frozenGitManifest(directory) + val sourceFile = directory.resolve("source/fixture.ts") + val rawOutput = directory.resolve("results.jsonl") + val engine = CallsSymbolicEngine { + result( + status = CallsSymbolicStatus.REACHED, + inputs = listOf(JsConcreteValue.Boolean(value = true)), + ) + } + val replayer = CallsTargetReplayer { _, _, _, _, _ -> + Files.writeString(sourceFile, "export function predicate(value: boolean): boolean { return !value; }") + CallsSourceReplayResult(status = CallsReplayStatus.CONFIRMED) + } + + val failure = assertFailsWith { + CallsExperimentRunner( + symbolicEngine = engine, + targetReplayer = replayer, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + ).run( + manifest = frozenManifest, + manifestDirectory = directory, + rawOutput = rawOutput, + ) + } + + assertTrue(failure.message.orEmpty().contains("Git diff --quiet failed")) + assertFalse(Files.exists(rawOutput)) + } + @Test fun `runner forwards an explicitly configured solver`(@TempDir directory: Path) { val solvers = mutableListOf() @@ -37,6 +95,7 @@ class CallsExperimentTest { symbolicEngine = engine, targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> error("No witness to replay") }, runtimeToolRevision = FIXTURE_TOOL_REVISION, + sourceCheckoutVerifier = { _, _ -> }, ).run( manifest = z3Manifest, manifestDirectory = directory, @@ -74,6 +133,7 @@ class CallsExperimentTest { symbolicEngine = engine, targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> error("No witness to replay") }, runtimeToolRevision = FIXTURE_TOOL_REVISION, + sourceCheckoutVerifier = { _, _ -> }, ).run( manifest = manifest(sourceRoot = ".", seeds = listOf(17L)), manifestDirectory = directory, @@ -110,6 +170,7 @@ class CallsExperimentTest { targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> error("No witness to replay") }, runtimeToolRevision = FIXTURE_TOOL_REVISION, eventRecordWriter = { _, _ -> error("Event write failed") }, + sourceCheckoutVerifier = { _, _ -> }, ).run( manifest = manifest(sourceRoot = ".", seeds = listOf(17L)), manifestDirectory = directory, @@ -207,6 +268,7 @@ class CallsExperimentTest { symbolicEngine = engine, targetReplayer = replayer, runtimeToolRevision = FIXTURE_TOOL_REVISION, + sourceCheckoutVerifier = { _, _ -> }, ).run( manifest = manifest(sourceRoot = ".", seeds = listOf(1L)), manifestDirectory = directory, @@ -290,6 +352,7 @@ class CallsExperimentTest { CallsSourceReplayResult(status = CallsReplayStatus.CONFIRMED) }, runtimeToolRevision = FIXTURE_TOOL_REVISION, + sourceCheckoutVerifier = { _, _ -> }, ).run( manifest = frozenManifest, manifestDirectory = directory, @@ -372,6 +435,7 @@ class CallsExperimentTest { CallsSourceReplayResult(status = CallsReplayStatus.CONFIRMED) }, runtimeToolRevision = FIXTURE_TOOL_REVISION, + sourceCheckoutVerifier = { _, _ -> }, ).run( manifest = frozenManifest, manifestDirectory = directory, @@ -414,6 +478,7 @@ class CallsExperimentTest { CallsSourceReplayResult(status = CallsReplayStatus.CONFIRMED) }, runtimeToolRevision = FIXTURE_TOOL_REVISION, + sourceCheckoutVerifier = { _, _ -> }, ).run( manifest = frozenManifest, manifestDirectory = directory, @@ -479,6 +544,7 @@ class CallsExperimentTest { symbolicEngine = engine, targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> error("Replay must not run") }, runtimeToolRevision = FIXTURE_TOOL_REVISION, + sourceCheckoutVerifier = { _, _ -> }, ).run( manifest = manifest(sourceRoot = ".", seeds = listOf(0L)), manifestDirectory = directory, @@ -523,6 +589,7 @@ class CallsExperimentTest { symbolicEngine = engine, targetReplayer = replayer, runtimeToolRevision = FIXTURE_TOOL_REVISION, + sourceCheckoutVerifier = { _, _ -> }, ).run( manifest = manifest(sourceRoot = ".", seeds = listOf(0L, 3L)), manifestDirectory = directory, @@ -572,6 +639,7 @@ class CallsExperimentTest { symbolicEngine = engine, targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> error("No witness to replay") }, runtimeToolRevision = FIXTURE_TOOL_REVISION, + sourceCheckoutVerifier = { _, _ -> }, ).run( manifest = manifest(sourceRoot = ".", seeds = listOf(0L)), manifestDirectory = directory, @@ -596,6 +664,20 @@ class CallsExperimentTest { } } + private fun frozenGitManifest(directory: Path): CallsExperimentManifest { + val sourceRoot = Files.createDirectory(directory.resolve("source")) + Files.writeString(sourceRoot.resolve("fixture.ts"), "export function predicate(value: boolean): boolean { return value; }") + runCallsGit(sourceRoot, "init") + runCallsGit(sourceRoot, "config", "user.name", "USVM Tests") + runCallsGit(sourceRoot, "config", "user.email", "usvm@example.test") + runCallsGit(sourceRoot, "add", ".") + runCallsGit(sourceRoot, "commit", "-m", "Create fixture") + val revision = runCallsGit(sourceRoot, "rev-parse", "HEAD").trim() + val base = manifest(sourceRoot = "source", seeds = listOf(0L)) + + return base.copy(projects = listOf(base.projects.single().copy(revision = revision))) + } + private fun manifest(sourceRoot: String, seeds: List) = CallsExperimentManifest( schemaVersion = CallsExperimentManifest.SCHEMA_VERSION, experimentId = "fixture", From cd1500ed6270848fee59d5abd6a1fed370f9d94d Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 00:21:47 +0300 Subject: [PATCH 43/97] Clarify synthetic method parameter construction --- .../src/main/kotlin/org/usvm/util/BuildEtsMethod.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/BuildEtsMethod.kt b/usvm-ts/src/main/kotlin/org/usvm/util/BuildEtsMethod.kt index de11cc07ad..99ef0b2b0d 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/BuildEtsMethod.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/BuildEtsMethod.kt @@ -25,15 +25,19 @@ fun buildEtsMethod( signature = EtsMethodSignature( enclosingClass = enclosingClass.signature, name = name, - parameters = parameters.mapIndexed { index, (name, type) -> - EtsMethodParameter(index, name, type) + parameters = parameters.mapIndexed { index, (parameterName, type) -> + EtsMethodParameter( + index = index, + name = parameterName, + type = type, + ) }, returnType = returnType, ) ) - val prog = program(program) - val blockCfg = prog.toBlockCfg() + val dslProgram = program(program) + val blockCfg = dslProgram.toBlockCfg() val etsCfg = blockCfg.toEtsBlockCfg(method) method.body.cfg = etsCfg From 1dd8ab92fbd0658caa6c9afc3a1e8c93003205e8 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 00:27:13 +0300 Subject: [PATCH 44/97] Bound compatibility array growth and preserve legal call arities --- .../usvm/machine/expr/CallApproximations.kt | 145 ++++++++++-------- .../machine/call/TsArrayShiftReplayTest.kt | 115 ++++++++++++++ 2 files changed, 195 insertions(+), 65 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index ad61f3fc74..e28a827952 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -196,7 +196,7 @@ internal fun TsExprResolver.tryApproximateInstanceCall( // Handle 'Array.push()' method calls if (expr.callee.name == "push") { - return from(handleArrayPush(expr, instanceType, elementSort, array)) + return from(handleArrayPush(stmt, instanceType, array)) } // Handle `Array.pop() method calls @@ -211,7 +211,7 @@ internal fun TsExprResolver.tryApproximateInstanceCall( // Handle `Array.unshift() method calls if (expr.callee.name == "unshift") { - return from(handleArrayUnshift(expr, instanceType, array)) + return from(handleArrayUnshift(stmt, instanceType, array)) } // Handle `Array.shift() method calls @@ -548,42 +548,54 @@ private fun TsExprResolver.handleMathFloor(expr: EtsInstanceCallExpr): UExpr<*>? * https://tc39.es/ecma262/multipage/indexed-collections.html#sec-array.prototype.push */ private fun TsExprResolver.handleArrayPush( - expr: EtsInstanceCallExpr, + stmt: TsVirtualMethodCallStmt, arrayType: EtsArrayType, - elementSort: USort, array: UHeapRef, ): UExpr<*>? = with(ctx) { - check(expr.args.size == 1) { - "Array.push() should have exactly one argument, but got ${expr.args.size}" - } - val arg = resolve(expr.args.single()) ?: return null - - scope.calcOnState { - // Read the length of the array - val lengthLValue = mkArrayLengthLValue(array, arrayType) - val length = memory.read(lengthLValue) + val lengthLValue = mkArrayLengthLValue(array, arrayType) + val length = scope.calcOnState { memory.read(lengthLValue) } + ensureArrayGrowthWithinCapacity(length, stmt.args.size, operation = "push") ?: return null - // Increase the length of the array - val newLength = mkBvAddExpr(length, mkBv(1)) - memory.write(lengthLValue, newLength, guard = trueExpr) + val newLength = mkBvAddExpr(length, mkBv(stmt.args.size)) + if (stmt.args.isNotEmpty()) { + scope.doWithState { memory.write(lengthLValue, newLength, guard = trueExpr) } + } - // Write the new element to the end of the array + for ((offset, arg) in stmt.args.withIndex()) { + val index = mkBvAddExpr(length, mkBv(offset)) assignToArrayIndex( scope = scope, array = array, - index = length, + index = index, expr = arg, arrayType = arrayType, - ) ?: return@calcOnState null + ) ?: return null + } - // Return the new length of the array (as per ECMAScript spec for Array.push) - mkBvToFpExpr( - sort = fp64Sort, - roundingMode = fpRoundingModeSortDefaultValue(), - value = newLength.asExpr(sizeSort), - signed = true, + mkBvToFpExpr( + sort = fp64Sort, + roundingMode = fpRoundingModeSortDefaultValue(), + value = newLength.asExpr(sizeSort), + signed = true, + ) +} + +private fun TsExprResolver.ensureArrayGrowthWithinCapacity( + length: UExpr, + itemCount: Int, + operation: String, +): Unit? = with(ctx) { + val maximumCurrentLength = options.maxArraySize - itemCount + val withinCapacity = mkBvSignedLessOrEqualExpr(length, mkBv(maximumCurrentLength)) + val canExceedCapacity = scope.checkSat(mkNot(withinCapacity)) != null + if (canExceedCapacity) { + reportRuntimeFeatureLimitation( + reason = TsRuntimeFeatureLimitationReason.ARRAY_LENGTH_CAPACITY, + detail = "Array.$operation would grow beyond configured length bound ${options.maxArraySize}", ) } + + scope.assert(withinCapacity) } /** @@ -613,10 +625,6 @@ private fun TsExprResolver.handleArrayPop( arrayType: EtsArrayType, array: UHeapRef, ): UExpr<*>? = with(ctx) { - check(stmt.args.isEmpty()) { - "Array.pop() should have no arguments, but got ${stmt.args.size}" - } - removeArrayElement(stmt, array, arrayType, first = false) } @@ -643,9 +651,17 @@ private fun TsExprResolver.removeArrayElement( val removed = readArrayElement(scope, array, index, arrayType) if (first) { - copyArrayElements(array, array, arrayType, fromSrc = mkBv(1), fromDst = mkBv(0), length = newLength) + copyArrayElements( + srcRef = array, + dstRef = array, + arrayType = arrayType, + fromSrc = mkBv(1), + fromDst = mkBv(0), + length = newLength, + ) moveInitializedArrayIndices(array) { sourceIndex -> mkBvSubExpr(sourceIndex, mkBv(1)) } } + truncateKnownArrayIndices(array, newLength) memory.write(lengthLValue, newLength, guard = trueExpr) @@ -832,49 +848,48 @@ private fun TsExprResolver.handleArrayShift( * https://tc39.es/ecma262/multipage/indexed-collections.html#sec-array.prototype.unshift */ private fun TsExprResolver.handleArrayUnshift( - expr: EtsInstanceCallExpr, + stmt: TsVirtualMethodCallStmt, arrayType: EtsArrayType, array: UHeapRef, ): UExpr<*>? = with(ctx) { - // TODO: support vararg - check(expr.args.size == 1) { - "Array.unshift() should have exactly one argument, but got ${expr.args.size}" - } - val arg = resolve(expr.args.single()) ?: return null + val lengthLValue = mkArrayLengthLValue(array, arrayType) + val length = scope.calcOnState { memory.read(lengthLValue) } + ensureArrayGrowthWithinCapacity(length, stmt.args.size, operation = "unshift") ?: return null - scope.calcOnState { - // Read the length of the array - val lengthLValue = mkArrayLengthLValue(array, arrayType) - val length = memory.read(lengthLValue) + val itemCount = stmt.args.size + val newLength = mkBvAddExpr(length, mkBv(itemCount)) + if (itemCount != 0) { + scope.doWithState { + memory.write(lengthLValue, newLength, guard = trueExpr) - // Increase the length of the array - val newLength = mkBvAddExpr(length, mkBv(1)) - memory.write(lengthLValue, newLength, guard = trueExpr) + copyArrayElements( + srcRef = array, + dstRef = array, + arrayType = arrayType, + fromSrc = mkBv(0), + fromDst = mkBv(itemCount), + length = length, + ) + moveInitializedArrayIndices(array) { index -> mkBvAddExpr(index, mkBv(itemCount)) } + } + } - // Shift elements to the right - copyArrayElements( - srcRef = array, - dstRef = array, + for ((index, arg) in stmt.args.withIndex()) { + assignToArrayIndex( + scope = scope, + array = array, + index = mkBv(index), + expr = arg, arrayType = arrayType, - fromSrc = mkBv(0), - fromDst = mkBv(1), - length = length, - ) - - moveInitializedArrayIndices(array) { index -> mkBvAddExpr(index, mkBv(1)) } - - // Write the new element to the start of the array - assignToArrayIndex(scope, array, index = mkBv(0), expr = arg, arrayType = arrayType) - ?: return@calcOnState null - - // Return the new length of the array (as per ECMAScript spec for Array.unshift) - mkBvToFpExpr( - sort = fp64Sort, - roundingMode = fpRoundingModeSortDefaultValue(), - value = newLength.asExpr(sizeSort), - signed = true, - ) + ) ?: return null } + + mkBvToFpExpr( + sort = fp64Sort, + roundingMode = fpRoundingModeSortDefaultValue(), + value = newLength.asExpr(sizeSort), + signed = true, + ) } /** diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt index 867e20712a..44bd400378 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt @@ -4,6 +4,7 @@ import org.jacodb.ets.model.EtsScene import org.jacodb.ets.utils.EtsIrProvider import org.jacodb.ets.utils.loadEtsFileAutoConvert import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestFactory import org.junit.jupiter.api.io.TempDir import org.usvm.PathSelectionStrategy @@ -11,8 +12,11 @@ import org.usvm.SolverType import org.usvm.StateCollectionStrategy import org.usvm.UMachineOptions import org.usvm.api.TsTestValue +import org.usvm.machine.TsInterpreterObserver import org.usvm.machine.TsMachine import org.usvm.machine.TsOptions +import org.usvm.machine.TsRuntimeFeatureLimitationEvent +import org.usvm.machine.TsRuntimeFeatureLimitationReason import org.usvm.util.TsTestResolver import org.usvm.util.assertNodeReplay import org.usvm.util.jsString @@ -27,6 +31,80 @@ class TsArrayShiftReplayTest { @TempDir lateinit var directory: Path + @Test + fun `compatibility push and unshift reject capacity overflow`() { + val source = directory.resolve("ArrayCompatibilityCapacity.ts") + source.writeText( + """ + class ArrayCompatibilityCapacity { + pushBelow() { + const values = [1]; + const alias = values; + const length = values.push(3); + return length === 2 && alias[0] === 1 && alias[1] === 3 ? 1 : -1; + } + + unshiftBelow() { + const values = [1]; + const alias = values; + const length = values.unshift(3); + return length === 2 && alias[0] === 3 && alias[1] === 1 ? 1 : -1; + } + + pushAtLimit() { + const values = [1, 2]; + return values.push(3); + } + + unshiftAtLimit() { + const values = [1, 2]; + return values.unshift(3); + } + } + """.trimIndent() + ) + val scene = EtsScene(projectFiles = listOf(loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND))) + val methods = scene.projectClasses.single { it.name == "ArrayCompatibilityCapacity" }.methods.associateBy { it.name } + + for (operation in listOf("push", "unshift")) { + val events = mutableListOf() + val observer = object : TsInterpreterObserver { + override fun onRuntimeFeatureLimitation(event: TsRuntimeFeatureLimitationEvent) { + events += event + } + } + val below = methods.getValue("${operation}Below") + val atLimit = methods.getValue("${operation}AtLimit") + + val belowAnalysis = TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(maxArraySize = 2), + observer = observer, + unknownCallDispatcher = TsCompatibilityUnknownCallDispatcher, + ).use { machine -> machine.analyzeWithMetadata(methods = listOf(below)) } + val belowReturns = belowAnalysis.states.map { state -> + assertIs(TsTestResolver().resolve(below, state).returnValue).number + } + + assertEquals(listOf(1.0), belowReturns) + assertTrue(!belowAnalysis.engineFailed) + assertTrue(events.isEmpty()) + + val atLimitAnalysis = TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(maxArraySize = 2), + observer = observer, + unknownCallDispatcher = TsCompatibilityUnknownCallDispatcher, + ).use { machine -> machine.analyzeWithMetadata(methods = listOf(atLimit)) } + + assertTrue(atLimitAnalysis.states.isEmpty()) + assertTrue(!atLimitAnalysis.engineFailed) + assertEquals(listOf(TsRuntimeFeatureLimitationReason.ARRAY_LENGTH_CAPACITY), events.map { it.reason }) + } + } + @TestFactory fun `symbolic shift inputs results and heap changes replay in JavaScript`(): List { val cases = replayCases() @@ -290,6 +368,43 @@ class TsArrayShiftReplayTest { ) private fun mutationCases(type: String): List = listOf( + ReplayCase( + name = "$type compatibility pop ignores evaluated extra arguments", + parameters = "values: $type[]", + maxResult = 1, + body = """ + if (values.length !== 1) return 0; + const alias = values; + const removed = values.pop(values.push(true)); + return removed === true && alias.length === 1 ? 1 : -1; + """.trimIndent(), + ), + ReplayCase( + name = "$type compatibility push accepts zero and three values", + parameters = "values: $type[]", + maxResult = 1, + body = """ + if (values.length !== 1) return 0; + const alias = values; + const unchangedLength = values.push(); + const newLength = values.push(7, true, null); + return unchangedLength === 1 && newLength === 4 && alias[1] === 7 && + alias[2] === true && alias[3] === null ? 1 : -1; + """.trimIndent(), + ), + ReplayCase( + name = "$type compatibility unshift accepts zero and three values", + parameters = "values: $type[]", + maxResult = 1, + body = """ + if (values.length !== 1) return 0; + const alias = values; + const unchangedLength = values.unshift(); + const newLength = values.unshift(7, true, null); + return unchangedLength === 1 && newLength === 4 && alias[0] === 7 && + alias[1] === true && alias[2] === null ? 1 : -1; + """.trimIndent(), + ), ReplayCase( name = "$type unshift preserves unread tail and pop kind", parameters = "values: $type[]", From 8b80465ad535554c6abe0c72b92e611ad18373df Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 00:31:11 +0300 Subject: [PATCH 45/97] Resolve unknown call inputs with explicit ordered control flow --- .../src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt index 650ef9599a..2493230340 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt @@ -50,7 +50,11 @@ data class TsUnknownCallValue( /** Takes the meaningful values from arguments already evaluated by the caller. */ internal fun TsUnknownCall.resolvedInstanceInputs(maxArgumentCount: Int): List>? { val resolvedReceiver = receiver?.resolved ?: return null - val resolvedArguments = arguments.take(maxArgumentCount).map { argument -> argument.resolved ?: return null } + val resolvedArguments = mutableListOf>() + for (argument in arguments.take(maxArgumentCount)) { + val resolvedArgument = argument.resolved ?: return null + resolvedArguments += resolvedArgument + } return listOf(resolvedReceiver) + resolvedArguments } From 0147f867a5450cc0bf3d86ab495282893eb5fa52 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 00:46:50 +0300 Subject: [PATCH 46/97] Freeze selected native frontend runtime identity --- .../org/usvm/ts/calls/CallsExperiment.kt | 4 + .../org/usvm/ts/calls/CallsExperimentCli.kt | 8 ++ .../ts/calls/CurrentTsCallsSymbolicEngine.kt | 104 +++++++++++++--- .../org/usvm/ts/calls/CallsExperimentTest.kt | 41 ++++++- .../calls/CurrentTsCallsSymbolicEngineTest.kt | 111 +++++++++++++++++- usvm-ts/UNKNOWN_CALL_MODELS.md | 7 ++ 6 files changed, 257 insertions(+), 18 deletions(-) diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt index 4f608c5b3f..8339702cdb 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt @@ -289,6 +289,7 @@ internal class CallsExperimentRunner( private val runtimeToolRevision: String = CallsBuildIdentity.toolRevision, private val eventRecordWriter: ((Path, CallsRawRecord) -> Unit)? = null, private val sourceCheckoutVerifier: (Path, String) -> Unit = ::verifyCallsGitCheckout, + private val frontendRuntimeVerifier: (String) -> Unit = ::verifyCallsExternalFrontendRuntime, ) { fun run( manifest: CallsExperimentManifest, @@ -360,6 +361,9 @@ internal class CallsExperimentRunner( val sourceRoot = manifestDirectory.resolve(project.sourceRoot).normalize().toRealPath() sourceCheckoutVerifier(sourceRoot, project.revision) } + if (!manifest.nativeFrontendRevision.startsWith("bundled:")) { + frontendRuntimeVerifier(manifest.nativeFrontendRevision) + } Files.move( partialOutput, diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperimentCli.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperimentCli.kt index 587aa2237f..5c67559810 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperimentCli.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperimentCli.kt @@ -18,6 +18,7 @@ fun main(args: Array) { "coverage-cell" -> runCoverageCell(args.drop(1)) "replay-witness" -> replayWitness(args.drop(1)) "summarize" -> summarize(args.drop(1)) + "frontend-identity" -> System.out.appendLine(frontendIdentity(args.drop(1))) else -> error(usage()) } } @@ -91,6 +92,12 @@ private fun summarize(args: List) { Files.writeString(output, json.encodeToUtf8SafeString(summary) + "\n") } +internal fun frontendIdentity(args: List): String { + require(args.size == 1) { usage() } + val frontendDirectory = Path.of(args.single()).toRealPath() + return callsExternalFrontendIdentity(frontendDirectory) +} + private fun usage(): String = """ Usage: calls class-catalog @@ -102,6 +109,7 @@ private fun usage(): String = """ calls coverage-cell calls replay-witness calls summarize + calls frontend-identity """.trimIndent() private const val REPLAY_WITNESS_ARGUMENT_COUNT = 7 diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt index 9fbf3d7c67..e7a79b4a32 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt @@ -30,7 +30,10 @@ import org.usvm.ts.pbt.mapping.EtsMappingStatus import org.usvm.ts.pbt.mapping.PropertyEtsMapper import org.usvm.ts.pbt.model.JsConcreteValue import org.usvm.ts.pbt.model.contains +import java.nio.ByteBuffer +import java.nio.file.Files import java.nio.file.Path +import java.security.MessageDigest import kotlin.time.TimeSource private val logger = KotlinLogging.logger {} @@ -112,7 +115,6 @@ internal class CurrentTsCallsSymbolicEngine( private val verifiedProjects = mutableMapOf() private val preparedSources = mutableMapOf, CallsSourceProject>() private var preparedTarget: Pair? = null - private var verifiedNativeFrontendIdentity: String? = null override fun search(request: CallsSymbolicSearchRequest): CallsSymbolicSearchResult { val startedAt = TimeSource.Monotonic.markNow() @@ -278,9 +280,14 @@ internal class CurrentTsCallsSymbolicEngine( private fun prepareSafely(request: CallsSymbolicPreflightRequest): CallsTargetPreparation { val cached = preparedTarget val cachedRevisionMatches = verifiedProjects[request.sourceRoot] == request.project.revision - if (cached?.first == request && cachedRevisionMatches) return cached.second - - val preparation = runCatching { prepare(request) }.getOrElse { error -> + val preparation = runCatching { + if (cached?.first == request && cachedRevisionMatches) { + verifyNativeFrontend(expectedRevision = request.expectedNativeFrontendRevision) + cached.second + } else { + prepare(request) + } + }.getOrElse { error -> CallsTargetPreparation.Rejected( status = CallsSymbolicStatus.TOOL_ERROR, reasonCode = CallsSymbolicPreflightReasonCode.FRONTEND_OR_PREFLIGHT_ERROR, @@ -321,7 +328,7 @@ internal class CurrentTsCallsSymbolicEngine( cache = verifiedProjects, ) } - verifyNativeFrontendOnce(expectedRevision = request.expectedNativeFrontendRevision) + verifyNativeFrontend(expectedRevision = request.expectedNativeFrontendRevision) val source = request.sourceRoot.resolve(request.function.sourceFile).normalize() require(source.startsWith(request.sourceRoot)) { "Function source escapes its frozen source root" } @@ -623,7 +630,7 @@ internal class CurrentTsCallsSymbolicEngine( diagnostic = diagnostic, ) - internal fun verifyNativeFrontendOnce(expectedRevision: String) { + internal fun verifyNativeFrontend(expectedRevision: String) { val configuredScript = environment("ETS_FRONTEND_SCRIPT") require(configuredScript == null) { "ETS_FRONTEND_SCRIPT must be unset so the frozen native frontend runtime is used" @@ -632,11 +639,13 @@ internal class CurrentTsCallsSymbolicEngine( require(environment("ETS_FRONTEND_DIR") == null) { "ETS_FRONTEND_DIR must be unset when the bundled native frontend is selected" } + require(System.getProperty(ETS_FRONTEND_DIR_PROPERTY) == null) { + "$ETS_FRONTEND_DIR_PROPERTY must be unset when the bundled native frontend is selected" + } require(expectedRevision == bundledNativeFrontendRevision) { "Bundled native frontend revision $expectedRevision does not match running build " + bundledNativeFrontendRevision } - verifiedNativeFrontendIdentity = expectedRevision return } @@ -644,13 +653,7 @@ internal class CurrentTsCallsSymbolicEngine( "ETS_FRONTEND_DIR is required to verify the frozen native frontend revision" } val frontendDirectory = Path.of(configuredFrontend).toRealPath() - val expectedIdentity = "$frontendDirectory@$expectedRevision" - if (verifiedNativeFrontendIdentity == expectedIdentity) { - return - } - - verifyCallsGitCheckout(frontendDirectory, expectedRevision) - verifiedNativeFrontendIdentity = expectedIdentity + verifyCallsExternalFrontendRuntime(frontendDirectory, expectedRevision) } private fun verifyGitCheckoutOnce( @@ -759,6 +762,7 @@ internal class CurrentTsCallsSymbolicEngine( private companion object { const val BUNDLED_FRONTEND_PREFIX: String = "bundled:" + const val ETS_FRONTEND_DIR_PROPERTY: String = "ets.frontend.dir" } } @@ -839,6 +843,78 @@ internal fun verifyCallsGitCheckout(checkout: Path, expectedRevision: String) { runCallsGit(checkout, "diff", "--quiet", "HEAD", "--") } +internal fun verifyCallsExternalFrontendRuntime(expectedIdentity: String) { + val configuredFrontend = requireNotNull(System.getenv("ETS_FRONTEND_DIR")) { + "ETS_FRONTEND_DIR is required to verify the frozen native frontend runtime" + } + val frontendDirectory = Path.of(configuredFrontend).toRealPath() + verifyCallsExternalFrontendRuntime(frontendDirectory, expectedIdentity) +} + +internal fun verifyCallsExternalFrontendRuntime(frontendDirectory: Path, expectedIdentity: String) { + val match = requireNotNull(EXTERNAL_FRONTEND_IDENTITY.matchEntire(expectedIdentity)) { + "External native frontend identity must be <40-character Git SHA>@sha256:<64-character runtime digest>" + } + val expectedRevision = match.groupValues[1] + val expectedDigest = match.groupValues[2] + + verifyCallsGitCheckout(frontendDirectory, expectedRevision) + val actualDigest = callsExternalFrontendRuntimeDigest(frontendDirectory) + require(actualDigest == expectedDigest) { + "External native frontend runtime digest differs from frozen identity for $frontendDirectory" + } +} + +internal fun callsExternalFrontendIdentity(frontendDirectory: Path): String { + val realDirectory = frontendDirectory.toRealPath() + val revision = runCallsGit(realDirectory, "rev-parse", "HEAD").trim() + verifyCallsGitCheckout(realDirectory, revision) + return "$revision@sha256:${callsExternalFrontendRuntimeDigest(realDirectory)}" +} + +private fun callsExternalFrontendRuntimeDigest(frontendDirectory: Path): String { + val dist = frontendDirectory.resolve("dist") + require(Files.isDirectory(dist)) { "External native frontend dist directory is missing: $dist" } + + val files = Files.walk(dist).use { paths -> + paths.iterator().asSequence().filter { path -> + require(!Files.isSymbolicLink(path)) { "External native frontend dist contains a symlink: $path" } + val isDirectory = Files.isDirectory(path) + if (!isDirectory) { + require(Files.isRegularFile(path)) { "External native frontend dist contains a non-file: $path" } + } + !isDirectory + }.sortedBy { path -> dist.relativize(path).toString().replace('\\', '/') }.toList() + } + require(dist.resolve("index.js") in files) { "External native frontend dist is missing index.js: $dist" } + + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + for (file in files) { + val relativePath = dist.relativize(file).toString().replace('\\', '/') + val nameBytes = relativePath.toByteArray(Charsets.UTF_8) + val fileSize = Files.size(file) + digest.update(ByteBuffer.allocate(Int.SIZE_BYTES).putInt(nameBytes.size).array()) + digest.update(nameBytes) + digest.update(ByteBuffer.allocate(Long.SIZE_BYTES).putLong(fileSize).array()) + + var bytesRead = 0L + Files.newInputStream(file).use { input -> + while (true) { + val count = input.read(buffer) + if (count < 0) break + digest.update(buffer, 0, count) + bytesRead += count + } + } + require(bytesRead == fileSize) { "External native frontend dist changed while hashing: $file" } + } + + return digest.digest().joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) } +} + +private val EXTERNAL_FRONTEND_IDENTITY = Regex("([0-9a-f]{40})@sha256:([0-9a-f]{64})") + internal fun runCallsGit(checkout: Path, vararg arguments: String): String { val process = ProcessBuilder(listOf("git", "-C", checkout.toString()) + arguments) .redirectErrorStream(true) diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt index 1cde9e56ba..872cb2e37c 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt @@ -24,6 +24,45 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class CallsExperimentTest { + @Test + fun `runner rejects changed external frontend bytes before raw publication`(@TempDir directory: Path) { + val sourceManifest = frozenGitManifest(directory) + val frontend = Files.createDirectory(directory.resolve("frontend")) + Files.writeString(frontend.resolve(".gitignore"), "dist/\n") + runCallsGit(frontend, "init") + runCallsGit(frontend, "config", "user.name", "USVM Tests") + runCallsGit(frontend, "config", "user.email", "usvm@example.test") + runCallsGit(frontend, "add", ".") + runCallsGit(frontend, "commit", "-m", "Create frontend fixture") + val dist = Files.createDirectory(frontend.resolve("dist")) + val script = dist.resolve("index.js") + Files.writeString(script, "original script") + Files.writeString(dist.resolve("lib.d.ts"), "original library") + val frontendIdentity = callsExternalFrontendIdentity(frontend) + val manifest = sourceManifest.copy(nativeFrontendRevision = frontendIdentity) + val rawOutput = directory.resolve("results.jsonl") + val engine = CallsSymbolicEngine { + Files.writeString(script, "changed script") + result(status = CallsSymbolicStatus.UNREACHED) + } + + val failure = assertFailsWith { + CallsExperimentRunner( + symbolicEngine = engine, + targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> error("Replay must not run") }, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + frontendRuntimeVerifier = { identity -> verifyCallsExternalFrontendRuntime(frontend, identity) }, + ).run( + manifest = manifest, + manifestDirectory = directory, + rawOutput = rawOutput, + ) + } + + assertTrue(failure.message.orEmpty().contains("runtime digest differs")) + assertFalse(Files.exists(rawOutput)) + } + @Test fun `runner rejects a source edit during symbolic search before replay or publication`(@TempDir directory: Path) { val frozenManifest = frozenGitManifest(directory) @@ -682,7 +721,7 @@ class CallsExperimentTest { schemaVersion = CallsExperimentManifest.SCHEMA_VERSION, experimentId = "fixture", toolRevision = FIXTURE_TOOL_REVISION, - nativeFrontendRevision = "frontend-revision", + nativeFrontendRevision = "bundled:test", solver = "YICES", searchPolicy = CALLS_PATH_SELECTION_STRATEGY.name, modelSet = CallsModelSetIdentity( diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt index c7951b7e82..6beaa51f38 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt @@ -7,6 +7,8 @@ import org.usvm.ts.pbt.model.JsNumber import org.usvm.ts.pbt.model.NumberDomain import org.usvm.ts.pbt.model.PropertyInput import org.usvm.ts.pbt.model.StringDomain +import java.nio.file.Files +import java.nio.file.Path import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -22,10 +24,10 @@ internal class CurrentTsCallsSymbolicEngineTest : CurrentTsCallsSymbolicEngineTe bundledNativeFrontendRevision = "bundled:published-jacodb", ) - engine.verifyNativeFrontendOnce(expectedRevision = "bundled:published-jacodb") + engine.verifyNativeFrontend(expectedRevision = "bundled:published-jacodb") assertFailsWith { - engine.verifyNativeFrontendOnce(expectedRevision = "bundled:different-jacodb") + engine.verifyNativeFrontend(expectedRevision = "bundled:different-jacodb") } } @@ -38,10 +40,113 @@ internal class CurrentTsCallsSymbolicEngineTest : CurrentTsCallsSymbolicEngineTe ) assertFailsWith { - engine.verifyNativeFrontendOnce(expectedRevision = "bundled:published-jacodb") + engine.verifyNativeFrontend(expectedRevision = "bundled:published-jacodb") } } + @Test + fun `bundled frontend rejects native frontend directory system property`() { + val propertyName = "ets.frontend.dir" + val previousValue = System.getProperty(propertyName) + val engine = CurrentTsCallsSymbolicEngine( + environment = emptyMap()::get, + bundledNativeFrontendRevision = "bundled:published-jacodb", + ) + + System.setProperty(propertyName, "/unused/frontend") + try { + val failure = assertFailsWith { + engine.verifyNativeFrontend(expectedRevision = "bundled:published-jacodb") + } + + assertTrue(failure.message.orEmpty().contains(propertyName)) + } finally { + if (previousValue == null) { + System.clearProperty(propertyName) + } else { + System.setProperty(propertyName, previousValue) + } + } + } + + @Test + fun `external frontend verifies generated script and library bytes`() { + val frontend = externalFrontendFixture() + val identity = callsExternalFrontendIdentity(frontend) + val revision = runGit(frontend, "rev-parse", "HEAD").trim() + val engine = CurrentTsCallsSymbolicEngine( + environment = mapOf("ETS_FRONTEND_DIR" to frontend.toString())::get, + ) + + engine.verifyNativeFrontend(expectedRevision = identity) + + val malformedIdentity = assertFailsWith { + engine.verifyNativeFrontend(expectedRevision = revision) + } + assertTrue(malformedIdentity.message.orEmpty().contains("@sha256:")) + + Files.writeString(frontend.resolve("dist/index.js"), "different script") + val changedScript = assertFailsWith { + engine.verifyNativeFrontend(expectedRevision = identity) + } + assertTrue(changedScript.message.orEmpty().contains("runtime digest differs")) + + Files.writeString(frontend.resolve("dist/index.js"), "original script") + Files.writeString(frontend.resolve("dist/lib.d.ts"), "different library") + val changedLibrary = assertFailsWith { + engine.verifyNativeFrontend(expectedRevision = identity) + } + assertTrue(changedLibrary.message.orEmpty().contains("runtime digest differs")) + } + + @Test + fun `external frontend environment directory takes precedence over system property`() { + val frontend = externalFrontendFixture() + val identity = callsExternalFrontendIdentity(frontend) + val propertyName = "ets.frontend.dir" + val previousValue = System.getProperty(propertyName) + val engine = CurrentTsCallsSymbolicEngine( + environment = mapOf("ETS_FRONTEND_DIR" to frontend.toString())::get, + ) + + System.setProperty(propertyName, "/unused/frontend") + try { + engine.verifyNativeFrontend(expectedRevision = identity) + } finally { + if (previousValue == null) { + System.clearProperty(propertyName) + } else { + System.setProperty(propertyName, previousValue) + } + } + } + + @Test + fun `frontend identity command returns frozen external source and runtime identity`() { + val frontend = externalFrontendFixture() + + val identity = frontendIdentity(args = listOf(frontend.toString())) + + assertEquals(callsExternalFrontendIdentity(frontend), identity) + assertTrue(identity.matches(Regex("[0-9a-f]{40}@sha256:[0-9a-f]{64}"))) + } + + private fun externalFrontendFixture(): Path { + val frontend = Files.createDirectory(directory.resolve("frontend")) + Files.writeString(frontend.resolve(".gitignore"), "dist/\n") + Files.writeString(frontend.resolve("source.ts"), "export const value = 1;\n") + runGit(frontend, "init") + runGit(frontend, "config", "user.name", "USVM Tests") + runGit(frontend, "config", "user.email", "usvm@example.test") + runGit(frontend, "add", ".") + runGit(frontend, "commit", "-m", "Create frontend fixture") + + val dist = Files.createDirectory(frontend.resolve("dist")) + Files.writeString(dist.resolve("index.js"), "original script") + Files.writeString(dist.resolve("lib.d.ts"), "original library") + return frontend + } + @Test fun `extracts nonempty generic number array containing zero and replays source`() { val fixture = fixture( diff --git a/usvm-ts/UNKNOWN_CALL_MODELS.md b/usvm-ts/UNKNOWN_CALL_MODELS.md index 1f416b14de..7d826a6d6b 100644 --- a/usvm-ts/UNKNOWN_CALL_MODELS.md +++ b/usvm-ts/UNKNOWN_CALL_MODELS.md @@ -139,6 +139,13 @@ target set; failures discovered after that freeze remain scheduled failures. The target, profile and seed; JVM/frontend startup and original-source replay have separate recorded limits. Readiness traverses reachable same-file callees and the initializers triggered by their static-field accesses. +Frozen Calls runs normally record `nativeFrontendRevision` as `bundled:`. For an +`ETS_FRONTEND_DIR` override, run `calls frontend-identity ` after +building the frontend and copy its `@sha256:` output into that manifest field. +The command checks the checkout and hashes the generated `dist` files; it does not build them. +The digest pins the actual generated runtime bytes, not the Node environment or a proof that +those bytes were produced from the recorded source commit. + ### `unknownCallFallback` The fallback is applied when: From 54b2ac64bcbfa0391c3c976e0e19634b900dfdc9 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 00:59:52 +0300 Subject: [PATCH 47/97] Preserve bounded array copy semantics and own-slot provenance --- .../usvm/machine/expr/CallApproximations.kt | 120 +++++++++++++----- .../org/usvm/util/ArrayIndexPresence.kt | 24 ++++ .../machine/call/TsArrayShiftReplayTest.kt | 79 +++++++++++- 3 files changed, 187 insertions(+), 36 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index e28a827952..5adea77b24 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -47,6 +47,7 @@ import org.usvm.types.TypesResult import org.usvm.util.arrayStorageType import org.usvm.util.componentType import org.usvm.util.copyArrayElements +import org.usvm.util.copyKnownArrayIndexPresence import org.usvm.util.forEachArrayPayloadRegion import org.usvm.util.initializeArrayKind import org.usvm.util.initializeTypedArrayOverrides @@ -226,7 +227,7 @@ internal fun TsExprResolver.tryApproximateInstanceCall( // Handle `Array.slice() method calls if (expr.callee.name == "slice") { - return from(handleArraySlice(expr, instanceType, array)) + return from(handleArraySlice(stmt, instanceType, array)) } // Handle `Array.concat() method calls @@ -953,19 +954,11 @@ private const val ARRAY_JOIN_RESULT = "joined_array_result" * https://tc39.es/ecma262/multipage/indexed-collections.html#sec-array.prototype.slice */ private fun TsExprResolver.handleArraySlice( - expr: EtsInstanceCallExpr, + stmt: TsVirtualMethodCallStmt, arrayType: EtsArrayType, array: UHeapRef, ): UExpr<*>? = with(ctx) { - check(expr.args.size <= 2) { - "Array.slice() should have at most two arguments, but got ${expr.args.size}" - } - - val start = if (expr.args.isNotEmpty()) { - resolve(expr.args[0]) ?: return null - } else { - mkBv(0) - } + val start = stmt.args.getOrNull(0) ?: mkBv(0) val startBv: UExpr = when (start.sort) { sizeSort -> start.asExpr(sizeSort) @@ -982,9 +975,7 @@ private fun TsExprResolver.handleArraySlice( } } - val end = if (expr.args.size > 1) { - resolve(expr.args[1]) ?: return null - } else { + val end = stmt.args.getOrNull(1) ?: run { val lengthLValue = mkArrayLengthLValue(array, arrayType) scope.calcOnState { memory.read(lengthLValue) } } @@ -1006,6 +997,7 @@ private fun TsExprResolver.handleArraySlice( scope.calcOnState { val descriptor = arrayDescriptorOf(arrayType) + val hasKnownOwnSlots = array in knownArrayPropertyPresence val length = memory.read(mkArrayLengthLValue(array, arrayType)) val zero = mkBv(0) @@ -1032,6 +1024,16 @@ private fun TsExprResolver.handleArraySlice( fromDst = mkBv(0), length = newLength, ) + if (hasKnownOwnSlots) { + copyKnownArrayIndexPresence( + srcRef = array, + dstRef = slicedArray, + fromSrc = from, + fromDst = mkBv(0), + length = newLength, + ) + knownArrayPropertyPresence += slicedArray + } memory.write(mkArrayLengthLValue(slicedArray, arrayType), newLength, guard = trueExpr) @@ -1049,7 +1051,7 @@ private fun TsExprResolver.handleArraySlice( * let a = [1, 2]; * let b = [3, 4]; * - * a.concat() -> [1, 2] // same array + * a.concat() -> [1, 2] // new array with the same elements * a.concat(b) -> [1, 2, 3, 4] // concat two arrays * a.concat(b, 5) -> [1, 2, 3, 4, 5] // concat with an array and a single element * @@ -1064,34 +1066,63 @@ private fun TsExprResolver.handleArrayConcat( array: UHeapRef, ): TsExprApproximationResult = with(ctx) { val elementSort = typeToSort(arrayType.componentType) - val arrayTypes = stmt.args.mapIndexed { index, arg -> + val arrayTypes = mutableListOf() + for ((index, arg) in stmt.args.withIndex()) { if (arg.sort == addressSort) { val ref = arg.asExpr(addressSort) // A fake or an untyped reference may itself contain an array; spreading requires runtime dispatch. if (ref.hasFakeValueBranch()) return TsExprApproximationResult.NoApproximation val type = scope.calcOnState { arrayStorageType(ref, stmt.call.args[index].type) } if (typeToSort(type) is TsUnresolvedSort) return TsExprApproximationResult.NoApproximation - type as? EtsArrayType + arrayTypes += type as? EtsArrayType } else { - null + arrayTypes += null } } - if (arrayTypes.withIndex().any { (index, type) -> - if (type != null) { - typeToSort(type.componentType) != elementSort - } else { - elementSort !is TsUnresolvedSort && stmt.args[index].sort != elementSort - } + val hasIncompatibleElementSort = arrayTypes.withIndex().any { (index, type) -> + if (type != null) { + typeToSort(type.componentType) != elementSort + } else { + elementSort !is TsUnresolvedSort && stmt.args[index].sort != elementSort } - ) { + } + if (hasIncompatibleElementSort) { logger.debug { "Array.concat requires conversion between different element storage sorts" } return TsExprApproximationResult.NoApproximation } + val copySources = mutableListOf(array) + for ((index, type) in arrayTypes.withIndex()) { + if (type != null) { + copySources += stmt.args[index].asExpr(addressSort) + } + } + val knownSources = scope.calcOnState { copySources.map { it in knownArrayPropertyPresence } } + val preserveKnownPresence = knownSources.all { it } + val hasMixedPresence = knownSources.any { it } && !preserveKnownPresence + if (hasMixedPresence) return TsExprApproximationResult.NoApproximation + + val originalLength = scope.calcOnState { memory.read(mkArrayLengthLValue(array, arrayType)) } + val argumentLengths = scope.calcOnState { + arrayTypes.mapIndexed { index, type -> + if (type == null) { + mkBv(1) + } else { + val ref = stmt.args[index].asExpr(addressSort) + memory.read(mkArrayLengthLValue(ref, type)) + } + } + } + var resultLength = originalLength + for (length in argumentLengths) { + resultLength = mkBvAddExpr(resultLength, length) + } + ensureArrayGrowthWithinCapacity(resultLength, itemCount = 0, operation = "concat") + ?: return TsExprApproximationResult.ResolveFailure + from( scope.calcOnState { val resultArray = memory.allocConcrete(arrayDescriptorOf(arrayType)) - val originalLength = memory.read(mkArrayLengthLValue(array, arrayType)) copyArrayElements( srcRef = array, dstRef = resultArray, @@ -1100,30 +1131,51 @@ private fun TsExprResolver.handleArrayConcat( fromDst = mkBv(0), length = originalLength, ) + if (preserveKnownPresence) { + copyKnownArrayIndexPresence( + srcRef = array, + dstRef = resultArray, + fromSrc = mkBv(0), + fromDst = mkBv(0), + length = originalLength, + ) + } - var totalLength = originalLength + var copiedLength = originalLength stmt.args.forEachIndexed { index, arg -> val argType = arrayTypes[index] if (argType != null) { val ref = arg.asExpr(addressSort) - val length = memory.read(mkArrayLengthLValue(ref, argType)) + val length = argumentLengths[index] copyArrayElements( srcRef = ref, dstRef = resultArray, arrayType = argType, fromSrc = mkBv(0), - fromDst = totalLength, + fromDst = copiedLength, length = length, ) - totalLength = mkBvAddExpr(totalLength, length) + if (preserveKnownPresence) { + copyKnownArrayIndexPresence( + srcRef = ref, + dstRef = resultArray, + fromSrc = mkBv(0), + fromDst = copiedLength, + length = length, + ) + } + copiedLength = mkBvAddExpr(copiedLength, length) } else { - val newLength = mkBvAddExpr(totalLength, mkBv(1)) + val newLength = mkBvAddExpr(copiedLength, mkBv(1)) memory.write(mkArrayLengthLValue(resultArray, arrayType), newLength, guard = trueExpr) - assignToArrayIndex(scope, resultArray, totalLength, arg, arrayType) ?: return@calcOnState null - totalLength = newLength + assignToArrayIndex(scope, resultArray, copiedLength, arg, arrayType) ?: return@calcOnState null + copiedLength = newLength } } - memory.write(mkArrayLengthLValue(resultArray, arrayType), totalLength, guard = trueExpr) + memory.write(mkArrayLengthLValue(resultArray, arrayType), resultLength, guard = trueExpr) + if (preserveKnownPresence) { + knownArrayPropertyPresence += resultArray + } resultArray } diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/ArrayIndexPresence.kt b/usvm-ts/src/main/kotlin/org/usvm/util/ArrayIndexPresence.kt index cf9b1dfb61..112f1d6505 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/ArrayIndexPresence.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/ArrayIndexPresence.kt @@ -67,3 +67,27 @@ internal fun TsState.moveInitializedArrayIndices( } initializedArrayIndices += array to moved } + +/** Copies known own slots into a fresh array; the caller establishes complete destination provenance. */ +internal fun TsState.copyKnownArrayIndexPresence( + srcRef: UHeapRef, + dstRef: UHeapRef, + fromSrc: UExpr, + fromDst: UExpr, + length: UExpr, +) = with(ctx) { + require(srcRef in knownArrayPropertyPresence) { "Source array has no known own-slot provenance" } + + val fromSrcEnd = mkBvAddExpr(fromSrc, length) + val copied = initializedArrayIndices[dstRef].orEmpty().toMutableMap() + for ((index, present) in initializedArrayIndices[srcRef].orEmpty()) { + val isInCopiedRange = mkAnd( + mkBvSignedGreaterOrEqualExpr(index, fromSrc), + mkBvSignedLessExpr(index, fromSrcEnd), + ) + val destinationIndex = mkBvAddExpr(fromDst, mkBvSubExpr(index, fromSrc)) + val copiedPresence = mkAnd(isInCopiedRange, present) + copied[destinationIndex] = mkOr(copied[destinationIndex] ?: falseExpr, copiedPresence) + } + initializedArrayIndices += dstRef to copied +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt index 44bd400378..250b3e19e1 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt @@ -32,7 +32,7 @@ class TsArrayShiftReplayTest { lateinit var directory: Path @Test - fun `compatibility push and unshift reject capacity overflow`() { + fun `compatibility array growth rejects capacity overflow`() { val source = directory.resolve("ArrayCompatibilityCapacity.ts") source.writeText( """ @@ -60,13 +60,28 @@ class TsArrayShiftReplayTest { const values = [1, 2]; return values.unshift(3); } + + concatBelow() { + const values = [1]; + const alias = values; + + const combined = values.concat([3]); + + return combined[0] === 1 && combined[1] === 3 && + alias.length === 1 && alias[0] === 1 ? 1 : -1; + } + + concatAtLimit() { + const values = [1, 2]; + return values.concat([3]); + } } """.trimIndent() ) val scene = EtsScene(projectFiles = listOf(loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND))) val methods = scene.projectClasses.single { it.name == "ArrayCompatibilityCapacity" }.methods.associateBy { it.name } - for (operation in listOf("push", "unshift")) { + for (operation in listOf("push", "unshift", "concat")) { val events = mutableListOf() val observer = object : TsInterpreterObserver { override fun onRuntimeFeatureLimitation(event: TsRuntimeFeatureLimitationEvent) { @@ -302,6 +317,34 @@ class TsArrayShiftReplayTest { }.map { case -> case.copy(name = "compatibility storage: ${case.name}", compatibility = true) } private fun copyCases(type: String): List = listOf( + ReplayCase( + name = "$type slice ignores an evaluated extra argument", + parameters = "values: $type[]", + maxResult = 1, + body = """ + if (values.length !== 1) return 0; + const alias = values; + + const copy = values.slice(0, 1, values.push(true)); + + return copy.length === 1 && alias.length === 2 && alias[1] === true ? 1 : -1; + """.trimIndent(), + ), + ReplayCase( + name = "$type slice preserves a deleted slot at a translated index", + parameters = "values: $type[]", + maxResult = 1, + body = """ + if (values.length !== 1) return 0; + const source = [7, 11, 22]; + delete source[1]; + + const copy = source.slice(1, 3); + + return copy.length === 2 && copy[0] === undefined && copy[1] === 22 && + source[0] === 7 ? 1 : -1; + """.trimIndent(), + ), ReplayCase( name = "$type slice() retains all runtime kinds", parameters = "values: $type[]", @@ -452,6 +495,38 @@ class TsArrayShiftReplayTest { ) private fun concatCases(type: String): List = listOf( + ReplayCase( + name = "$type concat preserves deleted receiver slot and alias", + parameters = "values: $type[]", + maxResult = 1, + body = """ + if (values.length !== 1) return 0; + const source = [7, 11]; + const alias = source; + delete source[1]; + + const copy = source.concat([22], 33); + + return copy.length === 4 && copy[1] === undefined && copy[2] === 22 && + copy[3] === 33 && alias[1] === undefined && alias.length === 2 ? 1 : -1; + """.trimIndent(), + ), + ReplayCase( + name = "$type concat preserves deleted argument slot at offset", + parameters = "values: $type[]", + maxResult = 1, + body = """ + if (values.length !== 1) return 0; + const source = [7]; + const other = [11, 22]; + delete other[0]; + + const copy = source.concat(other); + + return copy.length === 3 && copy[0] === 7 && copy[1] === undefined && + copy[2] === 22 ? 1 : -1; + """.trimIndent(), + ), ReplayCase( name = "$type concat copies unread input arrays", parameters = "left: $type[], right: $type[]", From 71d29ed1480339d3ffdc01ce4b8460394881478e Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 00:59:56 +0300 Subject: [PATCH 48/97] Keep replay execution-kind errors outside source-throw observation --- .../kotlin/org/usvm/ts/calls/CallsSourceReplay.kt | 15 +++++++++------ .../org/usvm/ts/calls/CallsSourceReplayTest.kt | 11 +++++++++++ .../resources/calls/SourceTargetReplayFixture.ts | 4 ++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceReplay.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceReplay.kt index a7b0cd7923..f30d53b2d4 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceReplay.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceReplay.kt @@ -353,17 +353,20 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { }); let invocation: 'returned' | 'threw' = 'returned'; let caught: unknown; + let result: unknown = undefined; try { - const result = callable(...args.slice(0, $sourceArgumentCount)); - if (result !== null && (typeof result === 'object' || typeof result === 'function') - && typeof (result as { then?: unknown }).then === 'function') { - void Promise.resolve(result).catch(() => undefined); - throw new Error('Synchronous replay export returned an awaitable value'); - } + result = callable(...args.slice(0, $sourceArgumentCount)); } catch (error: unknown) { invocation = 'threw'; caught = error; } + const returnedAwaitable = invocation === 'returned' && result !== null + && (typeof result === 'object' || typeof result === 'function') + && typeof (result as { then?: unknown }).then === 'function'; + if (returnedAwaitable) { + void Promise.resolve(result).catch(() => undefined); + throw new Error('Synchronous replay export returned an awaitable value'); + } const targetObserved = (globalThis as Record)[${jsString(marker)}] === true; const targetHit = targetObserved && (${targetMode == CallsSourceTargetMode.ENTRY} || invocation === 'returned'); diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceReplayTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceReplayTest.kt index 8345526a19..d9b8372f06 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceReplayTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceReplayTest.kt @@ -108,6 +108,17 @@ class CallsSourceReplayTest { assertEquals("expected", replay.invocation?.errorMessage) } + @Test + fun `does not confirm an entry target when a synchronous export returns an awaitable`() { + val fixture = fixture() + val target = fixture.target(functionName = "returnsAwaitable", statement = "return Promise.resolve(1);") + + val replay = fixture.replay(exportName = "returnsAwaitable", inputs = emptyList(), target = target) + + assertEquals(CallsReplayStatus.TOOL_ERROR, replay.status, replay.toString()) + assertEquals(null, replay.invocation) + } + @Test fun `counts target hits from the selected invocation rather than module import`() { val fixture = fixture() diff --git a/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts b/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts index 1ba8c94235..1e168de81a 100644 --- a/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts +++ b/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts @@ -11,6 +11,10 @@ export function throwsAtTarget(): never { throw new Error('expected'); } +export function returnsAwaitable(): Promise { + return Promise.resolve(1); +} + export function checksArgumentCount(): number { if (arguments.length !== 0) { return 1; From b6be90aa001448a0d067a204dc68bb8a498ee821 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 01:18:37 +0300 Subject: [PATCH 49/97] Preserve backed string exception witnesses --- .../org/usvm/samples/lang/Exceptions.kt | 49 +++++++++++++++++++ .../kotlin/org/usvm/util/TsTestResolver.kt | 39 ++++++++++----- .../test/resources/samples/lang/Exceptions.ts | 14 ++++++ 3 files changed, 90 insertions(+), 12 deletions(-) diff --git a/usvm-ts/src/test/kotlin/org/usvm/samples/lang/Exceptions.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/lang/Exceptions.kt index b243547c85..f2310ad03e 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/samples/lang/Exceptions.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/samples/lang/Exceptions.kt @@ -35,6 +35,55 @@ class Exceptions : TsMethodTestRunner() { ) } + @Test + fun `throw symbolic string preserves its message`() { + val method = getMethod("throwSymbolicString") + + discoverProperties( + method = method, + { value, result -> + result is TsTestValue.TsException.StringException && result.message == value.value + }, + invariants = arrayOf( + { value, result -> + result is TsTestValue.TsException.StringException && result.message == value.value + }, + ), + ) + } + + @Test + fun `throw non-string reference remains unknown`() { + val method = getMethod("throwThis") + + discoverProperties( + method = method, + { result -> result == TsTestValue.TsException.UnknownException }, + invariants = arrayOf( + { result -> result == TsTestValue.TsException.UnknownException }, + ), + ) + } + + @Test + fun `engine-generated string reason preserves its message`() { + val method = getMethod("throwEngineInvalidArrayLength") + + discoverProperties( + method = method, + { result -> + result is TsTestValue.TsException.StringException && + result.message.startsWith("RangeError: Invalid array length:") + }, + invariants = arrayOf( + { result -> + result is TsTestValue.TsException.StringException && + result.message.startsWith("RangeError: Invalid array length:") + }, + ), + ) + } + @Test fun `throw number`() { val method = getMethod("throwNumber") diff --git a/usvm-ts/src/test/kotlin/org/usvm/util/TsTestResolver.kt b/usvm-ts/src/test/kotlin/org/usvm/util/TsTestResolver.kt index 81a3c82ea0..50d2b57ecd 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/util/TsTestResolver.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/util/TsTestResolver.kt @@ -162,17 +162,9 @@ class TsTestResolver { } is EtsStringType -> { - val concreteRef = evaluateInModel(res.value) as? UConcreteHeapRef - if (concreteRef != null && isAllocatedConcreteHeapRef(concreteRef)) { - val stringValue = ctx.getStringConstantValue(concreteRef) - if (stringValue != null) { - TsTestValue.TsException.StringException(stringValue) - } else { - TsTestValue.TsException.UnknownException - } - } else { - TsTestValue.TsException.UnknownException - } + val stringValue = resolveModeledString(res.value) + stringValue?.let { TsTestValue.TsException.StringException(it.value) } + ?: TsTestValue.TsException.UnknownException } else -> { @@ -265,6 +257,29 @@ open class TsTestStateResolver( } } + fun resolveModeledString(expr: UExpr): TsTestValue.TsString? { + if (expr.sort != ctx.addressSort) return null + + val heapRef = expr.asExpr(ctx.addressSort) + val concreteRef = evaluateInModel(heapRef) as? UConcreteHeapRef ?: return null + if (concreteRef.address == 0) return null + + val constantValue = ctx.getStringConstantValue(concreteRef) + if (constantValue != null) return TsTestValue.TsString(constantValue) + + val runtimeType = if (concreteRef.isAllocated) { + finalStateMemory.typeStreamOf(concreteRef).first() + } else { + model.typeStreamOf(concreteRef).first() + } + if (runtimeType != EtsStringType) return null + + val hasBoundedBacking = heapRef in stringLengthBounds || concreteRef in stringLengthBounds + if (!hasBoundedBacking) return null + + return resolveString(heapRef, concreteRef) + } + private fun resolveTsValue( heapRef: UExpr, ): TsTestValue { @@ -400,7 +415,7 @@ open class TsTestStateResolver( } fun resolveThisInstance(): TsTestValue { - val ref = mkRegisterStackLValue(ctx.addressSort, 0) // TODO check for statics + val ref = mkRegisterStackLValue(ctx.addressSort, idx = 0) // TODO check for statics return resolveLValue(ref) } diff --git a/usvm-ts/src/test/resources/samples/lang/Exceptions.ts b/usvm-ts/src/test/resources/samples/lang/Exceptions.ts index cb2d27af54..753f845e83 100644 --- a/usvm-ts/src/test/resources/samples/lang/Exceptions.ts +++ b/usvm-ts/src/test/resources/samples/lang/Exceptions.ts @@ -12,6 +12,20 @@ class Exceptions { return 42; } + throwSymbolicString(value: string): number { + throw value; + } + + throwThis(): number { + throw this; + } + + throwEngineInvalidArrayLength(): number { + const values = [11, 22]; + values.length = 1 / 0; + return values.length; + } + throwNumber(): number { throw 123; return 42; From babeaaefbb860c91a4bf612b0ae4fcd9bcf26637 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 01:25:54 +0300 Subject: [PATCH 50/97] Bound legacy array fill and preserve boxed values --- .../usvm/machine/expr/CallApproximations.kt | 143 ++++++++++++++---- .../org/usvm/samples/arrays/ArrayMethods.kt | 60 ++++++++ .../resources/samples/arrays/ArrayMethods.ts | 42 +++++ 3 files changed, 218 insertions(+), 27 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index 5adea77b24..ad43d031d7 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -44,7 +44,9 @@ import org.usvm.machine.state.newStmt import org.usvm.machine.types.TsUnresolvedArrayKind import org.usvm.sizeSort import org.usvm.types.TypesResult +import org.usvm.util.TsTypedArrayStorage import org.usvm.util.arrayStorageType +import org.usvm.util.boxArrayValue import org.usvm.util.componentType import org.usvm.util.copyArrayElements import org.usvm.util.copyKnownArrayIndexPresence @@ -247,7 +249,7 @@ internal fun TsExprResolver.tryApproximateInstanceCall( // Handle `Array.reverse() method calls if (expr.callee.name == "reverse") { - return from(handleArrayReverse(expr, instanceType, array)) + return from(handleArrayReverse(instanceType, array)) } } @@ -701,23 +703,38 @@ private fun TsExprResolver.handleArrayFill( elementSort: USort, array: UHeapRef, ): UExpr<*>? = with(ctx) { - check(expr.args.size >= 1 && expr.args.size <= 3) { - "Array.fill() should have 1 to 3 arguments, but got ${expr.args.size}" - } - val resolvedValue = resolve(expr.args[0]) ?: return null - val value = if (typeToSort(arrayType.componentType) is TsUnresolvedSort) { - resolvedValue.toFakeObject(scope) + val resolvedValue = if (expr.args.isEmpty()) { + mkUndefinedValue() } else { - resolvedValue + resolve(expr.args[0]) ?: return null + } + val hasResolvedElementSort = typeToSort(arrayType.componentType) !is TsUnresolvedSort + val valueMatchesElementSort = resolvedValue.sort == elementSort + val containsFakeValue = resolvedValue.sort == addressSort && + resolvedValue.asExpr(addressSort).hasFakeValueBranch() + val usesBoxedValue = hasResolvedElementSort && (!valueMatchesElementSort || containsFakeValue) + val boxedValue = if (usesBoxedValue) boxArrayValue(scope, resolvedValue) else null + + val nativeValue = when { + !hasResolvedElementSort -> resolvedValue.toFakeObject(scope) + !usesBoxedValue -> resolvedValue + elementSort == boolSort -> falseExpr + elementSort == fp64Sort -> mkFp64(0.0) + elementSort == addressSort -> mkUndefinedValue() + else -> { + logger.warn { "Unsupported element sort in Array.fill(): $elementSort" } + return null + } } - // TODO: Support negative `start` and `end` indices. val start = if (expr.args.size > 1) { resolve(expr.args[1]) ?: return null } else { mkBv(0) } - val startBv: UExpr = when (start.sort) { + val startBv: UExpr = if (start == mkUndefinedValue()) { + mkBv(0) + } else when (start.sort) { sizeSort -> start.asExpr(sizeSort) fp64Sort -> mkFpToBvExpr( @@ -739,7 +756,10 @@ private fun TsExprResolver.handleArrayFill( val lengthLValue = mkArrayLengthLValue(array, arrayType) scope.calcOnState { memory.read(lengthLValue) } } - val endBv: UExpr = when (end.sort) { + val endBv: UExpr = if (end == mkUndefinedValue()) { + val lengthLValue = mkArrayLengthLValue(array, arrayType) + scope.calcOnState { memory.read(lengthLValue) } + } else when (end.sort) { sizeSort -> end.asExpr(sizeSort) fp64Sort -> mkFpToBvExpr( @@ -757,13 +777,35 @@ private fun TsExprResolver.handleArrayFill( scope.calcOnState { val descriptor = arrayDescriptorOf(arrayType) + val length = memory.read(mkArrayLengthLValue(array, arrayType)) + val zero = mkBv(0) + + fun normalizeIndex(index: UExpr): UExpr { + val relative = mkIte(mkBvSignedLessExpr(index, zero), mkBvAddExpr(length, index), index) + val capped = mkIte(mkBvSignedGreaterExpr(relative, length), length, relative) + return mkIte(mkBvSignedLessExpr(relative, zero), zero, capped) + } - // Calculate the length of the range to fill - val fillLength = mkBvSubExpr(endBv, startBv) + val from = normalizeIndex(startBv) + val to = normalizeIndex(endBv) + val fillLength = mkIte(mkBvSignedLessExpr(from, to), mkBvSubExpr(to, from), zero) + val withinCapacity = mkBvSignedLessOrEqualExpr(fillLength, mkBv(ARRAY_FILL_MAX_SIZE)) + if (scope.checkSat(mkNot(withinCapacity)) != null) { + reportRuntimeFeatureLimitation( + reason = TsRuntimeFeatureLimitationReason.ARRAY_LENGTH_CAPACITY, + detail = "Array.fill exceeds the supported range of $ARRAY_FILL_MAX_SIZE elements", + ) + } + scope.assert(withinCapacity) ?: return@calcOnState null - // Concrete ranges need no unused entries in the temporary array. - val tempSize = getIntValue(fillLength)?.coerceIn(0, ARRAY_FILL_MAX_SIZE) ?: ARRAY_FILL_MAX_SIZE - // TODO: check that symbolic `fillLength` is less than `ARRAY_FILL_MAX_SIZE`. + val tempSize = getIntValue(fillLength)?.coerceAtLeast(0) ?: ARRAY_FILL_MAX_SIZE + val hasKnownOwnSlots = array in knownArrayPropertyPresence + val concreteStart = getIntValue(from) + val concreteFillLength = getIntValue(fillLength) + if (hasKnownOwnSlots && (concreteStart == null || concreteFillLength == null)) { + logger.warn { "Array.fill() cannot preserve symbolic own-slot presence in the legacy path" } + return@calcOnState null + } // Allocate a temporary array to hold the filled values val tempArray = memory.allocConcrete(descriptor) @@ -774,23 +816,75 @@ private fun TsExprResolver.handleArrayFill( descriptor, elementSort, sizeSort, - (0 until tempSize).asSequence().map { value.asExpr(elementSort) } + (0 until tempSize).asSequence().map { nativeValue.asExpr(elementSort) }, ) + if (hasResolvedElementSort) { + initializeTypedArrayOverrides(array) + memory.initializeArray( + tempArray, + TsTypedArrayStorage.HAS_BOXED_VALUE, + boolSort, + sizeSort, + (0 until tempSize).asSequence().map { if (usesBoxedValue) trueExpr else falseExpr }, + ) + + if (usesBoxedValue) { + val boxedFillValue = checkNotNull(boxedValue) + memory.initializeArray( + tempArray, + TsTypedArrayStorage.BOXED_VALUE, + addressSort, + sizeSort, + (0 until tempSize).asSequence().map { boxedFillValue }, + ) + } + } + // Copy the filled values to the specified range in the original array memory.memcpy( srcRef = tempArray, dstRef = array, type = descriptor, elementSort = elementSort, - fromSrc = mkBv(0), - fromDst = startBv, + fromSrc = zero, + fromDst = from, length = fillLength, ) - // This legacy fill path does not preserve boxed overrides or sparse-slot metadata. - knownArrayPropertyPresence -= array - initializedArrayIndices -= array + if (hasResolvedElementSort) { + memory.memcpy( + srcRef = tempArray, + dstRef = array, + type = TsTypedArrayStorage.HAS_BOXED_VALUE, + elementSort = boolSort, + fromSrc = zero, + fromDst = from, + length = fillLength, + ) + + if (usesBoxedValue) { + memory.memcpy( + srcRef = tempArray, + dstRef = array, + type = TsTypedArrayStorage.BOXED_VALUE, + elementSort = addressSort, + fromSrc = zero, + fromDst = from, + length = fillLength, + ) + } + } + + if (hasKnownOwnSlots) { + val fillCount = checkNotNull(concreteFillLength) + val filledIndices = (0 until fillCount).associate { offset -> + mkBvAddExpr(from, mkBv(offset)) to trueExpr + } + initializedArrayIndices += array to (initializedArrayIndices[array].orEmpty() + filledIndices) + } + + // Unknown own-slot metadata cannot be inferred from this bulk copy. unsupportedIteratorArrays += array // Return the modified original array @@ -1309,14 +1403,9 @@ private fun TsExprResolver.handleArrayIncludes( * https://tc39.es/ecma262/multipage/indexed-collections.html#sec-array.prototype.reverse */ private fun TsExprResolver.handleArrayReverse( - expr: EtsInstanceCallExpr, arrayType: EtsArrayType, array: UHeapRef, ): UExpr<*>? = with(ctx) { - check(expr.args.isEmpty()) { - "Array.reverse() should have no arguments, but got ${expr.args.size}" - } - scope.calcOnState { val descriptor = arrayDescriptorOf(arrayType) diff --git a/usvm-ts/src/test/kotlin/org/usvm/samples/arrays/ArrayMethods.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/arrays/ArrayMethods.kt index a765ce98f3..9b0dcc5e11 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/samples/arrays/ArrayMethods.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/samples/arrays/ArrayMethods.kt @@ -73,6 +73,56 @@ class ArrayMethods : TsMethodTestRunner() { ) } + @Test + fun testArrayFillCompatibilityBoundsAndExtraArguments() { + val method = getMethod("arrayFillIgnoresExtraArgumentsAndClampsBounds") + + discoverProperties( + method = method, + { result -> result.number == 129.0 }, + ) + } + + @Test + fun testArrayFillClearsBoxedOverride() { + val method = getMethod("arrayFillClearsBoxedOverride") + + discoverProperties( + method = method, + { result -> result.number == 77.0 }, + ) + } + + @Test + fun testArrayFillWithoutValueOnEmptyArray() { + val method = getMethod("arrayFillWithoutValueOnEmptyArray") + + discoverProperties( + method = method, + { result -> result.number == 0.0 }, + ) + } + + @Test + fun testArrayFillWithoutValueOnTypedArray() { + val method = getMethod("arrayFillWithoutValueOnTypedArray") + + discoverProperties( + method = method, + { result -> result.number == 1.0 }, + ) + } + + @Test + fun testArrayFillPreservesBoxedOverridesOutsideRange() { + val method = getMethod("arrayFillPreservesBoxedOverridesOutsideRange") + + discoverProperties( + method = method, + { result -> result.number == 1.0 }, + ) + } + @Test fun testArrayShift() { val method = getMethod("arrayShift") @@ -202,4 +252,14 @@ class ArrayMethods : TsMethodTestRunner() { }, ) } + + @Test + fun testArrayReverseIgnoresArguments() { + val method = getMethod("arrayReverseIgnoresArguments") + + discoverProperties( + method = method, + { result -> result.number == 321.0 }, + ) + } } diff --git a/usvm-ts/src/test/resources/samples/arrays/ArrayMethods.ts b/usvm-ts/src/test/resources/samples/arrays/ArrayMethods.ts index cc46af5c98..d86392b791 100644 --- a/usvm-ts/src/test/resources/samples/arrays/ArrayMethods.ts +++ b/usvm-ts/src/test/resources/samples/arrays/ArrayMethods.ts @@ -42,6 +42,42 @@ class ArrayMethods { } } + arrayFillIgnoresExtraArgumentsAndClampsBounds() { + const values = [1, 2, 3]; + values.fill(9, -1, 10, 100); + return values[0] * 100 + values[1] * 10 + values[2]; + } + + arrayFillClearsBoxedOverride() { + const values: number[] = [1, 2]; + const alias: any[] = values; + alias[0] = undefined; + alias.fill(7); + return values[0] * 10 + values[1]; + } + + arrayFillWithoutValueOnEmptyArray() { + const values: any[] = []; + values.fill(); + return values.length; + } + + arrayFillWithoutValueOnTypedArray() { + const values: number[] = [1, 2]; + const alias: any[] = values; + alias.fill(); + return alias[0] === undefined && alias[1] === undefined ? 1 : -1; + } + + arrayFillPreservesBoxedOverridesOutsideRange() { + const values: number[] = [1, 2, 3]; + const alias: any[] = values; + alias[0] = undefined; + alias[2] = undefined; + alias.fill(7, 1, 2); + return alias[0] === undefined && alias[1] === 7 && alias[2] === undefined ? 1 : -1; + } + arrayShift(x: boolean) { const arr = [10, 20, 30]; const firstElement = arr.shift(); @@ -118,4 +154,10 @@ class ArrayMethods { return arr; // [3, 2, 1] - modified in place } } + + arrayReverseIgnoresArguments() { + const values = [1, 2, 3]; + values.reverse(99); + return values[0] * 100 + values[1] * 10 + values[2]; + } } From 48b1190bbce678e2e26b3a818e361d9526fdb79b Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 01:36:24 +0300 Subject: [PATCH 51/97] Decode added object fields from their physical storage --- .../org/usvm/samples/types/RuntimeNewTest.kt | 33 +++++++++++++++++++ .../kotlin/org/usvm/util/TsTestResolver.kt | 11 +++++-- .../resources/samples/types/RuntimeNew.ts | 9 +++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/usvm-ts/src/test/kotlin/org/usvm/samples/types/RuntimeNewTest.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/types/RuntimeNewTest.kt index d85e220e69..60e850fd05 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/samples/types/RuntimeNewTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/samples/types/RuntimeNewTest.kt @@ -384,6 +384,39 @@ class RuntimeNewTest : TsMethodTestRunner() { }) } + @Test + fun `returned object materializes added boxed fields alongside declared fields`() { + val methodName = "returnAddedFieldFromBoxedValue" + val method = getMethod(methodName = methodName, className = "RuntimeNew") + val hasExpectedFields: (TsTestValue.TsBoolean, TsTestValue.TsClass) -> Boolean = { input, result -> + val marker = result.properties["marker"] as? TsTestValue.TsNumber + val value = result.properties["value"] + val expectedValue = if (input.value) { + (value as? TsTestValue.TsNumber)?.number == 7.0 + } else { + value == TsTestValue.TsBoolean(false) + } + + result.properties.keys == setOf("marker", "value") && marker?.number == 1.0 && expectedValue + } + + withOptions(options = options.copy(stateCollectionStrategy = StateCollectionStrategy.ALL, stopOnCoverage = 0)) { + discoverProperties( + method = method, + { input, result -> input.value && hasExpectedFields(input, result) }, + { input, result -> !input.value && hasExpectedFields(input, result) }, + invariants = arrayOf(hasExpectedFields), + ) + } + + replay(listOf( + "const boxedNumber = new RuntimeNew().$methodName(true); " + + "if (boxedNumber.marker !== 1 || boxedNumber.value !== 7) throw Error('boxed number witness');", + "const boxedBoolean = new RuntimeNew().$methodName(false); " + + "if (boxedBoolean.marker !== 1 || boxedBoolean.value !== false) throw Error('boxed boolean witness');", + )) + } + @Test fun `incompatible boxed field writes stop the step and remain explicit`() { val methods = listOf("writeIncompatibleBoxedValue", "writeIncompatibleBoxedValueByKey") diff --git a/usvm-ts/src/test/kotlin/org/usvm/util/TsTestResolver.kt b/usvm-ts/src/test/kotlin/org/usvm/util/TsTestResolver.kt index 50d2b57ecd..ecc3a2f44a 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/util/TsTestResolver.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/util/TsTestResolver.kt @@ -621,7 +621,7 @@ open class TsTestStateResolver( properties.remove(name) } else if (name !in properties) { val sort = propertyState?.writtenObjectLiteralFieldSorts?.get(ref to name) ?: addressSort - properties[name] = resolveLValue(mkFieldLValue(sort, heapRef, name)) + properties[name] = resolveObjectFieldValue(ref, heapRef, name, sort) } } } @@ -632,8 +632,15 @@ open class TsTestStateResolver( name: String, type: EtsType, initial: Boolean = false, + ): TsTestValue = resolveObjectFieldValue(ref, heapRef, name, ctx.typeToSort(type), initial) + + private fun resolveObjectFieldValue( + ref: UConcreteHeapRef, + heapRef: UHeapRef, + name: String, + sort: USort, + initial: Boolean = false, ): TsTestValue = with(ctx) { - val sort = typeToSort(type) val valueMemory = if (initial) model else memory val valueRef = if (initial || resolveMode == ResolveMode.MODEL) ref else heapRef if (sort !is TsUnresolvedSort) { diff --git a/usvm-ts/src/test/resources/samples/types/RuntimeNew.ts b/usvm-ts/src/test/resources/samples/types/RuntimeNew.ts index bdf9810d9d..1e5b424204 100644 --- a/usvm-ts/src/test/resources/samples/types/RuntimeNew.ts +++ b/usvm-ts/src/test/resources/samples/types/RuntimeNew.ts @@ -142,6 +142,15 @@ class RuntimeNew { return useNumber ? target.value === 7 : target.value === false; } + returnAddedFieldFromBoxedValue(useNumber: boolean): any { + const source: any = { payload: useNumber ? 7 : false }; + const target: any = { marker: 1 }; + + target.value = source.payload; + + return target; + } + writeTypedPrimitiveFromBoxedValue(useNumber: boolean): boolean { const source: any = { payload: useNumber ? 7 : false }; const target = new NewTypedFields(); From c7b33f34f19be27be608011ca6f493db0865d2f2 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 01:43:30 +0300 Subject: [PATCH 52/97] Clarify source target observer predicates --- .../ts/calls/CurrentTsCallsSymbolicEngine.kt | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt index e7a79b4a32..246af63366 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt @@ -534,8 +534,8 @@ internal class CurrentTsCallsSymbolicEngine( private fun exactTargetCandidates(method: EtsMethod, target: CallsSourceTarget): List = method.cfg.stmts.filter { statement -> - val origin = statement.location.origin ?: return@filter false - origin.startOffset == target.startOffset && + val origin = statement.location.origin + origin != null && origin.startOffset == target.startOffset && origin.endOffset == target.endOffset && origin.startLine == target.start.line && origin.startColumn == target.start.column && @@ -562,8 +562,8 @@ internal class CurrentTsCallsSymbolicEngine( } val containingReturns = method.cfg.stmts.filterIsInstance().filter { statement -> - val origin = statement.location.origin ?: return@filter false - origin.startOffset <= expressionStart && origin.endOffset >= expressionEnd + val origin = statement.location.origin + origin != null && origin.startOffset <= expressionStart && origin.endOffset >= expressionEnd } val smallestContainingRange = containingReturns.minOfOrNull { statement -> val origin = requireNotNull(statement.location.origin) @@ -580,8 +580,8 @@ internal class CurrentTsCallsSymbolicEngine( } val containingStatements = method.cfg.stmts.filter { statement -> - val origin = statement.location.origin ?: return@filter false - origin.startOffset <= expressionStart && origin.endOffset >= expressionEnd + val origin = statement.location.origin + origin != null && origin.startOffset <= expressionStart && origin.endOffset >= expressionEnd } val smallestStatementRange = containingStatements.minOfOrNull { statement -> val origin = requireNotNull(statement.location.origin) @@ -789,8 +789,8 @@ internal data class SourceStatementEntry( internal fun sourceStatementEntry(method: EtsMethod, target: CallsSourceTarget): SourceStatementEntry? { val loweringRegion = method.cfg.stmts.filter { statement -> - val origin = statement.location.origin ?: return@filter false - origin.startOffset >= target.startOffset && origin.endOffset <= target.endOffset + val origin = statement.location.origin + origin != null && origin.startOffset >= target.startOffset && origin.endOffset <= target.endOffset } val loweringRegionSet = loweringRegion.toSet() if (loweringRegionSet.isEmpty()) { @@ -806,8 +806,8 @@ internal fun sourceStatementEntry(method: EtsMethod, target: CallsSourceTarget): predecessor !in loweringRegionSet } val outsideOriginsAreBeforeTarget = outsidePredecessors.all { predecessor -> - val origin = predecessor.location.origin ?: return@all false - origin.endOffset <= target.startOffset + val origin = predecessor.location.origin + origin != null && origin.endOffset <= target.startOffset } if (!outsideOriginsAreBeforeTarget) { return null From af329a0d09645805abb20e0f6ec6d24186db11b7 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 01:53:32 +0300 Subject: [PATCH 53/97] Ignore evaluated extra array model arguments --- .../call/intrinsic/TsArrayEtsIrModelFamily.kt | 30 ++++++++----------- .../call/TsArrayMutationEtsIrModelTest.kt | 24 +++++++++++++++ .../resources/models/ArrayMutationEtsIr.ts | 21 +++++++++++++ 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt index b2dab02fd5..75d3f52035 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt @@ -243,17 +243,17 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { private val fillAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> val inputs = call.resolvedInstanceInputs( - maxArgumentCount = call.arguments.size, + maxArgumentCount = MAX_FILL_ARGUMENTS, ) ?: return@TsEtsIrUnknownCallModelInputAdapter null val arguments = inputs.drop(1) - if (arguments.isEmpty() || arguments.size > MAX_FILL_ARGUMENTS) { + if (arguments.isEmpty()) { return@TsEtsIrUnknownCallModelInputAdapter null } - if ( - arguments.drop(1).any { value -> - value != state.ctx.mkUndefinedValue() && value.sort != state.ctx.fp64Sort - } - ) { + + val hasUnsupportedIndex = arguments.drop(1).any { value -> + value != state.ctx.mkUndefinedValue() && value.sort != state.ctx.fp64Sort + } + if (hasUnsupportedIndex) { return@TsEtsIrUnknownCallModelInputAdapter null } @@ -268,13 +268,13 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { private val sliceAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> val inputs = call.resolvedInstanceInputs( - maxArgumentCount = call.arguments.size, + maxArgumentCount = 2, ) ?: return@TsEtsIrUnknownCallModelInputAdapter null val arguments = inputs.drop(1) - if (arguments.size > 2 || arguments.any { value -> - value != state.ctx.mkUndefinedValue() && value.sort != state.ctx.fp64Sort - } - ) { + val hasUnsupportedIndex = arguments.any { value -> + value != state.ctx.mkUndefinedValue() && value.sort != state.ctx.fp64Sort + } + if (hasUnsupportedIndex) { return@TsEtsIrUnknownCallModelInputAdapter null } @@ -287,10 +287,6 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { listOf(inputs.first(), start, end) } - private val noArgumentsAdapter = TsEtsIrUnknownCallModelInputAdapter { _, call -> - if (call.arguments.isEmpty()) call.resolvedInstanceInputs(maxArgumentCount = call.arguments.size) else null - } - private val arrayConstructorAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> if (!call.hasGlobalArrayConstructorShape() || call.arguments.size != 1) { return@TsEtsIrUnknownCallModelInputAdapter null @@ -363,7 +359,7 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { sourceModel( id = "ts.array.reverse", methodName = "reverse", - inputAdapter = noArgumentsAdapter, + inputAdapter = receiverOnlyAdapter, domainGuard = TsArraySlotPrimitives.reverseDomain, ), sourceModel( diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayMutationEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayMutationEtsIrModelTest.kt index 57ca99dc99..4b3eea8c6a 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayMutationEtsIrModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayMutationEtsIrModelTest.kt @@ -121,6 +121,30 @@ class TsArrayMutationEtsIrModelTest { assertTrue("ts.array.concat" in concatenated.modelIds) } + @Test + fun `array models evaluate and ignore extra arguments`() { + val cases = mapOf( + "sliceIgnoresExtraString" to (12.0 to "ts.array.slice"), + "sliceEvaluatesIgnoredArgument" to (1237.0 to "ts.array.slice"), + "fillIgnoresExtraString" to (993.0 to "ts.array.fill"), + "reverseIgnoresExtraString" to (321.0 to "ts.array.reverse"), + ) + + for ((methodName, expected) in cases) { + val result = analyze( + methodName = methodName, + denseInputs = listOf(listOf(1.0, 2.0, 3.0)), + ) + + assertEquals(expected.first, result.singleNumber(), methodName) + assertTrue(expected.second in result.modelIds, "$methodName: ${result.events}") + assertTrue( + result.events.none { event -> event.decision is TsUnknownCallDecision.ResidualFallback }, + "$methodName: ${result.events}", + ) + } + } + @Test fun `reverse without a slot presence or dense input proof remains residual`() { val result = analyze(methodName = "unprovenReverseUsesResidual") diff --git a/usvm-ts/src/test/resources/models/ArrayMutationEtsIr.ts b/usvm-ts/src/test/resources/models/ArrayMutationEtsIr.ts index c6fba0783d..32b4fa0d66 100644 --- a/usvm-ts/src/test/resources/models/ArrayMutationEtsIr.ts +++ b/usvm-ts/src/test/resources/models/ArrayMutationEtsIr.ts @@ -68,6 +68,27 @@ export class ArrayMutationEtsIr { + values.length; } + sliceIgnoresExtraString(values: number[]): number { + const result = values.slice(0, 2, "ignored"); + return result[0] * 10 + result[1]; + } + + sliceEvaluatesIgnoredArgument(values: number[]): number { + let extraEffect = 0; + const result = values.slice(0, 2, extraEffect = 7); + return result[0] * 1000 + result[1] * 100 + values[2] * 10 + extraEffect; + } + + fillIgnoresExtraString(values: number[]): number { + values.fill(9, 0, 2, "ignored"); + return values[0] * 100 + values[1] * 10 + values[2]; + } + + reverseIgnoresExtraString(values: number[]): number { + values.reverse("ignored"); + return values[0] * 100 + values[1] * 10 + values[2]; + } + concatDense(values: number[], other: number[]): number { const result = values.concat(other); return result.length * 10000 From e4a75ceda942fdf9b832355ffb8a62f326c60d76 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 01:53:36 +0300 Subject: [PATCH 54/97] Separate runtime telemetry setup from event handler --- .../main/kotlin/org/usvm/ts/calls/CallsUnknownCallTelemetry.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsUnknownCallTelemetry.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsUnknownCallTelemetry.kt index c2015aaf42..457723ee3c 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsUnknownCallTelemetry.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsUnknownCallTelemetry.kt @@ -89,6 +89,7 @@ internal fun callsRuntimeLimitationEventSink( appendAndFlush: (CallsRuntimeLimitationRecord) -> Unit, ): (TsRuntimeFeatureLimitationEvent) -> Unit { var eventIndex = 0 + return { event -> eventIndex++ val record = CallsRuntimeLimitationRecord( From 3eb8dafe951ac7da3ad4e764667674571544d5f0 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 02:17:45 +0300 Subject: [PATCH 55/97] Stop unsupported raw TypeScript statements explicitly --- .../usvm/machine/interpreter/TsInterpreter.kt | 4 ++ .../org/usvm/machine/TsRawStmtBoundaryTest.kt | 43 +++++++++++++++++++ .../resources/models/RawStatementBoundary.js | 7 +++ 3 files changed, 54 insertions(+) create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/TsRawStmtBoundaryTest.kt create mode 100644 usvm-ts/src/test/resources/models/RawStatementBoundary.js diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt index a437105718..264a7dfafa 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt @@ -20,6 +20,7 @@ import org.jacodb.ets.model.EtsLocal import org.jacodb.ets.model.EtsMethod import org.jacodb.ets.model.EtsNopStmt import org.jacodb.ets.model.EtsPropertyRef +import org.jacodb.ets.model.EtsRawStmt import org.jacodb.ets.model.EtsRefType import org.jacodb.ets.model.EtsReturnStmt import org.jacodb.ets.model.EtsStaticFieldRef @@ -161,6 +162,9 @@ class TsInterpreter( is EtsCallStmt -> visitCallStmt(scope, stmt) is EtsThrowStmt -> visitThrowStmt(scope, stmt) is EtsNopStmt -> visitNopStmt(scope, stmt) + is EtsRawStmt -> { + throw UnsupportedOperationException("Unsupported frontend statement: $stmt") + } else -> { logger.error { "Unknown stmt: $stmt" } scope.doWithState { diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/TsRawStmtBoundaryTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/TsRawStmtBoundaryTest.kt new file mode 100644 index 0000000000..8b24a30b0b --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/TsRawStmtBoundaryTest.kt @@ -0,0 +1,43 @@ +package org.usvm.machine + +import org.jacodb.ets.model.EtsRawStmt +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.junit.jupiter.api.Test +import org.usvm.SolverType +import org.usvm.StateCollectionStrategy +import org.usvm.UMachineOptions +import org.usvm.util.getResourcePath +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TsRawStmtBoundaryTest { + @Test + fun `reachable raw statement terminates as unsupported before the following return`() { + val file = loadEtsFileAutoConvert( + getResourcePath("/models/RawStatementBoundary.js"), + provider = EtsIrProvider.TS_FRONTEND, + ) + val scene = EtsScene(projectFiles = listOf(file)) + val method = scene.projectClasses.flatMap { it.methods } + .single { it.name == "rawWithSideEffect" } + val rawStatement = method.cfg.stmts.filterIsInstance().single() + val options = UMachineOptions( + solverType = SolverType.YICES, + stateCollectionStrategy = StateCollectionStrategy.ALL, + stopOnCoverage = 0, + throwExceptionOnStepFailure = false, + ) + + val outcome = TsMachine(scene = scene, options = options, tsOptions = TsOptions()).use { machine -> + machine.analyzeWithMetadata(methods = listOf(method)) + } + + assertTrue(rawStatement.kind == "UnsupportedStmt") + assertTrue("WithStatement" in rawStatement.extra["kindName"].toString()) + assertTrue(outcome.states.isEmpty()) + assertTrue(outcome.unsupportedPaths.any { "WithStatement" in it }, outcome.unsupportedPaths.toString()) + assertFalse(outcome.engineFailed) + } +} diff --git a/usvm-ts/src/test/resources/models/RawStatementBoundary.js b/usvm-ts/src/test/resources/models/RawStatementBoundary.js new file mode 100644 index 0000000000..a37b9f67fa --- /dev/null +++ b/usvm-ts/src/test/resources/models/RawStatementBoundary.js @@ -0,0 +1,7 @@ +function rawWithSideEffect() { + let x = 0; + with ({}) { + x = 1; + } + return x; +} From bad193bb5864b6c37ce71ed5b190dd9508bd5ef1 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 02:18:54 +0300 Subject: [PATCH 56/97] Accept ignored extra arguments in the String replaceAll model --- .../call/intrinsic/TsStringEtsIrModelFamily.kt | 2 +- .../machine/call/TsStringReplaceAllEtsIrModelTest.kt | 10 ++++++++++ .../test/resources/models/StringReplaceAllEtsIr.ts | 12 ++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt index 501a84638f..03cffaef8d 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt @@ -156,7 +156,7 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { } private val replaceAllAdapter = TsEtsIrUnknownCallModelInputAdapter { _, call -> - if (call.arguments.size == 2) call.resolvedInstanceInputs(maxArgumentCount = 2) else null + if (call.arguments.size >= 2) call.resolvedInstanceInputs(maxArgumentCount = 2) else null } private val replaceAllDomain = TsEtsIrUnknownCallModelDomainGuard { state, _, inputs -> diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsStringReplaceAllEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsStringReplaceAllEtsIrModelTest.kt index f0f6df7652..4b9232e2f4 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsStringReplaceAllEtsIrModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsStringReplaceAllEtsIrModelTest.kt @@ -46,9 +46,19 @@ class TsStringReplaceAllEtsIrModelTest { } } + @Test + fun `replaceAll evaluates ignored extra argument and applies the model`() { + val (values, events) = analyze(methodName = "ignoredExtraArgumentIsEvaluated") + + assertTrue(assertIs(values.single()).value) + assertTrue(events.any { (it.decision as? TsUnknownCallDecision.ModelApplied)?.modelId == MODEL_ID }) + assertTrue(events.all { it.outcome == TsUnknownCallOutcome.MODEL_APPLIED }) + } + @Test fun `non string arguments remain explicit residual calls`() { val cases = listOf( + "missingReplacementIsResidual", "numericSearchIsResidual", "numericReplacementIsResidual", "callbackReplacementIsResidual", diff --git a/usvm-ts/src/test/resources/models/StringReplaceAllEtsIr.ts b/usvm-ts/src/test/resources/models/StringReplaceAllEtsIr.ts index 685f56c3a4..945029a62d 100644 --- a/usvm-ts/src/test/resources/models/StringReplaceAllEtsIr.ts +++ b/usvm-ts/src/test/resources/models/StringReplaceAllEtsIr.ts @@ -24,6 +24,18 @@ export class StringReplaceAllEtsIr { return "aa".replaceAll("a", "aa"); } + ignoredExtraArgumentIsEvaluated(): boolean { + let evaluations = 0; + + const result = "aab".replaceAll("a", "X", (evaluations = evaluations + 1)); + + return result === "XXb" && evaluations === 1; + } + + missingReplacementIsResidual(): string { + return "abc".replaceAll("a"); + } + numericSearchIsResidual(): string { return "123".replaceAll(1, "x"); } From f3a31a6119b8df86b60564c70ae5189fe410eecd Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 02:19:52 +0300 Subject: [PATCH 57/97] Resolve source bindings before numeric builtin local fallbacks --- .../org/usvm/machine/expr/TsExprResolver.kt | 27 ++++++++++- .../test/kotlin/org/usvm/samples/lang/Arg.kt | 45 +++++++++++++++++++ .../src/test/resources/samples/lang/Arg.ts | 20 +++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt index b112512c5f..db89e8e3a5 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt @@ -1597,6 +1597,23 @@ class TsSimpleValueResolver( private val scope: TsStepScope, ) : EtsValue.Visitor?> { + private fun hasSourceBinding(local: EtsLocal): Boolean { + val currentMethod = scope.calcOnState { lastEnteredMethod } + val isCurrentLocal = ctx.getLocalIdx(local, currentMethod) != null + val isParameter = currentMethod.parameters.any { it.name == local.name } + if (isCurrentLocal || isParameter) return true + + val currentFile = currentMethod.enclosingClass?.declaringFile + val defaultClass = currentFile?.classes?.firstOrNull { it.name == DEFAULT_ARK_CLASS_NAME } + val defaultMethod = defaultClass?.methods?.firstOrNull { it.name == DEFAULT_ARK_METHOD_NAME } + val isGlobal = defaultMethod?.cfg?.stmts + ?.filterIsInstance() + ?.any { (it.lhv as? EtsLocal)?.name == local.name } == true + val isImported = currentFile?.importInfos?.any { it.name == local.name } == true + + return isGlobal || isImported + } + private fun resolveLocal(local: EtsValue): UExpr<*>? = with(ctx) { check(local is EtsLocal || local is EtsThis || local is EtsParameterRef) { "Expected EtsLocal, EtsThis, or EtsParameterRef, but got ${local::class.java}: $local" @@ -1669,7 +1686,8 @@ class TsSimpleValueResolver( } // If local is a global variable: - if (globals.any { it.name == local.name }) { + val isGlobal = globals.any { it.name == local.name } + if (isGlobal) { logger.info { "Reading global variable: $local in $file" } return readGlobal(scope, file, local.name) } @@ -1705,11 +1723,16 @@ class TsSimpleValueResolver( } override fun visit(local: EtsLocal): UExpr? { + val isNonFiniteNumericName = local.name == "NaN" || local.name == "Infinity" + if (isNonFiniteNumericName && hasSourceBinding(local)) { + return resolveLocal(local) + } + if (local.name == "NaN") { return ctx.mkFp64NaN() } if (local.name == "Infinity") { - return ctx.mkFpInf(false, ctx.fp64Sort) + return ctx.mkFpInf(signBit = false, ctx.fp64Sort) } if (local.name.startsWith(NAMED_FUNCTION_REFERENCE_PREFIX)) { diff --git a/usvm-ts/src/test/kotlin/org/usvm/samples/lang/Arg.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/lang/Arg.kt index ee9a809e66..2ec358257c 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/samples/lang/Arg.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/samples/lang/Arg.kt @@ -29,4 +29,49 @@ class Arg : TsMethodTestRunner() { ) ) } + + @Test + fun `shadowed Infinity reads its parameter`() { + val method = getMethod(methodName = "shadowInfinity") + + discoverProperties( + method = method, + { argument, result -> argument.number == 3.0 && result.number == 3.0 }, + { argument, result -> argument.number == -2.0 && result.number == -2.0 }, + invariants = arrayOf({ argument, result -> + if (argument.isNaN()) result.isNaN() else result.number == argument.number + }), + ) + } + + @Test + fun `shadowed NaN reads its parameter`() { + val method = getMethod(methodName = "shadowNaN") + + discoverProperties( + method = method, + { argument, result -> argument.number == 4.0 && result.number == 4.0 }, + { argument, result -> argument.number == -5.0 && result.number == -5.0 }, + invariants = arrayOf({ argument, result -> + if (argument.isNaN()) result.isNaN() else result.number == argument.number + }), + ) + } + + @Test + fun `builtin nonfinite values remain numeric constants`() { + val infinityMethod = getMethod(methodName = "builtinInfinity") + val nanMethod = getMethod(methodName = "builtinNaN") + + discoverProperties( + method = infinityMethod, + { result -> result.number == Double.POSITIVE_INFINITY }, + invariants = arrayOf({ result -> result.number == Double.POSITIVE_INFINITY }), + ) + discoverProperties( + method = nanMethod, + { result -> result.isNaN() }, + invariants = arrayOf({ result -> result.isNaN() }), + ) + } } diff --git a/usvm-ts/src/test/resources/samples/lang/Arg.ts b/usvm-ts/src/test/resources/samples/lang/Arg.ts index 8ea4e1073c..401f3c7387 100644 --- a/usvm-ts/src/test/resources/samples/lang/Arg.ts +++ b/usvm-ts/src/test/resources/samples/lang/Arg.ts @@ -8,4 +8,24 @@ class Arg { if (a == 1) return a; return a; } + + shadowInfinity(Infinity: number): number { + if (Infinity === 3) return Infinity; + if (Infinity === -2) return Infinity; + return Infinity; + } + + shadowNaN(NaN: number): number { + if (NaN === 4) return NaN; + if (NaN === -5) return NaN; + return NaN; + } + + builtinInfinity(): number { + return Infinity; + } + + builtinNaN(): number { + return NaN; + } } From 82e08a38b35d1e8b6afeb2cd76202f6c61f72575 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 02:22:11 +0300 Subject: [PATCH 58/97] Separate String replaceAll and substitution model operations --- .../main/resources/org/usvm/machine/call/models/StringModels.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts index 5ee6af4642..2e7c6a05c1 100644 --- a/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts @@ -187,6 +187,7 @@ export class StringModels { } return result + StringModels.slice(receiver, endOfLastMatch, length); } + private static substitution(receiver: string, search: string, replacement: string, position: number): string { const replacementLength = replacement.length; let result = ""; From 759fa12f9f7e1bb1d65ecd3f00eaaae2b0548765 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 02:31:26 +0300 Subject: [PATCH 59/97] Name the frontend numeric snapshot guard before update resolution --- .../src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt index db89e8e3a5..7733eff14a 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt @@ -396,9 +396,11 @@ class TsExprResolver( // The native frontend saves ToNumeric's result and writes the source target // in a separate assignment. Mutating that snapshot would corrupt postfix results. - val numericSnapshot = target is EtsLocal && target.name.startsWith("%") && scope.calcOnState { + val frontendTemp = target as? EtsLocal + val isFrontendTemp = frontendTemp?.name?.startsWith("%") == true + val numericSnapshot = isFrontendTemp && scope.calcOnState { lastEnteredMethod.cfg.stmts.any { stmt -> - stmt is EtsAssignStmt && stmt.lhv == target && stmt.rhv is EtsToNumericExpr + stmt is EtsAssignStmt && stmt.lhv == frontendTemp && stmt.rhv is EtsToNumericExpr } } if (numericSnapshot) return updatedValue From 80ec4fd06635aff305f2f5468a87f3bba7e6b4dc Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 02:37:04 +0300 Subject: [PATCH 60/97] Clarify legacy array approximations and remove unused sort threading --- .../usvm/machine/expr/CallApproximations.kt | 67 ++----------------- 1 file changed, 5 insertions(+), 62 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index ad43d031d7..d1ffcbd0fb 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -239,7 +239,7 @@ internal fun TsExprResolver.tryApproximateInstanceCall( // Handle Array search and indexed access method calls. if (expr.callee.name in setOf("indexOf", "lastIndexOf")) { - return handleArrayIndexSearchCall(stmt, instanceType, elementSort, array) + return handleArrayIndexSearchCall(stmt, instanceType, array) } // Handle `Array.includes() method calls @@ -313,7 +313,6 @@ private fun TsExprResolver.hasProjectToStringTarget(stmt: TsVirtualMethodCallStm private fun TsExprResolver.handleArrayIndexSearchCall( stmt: TsVirtualMethodCallStmt, instanceType: EtsArrayType, - elementSort: USort, array: UHeapRef, ): TsExprApproximationResult { val dispatcher = unknownCallDispatcher @@ -322,7 +321,7 @@ private fun TsExprResolver.handleArrayIndexSearchCall( return TsExprApproximationResult.NoApproximation } - return from(handleArrayIndexOf(stmt.call, instanceType, elementSort, array)) + return from(handleArrayIndexOf(stmt.call, instanceType, array)) } dispatchArrayModel(stmt) @@ -987,26 +986,7 @@ private fun TsExprResolver.handleArrayUnshift( ) } -/** - * Handles the `Array.join(separator)` method call. - * Joins all elements of the array into a string, separated by the specified `separator`. - * If `separator` is not provided, it defaults to `","`. - * This method returns the resulting string. - * - * ### Examples: - * ``` - * let a = [1, 2, 3]; - * - * a.join() -> "1,2,3" (default comma separator) - * a.join("-") -> "1-2-3" (custom separator) - * a.join("") -> "123" (no separator) - * - * let b = ["hello", "world"]; - * b.join(" ") -> "hello world" - * ``` - * - * https://tc39.es/ecma262/multipage/indexed-collections.html#sec-array.prototype.join - */ +/** Compatibility approximation used when the source model dispatcher is unavailable. */ private fun TsExprResolver.handleArrayJoin( expr: EtsInstanceCallExpr, ): UExpr<*>? = with(ctx) { @@ -1276,29 +1256,10 @@ private fun TsExprResolver.handleArrayConcat( ) } -/** - * Handles the `Array.indexOf(searchElement)` method call. - * Returns the index of the first occurrence of `searchElement` in the array, or `-1` if not found. - * - * ### Examples: - * ``` - * let a = [1, 2, 3, 2, 4]; - * - * a.indexOf(2) -> 1 (first occurrence at index 1) - * a.indexOf(5) -> -1 (not found) - * a.indexOf(3) -> 2 (found at index 2) - * - * let b = ["hello", "world", "hello"]; - * b.indexOf("hello") -> 0 (first occurrence) - * b.indexOf("foo") -> -1 (not found) - * ``` - * - * https://tc39.es/ecma262/multipage/indexed-collections.html#sec-array.prototype.indexof - */ +/** Compatibility approximation constrained to -1 or an in-range index. */ private fun TsExprResolver.handleArrayIndexOf( expr: EtsInstanceCallExpr, arrayType: EtsArrayType, - elementSort: USort, array: UHeapRef, ): UExpr<*>? = with(ctx) { check(expr.args.size == 1) { @@ -1337,25 +1298,7 @@ private fun TsExprResolver.handleArrayIndexOf( } } -/** - * Handles the `Array.includes(searchElement)` method call. - * Returns `true` if the array contains the `searchElement`, and `false` otherwise. - * - * ### Examples: - * ``` - * let a = [1, 2, 3, 4, 5]; - * - * a.includes(3) -> true (element found) - * a.includes(6) -> false (element not found) - * a.includes(1) -> true (first element) - * - * let b = ["hello", "world"]; - * b.includes("hello") -> true (string found) - * b.includes("foo") -> false (string not found) - * ``` - * - * https://tc39.es/ecma262/multipage/indexed-collections.html#sec-array.prototype.includes - */ +/** Compatibility approximation with an unconstrained boolean result. */ private fun TsExprResolver.handleArrayIncludes( expr: EtsInstanceCallExpr, ): UExpr<*>? = with(ctx) { From 971a864bb861fd7d5be0c0508d13a81b925c42d8 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 02:44:23 +0300 Subject: [PATCH 61/97] Preserve target mode when validating stored Calls witnesses --- .../org/usvm/ts/calls/CallsWitnessReplay.kt | 1 + .../org/usvm/ts/calls/CallsExperimentTest.kt | 57 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsWitnessReplay.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsWitnessReplay.kt index 101b412064..c5f95c8d02 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsWitnessReplay.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsWitnessReplay.kt @@ -133,6 +133,7 @@ internal class CallsWitnessReplayer( functionId = function.functionId, targetId = target.targetId, siteId = target.siteId, + targetMode = target.mode, ) } } diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt index 872cb2e37c..3e5376373c 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt @@ -436,6 +436,63 @@ class CallsExperimentTest { assertEquals(directory.toRealPath(), verifiedCheckout) } + @Test + fun `single witness replay accepts stored completed return target`(@TempDir directory: Path) { + val baseManifest = manifest(sourceRoot = ".", seeds = listOf(13L)) + val project = baseManifest.projects.single() + val function = project.functions.single() + val completedReturn = function.targets.single().copy( + mode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpressionStartOffset = 7, + returnExpressionEndOffset = 10, + ) + val frozenManifest = baseManifest.copy( + projects = listOf(project.copy(functions = listOf(function.copy(targets = listOf(completedReturn))))), + ) + val rawOutput = directory.resolve("results.jsonl") + val storedInputs = listOf(JsConcreteValue.Boolean(true)) + CallsExperimentRunner( + symbolicEngine = CallsSymbolicEngine { + result(status = CallsSymbolicStatus.REACHED, inputs = storedInputs) + }, + targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> + CallsSourceReplayResult(status = CallsReplayStatus.CONFIRMED) + }, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + sourceCheckoutVerifier = { _, _ -> }, + ).run( + manifest = frozenManifest, + manifestDirectory = directory, + rawOutput = rawOutput, + ) + + val raw = CallsRawResultsReader.read(rawOutput) + val storedRow = raw.results.single { row -> row.profile == CallsExperimentProfile.FROZEN_STOP } + var replayedInputs: List? = null + var replayedTarget: CallsSourceTarget? = null + val replay = CallsWitnessReplayer( + targetReplayer = CallsTargetReplayer { _, _, inputs, target, _ -> + replayedInputs = inputs + replayedTarget = target + CallsSourceReplayResult(status = CallsReplayStatus.REJECTED) + }, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + verifyProjectCheckout = { _, _ -> }, + ).replay( + manifest = frozenManifest, + manifestDirectory = directory, + rawInput = rawOutput, + selector = selector(seed = 13L, profile = CallsExperimentProfile.FROZEN_STOP), + ) + + assertEquals(CallsSourceTargetMode.COMPLETED_RETURN, raw.metadata.targets.single().targetMode) + assertEquals(CallsSourceTargetMode.COMPLETED_RETURN, storedRow.targetMode) + assertEquals(storedInputs, storedRow.inputs) + assertEquals(storedInputs, replayedInputs) + assertEquals(completedReturn, replayedTarget) + assertEquals(CallsReplayStatus.REJECTED, replay.status) + } + @Test fun `single witness replay selects function when projects share an ID`(@TempDir directory: Path) { val baseManifest = manifest(sourceRoot = ".", seeds = listOf(19L)) From 97be34ceade474c0e7172d39de0ec21432d320f6 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 02:48:02 +0300 Subject: [PATCH 62/97] Clarify coverage replay types and plain-object encoding --- .../kotlin/org/usvm/ts/calls/CallsCoverageReplay.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsCoverageReplay.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsCoverageReplay.kt index 1dadb1eec8..33707ba952 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsCoverageReplay.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsCoverageReplay.kt @@ -7,10 +7,12 @@ import org.usvm.ts.pbt.backend.PropertyCoverageRequest import org.usvm.ts.pbt.backend.PropertyRunConfiguration import org.usvm.ts.pbt.backend.PropertyRunStatus import org.usvm.ts.pbt.backend.SourceFileCoverage +import org.usvm.ts.pbt.backend.SourceRange import org.usvm.ts.pbt.fastcheck.FastCheckBackend import org.usvm.ts.pbt.fastcheck.FastCheckRuntime import org.usvm.ts.pbt.fastcheck.TypeScriptSourceInspector import org.usvm.ts.pbt.model.BooleanDomain +import org.usvm.ts.pbt.model.ExecutionKind import org.usvm.ts.pbt.model.JsConcreteValue import org.usvm.ts.pbt.model.PropertyDefinition import org.usvm.ts.pbt.model.PropertyId @@ -71,7 +73,7 @@ internal class OriginalTypeScriptCoverageReplayer( } private fun prepareReplay(): CoverageReplaySetup { - require(function.entryPoint.executionKind == org.usvm.ts.pbt.model.ExecutionKind.SYNC) { + require(function.entryPoint.executionKind == ExecutionKind.SYNC) { "Coverage replay requires a synchronous entry point" } val source = sourceRoot.resolve(function.sourceFile).normalize() @@ -262,7 +264,7 @@ private fun collectHits( } } -private fun org.usvm.ts.pbt.backend.SourceRange.stableKey(): String = +private fun SourceRange.stableKey(): String = "${start.line}:${start.column}-${end.line}:${end.column}" private fun coverageWrapper( @@ -302,8 +304,9 @@ private fun coverageWrapper( return { kind: 'number', value: 'finite', bits }; } if (Array.isArray(value)) return { kind: 'array', elements: value.map(encodeValue) }; - if (value !== null && typeof value === 'object' && - (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)) { + const isPlainObject = value !== null && typeof value === 'object' && + (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); + if (isPlainObject) { const fields = Object.fromEntries(Object.entries(value).map(([name, field]) => { if (['__proto__', 'constructor', 'prototype'].includes(name)) { throw new Error('Unsupported replay object property name'); From 3bd31811a492cf11eba18634ae7bc8a276ca79fd Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 02:54:42 +0300 Subject: [PATCH 63/97] Separate static initialization bookkeeping from source field names --- .../org/usvm/machine/interpreter/TsStatic.kt | 12 +++---- .../src/main/kotlin/org/usvm/util/Utils.kt | 1 - .../lang/StaticInitializationMarker.kt | 33 +++++++++++++++++++ .../lang/StaticInitializationMarker.ts | 26 +++++++++++++++ 4 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 usvm-ts/src/test/kotlin/org/usvm/samples/lang/StaticInitializationMarker.kt create mode 100644 usvm-ts/src/test/resources/samples/lang/StaticInitializationMarker.ts diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsStatic.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsStatic.kt index 65218fc1b0..cd5b8518f1 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsStatic.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsStatic.kt @@ -2,7 +2,6 @@ package org.usvm.machine.interpreter import mu.KotlinLogging import org.jacodb.ets.model.EtsClass -import org.jacodb.ets.model.EtsClassSignature import org.jacodb.ets.model.EtsMethod import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME import org.jacodb.ets.utils.DEFAULT_ARK_METHOD_NAME @@ -16,26 +15,25 @@ import org.usvm.machine.state.TsMethodResult import org.usvm.machine.state.TsState import org.usvm.machine.state.localsCount import org.usvm.machine.state.newStmt -import org.usvm.util.mkFieldLValue private val logger = KotlinLogging.logger {} +private object StaticFieldsInitializedFlag private fun mkStaticFieldsInitializedFlag( instance: UHeapRef, - clazz: EtsClassSignature, -): UFieldLValue { - return mkFieldLValue(instance.ctx.boolSort, instance, "__initialized__") +): UFieldLValue<*, UBoolSort> { + return UFieldLValue(instance.ctx.boolSort, instance, StaticFieldsInitializedFlag) } internal fun TsState.isInitialized(clazz: EtsClass): Boolean { val instance = getStaticInstance(clazz) - val initializedFlag = mkStaticFieldsInitializedFlag(instance, clazz.signature) + val initializedFlag = mkStaticFieldsInitializedFlag(instance) return memory.read(initializedFlag).isTrue } internal fun TsState.markStaticsInitialized(clazz: EtsClass) { val instance = getStaticInstance(clazz) - val initializedFlag = mkStaticFieldsInitializedFlag(instance, clazz.signature) + val initializedFlag = mkStaticFieldsInitializedFlag(instance) memory.write(initializedFlag, ctx.trueExpr, guard = ctx.trueExpr) } diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/Utils.kt b/usvm-ts/src/main/kotlin/org/usvm/util/Utils.kt index 5762923b69..51faff657a 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/Utils.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/Utils.kt @@ -20,7 +20,6 @@ import org.usvm.machine.state.TsMethodResult import org.usvm.machine.state.TsState import org.usvm.machine.types.mkFakeValue -// Built-in KContext.bvToBool has identical implementation. fun TsContext.boolToFp(expr: UExpr): UExpr = mkIte(expr, mkFp64(1.0), mkFp64(0.0)) diff --git a/usvm-ts/src/test/kotlin/org/usvm/samples/lang/StaticInitializationMarker.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/lang/StaticInitializationMarker.kt new file mode 100644 index 0000000000..b9abcbe380 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/samples/lang/StaticInitializationMarker.kt @@ -0,0 +1,33 @@ +package org.usvm.samples.lang + +import org.jacodb.ets.model.EtsScene +import org.usvm.api.TsTestValue +import org.usvm.util.TsMethodTestRunner +import org.usvm.util.eq +import kotlin.test.Test + +class StaticInitializationMarker : TsMethodTestRunner() { + override val scene: EtsScene = loadScene("/samples/lang/StaticInitializationMarker.ts") + + @Test + fun `source field does not replace the static initialization marker`() { + val method = getMethod("readAndWrite", className = "StaticInitializationCollision") + + discoverProperties( + method = method, + { result -> result eq 42 }, + invariants = arrayOf({ result -> result eq 42 }), + ) + } + + @Test + fun `independent static class still initializes`() { + val method = getMethod("read", className = "StaticInitializationControl") + + discoverProperties( + method = method, + { result -> result eq 7 }, + invariants = arrayOf({ result -> result eq 7 }), + ) + } +} diff --git a/usvm-ts/src/test/resources/samples/lang/StaticInitializationMarker.ts b/usvm-ts/src/test/resources/samples/lang/StaticInitializationMarker.ts new file mode 100644 index 0000000000..84053556c4 --- /dev/null +++ b/usvm-ts/src/test/resources/samples/lang/StaticInitializationMarker.ts @@ -0,0 +1,26 @@ +// noinspection JSUnusedGlobalSymbols + +class StaticInitializationCollision { + static __initialized__: boolean = false; + static value: number = 41; + + static readAndWrite(): number { + const initiallyFalse = StaticInitializationCollision.__initialized__ === false; + StaticInitializationCollision.__initialized__ = true; + const writtenTrue = StaticInitializationCollision.__initialized__ === true; + + if (initiallyFalse && writtenTrue) { + return StaticInitializationCollision.value + 1; + } + + return -1; + } +} + +class StaticInitializationControl { + static value: number = 7; + + static read(): number { + return StaticInitializationControl.value; + } +} From 23be54e80cbda40eafa8384f5ae2a5268d2e851d Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 02:57:57 +0300 Subject: [PATCH 64/97] Use relational coercion for inclusive TypeScript comparisons --- .../org/usvm/machine/expr/TsExprResolver.kt | 10 +- .../usvm/machine/operator/TsBinaryOperator.kt | 118 +++++++----------- .../usvm/machine/TsStringRelationalTest.kt | 45 +++++++ .../test/resources/models/StringRelational.ts | 25 ++++ 4 files changed, 116 insertions(+), 82 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt index 7733eff14a..469a3d44f7 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt @@ -1066,17 +1066,11 @@ class TsExprResolver( } override fun visit(expr: EtsLtEqExpr): UExpr? { - val eq = resolve(EtsEqExpr(expr.left, expr.right)) ?: return null - val lt = resolve(EtsLtExpr(expr.left, expr.right)) ?: return null - - return ctx.mkOr(eq.asExpr(ctx.boolSort), lt.asExpr(ctx.boolSort)) + return resolveBinaryOperator(TsBinaryOperator.LtEq, expr) } override fun visit(expr: EtsGtEqExpr): UExpr? { - val eq = resolve(EtsEqExpr(expr.left, expr.right)) ?: return null - val gt = resolve(EtsGtExpr(expr.left, expr.right)) ?: return null - - return ctx.mkOr(eq.asExpr(ctx.boolSort), gt.asExpr(ctx.boolSort)) + return resolveBinaryOperator(TsBinaryOperator.GtEq, expr) } override fun visit(expr: EtsInExpr): UExpr? = with(ctx) { diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt index 307bad7366..9391e0d827 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt @@ -174,13 +174,13 @@ private fun TsContext.relationalComparison( rhs: UExpr<*>, scope: TsStepScope, activeGuard: UBoolExpr, - reversed: Boolean, + comparison: TsBinaryOperator.Relational.Comparison, ): UBoolExpr { if (lhs is UIteExpr<*>) { val trueGuard = mkAnd(activeGuard, lhs.condition) val falseGuard = mkAnd(activeGuard, mkNot(lhs.condition)) - val trueResult = relationalComparison(lhs.trueBranch, rhs, scope, trueGuard, reversed) - val falseResult = relationalComparison(lhs.falseBranch, rhs, scope, falseGuard, reversed) + val trueResult = relationalComparison(lhs.trueBranch, rhs, scope, trueGuard, comparison) + val falseResult = relationalComparison(lhs.falseBranch, rhs, scope, falseGuard, comparison) return mkIte(lhs.condition, trueResult, falseResult) } @@ -188,8 +188,8 @@ private fun TsContext.relationalComparison( if (rhs is UIteExpr<*>) { val trueGuard = mkAnd(activeGuard, rhs.condition) val falseGuard = mkAnd(activeGuard, mkNot(rhs.condition)) - val trueResult = relationalComparison(lhs, rhs.trueBranch, scope, trueGuard, reversed) - val falseResult = relationalComparison(lhs, rhs.falseBranch, scope, falseGuard, reversed) + val trueResult = relationalComparison(lhs, rhs.trueBranch, scope, trueGuard, comparison) + val falseResult = relationalComparison(lhs, rhs.falseBranch, scope, falseGuard, comparison) return mkIte(rhs.condition, trueResult, falseResult) } @@ -202,13 +202,23 @@ private fun TsContext.relationalComparison( val leftNumber = mkNumericExprWithGuard(expr = lhs, scope = scope, activeGuard = numericGuard) val rightNumber = mkNumericExprWithGuard(expr = rhs, scope = scope, activeGuard = numericGuard) - val numericResult = if (reversed) mkFpGreaterExpr(leftNumber, rightNumber) else mkFpLessExpr(leftNumber, rightNumber) + val numericResult = when (comparison) { + TsBinaryOperator.Relational.Comparison.LT -> mkFpLessExpr(leftNumber, rightNumber) + TsBinaryOperator.Relational.Comparison.GT -> mkFpGreaterExpr(leftNumber, rightNumber) + TsBinaryOperator.Relational.Comparison.LTE -> mkFpLessOrEqualExpr(leftNumber, rightNumber) + TsBinaryOperator.Relational.Comparison.GTE -> mkFpGreaterOrEqualExpr(leftNumber, rightNumber) + } if (lhsReference == null || rhsReference == null) return numericResult - val stringResult = if (reversed) { - stringValueLessThan(rhsReference, lhsReference, scope, stringGuard) - } else { - stringValueLessThan(lhsReference, rhsReference, scope, stringGuard) + val stringResult = when (comparison) { + TsBinaryOperator.Relational.Comparison.LT -> + stringValueLessThan(lhsReference, rhsReference, scope, stringGuard) + TsBinaryOperator.Relational.Comparison.GT -> + stringValueLessThan(rhsReference, lhsReference, scope, stringGuard) + TsBinaryOperator.Relational.Comparison.LTE -> + mkNot(stringValueLessThan(rhsReference, lhsReference, scope, stringGuard)) + TsBinaryOperator.Relational.Comparison.GTE -> + mkNot(stringValueLessThan(lhsReference, rhsReference, scope, stringGuard)) } return mkIte(bothStrings, stringResult, numericResult) @@ -1487,14 +1497,18 @@ sealed interface TsBinaryOperator { } } - data object Lt : TsBinaryOperator { + sealed interface Relational : TsBinaryOperator { + enum class Comparison { LT, GT, LTE, GTE } + + val comparison: Comparison + override fun TsContext.resolve( lhs: UExpr<*>, rhs: UExpr<*>, scope: TsStepScope, activeGuard: UBoolExpr, ): UBoolExpr { - return relationalComparison(lhs, rhs, scope, activeGuard, reversed = false) + return relationalComparison(lhs, rhs, scope, activeGuard, comparison) } override fun TsContext.onRefWithGuard( @@ -1516,7 +1530,7 @@ sealed interface TsBinaryOperator { rhs: UBoolExpr, scope: TsStepScope, ): UBoolExpr { - return mkAnd(lhs.not(), rhs) + return onFp(boolToFp(lhs), boolToFp(rhs), scope) } override fun TsContext.onFp( @@ -1524,7 +1538,12 @@ sealed interface TsBinaryOperator { rhs: UExpr, scope: TsStepScope, ): UBoolExpr { - return mkFpLessExpr(lhs, rhs) + return when (comparison) { + Comparison.LT -> mkFpLessExpr(lhs, rhs) + Comparison.GT -> mkFpGreaterExpr(lhs, rhs) + Comparison.LTE -> mkFpLessOrEqualExpr(lhs, rhs) + Comparison.GTE -> mkFpGreaterOrEqualExpr(lhs, rhs) + } } override fun TsContext.onRef( @@ -1552,69 +1571,20 @@ sealed interface TsBinaryOperator { } } - data object Gt : TsBinaryOperator { - override fun TsContext.resolve( - lhs: UExpr<*>, - rhs: UExpr<*>, - scope: TsStepScope, - activeGuard: UBoolExpr, - ): UBoolExpr { - return relationalComparison(lhs, rhs, scope, activeGuard, reversed = true) - } - - override fun TsContext.onRefWithGuard( - lhs: UHeapRef, - rhs: UHeapRef, - scope: TsStepScope, - activeGuard: UBoolExpr, - ): UBoolExpr = resolve(lhs, rhs, scope, activeGuard) - - override fun TsContext.resolveFakeObjectWithGuard( - lhs: UExpr<*>, - rhs: UExpr<*>, - scope: TsStepScope, - activeGuard: UBoolExpr, - ): UBoolExpr = resolve(lhs, rhs, scope, activeGuard) - - override fun TsContext.onBool( - lhs: UBoolExpr, - rhs: UBoolExpr, - scope: TsStepScope, - ): UBoolExpr { - return mkAnd(lhs, rhs.not()) - } - - override fun TsContext.onFp( - lhs: UExpr, - rhs: UExpr, - scope: TsStepScope, - ): UBoolExpr { - return mkFpGreaterExpr(lhs, rhs) - } + data object Lt : Relational { + override val comparison = Relational.Comparison.LT + } - override fun TsContext.onRef( - lhs: UHeapRef, - rhs: UHeapRef, - scope: TsStepScope, - ): UBoolExpr { - return resolve(lhs, rhs, scope, activeGuard = trueExpr) - } + data object Gt : Relational { + override val comparison = Relational.Comparison.GT + } - override fun TsContext.resolveFakeObject( - lhs: UExpr<*>, - rhs: UExpr<*>, - scope: TsStepScope, - ): UBoolExpr { - return resolve(lhs, rhs, scope, activeGuard = trueExpr) - } + data object LtEq : Relational { + override val comparison = Relational.Comparison.LTE + } - override fun TsContext.internalResolve( - lhs: UExpr<*>, - rhs: UExpr<*>, - scope: TsStepScope, - ): UBoolExpr { - return resolve(lhs, rhs, scope, activeGuard = trueExpr) - } + data object GtEq : Relational { + override val comparison = Relational.Comparison.GTE } sealed interface TsArithmeticOperator : TsBinaryOperator { diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/TsStringRelationalTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/TsStringRelationalTest.kt index 5ae79deb46..67490a871b 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/TsStringRelationalTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/TsStringRelationalTest.kt @@ -43,6 +43,51 @@ class TsStringRelationalTest : TsMethodTestRunner() { ) } + @Test + fun `inclusive comparisons coerce null and primitive strings while rejecting unordered numbers`() { + val methodNames = listOf( + "inclusiveNullAndNumber", + "inclusiveMixedStringAndNumber", + "inclusiveUnordered", + ) + + for (name in methodNames) { + val method = getMethod(methodName = name, className = "StringRelational") + + discoverProperties( + method = method, + { result -> result.number == 1.0 }, + invariants = arrayOf({ result -> result.number == 1.0 }), + ) + } + } + + @Test + fun `inclusive symbolic string comparisons include equal values`() { + val comparisons = mapOf Boolean>( + "symbolicLessOrEqual" to { left, right -> left <= right }, + "symbolicGreaterOrEqual" to { left, right -> left >= right }, + ) + + comparisons.forEach { (name, expected) -> + val method = getMethod(methodName = name, className = "StringRelational") + + discoverProperties( + method = method, + { left, right, result -> left.value == right.value && result.number == 1.0 }, + { left, right, result -> + left.value != right.value && expected(left.value, right.value) && result.number == 1.0 + }, + { left, right, result -> !expected(left.value, right.value) && result.number == 2.0 }, + invariants = arrayOf({ left, right, result -> + val expectedResult = if (expected(left.value, right.value)) 1.0 else 2.0 + + result.number == expectedResult + }), + ) + } + } + @Test fun `symbolic string pairs compare their source values`() { val method = getMethod(methodName = "symbolicLess", className = "StringRelational") diff --git a/usvm-ts/src/test/resources/models/StringRelational.ts b/usvm-ts/src/test/resources/models/StringRelational.ts index 526f564b10..b712d2d898 100644 --- a/usvm-ts/src/test/resources/models/StringRelational.ts +++ b/usvm-ts/src/test/resources/models/StringRelational.ts @@ -16,6 +16,31 @@ class StringRelational { return "2" < 10 && 10 > "2" && !("2" < 1) ? 1 : 0; } + inclusiveNullAndNumber(): number { + return null <= 0 && null >= 0 && 0 <= null && 0 >= null && + !(null <= -1) && !(null >= 1) ? 1 : 0; + } + + inclusiveMixedStringAndNumber(): number { + const two: any = "2"; + return two <= 2 && two >= 2 && 2 <= two && 2 >= two ? 1 : 0; + } + + inclusiveUnordered(): number { + const missing: any = undefined; + const nan = 0 / 0; + return !(missing <= 0) && !(missing >= 0) && + !(nan <= 0) && !(nan >= 0) ? 1 : 0; + } + + symbolicLessOrEqual(left: string, right: string): number { + return left <= right ? 1 : 2; + } + + symbolicGreaterOrEqual(left: string, right: string): number { + return left >= right ? 1 : 2; + } + symbolicLess(left: string, right: string): number { return left < right ? 1 : 2; } From 0b7c68f23ee5b2687113f5593e3f0cb5a2b041b9 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 02:59:02 +0300 Subject: [PATCH 65/97] Name the Date argument-prefix conversion predicate --- .../call/intrinsic/TsDateEtsIrModelFamily.kt | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt index b6813d6de1..c1f543c1c7 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt @@ -386,9 +386,17 @@ internal object TsDateEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { maxArgs: Int, state: TsState, allowDateCopy: Boolean = false, - ): List? = arguments.take(maxArgs).map { argument -> - val resolved = argument.resolved ?: return null - numericArgument(state = state, value = resolved, allowDateCopy = allowDateCopy && arguments.size == 1) + ): List? { + val numericArguments = mutableListOf() + val copiesSingleDate = allowDateCopy && arguments.size == 1 + for (argument in arguments.take(maxArgs)) { + val resolved = argument.resolved + if (resolved == null) return null + + numericArguments += numericArgument(state = state, value = resolved, allowDateCopy = copiesSingleDate) + } + + return numericArguments } private fun numericArgument( From 9c441af9975d493c9979ee6450922ee58f6d8a9e Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 03:01:58 +0300 Subject: [PATCH 66/97] Name Date constructor normalization requirements before branching --- .../resources/org/usvm/machine/call/models/DateModels.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts index 701bb52013..95cf12d404 100644 --- a/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts @@ -394,11 +394,11 @@ export class DateModels { seconds = DateModels.toInteger(seconds); milliseconds = DateModels.toInteger(milliseconds); - if ( + const hasInvalidComponent = DateModels.isInvalid(year) || DateModels.isInvalid(month) || DateModels.isInvalid(date) || DateModels.isInvalid(hours) || DateModels.isInvalid(minutes) || DateModels.isInvalid(seconds) || - DateModels.isInvalid(milliseconds) - ) { + DateModels.isInvalid(milliseconds); + if (hasInvalidComponent) { return NaN; } From 43f06017e8bfcca6230ff0044917973c20e72127 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 03:05:42 +0300 Subject: [PATCH 67/97] Reuse the canonical TypeError signature in Error storage --- .../src/main/kotlin/org/usvm/machine/expr/ErrorModelStorage.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ErrorModelStorage.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ErrorModelStorage.kt index cb0561b411..00b6b981c9 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ErrorModelStorage.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ErrorModelStorage.kt @@ -29,7 +29,7 @@ private val builtInErrorSignatures = setOf(builtInErrorSignature, builtInTypeErr /** Matches exact SDK identities and the interpreter's own TypeError, excluding source class shadows. */ internal fun EtsType.isModeledErrorType(): Boolean = this == EtsClassType(signature = builtInErrorSignature) || - this == EtsClassType(signature = EtsClassSignature.UNKNOWN.copy(name = "TypeError")) || + this == EtsClassType(signature = builtInTypeErrorSignature) || this == TS_TYPE_ERROR_TYPE internal const val ERROR_NAME_STORAGE_FIELD = "__usvmErrorName" From c8796b0a2d7c2356f6a4744fb27d3759d07c06a6 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 03:26:24 +0300 Subject: [PATCH 68/97] fix(ts): preserve census input completeness and outcome status --- .../experiments/unknown-call-census/README.md | 14 +++ .../usvm/census/UnknownCallCensusArtifacts.kt | 107 ++++++++++++++++-- .../census/UnknownCallCensusAggregatorTest.kt | 96 +++++++++++++++- 3 files changed, 203 insertions(+), 14 deletions(-) diff --git a/usvm-ts/experiments/unknown-call-census/README.md b/usvm-ts/experiments/unknown-call-census/README.md index 46d637c07c..96feac802c 100644 --- a/usvm-ts/experiments/unknown-call-census/README.md +++ b/usvm-ts/experiments/unknown-call-census/README.md @@ -18,4 +18,18 @@ The command refuses a checkout whose Git `HEAD` differs from the manifest or who ./gradlew :usvm-ts:runUnknownCallCensus --args='summarize --input /absolute/path/to/results/raw.jsonl --output /absolute/path/to/results/summary.json' ``` +The persisted summary includes `input.completeness` (`COMPLETE` or `PARTIAL`) and +`input.issues` (typed reasons for a partial input). Only one ordered schema-v2 +`run_start` and a matching `run_result` with `status: "completed"`, followed by +no more records, certify a complete run. The start timestamp must match and the +end marker must include a finish timestamp. A complete run can still contain +method or project timeouts and errors; inspect their outcome counts separately. +Interrupted raw files and older data-only fixtures remain summarizable with +their available counts, but their summaries are marked `PARTIAL`. Unsupported +data-record schema versions also make the summary partial. Unsupported method +or project statuses fail summarization with the raw line number and status, +so they cannot silently disappear from outcome categories. The summary +`schemaVersion` identifies the summary format, not proof that its input is a +complete supported run. + `EMPTY_STOP` can be added as a separate profile when stopping-site counts are needed. Do not combine its counts with the primary `EMPTY_FRESH` profile. diff --git a/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusArtifacts.kt b/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusArtifacts.kt index c6aa0b3c1a..d99fce62af 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusArtifacts.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusArtifacts.kt @@ -3,9 +3,9 @@ package org.usvm.census import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.jsonPrimitive internal const val CENSUS_SCHEMA_VERSION = 2 @@ -57,8 +57,31 @@ internal data class UnknownCallCensusLimits( internal data class UnknownCallCensusSummary( val schemaVersion: Int = CENSUS_SCHEMA_VERSION, val profiles: Map, + val input: UnknownCallCensusInput, ) +@Serializable +internal data class UnknownCallCensusInput( + val completeness: UnknownCallCensusCompleteness, + val issues: List, +) + +@Serializable +internal enum class UnknownCallCensusCompleteness { COMPLETE, PARTIAL } + +@Serializable +internal enum class UnknownCallCensusInputIssue { + MISSING_RUN_START, + MISSING_RUN_RESULT, + INVALID_RUN_START, + INVALID_RUN_RESULT, + DUPLICATE_RUN_START, + DUPLICATE_RUN_RESULT, + OUT_OF_ORDER_MARKERS, + DATA_OUTSIDE_RUN, + UNSUPPORTED_DATA_SCHEMA, +} + @Serializable internal data class UnknownCallCensusProfileSummary( val projects: Int, @@ -88,13 +111,50 @@ internal data class UnknownCallCensusIssue( internal object UnknownCallCensusAggregator { fun summarize(lines: Sequence): UnknownCallCensusSummary { val profiles = linkedMapOf() + val inputIssues = linkedSetOf() + var runStartSeen = false + var runResultSeen = false + var dataSeen = false + var startedAt: String? = null + + lines.forEachIndexed { index, line -> + if (line.isBlank()) return@forEachIndexed - lines.filter(String::isNotBlank).forEachIndexed { index, line -> val record = censusJson.parseToJsonElement(line) as? JsonObject ?: error("Raw census record ${index + 1} is not a JSON object") val kind = record.requiredString("kind", index) - if (kind == "run_start" || kind == "run_result") { - return@forEachIndexed + when (kind) { + "run_start" -> { + if (runStartSeen) inputIssues += UnknownCallCensusInputIssue.DUPLICATE_RUN_START + if (dataSeen || runResultSeen) inputIssues += UnknownCallCensusInputIssue.OUT_OF_ORDER_MARKERS + val markerStartedAt = record.optionalString("startedAt") + val validSchema = record.optionalInt("schemaVersion") == CENSUS_SCHEMA_VERSION + if (!validSchema || markerStartedAt.isNullOrBlank()) { + inputIssues += UnknownCallCensusInputIssue.INVALID_RUN_START + } + startedAt = markerStartedAt + runStartSeen = true + return@forEachIndexed + } + "run_result" -> { + if (runResultSeen) inputIssues += UnknownCallCensusInputIssue.DUPLICATE_RUN_RESULT + if (!runStartSeen) inputIssues += UnknownCallCensusInputIssue.OUT_OF_ORDER_MARKERS + val validSchema = record.optionalInt("schemaVersion") == CENSUS_SCHEMA_VERSION + val completed = record.optionalString("status") == "completed" + val matchingStart = record.optionalString("startedAt") == startedAt && !startedAt.isNullOrBlank() + val hasFinish = !record.optionalString("finishedAt").isNullOrBlank() + if (!validSchema || !completed || !matchingStart || !hasFinish) { + inputIssues += UnknownCallCensusInputIssue.INVALID_RUN_RESULT + } + runResultSeen = true + return@forEachIndexed + } + } + + dataSeen = true + if (!runStartSeen || runResultSeen) inputIssues += UnknownCallCensusInputIssue.DATA_OUTSIDE_RUN + if (record.optionalInt("schemaVersion") != CENSUS_SCHEMA_VERSION) { + inputIssues += UnknownCallCensusInputIssue.UNSUPPORTED_DATA_SCHEMA } val profile = record.requiredString("profile", index) @@ -108,8 +168,19 @@ internal object UnknownCallCensusAggregator { } } + if (!runStartSeen) inputIssues += UnknownCallCensusInputIssue.MISSING_RUN_START + if (!runResultSeen) inputIssues += UnknownCallCensusInputIssue.MISSING_RUN_RESULT + + val completeness = if (inputIssues.isEmpty()) { + UnknownCallCensusCompleteness.COMPLETE + } else { + UnknownCallCensusCompleteness.PARTIAL + } val summaries = profiles.toSortedMap().mapValues { (_, accumulator) -> accumulator.toSummary() } - return UnknownCallCensusSummary(profiles = summaries) + return UnknownCallCensusSummary( + profiles = summaries, + input = UnknownCallCensusInput(completeness = completeness, issues = inputIssues.toList()), + ) } } @@ -150,7 +221,11 @@ private class ProfileAccumulator { fun addMethodResult(record: JsonObject, index: Int) { val projectId = record.requiredString("projectId", index) val functionId = record.requiredString("functionId", index) - val status = record.requiredString("status", index) + val rawStatus = record.requiredString("status", index) + val matchingStatus = MethodStatus.entries.firstOrNull { it.serializedName == rawStatus } + val status = requireNotNull(matchingStatus) { + "Raw census line ${index + 1} has unsupported method_result status '$rawStatus'" + } val issue = UnknownCallCensusIssue( projectId = projectId, functionId = functionId, @@ -161,19 +236,22 @@ private class ProfileAccumulator { projects += projectId functions += functionId when (status) { - "completed" -> completedFunctions += functionId - "partial" -> { + MethodStatus.COMPLETED -> completedFunctions += functionId + MethodStatus.PARTIAL -> { partialFunctions += functionId partials += issue } - "timeout" -> timeouts += issue - "tool_error" -> errors += issue + MethodStatus.TIMEOUT -> timeouts += issue + MethodStatus.TOOL_ERROR -> errors += issue } } fun addProjectResult(record: JsonObject, index: Int) { val projectId = record.requiredString("projectId", index) val status = record.requiredString("status", index) + require(status in projectResultStatuses) { + "Raw census line ${index + 1} has unsupported project_result status '$status'" + } val issue = UnknownCallCensusIssue( projectId = projectId, message = record.optionalString("error"), @@ -181,6 +259,7 @@ private class ProfileAccumulator { projects += projectId when (status) { + "completed" -> Unit "timeout" -> timeouts += issue "tool_error" -> errors += issue } @@ -204,6 +283,8 @@ private class ProfileAccumulator { ) } +private val projectResultStatuses = setOf("completed", "timeout", "tool_error") + private val issueComparator = compareBy( UnknownCallCensusIssue::projectId, { it.functionId.orEmpty() }, @@ -217,6 +298,8 @@ private fun MutableMap.increment(key: String) { private fun JsonObject.requiredString(name: String, recordIndex: Int): String = optionalString(name) ?: error("Raw census record ${recordIndex + 1} has no string '$name'") -private fun JsonObject.optionalString(name: String): String? = get(name)?.jsonPrimitive?.contentOrNull +private fun JsonObject.optionalString(name: String): String? = + (get(name) as? JsonPrimitive)?.takeIf(JsonPrimitive::isString)?.contentOrNull -private fun JsonObject.optionalInt(name: String): Int? = get(name)?.jsonPrimitive?.intOrNull +private fun JsonObject.optionalInt(name: String): Int? = + (get(name) as? JsonPrimitive)?.takeUnless(JsonPrimitive::isString)?.intOrNull diff --git a/usvm-ts/src/test/kotlin/org/usvm/census/UnknownCallCensusAggregatorTest.kt b/usvm-ts/src/test/kotlin/org/usvm/census/UnknownCallCensusAggregatorTest.kt index 6807cdaffa..ca8d9e2b9e 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/census/UnknownCallCensusAggregatorTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/census/UnknownCallCensusAggregatorTest.kt @@ -2,6 +2,8 @@ package org.usvm.census import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertContains +import kotlin.test.assertFailsWith class UnknownCallCensusAggregatorTest { @Test @@ -26,6 +28,10 @@ class UnknownCallCensusAggregatorTest { val summary = UnknownCallCensusAggregator.summarize(rawRecords) val profile = requireNotNull(summary.profiles["EMPTY_FRESH"]) + assertEquals(UnknownCallCensusCompleteness.PARTIAL, summary.input.completeness) + assertContains(summary.input.issues, UnknownCallCensusInputIssue.MISSING_RUN_START) + assertContains(summary.input.issues, UnknownCallCensusInputIssue.MISSING_RUN_RESULT) + assertContains(summary.input.issues, UnknownCallCensusInputIssue.UNSUPPORTED_DATA_SCHEMA) assertEquals(2, profile.projects) assertEquals(4, profile.functionsAnalyzed) assertEquals(1, profile.functionsCompleted) @@ -43,6 +49,87 @@ class UnknownCallCensusAggregatorTest { assertEquals(2, profile.errors.size) } + @Test + fun `ordered supported run is complete despite method and project failures`() { + val records = sequenceOf( + runStart(), + methodRecord(functionId = "project:file:partial", status = "partial"), + projectRecord(projectId = "project", status = "completed", schemaVersion = 2), + projectRecord(projectId = "broken", status = "tool_error", schemaVersion = 2), + runResult(), + ) + + val summary = UnknownCallCensusAggregator.summarize(records) + + assertEquals(UnknownCallCensusCompleteness.COMPLETE, summary.input.completeness) + assertEquals(emptyList(), summary.input.issues) + val profile = requireNotNull(summary.profiles["EMPTY_FRESH"]) + assertEquals(1, profile.functionsPartial) + assertEquals(1, profile.errors.size) + } + + @Test + fun `interrupted run retains counts and reports missing end`() { + val records = sequenceOf(runStart(), methodRecord(functionId = "project:file:fn", status = "completed")) + + val summary = UnknownCallCensusAggregator.summarize(records) + + assertEquals(UnknownCallCensusCompleteness.PARTIAL, summary.input.completeness) + assertContains(summary.input.issues, UnknownCallCensusInputIssue.MISSING_RUN_RESULT) + assertEquals(1, summary.profiles.getValue("EMPTY_FRESH").functionsCompleted) + } + + @Test + fun `duplicate reversed unsupported and trailing markers cannot certify a complete run`() { + val data = methodRecord(functionId = "project:file:fn", status = "completed") + val cases = listOf( + listOf(runStart(), data, runResult(), runResult()) to UnknownCallCensusInputIssue.DUPLICATE_RUN_RESULT, + listOf(runResult(), runStart(), data) to UnknownCallCensusInputIssue.OUT_OF_ORDER_MARKERS, + listOf(runStart(schemaVersion = 3), data, runResult()) to UnknownCallCensusInputIssue.INVALID_RUN_START, + listOf("""{"kind":"run_start","schemaVersion":{}}""", data, runResult()) to + UnknownCallCensusInputIssue.INVALID_RUN_START, + listOf(runStart(), data, runResult(schemaVersion = 3)) to UnknownCallCensusInputIssue.INVALID_RUN_RESULT, + listOf(runStart(), data, runResult(status = "failed")) to UnknownCallCensusInputIssue.INVALID_RUN_RESULT, + listOf(runStart(), data, runResult().replace("2026-10-15T00:00:00Z", "2026-10-15T00:02:00Z")) to + UnknownCallCensusInputIssue.INVALID_RUN_RESULT, + listOf(runStart(), runResult(), data) to UnknownCallCensusInputIssue.DATA_OUTSIDE_RUN, + listOf(runStart(), data, runStart(), runResult()) to UnknownCallCensusInputIssue.DUPLICATE_RUN_START, + listOf(runStart(), data.replace("\"schemaVersion\":2", "\"schemaVersion\":3"), runResult()) to + UnknownCallCensusInputIssue.UNSUPPORTED_DATA_SCHEMA, + ) + + cases.forEach { (records, expectedIssue) -> + val summary = UnknownCallCensusAggregator.summarize(records.asSequence()) + + assertEquals(UnknownCallCensusCompleteness.PARTIAL, summary.input.completeness) + assertContains(summary.input.issues, expectedIssue) + assertEquals(1, summary.profiles.getValue("EMPTY_FRESH").functionsCompleted) + } + } + + @Test + fun `unsupported current method and project statuses report raw line and value`() { + val cases = listOf( + methodRecord(functionId = "project:file:fn", status = "future_method") to "method_result status 'future_method'", + projectRecord(projectId = "project", status = "future_project") to "project_result status 'future_project'", + ) + + cases.forEach { (record, expectedMessage) -> + val failure = assertFailsWith { + UnknownCallCensusAggregator.summarize(sequenceOf(runStart(), "", record, runResult())) + } + + assertContains(failure.message.orEmpty(), "line 3") + assertContains(failure.message.orEmpty(), expectedMessage) + } + } + + private fun runStart(schemaVersion: Int = 2): String = + """{"kind":"run_start","schemaVersion":$schemaVersion,"startedAt":"2026-10-15T00:00:00Z"}""" + + private fun runResult(schemaVersion: Int = 2, status: String = "completed"): String = + """{"kind":"run_result","schemaVersion":$schemaVersion,"startedAt":"2026-10-15T00:00:00Z","finishedAt":"2026-10-15T00:01:00Z","status":"$status"}""" + private fun unknownCallRecord(siteId: String, failureReason: String): String = """ { "kind":"unknown_call", @@ -79,12 +166,17 @@ class UnknownCallCensusAggregatorTest { """.trimIndent() } - private fun projectRecord(projectId: String, status: String, error: String? = null): String { + private fun projectRecord( + projectId: String, + status: String, + error: String? = null, + schemaVersion: Int = 1, + ): String { val errorField = error?.let { ",\"error\":\"$it\"" }.orEmpty() return """ { "kind":"project_result", - "schemaVersion":1, + "schemaVersion":$schemaVersion, "projectId":"$projectId", "projectRevision":"0000000000000000000000000000000000000000", "profile":"EMPTY_FRESH", From b6a6c91cdb2d2403676ef39ca85463cf539f917f Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 03:27:10 +0300 Subject: [PATCH 69/97] fix(ts): isolate promise callbacks and preserve async result lifecycle --- .../main/kotlin/org/usvm/machine/TsContext.kt | 7 -- .../usvm/machine/expr/CallApproximations.kt | 13 +-- .../org/usvm/machine/expr/TsExprResolver.kt | 100 +++++++++++------- .../usvm/machine/interpreter/TsInterpreter.kt | 45 ++++++-- .../org/usvm/machine/interpreter/TsPromise.kt | 94 ++++++++++++++++ .../kotlin/org/usvm/machine/state/TsState.kt | 7 ++ .../kotlin/org/usvm/samples/lang/Async.kt | 44 ++++++++ .../src/test/resources/samples/lang/Async.ts | 46 ++++++++ 8 files changed, 290 insertions(+), 66 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt index b59c0b9987..eccacfa03b 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt @@ -398,13 +398,6 @@ class TsContext( return scope.calcOnState { extractRef(memory) } } - // This is an identifier for a special function representing the 'resolve' function used in promises. - // It is not a real function in the code, but we need it to handle promise resolution. - val resolveFunctionRef: UConcreteHeapRef = allocateConcreteRef() - - // This is an identifier for a special function representing the 'reject' function used in promises. - // It is not a real function in the code, but we need it to handle promise rejection. - val rejectFunctionRef: UConcreteHeapRef = allocateConcreteRef() } class Constants { diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index d1ffcbd0fb..b95cb88c8e 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -17,7 +17,6 @@ import org.usvm.UBoolExpr import org.usvm.UExpr import org.usvm.UHeapRef import org.usvm.USort -import org.usvm.api.allocateConcreteRef import org.usvm.api.initializeArray import org.usvm.api.makeSymbolicPrimitive import org.usvm.api.memcpy @@ -36,8 +35,7 @@ import org.usvm.machine.call.hasBuiltinGlobalOwner import org.usvm.machine.call.intrinsic.TsDateEtsIrModelFamily import org.usvm.machine.expr.TsExprApproximationResult.Companion.from import org.usvm.machine.interpreter.PromiseState -import org.usvm.machine.interpreter.markResolved -import org.usvm.machine.interpreter.setResolvedValue +import org.usvm.machine.interpreter.createSettledPromise import org.usvm.machine.state.TsMethodResult import org.usvm.machine.state.lastStmt import org.usvm.machine.state.newStmt @@ -491,7 +489,6 @@ private fun TsExprResolver.handlePromiseConstructor(expr: EtsInstanceCallExpr): } private fun TsExprResolver.handlePromiseResolveReject(expr: EtsInstanceCallExpr): UExpr<*>? = with(ctx) { - val promise = allocateConcreteRef() val newState = when (expr.callee.name) { "resolve" -> PromiseState.FULFILLED "reject" -> PromiseState.REJECTED @@ -501,13 +498,7 @@ private fun TsExprResolver.handlePromiseResolveReject(expr: EtsInstanceCallExpr) "Promise.${expr.callee.name}() should have exactly one argument, but got ${expr.args.size}" } val value = resolve(expr.args.single()) ?: return null - val fakeValue = value.toFakeObject(scope) - scope.doWithState { - markResolved(promise) - setPromiseState(promise, newState) - setResolvedValue(promise, fakeValue) - } - promise + createSettledPromise(scope = scope, value = value, settlement = newState) } private fun TsExprResolver.handleMathFloor(expr: EtsInstanceCallExpr): UExpr<*>? = with(ctx) { diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt index 469a3d44f7..6d9c7ea719 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt @@ -121,10 +121,12 @@ import org.usvm.machine.onRuntimeFeatureLimitationSafely import org.usvm.machine.call.TsUnknownCallDispatcher import org.usvm.machine.call.TsUnknownCallFailureReason import org.usvm.machine.call.dispatch +import org.usvm.machine.interpreter.PromiseCallback import org.usvm.machine.interpreter.PromiseState import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.interpreter.getGlobals import org.usvm.machine.interpreter.getResolvedValue +import org.usvm.machine.interpreter.isDefinitelyNonThenable import org.usvm.machine.interpreter.isResolved import org.usvm.machine.interpreter.markResolved import org.usvm.machine.interpreter.setResolvedValue @@ -615,34 +617,59 @@ class TsExprResolver( override fun visit(expr: EtsAwaitExpr): UExpr? = with(ctx) { val arg = resolve(expr.arg) ?: return null + val awaited = if (arg.isFakeObject()) { + arg.extractSingleValueFromFakeObjectOrNull(scope) + ?: throw UnsupportedOperationException("Awaiting a value with symbolic runtime kind is not modeled") + } else { + arg + } + // Awaiting primitives does nothing. - if (arg.sort != addressSort) { - return arg + if (awaited.sort != addressSort) { + return awaited } // ...including null/undefined - if (arg == mkTsNullValue() || arg == mkUndefinedValue()) { - return arg + if (awaited == mkTsNullValue() || awaited == mkUndefinedValue()) { + return awaited } - val promise = arg.asExpr(addressSort) - check(isAllocatedConcreteHeapRef(promise)) { - "Promise instance should be allocated, but it is not: $promise" + val promise = awaited.asExpr(addressSort) + if (!isAllocatedConcreteHeapRef(promise)) { + throw UnsupportedOperationException("Awaiting a symbolic object or thenable is not modeled") } - val promiseState = scope.calcOnState { - promiseState[promise] ?: PromiseState.PENDING + val registeredState = scope.calcOnState { promiseState[promise] } + val registeredExecutor = scope.calcOnState { promiseExecutor[promise] } + if (registeredState == null && registeredExecutor == null) { + if (isDefinitelyNonThenable(scope, promise, hierarchy)) { + return promise + } + + throw UnsupportedOperationException("Awaiting an unmodeled object or thenable is not supported") } + val promiseState = registeredState ?: PromiseState.PENDING + val isResolved = scope.calcOnState { isResolved(promise) } + val methodResult = scope.calcOnState { methodResult } + val completedExecutorResult = methodResult as? TsMethodResult.Success.RegularCall + val returnedFromThisExecutor = completedExecutorResult?.method == registeredExecutor && + registeredExecutor != null + if (returnedFromThisExecutor) { + if (!isResolved) { + throw UnsupportedOperationException("Awaited Promise executor returned before settling $promise") + } + + scope.doWithState { this.methodResult = TsMethodResult.NoCall } + } + return if (!isResolved) { // If the promise is not resolved yet, we need to call the executor to resolve it. check(promiseState == PromiseState.PENDING) { "Promise state should be PENDING, but it is $promiseState for $promise" } - val executor = scope.calcOnState { - promiseExecutor[promise] - ?: error("Await expression should have a promise executor, but it is not set for $promise") - } + val executor = registeredExecutor + ?: error("Await expression should have a promise executor, but it is not set for $promise") check(executor.cfg.stmts.isNotEmpty()) val args: MutableList> = mutableListOf() @@ -657,14 +684,22 @@ class TsExprResolver( args += mkUndefinedValue() } if (params.isNotEmpty()) { - args += resolveFunctionRef + val resolveCallback = allocateConcreteRef() + args += resolveCallback scope.doWithState { - setBoundThis(resolveFunctionRef, promise) + registerPromiseCallback( + resolveCallback, + PromiseCallback(promise = promise, settlement = PromiseState.FULFILLED), + ) } if (params.size >= 2) { - args += rejectFunctionRef + val rejectCallback = allocateConcreteRef() + args += rejectCallback scope.doWithState { - setBoundThis(rejectFunctionRef, promise) + registerPromiseCallback( + rejectCallback, + PromiseCallback(promise = promise, settlement = PromiseState.REJECTED), + ) } if (params.size >= 3) { error( @@ -1291,34 +1326,19 @@ class TsExprResolver( if (captured != null) return handleCapturedInstanceCall(expr, captured) if (isAllocatedConcreteHeapRef(ptr)) { - // Handle 'resolve' and 'reject' function call - if (ptr === resolveFunctionRef || ptr === rejectFunctionRef) { - val promise = scope.calcOnState { - boundThis[ptr] ?: error("No bound 'this' for ptr: $ptr") - } - check(isAllocatedConcreteHeapRef(promise)) { - "Promise instance should be allocated, but it is not: $promise" - } - val newState = when (ptr) { - resolveFunctionRef -> PromiseState.FULFILLED - rejectFunctionRef -> PromiseState.REJECTED - else -> error("Unexpected ptr: $ptr") - } + val callback = scope.calcOnState { promiseCallbacks[ptr] } + if (callback != null) { check(expr.args.size == 1) { - "${ - when (ptr) { - resolveFunctionRef -> "resolve" - rejectFunctionRef -> "reject" - else -> error("Unexpected ptr: $ptr") - } - }() should have exactly one argument, but got ${expr.args.size}" + "Promise settlement callback should have exactly one argument, but got ${expr.args.size}" } val value = resolve(expr.args.single()) ?: return null val fakeValue = value.toFakeObject(scope) scope.doWithState { - markResolved(promise.asExpr(addressSort)) - setPromiseState(promise, newState) - setResolvedValue(promise.asExpr(addressSort), fakeValue) + if (!isResolved(callback.promise)) { + markResolved(callback.promise) + setPromiseState(callback.promise, callback.settlement) + setResolvedValue(callback.promise, fakeValue) + } } return mkUndefinedValue() } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt index 264a7dfafa..443901d40d 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt @@ -114,16 +114,27 @@ class TsInterpreter( val result = state.methodResult val isEntryPointGuardResult = stmt is TsEntryPointGuardResultStmt if (result is TsMethodResult.TsException && !isEntryPointGuardResult) { - scope.doWithState { - val catchers = stmt.location.method.cfg.catchers(stmt) - val catcher = catchers.singleOrNull() - if (catcher != null) { + val catcher = stmt.location.method.cfg.catchers(stmt).singleOrNull() + if (catcher != null) { + scope.doWithState { caughtException = result.value methodResult = TsMethodResult.NoCall newStmt(catcher) - return@doWithState } + return scope.stepResult() + } + val throwingMethod = state.callStack.lastMethod() + val rejectedPromise = if (throwingMethod.isAsync) { + ctx.createSettledPromise( + scope = scope, + value = result.value, + settlement = PromiseState.REJECTED, + ) + } else { + null + } + scope.doWithState { leaveUnknownCallModelIfReturning() val returnSite = callStack.pop() if (callStack.isNotEmpty()) { @@ -131,6 +142,10 @@ class TsInterpreter( popLocalToSortStack() } + if (rejectedPromise != null) { + methodResult = TsMethodResult.Success.RegularCall(rejectedPromise, throwingMethod) + } + if (returnSite != null) { newStmt(returnSite) } @@ -527,9 +542,23 @@ class TsInterpreter( ?.let { exprResolver.resolve(it) ?: return } ?: ctx.mkUndefinedValue() - scope.doWithState { - returnValue(valueToReturn) + returnFromMethod(scope, valueToReturn) + } + + private fun returnFromMethod(scope: TsStepScope, value: UExpr<*>) { + val method = scope.calcOnState { callStack.lastMethod() } + val returnedValue = if (method.isAsync) { + ctx.ensureSupportedAsyncFulfillment(scope, value, graph.hierarchy) + ctx.createSettledPromise( + scope = scope, + value = value, + settlement = PromiseState.FULFILLED, + ) + } else { + value } + + scope.doWithState { returnValue(returnedValue) } } private fun assignTo( @@ -738,7 +767,7 @@ class TsInterpreter( private fun visitNopStmt(scope: TsStepScope, stmt: EtsNopStmt) { val successors = graph.successors(stmt).toList() when (successors.size) { - 0 -> scope.doWithState { returnValue(ctx.mkUndefinedValue()) } + 0 -> returnFromMethod(scope, ctx.mkUndefinedValue()) 1 -> scope.doWithState { newStmt(successors.single()) } else -> error("A NOP statement must not have multiple successors") } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsPromise.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsPromise.kt index f91f2dcc6b..5607860f12 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsPromise.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsPromise.kt @@ -1,13 +1,25 @@ package org.usvm.machine.interpreter +import io.ksmt.utils.asExpr +import org.jacodb.ets.model.EtsStringType import org.usvm.UBoolSort +import org.usvm.UConcreteHeapRef import org.usvm.UExpr import org.usvm.UHeapRef import org.usvm.USort +import org.usvm.api.allocateConcreteRef +import org.usvm.api.typeStreamOf import org.usvm.collection.field.UFieldLValue +import org.usvm.isAllocatedConcreteHeapRef import org.usvm.isTrue import org.usvm.machine.TsContext +import org.usvm.machine.expr.objectLiteralClass +import org.usvm.machine.expr.ensureNoPrototypeMutation import org.usvm.machine.state.TsState +import org.usvm.types.singleOrNull +import org.usvm.util.EtsHierarchy +import org.usvm.util.getAllFields +import org.usvm.util.getAllMethods import org.usvm.util.mkFieldLValue enum class PromiseState { @@ -16,6 +28,88 @@ enum class PromiseState { REJECTED; } +data class PromiseCallback( + val promise: UConcreteHeapRef, + val settlement: PromiseState, +) { + init { + require(settlement != PromiseState.PENDING) + } +} + +/** A conservative proof for values whose `then` lookup cannot start promise assimilation. */ +internal fun TsContext.isDefinitelyNonThenable( + scope: TsStepScope, + value: UHeapRef, + hierarchy: EtsHierarchy, +): Boolean { + if (!isAllocatedConcreteHeapRef(value) || value.isFakeObject()) return false + + val runtimeType = scope.calcOnState { memory.typeStreamOf(value).singleOrNull() } + if (runtimeType == EtsStringType) return true + + val objectClass = objectLiteralClass(scope, value, hierarchy) ?: return false + val thenWasWritten = scope.calcOnState { + (value to "then") in writtenConcreteFields || + trackedObjectProperties.any { it.instance == value && it.name == "then" } + } + val hasDeclaredThen = objectClass.getAllFields(hierarchy).any { it.name == "then" } || + objectClass.getAllMethods(hierarchy).any { it.name == "then" } + if (thenWasWritten || hasDeclaredThen) return false + + scope.ensureNoPrototypeMutation(value, objectClass) + return true +} + +/** Materializes one settled, state-owned Promise in the same format as Promise.resolve/reject. */ +internal fun TsContext.createSettledPromise( + scope: TsStepScope, + value: UExpr<*>, + settlement: PromiseState, +): UConcreteHeapRef { + require(settlement != PromiseState.PENDING) + + val promise = allocateConcreteRef() + val storedValue = value.toFakeObject(scope) + scope.doWithState { + markResolved(promise) + setPromiseState(promise, settlement) + setResolvedValue(promise, storedValue) + } + return promise +} + +/** Async returns assimilate Promises and thenables; keep unmodeled shapes out of success results. */ +internal fun TsContext.ensureSupportedAsyncFulfillment( + scope: TsStepScope, + value: UExpr<*>, + hierarchy: EtsHierarchy, +) { + val actualValue = if (value.isFakeObject()) { + value.extractSingleValueFromFakeObjectOrNull(scope) + ?: throw UnsupportedOperationException("Async return has a symbolic runtime kind") + } else { + value + } + if (actualValue.sort != addressSort) return + if (actualValue == mkTsNullValue() || actualValue == mkUndefinedValue()) return + + val reference = actualValue.asExpr(addressSort) + if (!isAllocatedConcreteHeapRef(reference)) { + throw UnsupportedOperationException("Async return requires unmodeled thenable assimilation") + } + + val registeredPromise = scope.calcOnState { + promiseState[reference] != null || promiseExecutor[reference] != null + } + if (registeredPromise) { + throw UnsupportedOperationException("Async return requires unmodeled Promise adoption") + } + if (!isDefinitelyNonThenable(scope, reference, hierarchy)) { + throw UnsupportedOperationException("Async return may require unmodeled thenable assimilation") + } +} + internal const val RESOLVED_FIELD = "__resolved__" internal fun TsContext.mkResolvedField(ref: UHeapRef): UFieldLValue { diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/state/TsState.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/state/TsState.kt index 11ceaa7621..53fdbc1bfd 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/state/TsState.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/state/TsState.kt @@ -34,6 +34,7 @@ import org.usvm.machine.expr.TsArrayIterator import org.usvm.machine.expr.TsCapturedInstanceCall import org.usvm.machine.expr.TsIteratorResult import org.usvm.machine.expr.TsStringIterator +import org.usvm.machine.interpreter.PromiseCallback import org.usvm.machine.interpreter.PromiseState import org.usvm.machine.interpreter.TsFunction import org.usvm.memory.ULValue @@ -81,6 +82,7 @@ class TsState( var discoveredCallees: UPersistentHashMap, EtsBlockCfg> = persistentHashMapOf(), var promiseState: UPersistentHashMap = persistentHashMapOf(), var promiseExecutor: UPersistentHashMap = persistentHashMapOf(), + internal var promiseCallbacks: UPersistentHashMap = persistentHashMapOf(), var methodToRef: UPersistentHashMap = persistentHashMapOf(), var associatedFunction: UPersistentHashMap = persistentHashMapOf(), var capturedInstanceCalls: UPersistentHashMap = persistentHashMapOf(), @@ -247,6 +249,10 @@ class TsState( promiseExecutor = promiseExecutor.put(promise, method, ownership) } + internal fun registerPromiseCallback(ref: UConcreteHeapRef, callback: PromiseCallback) { + promiseCallbacks = promiseCallbacks.put(ref, callback, ownership) + } + fun getMethodRef( method: EtsMethod, thisInstance: UHeapRef? = null, @@ -353,6 +359,7 @@ class TsState( discoveredCallees = discoveredCallees, promiseState = promiseState, promiseExecutor = promiseExecutor, + promiseCallbacks = promiseCallbacks, methodToRef = methodToRef, associatedFunction = associatedFunction, capturedInstanceCalls = capturedInstanceCalls, diff --git a/usvm-ts/src/test/kotlin/org/usvm/samples/lang/Async.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/lang/Async.kt index 42abe144d3..33eb0382c5 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/samples/lang/Async.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/samples/lang/Async.kt @@ -11,6 +11,50 @@ class Async : TsMethodTestRunner() { override val scene: EtsScene = loadScene(tsPath) + @Test + fun `await plain object preserves identity`() { + val method = getMethod("awaitPlainObject") + + discoverProperties( + method = method, + { result -> result eq 1 }, + invariants = arrayOf({ result -> result.number > 0 }), + ) + } + + @Test + fun `nested promise callbacks retain their owners`() { + val method = getMethod("awaitNestedPromises") + + discoverProperties( + method = method, + { result -> result eq 1 }, + invariants = arrayOf({ result -> result.number > 0 }), + ) + } + + @Test + fun `async return is represented by a promise`() { + val method = getMethod("asyncValueIsPromise") + + discoverProperties( + method = method, + { result -> result eq 1 }, + invariants = arrayOf({ result -> result.number > 0 }), + ) + } + + @Test + fun `async throw rejects after call returns`() { + val method = getMethod("asyncThrowRejectsAfterCall") + + discoverProperties( + method = method, + { result -> result eq 1 }, + invariants = arrayOf({ result -> result.number > 0 }), + ) + } + @Test fun `await resolving promise`() { val method = getMethod("awaitResolvingPromise") diff --git a/usvm-ts/src/test/resources/samples/lang/Async.ts b/usvm-ts/src/test/resources/samples/lang/Async.ts index 695a619896..c1c9eb1712 100644 --- a/usvm-ts/src/test/resources/samples/lang/Async.ts +++ b/usvm-ts/src/test/resources/samples/lang/Async.ts @@ -2,6 +2,52 @@ // noinspection JSUnusedGlobalSymbols class Async { + awaitPlainObject(): number { + const object = { answer: 42, __resolved__: true }; + const result = await object; + + return result === object && result.answer === 42 && result.__resolved__ ? 1 : 0; + } + + awaitNestedPromises(): number { + const outer = new Promise((resolve) => { + const inner = new Promise((resolveInner) => resolveInner(2)); + const innerValue = await inner; + resolve(innerValue + 1); + }); + const result = await outer; + + return result === 3 ? 1 : 0; + } + + async asyncValue(): Promise { + return 7; + } + + asyncValueIsPromise(): number { + const promise = this.asyncValue(); + if (promise === 7) return 0; + + const result = await promise; + return result === 7 ? 1 : 0; + } + + async asyncThrow(): Promise { + throw new Error("async failure"); + } + + asyncThrowRejectsAfterCall(): number { + let returnedFromCall = false; + try { + const promise = this.asyncThrow(); + returnedFromCall = true; + await promise; + return 0; + } catch (error) { + return returnedFromCall ? 1 : 0; + } + } + awaitResolvingPromise(): number { const promise = new Promise((resolve) => { resolve(42); From 8df3b09c96e24ce7ba7667e984173c86baecf876 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 03:34:23 +0300 Subject: [PATCH 70/97] fix(ts-tests): declare boolean result for Number.isNaN DSL calls --- usvm-ts/src/test/kotlin/org/usvm/util/DslCall.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/usvm-ts/src/test/kotlin/org/usvm/util/DslCall.kt b/usvm-ts/src/test/kotlin/org/usvm/util/DslCall.kt index d46635f3ff..e49bd1a245 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/util/DslCall.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/util/DslCall.kt @@ -4,13 +4,13 @@ import org.jacodb.ets.dsl.CustomValue import org.jacodb.ets.dsl.Expr import org.jacodb.ets.dsl.ProgramBuilder import org.jacodb.ets.model.EtsAnyType +import org.jacodb.ets.model.EtsBooleanType import org.jacodb.ets.model.EtsClassSignature import org.jacodb.ets.model.EtsFileSignature import org.jacodb.ets.model.EtsInstanceCallExpr import org.jacodb.ets.model.EtsLocal import org.jacodb.ets.model.EtsMethodParameter import org.jacodb.ets.model.EtsMethodSignature -import org.jacodb.ets.model.EtsNumberType fun ProgramBuilder.callNumberIsNaN(arg: EtsLocal): Expr { val call = EtsInstanceCallExpr( @@ -21,11 +21,13 @@ fun ProgramBuilder.callNumberIsNaN(arg: EtsLocal): Expr { file = EtsFileSignature.UNKNOWN, ), name = "isNaN", - parameters = listOf(EtsMethodParameter(0, "value", EtsAnyType)), - returnType = EtsNumberType, + parameters = listOf( + EtsMethodParameter(index = 0, name = "value", type = EtsAnyType), + ), + returnType = EtsBooleanType, ), args = listOf(arg), - type = EtsNumberType, + type = EtsBooleanType, ) return CustomValue(call) } From b07c4e50b148e906eb44e807c74a55ec3d93ef59 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 03:38:56 +0300 Subject: [PATCH 71/97] test(ts): assert exhaustive while-break reachability outcomes --- .../reachability/LoopsReachabilityTest.kt | 75 ++++++++++++++----- .../reachability/LoopsReachability.ts | 2 +- 2 files changed, 56 insertions(+), 21 deletions(-) diff --git a/usvm-ts/src/test/kotlin/org/usvm/reachability/LoopsReachabilityTest.kt b/usvm-ts/src/test/kotlin/org/usvm/reachability/LoopsReachabilityTest.kt index 79189c19ec..b46d0e0d91 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/reachability/LoopsReachabilityTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/reachability/LoopsReachabilityTest.kt @@ -1,20 +1,28 @@ package org.usvm.reachability import org.jacodb.ets.model.EtsIfStmt +import org.jacodb.ets.model.EtsMethod import org.jacodb.ets.model.EtsReturnStmt import org.jacodb.ets.model.EtsScene import org.jacodb.ets.utils.loadEtsFileAutoConvert import org.junit.jupiter.api.Disabled import org.usvm.PathSelectionStrategy import org.usvm.SolverType +import org.usvm.StateCollectionStrategy import org.usvm.UMachineOptions import org.usvm.api.targets.ReachabilityObserver import org.usvm.api.targets.TsReachabilityTarget import org.usvm.api.targets.TsTarget +import org.usvm.machine.TsAnalysisStopReason import org.usvm.machine.TsMachine +import org.usvm.machine.TsMachineAnalysisResult import org.usvm.machine.TsOptions +import org.usvm.machine.state.TsMethodResult import org.usvm.util.getResourcePath +import org.usvm.util.mkRegisterStackLValue import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Duration @@ -82,34 +90,61 @@ class LoopsReachabilityTest { } @Test - fun testWhileLoopBreakReachable() { - // Test reachability through while loop with break: - // while (counter < 100) -> if (counter === 20) break -> if (counter > 50) -> return 1 - val machine = TsMachine(scene, options, tsOptions, machineObserver = ReachabilityObserver()) + fun testWhileLoopBreakPreventsLaterReturnFromZero() { val method = scene.projectClasses .flatMap { it.methods } .single { it.name == "whileLoopBreakReachable" } + val returnOne = method.cfg.stmts.filterIsInstance().first() - val initialTarget = TsReachabilityTarget.InitialPoint(method.cfg.stmts.first()) - var target: TsTarget = initialTarget + val analysis = analyzeWhileLoopBreak(method, start = 0.0) - // if (counter === 20) - val breakCheck = method.cfg.stmts.filterIsInstance()[0] - target = target.addChild(TsReachabilityTarget.IntermediatePoint(breakCheck)) + assertCompleteWhileLoopAnalysis(analysis) + assertFalse(returnOne in analysis.states.flatMap { it.pathNode.allStatements }) + } - // if (counter > 50) - this should be unreachable due to break - val unreachableCheck = method.cfg.stmts.filterIsInstance()[1] - target = target.addChild(TsReachabilityTarget.IntermediatePoint(unreachableCheck)) + @Test + fun testWhileLoopCanReachLaterReturnWhenStartSkipsBreak() { + val method = scene.projectClasses + .flatMap { it.methods } + .single { it.name == "whileLoopBreakReachable" } + val returnOne = method.cfg.stmts.filterIsInstance().first() - // return 1 - should be unreachable - val returnStmt = method.cfg.stmts.filterIsInstance()[0] - target.addChild(TsReachabilityTarget.FinalPoint(returnStmt)) + val analysis = analyzeWhileLoopBreak(method, start = 50.0) - val results = machine.analyze(listOf(method), listOf(initialTarget)) - - // This test verifies that the unreachable path is correctly identified - val reachedStatements = results.flatMap { it.pathNode.allStatements }.toSet() - // The return 1 should NOT be reached due to break at counter == 20 + assertCompleteWhileLoopAnalysis(analysis) + assertTrue(returnOne in analysis.states.flatMap { it.pathNode.allStatements }) + } + + private fun analyzeWhileLoopBreak(method: EtsMethod, start: Double): TsMachineAnalysisResult { + val exhaustiveOptions = options.copy( + pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), + stateCollectionStrategy = StateCollectionStrategy.ALL, + stopOnCoverage = 0, + stopOnTargetsReached = false, + stepsFromLastCovered = null, + ) + + return TsMachine(scene = scene, options = exhaustiveOptions, tsOptions = tsOptions).use { machine -> + machine.analyzeWithMetadata( + methods = listOf(method), + configureInitialState = { _, state -> + with(state.ctx) { + val input = state.memory.read(mkRegisterStackLValue(fp64Sort, idx = 1)) + state.pathConstraints += mkEq(input, mkFp64(start)) + } + }, + ) + } + } + + private fun assertCompleteWhileLoopAnalysis(analysis: TsMachineAnalysisResult) { + assertEquals(TsAnalysisStopReason.EXHAUSTED, analysis.stopReason) + assertFalse(analysis.timedOut) + assertFalse(analysis.engineFailed) + assertFalse(analysis.unsupportedCall) + assertTrue(analysis.unsupportedPaths.isEmpty(), analysis.unsupportedPaths.toString()) + assertTrue(analysis.states.isNotEmpty()) + assertTrue(analysis.states.all { it.methodResult is TsMethodResult.Success }) } @Test diff --git a/usvm-ts/src/test/resources/reachability/LoopsReachability.ts b/usvm-ts/src/test/resources/reachability/LoopsReachability.ts index b6adf3435c..e5bbce6b8f 100644 --- a/usvm-ts/src/test/resources/reachability/LoopsReachability.ts +++ b/usvm-ts/src/test/resources/reachability/LoopsReachability.ts @@ -28,7 +28,7 @@ class LoopsReachability { break; } if (counter > 50) { - return 1; // Unreachable due to break at 20 + return 1; // Reachable when the starting value skips 20 } } return 0; From 0091ae9471f93b30e57f7711c3d1b0f918c5c1e9 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 03:43:39 +0300 Subject: [PATCH 72/97] fix(ts): preserve registered Promise identity in resolve --- .../usvm/machine/expr/CallApproximations.kt | 12 +++++++ .../usvm/machine/interpreter/TsInterpreter.kt | 11 ++++++- .../org/usvm/machine/interpreter/TsPromise.kt | 21 +++++++----- .../kotlin/org/usvm/samples/lang/Async.kt | 33 +++++++++++++++++++ .../src/test/resources/samples/lang/Async.ts | 29 ++++++++++++++++ 5 files changed, 96 insertions(+), 10 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index b95cb88c8e..f66103cb44 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -36,6 +36,7 @@ import org.usvm.machine.call.intrinsic.TsDateEtsIrModelFamily import org.usvm.machine.expr.TsExprApproximationResult.Companion.from import org.usvm.machine.interpreter.PromiseState import org.usvm.machine.interpreter.createSettledPromise +import org.usvm.machine.interpreter.existingPromiseOrEnsureNonThenable import org.usvm.machine.state.TsMethodResult import org.usvm.machine.state.lastStmt import org.usvm.machine.state.newStmt @@ -498,6 +499,17 @@ private fun TsExprResolver.handlePromiseResolveReject(expr: EtsInstanceCallExpr) "Promise.${expr.callee.name}() should have exactly one argument, but got ${expr.args.size}" } val value = resolve(expr.args.single()) ?: return null + + if (newState == PromiseState.FULFILLED) { + val existingPromise = existingPromiseOrEnsureNonThenable( + scope = scope, + value = value, + hierarchy = hierarchy, + operation = "Promise.resolve", + ) + if (existingPromise != null) return existingPromise + } + createSettledPromise(scope = scope, value = value, settlement = newState) } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt index 443901d40d..c7c562fd55 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt @@ -548,7 +548,16 @@ class TsInterpreter( private fun returnFromMethod(scope: TsStepScope, value: UExpr<*>) { val method = scope.calcOnState { callStack.lastMethod() } val returnedValue = if (method.isAsync) { - ctx.ensureSupportedAsyncFulfillment(scope, value, graph.hierarchy) + val existingPromise = ctx.existingPromiseOrEnsureNonThenable( + scope = scope, + value = value, + hierarchy = graph.hierarchy, + operation = "Async return", + ) + if (existingPromise != null) { + throw UnsupportedOperationException("Async return requires unmodeled Promise adoption") + } + ctx.createSettledPromise( scope = scope, value = value, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsPromise.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsPromise.kt index 5607860f12..e8d23fd504 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsPromise.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsPromise.kt @@ -79,35 +79,38 @@ internal fun TsContext.createSettledPromise( return promise } -/** Async returns assimilate Promises and thenables; keep unmodeled shapes out of success results. */ -internal fun TsContext.ensureSupportedAsyncFulfillment( +/** Returns a modeled Promise to reuse, or null when a value is safe to fulfill without assimilation. */ +internal fun TsContext.existingPromiseOrEnsureNonThenable( scope: TsStepScope, value: UExpr<*>, hierarchy: EtsHierarchy, -) { + operation: String, +): UConcreteHeapRef? { val actualValue = if (value.isFakeObject()) { value.extractSingleValueFromFakeObjectOrNull(scope) - ?: throw UnsupportedOperationException("Async return has a symbolic runtime kind") + ?: throw UnsupportedOperationException("$operation has a symbolic runtime kind") } else { value } - if (actualValue.sort != addressSort) return - if (actualValue == mkTsNullValue() || actualValue == mkUndefinedValue()) return + if (actualValue.sort != addressSort) return null + if (actualValue == mkTsNullValue() || actualValue == mkUndefinedValue()) return null val reference = actualValue.asExpr(addressSort) if (!isAllocatedConcreteHeapRef(reference)) { - throw UnsupportedOperationException("Async return requires unmodeled thenable assimilation") + throw UnsupportedOperationException("$operation requires unmodeled thenable assimilation") } val registeredPromise = scope.calcOnState { promiseState[reference] != null || promiseExecutor[reference] != null } if (registeredPromise) { - throw UnsupportedOperationException("Async return requires unmodeled Promise adoption") + return reference } if (!isDefinitelyNonThenable(scope, reference, hierarchy)) { - throw UnsupportedOperationException("Async return may require unmodeled thenable assimilation") + throw UnsupportedOperationException("$operation may require unmodeled thenable assimilation") } + + return null } internal const val RESOLVED_FIELD = "__resolved__" diff --git a/usvm-ts/src/test/kotlin/org/usvm/samples/lang/Async.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/lang/Async.kt index 33eb0382c5..38bb13f6b4 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/samples/lang/Async.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/samples/lang/Async.kt @@ -91,6 +91,39 @@ class Async : TsMethodTestRunner() { ) } + @Test + fun `resolve preserves settled promise identity and value`() { + val method = getMethod("resolvePreservesSettledPromise") + + discoverProperties( + method = method, + { result -> result eq 1 }, + invariants = arrayOf({ result -> result.number > 0 }), + ) + } + + @Test + fun `resolve preserves pending promise identity and value`() { + val method = getMethod("resolvePreservesPendingPromise") + + discoverProperties( + method = method, + { result -> result eq 1 }, + invariants = arrayOf({ result -> result.number > 0 }), + ) + } + + @Test + fun `resolve preserves rejected promise identity and rejection`() { + val method = getMethod("resolvePreservesRejectedPromise") + + discoverProperties( + method = method, + { result -> result eq 1 }, + invariants = arrayOf({ result -> result.number > 0 }), + ) + } + @Test fun `await rejected promise`() { val method = getMethod("awaitRejectedPromise") diff --git a/usvm-ts/src/test/resources/samples/lang/Async.ts b/usvm-ts/src/test/resources/samples/lang/Async.ts index c1c9eb1712..fe8cfb3180 100644 --- a/usvm-ts/src/test/resources/samples/lang/Async.ts +++ b/usvm-ts/src/test/resources/samples/lang/Async.ts @@ -73,6 +73,35 @@ class Async { return 0; } + resolvePreservesSettledPromise(): number { + const original = Promise.resolve(7); + const resolved = Promise.resolve(original); + const value = await resolved; + + return resolved === original && value === 7 ? 1 : 0; + } + + resolvePreservesPendingPromise(): number { + const original = new Promise((resolve) => resolve(7)); + const resolved = Promise.resolve(original); + const value = await resolved; + + return resolved === original && value === 7 ? 1 : 0; + } + + resolvePreservesRejectedPromise(): number { + const original = Promise.reject(new Error("rejected")); + const resolved = Promise.resolve(original); + if (resolved !== original) return 0; + + try { + await resolved; + return 0; + } catch (error) { + return 1; + } + } + awaitRejectedPromise() { const promise = Promise.reject(new Error("An error occurred")); await promise; // exception From 154ff8d308b9b40d462b4b7f27f26a5378ddfab1 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 03:46:31 +0300 Subject: [PATCH 73/97] refactor(ts): centralize default-scope local name routing --- .../org/usvm/machine/interpreter/TsInterpreter.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt index c7c562fd55..f1c36fe143 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt @@ -600,6 +600,10 @@ class TsInterpreter( ): Unit? = with(ctx) { val exprResolver = exprResolverWithScope(scope) val file = scope.calcOnState { lastEnteredMethod.enclosingClass!!.declaringFile!! } + + fun isDfltLocalName(name: String): Boolean = + name.startsWith("%") || name.startsWith("_tmp") || name == "this" + when (lhv) { is EtsPropertyRef -> { exprResolver.handleAssignToProperty(lhv, expr) @@ -611,7 +615,7 @@ class TsInterpreter( is EtsLocal -> { val name = lhv.name - if (name.startsWith("%") || name.startsWith("_tmp") || name == "this") { + if (isDfltLocalName(name)) { // Normal local variable exprResolver.handleAssignToLocal(lhv, expr) } else { @@ -625,7 +629,7 @@ class TsInterpreter( is EtsArrayAccess -> { val name = lhv.array.name - if (name.startsWith("%") || name.startsWith("_tmp") || name == "this") { + if (isDfltLocalName(name)) { // Normal local array variable exprResolver.handleAssignToArrayIndex(lhv, expr) } else { @@ -645,7 +649,7 @@ class TsInterpreter( is EtsInstanceFieldRef -> { val name = lhv.instance.name - if (name.startsWith("%") || name.startsWith("_tmp") || name == "this") { + if (isDfltLocalName(name)) { // Normal local instance variable exprResolver.handleAssignToInstanceField(lhv, expr) } else { From a7a8e5604faa4e137d8fda031b2ec409312983b9 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 03:53:42 +0300 Subject: [PATCH 74/97] refactor(ts-tests): mark import diagnostics manual and resolve aliases --- .../kotlin/org/usvm/project/ImportResolver.kt | 41 ++++++++----------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/usvm-ts/src/test/kotlin/org/usvm/project/ImportResolver.kt b/usvm-ts/src/test/kotlin/org/usvm/project/ImportResolver.kt index 03efb4f2c8..e48f0df378 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/project/ImportResolver.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/project/ImportResolver.kt @@ -8,6 +8,7 @@ import org.jacodb.ets.utils.loadEtsProjectAutoConvert import org.junit.jupiter.api.Assumptions import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import org.usvm.util.ImportResolutionResult @@ -16,15 +17,17 @@ import org.usvm.util.abort import org.usvm.util.getResourcePathOrNull import org.usvm.util.normalizeRelativePath import org.usvm.util.resolveImport -import org.usvm.util.resolveSymbol +import org.usvm.util.resolveImportInfo import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue private val logger = KotlinLogging.logger {} +/** Reports resolution outcomes for an optional demo project without assuming every external import is available. */ +@Tag("manual") @TestInstance(TestInstance.Lifecycle.PER_CLASS) -@DisplayName("Import Resolution Tests") +@DisplayName("Demo_Photos import resolution diagnostics") class ImportResolverTest { companion object { @@ -90,9 +93,9 @@ class ImportResolverTest { } @Test - @DisplayName("Test file-level import resolution with real imports from project files") - fun testFileImportResolver() { - println("\n--- Testing file-level import resolver ---") + @DisplayName("Report file-level import resolution across project files") + fun reportFileImportResolution() { + println("\n--- Reporting file-level import resolution ---") val allFiles = scene.projectFiles var totalImports = 0 @@ -141,15 +144,13 @@ class ImportResolverTest { println("File resolution success rate: $successRate%") } - // Assert that we found some imports and some were successful assertTrue(totalImports > 0, "Should find at least some imports in the project files") - // Note: We don't assert on success rate as it depends on the availability of imported files } @Test - @DisplayName("Test complete symbol resolution with real imports matching actual exports") - fun testSymbolResolver() { - println("\n--- Testing complete symbol resolver ---") + @DisplayName("Report symbol resolution across project imports") + fun reportSymbolResolution() { + println("\n--- Reporting symbol resolution ---") val allFiles = scene.projectFiles var totalSymbols = 0 @@ -169,13 +170,9 @@ class ImportResolverTest { imports.forEach { importInfo -> totalSymbols++ - when (val result = - scene.resolveSymbol( - currentFile = currentFile, - importPath = importInfo.from, - symbolName = importInfo.name, - importType = importInfo.type, - )) { + val result = scene.resolveImportInfo(currentFile, importInfo) + + when (result) { is SymbolResolutionResult.Success -> { successfulSymbols++ val exportInfo = result.exportInfo @@ -226,15 +223,13 @@ class ImportResolverTest { println("Complete symbol resolution success rate: $symbolSuccessRate%") } - // Assert that we found some symbols to resolve assertTrue(totalSymbols > 0, "Should find at least some symbols to resolve in the project files") - // Note: We don't assert on success rate as it depends on the availability of matching exports } @Test - @DisplayName("Test common import patterns and path normalization") - fun testCommonImportPatterns() { - println("\n--- Testing common import patterns ---") + @DisplayName("Report common import probes and check path normalization") + fun reportCommonImportPatterns() { + println("\n--- Reporting common import probes ---") Assumptions.assumeTrue( scene.projectFiles.isNotEmpty(), @@ -244,7 +239,7 @@ class ImportResolverTest { val testFile = scene.projectFiles.first() println("Using test file: ${testFile.signature.fileName}") - // Test cases for different import types + // Probe paths may be absent from the optional demo project. val testCases = mapOf( "System Library Imports" to listOf( "@ohos.router", From e62a531486471b2ef80c77d8dee9f97e72765fe3 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 04:05:25 +0300 Subject: [PATCH 75/97] fix(calls): classify stopped residual paths as unsupported --- .../ts/calls/CurrentTsCallsSymbolicEngine.kt | 6 ++-- .../calls/CurrentTsCallsSymbolicEngineTest.kt | 32 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt index 246af63366..ba024a3ead 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt @@ -211,7 +211,7 @@ internal class CurrentTsCallsSymbolicEngine( machineObserver = targetObserver, observer = interpreterObserver, ).use { machine -> - val outcome = machine.analyzeWithOutcome( + val outcome = machine.analyzeWithMetadata( methods = listOf(method), configureInitialState = { _, state -> symbolicInputs.initialize(state) }, ) @@ -219,6 +219,7 @@ internal class CurrentTsCallsSymbolicEngine( states = targetObserver.reachedStates, stopReason = outcome.stopReason, unsupportedPaths = outcome.unsupportedPaths, + unsupportedCall = outcome.unsupportedCall, ) } val states = analysis.states @@ -227,7 +228,7 @@ internal class CurrentTsCallsSymbolicEngine( TsAnalysisStopReason.EXHAUSTED -> { when { interpreterObserver.runtimeLimitations.isNotEmpty() -> CallsSymbolicStatus.RUNTIME_LIMITATION - analysis.unsupportedPaths.isNotEmpty() -> CallsSymbolicStatus.UNSUPPORTED + analysis.unsupportedPaths.isNotEmpty() || analysis.unsupportedCall -> CallsSymbolicStatus.UNSUPPORTED else -> CallsSymbolicStatus.UNREACHED } } @@ -758,6 +759,7 @@ internal class CurrentTsCallsSymbolicEngine( val states: List, val stopReason: TsAnalysisStopReason, val unsupportedPaths: List, + val unsupportedCall: Boolean, ) private companion object { diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt index 6beaa51f38..309699e83e 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt @@ -1,6 +1,7 @@ package org.usvm.ts.calls import org.usvm.machine.call.TsUnknownCallEvent +import org.usvm.machine.call.TsUnknownCallOutcome import org.usvm.ts.pbt.model.ArrayDomain import org.usvm.ts.pbt.model.JsConcreteValue import org.usvm.ts.pbt.model.JsNumber @@ -14,6 +15,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue internal class CurrentTsCallsSymbolicEngineTest : CurrentTsCallsSymbolicEngineTestSupport() { @@ -500,6 +502,36 @@ internal class CurrentTsCallsSymbolicEngineTest : CurrentTsCallsSymbolicEngineTe fixture.assertReplayConfirmed(assertNotNull(result.inputs, result.toString())) } + @Test + fun `stopped residual call before source target is unsupported`() { + val fixture = fixture( + source = """ + export const stoppedNativeDate = (value: number): boolean => { + if (value === 0 && Date.now() > 0) { + return true; + } + return false; + }; + """.trimIndent(), + exportName = "stoppedNativeDate", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + + val preflight = fixture.preflight() + val unknownCalls = mutableListOf() + val result = fixture.search( + modelIds = emptySet(), + profile = CallsExperimentProfile.FROZEN_STOP, + unknownCallEventSink = unknownCalls::add, + ) + + assertEquals(CallsSymbolicPreflightStatus.ELIGIBLE, preflight.status, preflight.toString()) + assertTrue(unknownCalls.any { event -> event.outcome == TsUnknownCallOutcome.PATH_STOPPED }) + assertEquals(CallsSymbolicStatus.UNSUPPORTED, result.status, result.toString()) + assertNull(result.inputs) + } + @Test fun `source Date object keeps its project method and is not replaced by a builtin`() { val fixture = fixture( From 4119ff9ad5555c381f051b98fab1a7b439f11df7 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 04:16:58 +0300 Subject: [PATCH 76/97] fix(ts-census): cap method execution by remaining project budget --- .../usvm/census/UnknownCallCensusRunner.kt | 25 ++++++++++++++--- .../census/UnknownCallCensusRunnerTest.kt | 28 +++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusRunner.kt b/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusRunner.kt index 970a67e3aa..308cb02609 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusRunner.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusRunner.kt @@ -84,6 +84,10 @@ internal class UnknownCallCensusRunner( ) { val projectStart = TimeSource.Monotonic.markNow() val projectTimeout = manifest.limits.projectTimeoutSeconds.seconds + val budget = CensusProjectBudget( + projectTimeout = projectTimeout, + methodTimeout = manifest.limits.methodTimeoutSeconds.seconds, + ) try { // A checkout entry may itself be a symlink. Its validated Git repository is the provenance boundary. @@ -129,11 +133,10 @@ internal class UnknownCallCensusRunner( var partialMethods = 0 var timedOutMethods = 0 var failedMethods = 0 - var projectTimedOut = false for (method in methods) { - if (projectStart.elapsedNow() >= projectTimeout) { - projectTimedOut = true + val effectiveMethodTimeout = budget.methodTimeoutAt(projectStart.elapsedNow()) + if (effectiveMethodTimeout == null) { break } @@ -143,6 +146,7 @@ internal class UnknownCallCensusRunner( scene = scene, method = method, profile = profile, + methodTimeout = effectiveMethodTimeout, pathsBySignature = loadedFiles.pathsBySignature, writer = writer, ) @@ -155,6 +159,7 @@ internal class UnknownCallCensusRunner( } } + val projectTimedOut = budget.timedOutAt(projectStart.elapsedNow()) writer.write( buildJsonObject { putCommonProjectFields(project, profile) @@ -209,6 +214,7 @@ internal class UnknownCallCensusRunner( scene: EtsScene, method: EtsMethod, profile: UnknownCallCensusProfile, + methodTimeout: Duration, pathsBySignature: Map, writer: CensusRecordWriter, ): MethodRunResult { @@ -221,7 +227,6 @@ internal class UnknownCallCensusRunner( pathsBySignature = pathsBySignature, writer = writer, ) - val methodTimeout = manifest.limits.methodTimeoutSeconds.seconds val machineOptions = UMachineOptions( pathSelectionStrategies = listOf(CENSUS_PATH_SELECTION_STRATEGY), randomSeed = manifest.randomSeed, @@ -530,6 +535,18 @@ internal class UnknownCallCensusRunner( } } +internal class CensusProjectBudget( + private val projectTimeout: Duration, + private val methodTimeout: Duration, +) { + fun methodTimeoutAt(elapsed: Duration): Duration? { + val remaining = projectTimeout - elapsed + return if (remaining <= Duration.ZERO) null else minOf(methodTimeout, remaining) + } + + fun timedOutAt(elapsed: Duration): Boolean = elapsed >= projectTimeout +} + private fun runtimeArtifactName(type: Class<*>): String = runCatching { Path.of(type.protectionDomain.codeSource.location.toURI()).name }.getOrDefault("unknown") diff --git a/usvm-ts/src/test/kotlin/org/usvm/census/UnknownCallCensusRunnerTest.kt b/usvm-ts/src/test/kotlin/org/usvm/census/UnknownCallCensusRunnerTest.kt index 7c07d66f35..fb0ab1b179 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/census/UnknownCallCensusRunnerTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/census/UnknownCallCensusRunnerTest.kt @@ -151,6 +151,34 @@ class UnknownCallCensusRunnerTest { assertEquals("Machine timeout reached", outcome.error) } + @Test + fun `project budget caps the final method and marks the project timed out afterward`() { + val budget = CensusProjectBudget( + projectTimeout = 10.seconds, + methodTimeout = 6.seconds, + ) + + val effectiveMethodTimeout = budget.methodTimeoutAt(elapsed = 8.seconds) + val finalProjectTimedOut = budget.timedOutAt(elapsed = 10.seconds) + + assertEquals(2.seconds, effectiveMethodTimeout) + assertTrue(finalProjectTimedOut) + } + + @Test + fun `project budget rejects another method after selection exhausts it`() { + val budget = CensusProjectBudget( + projectTimeout = 10.seconds, + methodTimeout = 6.seconds, + ) + + val nextMethodTimeout = budget.methodTimeoutAt(elapsed = 10.seconds) + val projectTimedOut = budget.timedOutAt(elapsed = 10.seconds) + + assertEquals(null, nextMethodTimeout) + assertTrue(projectTimedOut) + } + @Test fun `stable selection rank is reproducible and seed dependent`() { val rank = stableSelectionRank(seed = 0, identity = "project:file:Class") From e48ebf65c84bb635612336b4b6ab82e1d64bc42d Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 04:17:35 +0300 Subject: [PATCH 77/97] fix(ts-census): keep deadline diagnostic accurate after final method --- .../src/main/kotlin/org/usvm/census/UnknownCallCensusRunner.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusRunner.kt b/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusRunner.kt index 308cb02609..9b28442a02 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusRunner.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusRunner.kt @@ -177,7 +177,7 @@ internal class UnknownCallCensusRunner( put("rawEvents", rawEvents) put("durationMillis", projectStart.elapsedNow().inWholeMilliseconds) if (projectTimedOut) { - put("error", "Project timeout reached before all selected methods were analyzed") + put("error", "Project time budget exhausted") } } ) From 83fbe8e3e615ee44fb89b01774a0b19cce408f53 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 04:19:06 +0300 Subject: [PATCH 78/97] refactor(calls): name raw result target identity validation --- .../org/usvm/ts/calls/CallsExperiment.kt | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt index 8339702cdb..fb3b535b3a 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt @@ -567,16 +567,17 @@ internal object CallsRawResultsReader { require(results.all { result -> result.experimentId == metadata.experimentId }) { "Result experiment ID does not match metadata" } - require( - results.all { result -> - val identity = targetIdentities[Triple(result.projectId, result.functionId, result.targetId)] - identity != null && - result.revision == identity.revision && - result.development == identity.development && - result.siteId == identity.siteId && - result.targetMode == identity.targetMode - }, - ) { "Result target identity does not match metadata" } + val resultsMatchTargetIdentities = results.all { result -> + val identity = targetIdentities[Triple(result.projectId, result.functionId, result.targetId)] + identity != null && + result.revision == identity.revision && + result.development == identity.development && + result.siteId == identity.siteId && + result.targetMode == identity.targetMode + } + + require(resultsMatchTargetIdentities) { "Result target identity does not match metadata" } + val resultKeys = results.map { result -> ResultKey( projectId = result.projectId, From 966b5e2c85681ea6019e9b55ec4f89b782b064b9 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 04:29:58 +0300 Subject: [PATCH 79/97] fix(calls): preserve raw input during summarization --- .../org/usvm/ts/calls/CallsExperimentCli.kt | 3 + .../org/usvm/ts/calls/CallsExperimentTest.kt | 91 +++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperimentCli.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperimentCli.kt index 5c67559810..d646403e69 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperimentCli.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperimentCli.kt @@ -83,6 +83,9 @@ private fun summarize(args: List) { val rawInput = Path.of(args[0]).toAbsolutePath().normalize() val output = Path.of(args[1]).toAbsolutePath().normalize() val summary = CallsExperimentAggregator.summarize(rawInput) + val outputIsRawInput = rawInput == output || (Files.exists(output) && Files.isSameFile(rawInput, output)) + require(!outputIsRawInput) { "Summary output must differ from raw input" } + val json = Json { encodeDefaults = true explicitNulls = false diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt index 3e5376373c..0da912fd5b 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import org.jacodb.ets.utils.EtsIrProvider import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.junit.jupiter.api.Assumptions.assumeTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import org.usvm.SolverType @@ -15,8 +16,10 @@ import org.usvm.ts.pbt.model.BooleanDomain import org.usvm.ts.pbt.model.JsConcreteValue import org.usvm.ts.pbt.model.PropertyInput import org.usvm.ts.pbt.model.TypeScriptEntryPoint +import java.io.IOException import java.nio.file.Files import java.nio.file.Path +import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse @@ -654,6 +657,63 @@ class CallsExperimentTest { } } + @Test + fun `summarize rejects its raw input as output without changing it`(@TempDir directory: Path) { + val rawInput = directory.resolve("results.jsonl") + writeCompletedRawFixture(rawInput) + val originalBytes = Files.readAllBytes(rawInput) + + val failure = assertFailsWith { + main(arrayOf("summarize", rawInput.toString(), rawInput.toString())) + } + + assertEquals("Summary output must differ from raw input", failure.message) + assertContentEquals(originalBytes, Files.readAllBytes(rawInput)) + } + + @Test + fun `summarize rejects a link to its raw input without changing it`(@TempDir directory: Path) { + val rawInput = directory.resolve("results.jsonl") + writeCompletedRawFixture(rawInput) + val alias = directory.resolve("raw-alias.jsonl") + val linkCreated = try { + Files.createSymbolicLink(alias, rawInput) + true + } catch (_: UnsupportedOperationException) { + false + } catch (_: IOException) { + false + } catch (_: SecurityException) { + false + } + + assumeTrue(linkCreated, "Symbolic links are unavailable") + val originalBytes = Files.readAllBytes(rawInput) + + val failure = assertFailsWith { + main(arrayOf("summarize", rawInput.toString(), alias.toString())) + } + + assertEquals("Summary output must differ from raw input", failure.message) + assertContentEquals(originalBytes, Files.readAllBytes(rawInput)) + } + + @Test + fun `summarize replaces a distinct existing summary`(@TempDir directory: Path) { + val rawInput = directory.resolve("results.jsonl") + writeCompletedRawFixture(rawInput) + val originalBytes = Files.readAllBytes(rawInput) + val output = directory.resolve("summary.json") + Files.writeString(output, "stale summary") + + main(arrayOf("summarize", rawInput.toString(), output.toString())) + + val summary = CallsExperimentJson.json.decodeFromString(Files.readString(output)) + assertEquals("fixture", summary.experimentId) + assertEquals(4, summary.resultRows) + assertContentEquals(originalBytes, Files.readAllBytes(rawInput)) + } + @Test fun `aggregator counts each profile from raw rows without collapsing outcome stages`(@TempDir directory: Path) { val rawOutput = directory.resolve("results.jsonl") @@ -843,6 +903,37 @@ class CallsExperimentTest { ) } + private fun writeCompletedRawFixture(path: Path) { + val row = targetResult(inputs = listOf(JsConcreteValue.Boolean(value = true))) + val target = CallsRunTargetIdentity( + projectId = row.projectId, + revision = row.revision, + development = row.development, + functionId = row.functionId, + targetId = row.targetId, + siteId = row.siteId, + targetMode = row.targetMode, + ) + val metadata = CallsRunMetadata( + experimentId = row.experimentId, + toolRevision = FIXTURE_TOOL_REVISION, + nativeFrontendRevision = "bundled:test", + modelSet = CallsModelSetIdentity(ids = emptySet(), toolRevision = FIXTURE_TOOL_REVISION), + profiles = CallsExperimentProfile.entries, + seeds = listOf(row.seed), + commonEligibleTargets = 1, + targets = listOf(target), + ) + val completion = CallsRunCompletion(experimentId = row.experimentId, resultRows = CallsExperimentProfile.entries.size) + val records: List = buildList { + add(metadata) + CallsExperimentProfile.entries.forEach { profile -> add(row.copy(profile = profile)) } + add(completion) + } + + writeRecords(path, records) + } + private fun selector(seed: Long, profile: CallsExperimentProfile) = CallsWitnessSelector( projectId = "fixture/project", functionId = "fixture.ts::predicate/1", From 836b490e24443a0f22e9247ed870effc233d289f Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 04:31:03 +0300 Subject: [PATCH 80/97] fix(ts-tests): compare infinities and assert signed zero --- .../kotlin/org/usvm/samples/operators/Neg.kt | 16 ++++++---- .../test/kotlin/org/usvm/util/Assumptions.kt | 20 ------------- usvm-ts/src/test/kotlin/org/usvm/util/Util.kt | 12 +------- .../org/usvm/util/UtilNumberComparisonTest.kt | 29 +++++++++++++++++++ 4 files changed, 40 insertions(+), 37 deletions(-) create mode 100644 usvm-ts/src/test/kotlin/org/usvm/util/UtilNumberComparisonTest.kt diff --git a/usvm-ts/src/test/kotlin/org/usvm/samples/operators/Neg.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/operators/Neg.kt index 454f370c23..9b7a0c19a9 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/samples/operators/Neg.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/samples/operators/Neg.kt @@ -23,8 +23,10 @@ class Neg : TsMethodTestRunner() { a.isNaN() && r.isNaN() }, { a, r -> - // -0 = -0 - (a eq 0) && (r eq -0.0) + val isZeroInput = a.number == 0.0 + val expected = -a.number + + isZeroInput && r.number.toRawBits() == expected.toRawBits() }, { a, r -> // -positive number = negative number @@ -38,6 +40,8 @@ class Neg : TsMethodTestRunner() { { a, r -> if (a.isNaN()) { r.isNaN() + } else if (a.number == 0.0) { + r.number.toRawBits() == (-a.number).toRawBits() } else { r.number eq -a.number } @@ -57,7 +61,7 @@ class Neg : TsMethodTestRunner() { }, { a, r -> // -false = -0 - !a.value && (r eq 0) + !a.value && r.number.toRawBits() == (-0.0).toRawBits() }, ) } @@ -81,7 +85,7 @@ class Neg : TsMethodTestRunner() { method = method, { r -> // -null = -0 - r eq 0 + r.number.toRawBits() == (-0.0).toRawBits() }, ) } @@ -94,11 +98,11 @@ class Neg : TsMethodTestRunner() { method = method, { a, r -> // -"" = -0 - a.value == "" && (r eq 0.0) + a.value == "" && r.number.toRawBits() == (-0.0).toRawBits() }, { a, r -> // -"0" = -0 - a.value == "0" && (r eq 0.0) + a.value == "0" && r.number.toRawBits() == (-0.0).toRawBits() }, { a, r -> // -"1" = -1 diff --git a/usvm-ts/src/test/kotlin/org/usvm/util/Assumptions.kt b/usvm-ts/src/test/kotlin/org/usvm/util/Assumptions.kt index 452fc09935..f1a8d9fe7c 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/util/Assumptions.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/util/Assumptions.kt @@ -1,28 +1,8 @@ -@file:OptIn(ExperimentalContracts::class) - package org.usvm.util import org.junit.jupiter.api.Assumptions -import kotlin.contracts.ExperimentalContracts -import kotlin.contracts.contract fun abort(message: String = ""): Nothing { Assumptions.abort(message) error("Unreachable") } - -fun assumeTrue(assumption: Boolean, message: String = "Assumption failed") { - contract { returns() implies assumption } - Assumptions.assumeTrue(assumption, message) -} - -fun assumeFalse(assumption: Boolean, message: String = "Assumption failed") { - contract { returns() implies !assumption } - Assumptions.assumeFalse(assumption, message) -} - -fun assumeNotNull(value: T?, message: String = "Value should not be null"): T { - contract { returns() implies (value != null) } - assumeTrue(value != null, message) - return value -} diff --git a/usvm-ts/src/test/kotlin/org/usvm/util/Util.kt b/usvm-ts/src/test/kotlin/org/usvm/util/Util.kt index cc6824b75a..560ca689d7 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/util/Util.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/util/Util.kt @@ -3,13 +3,12 @@ package org.usvm.util import org.usvm.api.TsTestValue.TsBoolean import org.usvm.api.TsTestValue.TsNumber import org.usvm.api.TsTestValue.TsString -import org.usvm.targets.UTarget import kotlin.math.absoluteValue fun Boolean.toDouble() = if (this) 1.0 else 0.0 infix fun Double.eq(other: Double): Boolean { - return (this - other).absoluteValue <= Double.MIN_VALUE + return this == other || (this - other).absoluteValue <= Double.MIN_VALUE } infix fun Double.neq(other: Double): Boolean { @@ -75,12 +74,3 @@ infix fun TsBoolean.eq(other: TsBoolean): Boolean { infix fun TsBoolean.neq(other: TsBoolean): Boolean { return neq(other.value) } - -fun UTarget<*, T>.getRoot(): T where T : UTarget<*, T> { - var current = this - while (true) { - @Suppress("UNCHECKED_CAST") - val parent = current.parent ?: return current as T - current = parent - } -} diff --git a/usvm-ts/src/test/kotlin/org/usvm/util/UtilNumberComparisonTest.kt b/usvm-ts/src/test/kotlin/org/usvm/util/UtilNumberComparisonTest.kt new file mode 100644 index 0000000000..7ace60bd41 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/util/UtilNumberComparisonTest.kt @@ -0,0 +1,29 @@ +package org.usvm.util + +import org.junit.jupiter.api.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class UtilNumberComparisonTest { + @Test + fun `equal infinities compare equal`() { + assertTrue(Double.POSITIVE_INFINITY eq Double.POSITIVE_INFINITY) + assertTrue(Double.NEGATIVE_INFINITY eq Double.NEGATIVE_INFINITY) + assertFalse(Double.POSITIVE_INFINITY eq Double.NEGATIVE_INFINITY) + assertFalse(Double.POSITIVE_INFINITY neq Double.POSITIVE_INFINITY) + } + + @Test + fun `NaN stays unequal and finite tolerance stays unchanged`() { + assertFalse(Double.NaN eq Double.NaN) + assertTrue(Double.NaN neq Double.NaN) + assertTrue(0.0 eq Double.MIN_VALUE) + assertFalse(0.0 eq 1.0) + } + + @Test + fun `generic equality follows JavaScript zero equality`() { + assertTrue(0.0 eq -0.0) + assertFalse(0.0 neq -0.0) + } +} From fcd3efc125a9f1a5c55a2408166347fbcffa74c0 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 04:46:56 +0300 Subject: [PATCH 81/97] fix(ts-census): retain observed child handles for timeout cleanup --- .../usvm/census/UnknownCallCensusProcess.kt | 61 +++++++++++++++---- .../census/UnknownCallCensusRunnerTest.kt | 39 ++++++++++++ 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusProcess.kt b/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusProcess.kt index fe870c3174..e3eb71107b 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusProcess.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusProcess.kt @@ -71,6 +71,8 @@ internal fun boundedProcessOutput( var process: Process? = null var outputReader: Thread? = null + var completedNormally = false + val observedDescendants = linkedSetOf() try { val processStart = TimeSource.Monotonic.markNow() val startedProcess = ProcessBuilder(command) @@ -89,13 +91,14 @@ internal fun boundedProcessOutput( } outputReader = startedOutputReader - val remaining = timeout - processStart.elapsedNow() - val completed = remaining > Duration.ZERO && startedProcess.waitFor( - remaining.inWholeMilliseconds.coerceAtLeast(minimumValue = 1), - TimeUnit.MILLISECONDS, + val completed = waitForProcessWhileObservingDescendants( + process = startedProcess, + processStart = processStart, + timeout = timeout, + observedDescendants = observedDescendants, ) if (!completed) { - terminateProcessTreeBestEffort(startedProcess) + terminateProcessTreeBestEffort(startedProcess, observedDescendants) closeProcessOutputBestEffort(startedProcess) joinBestEffort(startedOutputReader, PROCESS_TERMINATION_GRACE) return BoundedProcessOutput.TimedOut @@ -106,19 +109,29 @@ internal fun boundedProcessOutput( timeout = timeout - processStart.elapsedNow(), ) if (!outputRead) { + terminateProcessTreeBestEffort(startedProcess, observedDescendants) closeProcessOutputBestEffort(startedProcess) joinBestEffort(startedOutputReader, PROCESS_TERMINATION_GRACE) return BoundedProcessOutput.TimedOut } outputCollector.failure?.let { throw it } - return BoundedProcessOutput.Completed( + val completedOutput = BoundedProcessOutput.Completed( exitCode = startedProcess.exitValue(), output = outputCollector.output(), truncated = outputCollector.truncated, ) + completedNormally = true + return completedOutput } finally { - process?.takeIf(Process::isAlive)?.let(::terminateProcessTreeBestEffort) + if (!completedNormally) { + process?.let { startedProcess -> + val hasLiveObservedDescendants = observedDescendants.any(ProcessHandle::isAlive) + if (startedProcess.isAlive || hasLiveObservedDescendants) { + terminateProcessTreeBestEffort(startedProcess, observedDescendants) + } + } + } process?.let(::closeProcessOutputBestEffort) outputReader?.takeIf(Thread::isAlive)?.let { reader -> joinBestEffort(reader, PROCESS_TERMINATION_GRACE) @@ -126,6 +139,31 @@ internal fun boundedProcessOutput( } } +private fun waitForProcessWhileObservingDescendants( + process: Process, + processStart: TimeSource.Monotonic.ValueTimeMark, + timeout: Duration, + observedDescendants: MutableSet, +): Boolean { + while (true) { + // Retain handles while the parent is alive; detached children may disappear from later descendant queries. + process.toHandle().descendants().use { descendants -> + descendants.forEach { descendant -> observedDescendants += descendant } + } + + val remaining = timeout - processStart.elapsedNow() + if (remaining <= Duration.ZERO) { + return false + } + + val waitMillis = minOf(remaining, PROCESS_DESCENDANT_POLL_INTERVAL) + .inWholeMilliseconds.coerceAtLeast(minimumValue = 1) + if (process.waitFor(waitMillis, TimeUnit.MILLISECONDS)) { + return true + } + } +} + private class BoundedOutputCollector(private val maxOutputBytes: Int) { private val retainedOutput = ByteArray(maxOutputBytes) private var retainedBytes = 0 @@ -190,11 +228,11 @@ private fun closeProcessOutputBestEffort(process: Process) { runCatching { process.inputStream.close() } } -private fun terminateProcessTree(process: Process) { +private fun terminateProcessTree(process: Process, observedDescendants: Set) { val descendants = process.toHandle().descendants().use { handles -> handles.iterator().asSequence().toList() } - val processTree = listOf(process.toHandle()) + descendants + val processTree = listOf(process.toHandle()) + (observedDescendants + descendants) processTree.asReversed().forEach(ProcessHandle::destroy) if (!awaitTermination(processTree, PROCESS_TERMINATION_GRACE)) { @@ -207,9 +245,9 @@ private fun terminateProcessTree(process: Process) { process.waitFor(PROCESS_TERMINATION_GRACE.inWholeMilliseconds, TimeUnit.MILLISECONDS) } -private fun terminateProcessTreeBestEffort(process: Process) { +private fun terminateProcessTreeBestEffort(process: Process, observedDescendants: Set) { try { - terminateProcessTree(process) + terminateProcessTree(process, observedDescendants) } catch (_: InterruptedException) { Thread.currentThread().interrupt() process.destroyForcibly() @@ -234,4 +272,5 @@ private fun awaitTermination(processes: List, timeout: Duration): } private val PROCESS_TERMINATION_GRACE = 500.milliseconds +private val PROCESS_DESCENDANT_POLL_INTERVAL = 20.milliseconds private const val PROCESS_OUTPUT_BUFFER_BYTES = 8 * 1024 diff --git a/usvm-ts/src/test/kotlin/org/usvm/census/UnknownCallCensusRunnerTest.kt b/usvm-ts/src/test/kotlin/org/usvm/census/UnknownCallCensusRunnerTest.kt index fb0ab1b179..7f7a19f52e 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/census/UnknownCallCensusRunnerTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/census/UnknownCallCensusRunnerTest.kt @@ -125,6 +125,45 @@ class UnknownCallCensusRunnerTest { } } + @Test + fun `bounded process output terminates an observed child after its parent exits`() { + val temporaryRoot = createTempDirectory("census-process-test-") + val childPidFile = temporaryRoot.resolve("child.pid") + try { + val start = TimeSource.Monotonic.markNow() + val result = boundedProcessOutput( + command = listOf( + "sh", + "-c", + // A timing window lets the polling loop observe the child before its parent exits. + "sleep 30 & child=\$!; printf %s \"\$child\" > \"\$1\"; sleep 0.2", + "census-exited-parent-test", + childPidFile.toString(), + ), + timeout = 500.milliseconds, + maxOutputBytes = 1_024, + ) + + assertEquals(BoundedProcessOutput.TimedOut, result) + assertTrue(start.elapsedNow() < 3.seconds) + + val childPid = Files.readString(childPidFile).toLong() + val childIsAlive = ProcessHandle.of(childPid) + .map(ProcessHandle::isAlive) + .orElse(false) + assertFalse(childIsAlive) + } finally { + try { + val childPid = runCatching { Files.readString(childPidFile).trim().toLong() }.getOrNull() + childPid?.let { pid -> + ProcessHandle.of(pid).ifPresent { child -> child.destroyForcibly() } + } + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + } + @Test fun `elapsed timeout preserves a partial failure`() { val outcome = methodOutcomeAfterTimeoutCheck( From 4ce6f4ac46137444755213fa2815c788de46d9c0 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 04:51:08 +0300 Subject: [PATCH 82/97] fix(ts): ignore failed terminal reachability callbacks --- .../usvm/api/targets/ReachabilityObserver.kt | 2 + .../usvm/checkers/ReachabilityObserverTest.kt | 74 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 usvm-ts/src/test/kotlin/org/usvm/checkers/ReachabilityObserverTest.kt diff --git a/usvm-ts/src/main/kotlin/org/usvm/api/targets/ReachabilityObserver.kt b/usvm-ts/src/main/kotlin/org/usvm/api/targets/ReachabilityObserver.kt index 4c12c769ed..8c18ea60cb 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/api/targets/ReachabilityObserver.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/api/targets/ReachabilityObserver.kt @@ -27,6 +27,8 @@ class ReachabilityObserver : UMachineObserver { } override fun onStateTerminated(state: TsState, stateReachable: Boolean) { + if (!stateReachable) return + state.targets .filter { it is TsReachabilityTarget } .forEach { target -> diff --git a/usvm-ts/src/test/kotlin/org/usvm/checkers/ReachabilityObserverTest.kt b/usvm-ts/src/test/kotlin/org/usvm/checkers/ReachabilityObserverTest.kt new file mode 100644 index 0000000000..e3596f5666 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/checkers/ReachabilityObserverTest.kt @@ -0,0 +1,74 @@ +package org.usvm.checkers + +import io.mockk.mockk +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.model.EtsStmt +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.usvm.UMachineOptions +import org.usvm.api.targets.ReachabilityObserver +import org.usvm.api.targets.TsReachabilityTarget +import org.usvm.collections.immutable.internal.MutabilityOwnership +import org.usvm.machine.TsComponents +import org.usvm.machine.TsContext +import org.usvm.machine.state.TsState +import org.usvm.machine.state.newStmt +import org.usvm.machine.types.TsTypeSystem +import org.usvm.targets.UTargetsSet +import org.usvm.util.EtsHierarchy +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class ReachabilityObserverTest { + private val scene = EtsScene(projectFiles = emptyList()) + private val typeSystem = TsTypeSystem( + scene = scene, + typeOperationsTimeout = 1.seconds, + hierarchy = EtsHierarchy(scene), + ) + private val components = TsComponents( + typeSystem = typeSystem, + options = UMachineOptions(useSolverForForks = false), + ) + private val ctx = TsContext(scene = scene, components = components) + + @AfterEach + fun closeContext() { + components.close() + ctx.close() + } + + @Test + fun `failed termination does not complete a shared target before a reachable termination`() { + val statement = mockk() + val target = TsReachabilityTarget.FinalPoint(statement) + val failedState = stateAt(statement, target) + val reachableState = stateAt(statement, target) + val observer = ReachabilityObserver() + + observer.onStateTerminated(failedState, stateReachable = false) + + assertFalse(target.isRemoved) + assertTrue(failedState.targets.reachedTerminal.isEmpty()) + + observer.onStateTerminated(reachableState, stateReachable = true) + + assertTrue(target.isRemoved) + assertTrue(target in reachableState.targets.reachedTerminal) + } + + private fun stateAt(statement: EtsStmt, target: TsReachabilityTarget): TsState { + val state = TsState( + ctx = ctx, + ownership = MutabilityOwnership(), + entrypoint = mockk(), + maxStringLength = 0, + targets = UTargetsSet.from(listOf(target)), + ) + state.newStmt(statement) + + return state + } +} From 8a209b00b829d47f0146f5da0120c7cef4521b2b Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 05:03:43 +0300 Subject: [PATCH 83/97] fix(ts-build): resolve timeout units and report SDK generation failure --- usvm-ts-dataflow/build.gradle.kts | 1 + usvm-ts/build.gradle.kts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/usvm-ts-dataflow/build.gradle.kts b/usvm-ts-dataflow/build.gradle.kts index 14d188e0b6..ff412d75a5 100644 --- a/usvm-ts-dataflow/build.gradle.kts +++ b/usvm-ts-dataflow/build.gradle.kts @@ -1,6 +1,7 @@ import java.io.FileNotFoundException import java.nio.file.Files import java.nio.file.attribute.FileTime +import java.util.concurrent.TimeUnit plugins { id("usvm.kotlin-conventions") diff --git a/usvm-ts/build.gradle.kts b/usvm-ts/build.gradle.kts index 7c3cffc452..3bc1038135 100644 --- a/usvm-ts/build.gradle.kts +++ b/usvm-ts/build.gradle.kts @@ -4,6 +4,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.io.FileNotFoundException import java.io.Reader +import java.util.concurrent.TimeUnit import kotlin.time.Duration plugins { @@ -91,7 +92,7 @@ val generateSdkIR by tasks.registering { println("Timeout!") } if (result.exitCode != 0) { - println("Exit code: ${result.exitCode}") + error("SDK IR generation failed with exit code ${result.exitCode}: $outputDir") } } } From 6c1bc641d695eddb75d98ae90624f4141dabf7e1 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 05:05:41 +0300 Subject: [PATCH 84/97] fix(ts): isolate state visualization output and process failures --- .../kotlin/org/usvm/util/TsStateVisualizer.kt | 98 +++++++++++++++++-- .../org/usvm/util/TsStateVisualizerTest.kt | 87 ++++++++++++++++ 2 files changed, 178 insertions(+), 7 deletions(-) create mode 100644 usvm-ts/src/test/kotlin/org/usvm/util/TsStateVisualizerTest.kt diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/TsStateVisualizer.kt b/usvm-ts/src/main/kotlin/org/usvm/util/TsStateVisualizer.kt index 430b9cdc02..0c4da690c6 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/TsStateVisualizer.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/TsStateVisualizer.kt @@ -7,7 +7,11 @@ import org.usvm.dataflow.ts.util.toMap import org.usvm.machine.TsInterpreterObserver import org.usvm.machine.state.TsState import org.usvm.statistics.UMachineObserver +import java.io.InterruptedIOException +import java.nio.file.Files import java.nio.file.Path +import java.util.StringTokenizer +import java.util.concurrent.CancellationException import kotlin.io.path.createTempDirectory import kotlin.io.path.writeText @@ -15,7 +19,19 @@ private val logger = KotlinLogging.logger {} class TsStateVisualizer : TsInterpreterObserver, UMachineObserver { override fun onStatePeeked(state: TsState) { - state.renderGraph() + try { + state.renderGraph() + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + throw e + } catch (e: InterruptedIOException) { + Thread.currentThread().interrupt() + throw e + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.warn(e) { "Could not visualize state at ${state.currentStatement}" } + } } } @@ -33,7 +49,6 @@ fun TsState.renderGraph(view: Boolean = true) { } } -@Suppress("DEPRECATION") fun myRenderDot( dot: String, outDir: Path = createTempDirectory(), @@ -46,12 +61,81 @@ fun myRenderDot( else -> "xdg-open" }, ) { - val dotFile = outDir.resolve("$baseName.dot") - val outFile = outDir.resolveSibling("$baseName.$format") + val dotCommand = splitCommandPrefix(dotCmd) + val viewerCommand = viewerCmd?.let(::splitCommandPrefix) + + myRenderDot( + dot = dot, + outDir = outDir, + baseName = baseName, + dotCommand = dotCommand, + format = format, + viewerCommand = viewerCommand, + ) +} + +fun myRenderDot( + dot: String, + outDir: Path, + baseName: String, + dotCommand: List, + format: String, + viewerCommand: List?, +) { + require(dotCommand.isNotEmpty()) { "Graphviz command must not be empty" } + require(viewerCommand == null || viewerCommand.isNotEmpty()) { "Viewer command must not be empty" } + + val dotFile = outDir.resolve("$baseName.input.dot") + val outFile = outDir.resolve("$baseName.$format") dotFile.writeText(dot) - Runtime.getRuntime().exec("$dotCmd $dotFile -T$format -o $outFile").waitFor() + Files.deleteIfExists(outFile) + + val renderCommand = renderCommandArguments(dotCommand, dotFile, outFile, format) + val renderExitCode = runCommand(renderCommand) + checkRenderResult(renderExitCode, outFile) + logger.debug { "Rendered DOT to ${format.uppercase()}: $outFile" } - if (viewerCmd != null) { - Runtime.getRuntime().exec("$viewerCmd $outFile").waitFor() + if (viewerCommand != null) { + val openCommand = viewerCommand + outFile.toString() + val viewerExitCode = runCommand(openCommand) + check(viewerExitCode == 0) { "Viewer exited with code $viewerExitCode for $outFile" } + } +} + +internal fun splitCommandPrefix(command: String): List { + val tokenizer = StringTokenizer(command) + val tokens = buildList { + while (tokenizer.hasMoreTokens()) { + add(tokenizer.nextToken()) + } + } + require(tokens.isNotEmpty()) { "Command must not be blank" } + + return if (tokens == listOf("cmd", "/c", "start")) tokens + "" else tokens +} + +internal fun renderCommandArguments( + dotCommand: List, + dotFile: Path, + outFile: Path, + format: String, +): List = dotCommand + listOf(dotFile.toString(), "-T$format", "-o", outFile.toString()) + +internal fun checkRenderResult(exitCode: Int, outFile: Path) { + check(exitCode == 0) { "Graphviz exited with code $exitCode for $outFile" } + check(Files.isRegularFile(outFile)) { "Graphviz did not create $outFile" } +} + +private fun runCommand(command: List): Int { + val process = ProcessBuilder(command) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start() + + return try { + process.waitFor() + } catch (e: InterruptedException) { + process.destroyForcibly() + throw e } } diff --git a/usvm-ts/src/test/kotlin/org/usvm/util/TsStateVisualizerTest.kt b/usvm-ts/src/test/kotlin/org/usvm/util/TsStateVisualizerTest.kt new file mode 100644 index 0000000000..8866f92a03 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/util/TsStateVisualizerTest.kt @@ -0,0 +1,87 @@ +package org.usvm.util + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TsStateVisualizerTest { + @Test + fun `DOT output cannot delete input and stays inside its render directory`(@TempDir directory: Path) { + val renderDirectory = Files.createDirectory(directory.resolve("render with spaces")) + val dot = "digraph test {}" + val staleOutput = renderDirectory.resolve("cfg.dot") + val siblingOutput = directory.resolve("cfg.dot") + staleOutput.writeText("stale") + siblingOutput.writeText("sibling") + val nonexistentRenderer = directory.resolve("missing renderer").toString() + + assertFailsWith { + myRenderDot( + dot = dot, + outDir = renderDirectory, + baseName = "cfg", + dotCommand = listOf(nonexistentRenderer), + format = "dot", + viewerCommand = null, + ) + } + + assertEquals(dot, renderDirectory.resolve("cfg.input.dot").readText()) + assertFalse(Files.exists(staleOutput)) + assertEquals("sibling", siblingOutput.readText()) + } + + @Test + fun `render command preserves paths with spaces as single arguments`(@TempDir directory: Path) { + val dotFile = directory.resolve("my graphs").resolve("cfg.dot") + val outFile = directory.resolve("my graphs").resolve("cfg.svg") + val executable = directory.resolve("Graphviz tools").resolve("dot").toString() + + val arguments = renderCommandArguments( + dotCommand = listOf(executable, "-Gdpi=100"), + dotFile = dotFile, + outFile = outFile, + format = "svg", + ) + + assertEquals(listOf(executable, "-Gdpi=100", dotFile.toString(), "-Tsvg", "-o", outFile.toString()), arguments) + } + + @Test + fun `legacy Windows viewer prefix retains an empty start title`() { + val arguments = splitCommandPrefix(command = "cmd /c start") + + assertEquals(listOf("cmd", "/c", "start", ""), arguments) + } + + @Test + fun `renderer failure does not count as a successful image`(@TempDir directory: Path) { + val outFile = directory.resolve("cfg.svg") + + val exitFailure = assertFailsWith { + checkRenderResult(exitCode = 2, outFile = outFile) + } + val missingOutput = assertFailsWith { + checkRenderResult(exitCode = 0, outFile = outFile) + } + + assertTrue(exitFailure.message.orEmpty().contains("code 2")) + assertTrue(missingOutput.message.orEmpty().contains("did not create")) + } + + @Test + fun `renderer success accepts an existing image`(@TempDir directory: Path) { + val outFile = directory.resolve("cfg.svg") + outFile.writeText("") + + checkRenderResult(exitCode = 0, outFile = outFile) + } +} From 05d6b574155d35a6187a53ffe018bccb36a62397 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 05:18:40 +0300 Subject: [PATCH 85/97] refactor(calls-tests): separate fixture setup stages --- .../ts/calls/CurrentTsCallsSymbolicEngineTestSupport.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTestSupport.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTestSupport.kt index 9e030f9db8..9c388e189d 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTestSupport.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTestSupport.kt @@ -34,12 +34,14 @@ internal open class CurrentTsCallsSymbolicEngineTestSupport { Files.createDirectories(dependency.parent) Files.writeString(dependency, content) } + runGit(sourceRoot, "init") runGit(sourceRoot, "config", "user.name", "USVM Tests") runGit(sourceRoot, "config", "user.email", "usvm@example.test") runGit(sourceRoot, "add", ".") runGit(sourceRoot, "commit", "-m", "fixture") val revision = runGit(sourceRoot, "rev-parse", "HEAD").trim() + val targetStart = source.indexOf(targetStatement) check(targetStart >= 0) { "Missing target statement: $targetStatement" } val targetEnd = targetStart + targetStatement.length @@ -49,6 +51,7 @@ internal open class CurrentTsCallsSymbolicEngineTestSupport { } ?: error("Missing return expression inside target: $expression") } + val target = CallsSourceTarget( targetId = "$exportName#$targetStatement", siteId = "$exportName:$targetStart:$targetEnd", @@ -61,6 +64,7 @@ internal open class CurrentTsCallsSymbolicEngineTestSupport { returnExpressionStartOffset = returnExpressionStart, returnExpressionEndOffset = returnExpressionStart?.plus(requireNotNull(returnExpression).length), ) + val function = CallsFunctionCase( functionId = exportName, sourceFile = sourceFileName, @@ -68,6 +72,7 @@ internal open class CurrentTsCallsSymbolicEngineTestSupport { inputs = inputs, targets = listOf(target), ) + val project = CallsProjectCase( projectId = exportName, revision = revision, @@ -90,6 +95,7 @@ internal open class CurrentTsCallsSymbolicEngineTestSupport { .start() val output = process.inputStream.bufferedReader().use { it.readText() } check(process.waitFor() == 0) { "Git ${arguments.joinToString()} failed: $output" } + return output } From d87bf0b5027e1623f6c528a9a2c68f4f85f51a07 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 05:24:17 +0300 Subject: [PATCH 86/97] refactor(pbt-tests): remove unused resource root helper --- usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/TestResources.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/TestResources.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/TestResources.kt index 82542885a0..89f30f086b 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/TestResources.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/TestResources.kt @@ -12,6 +12,4 @@ internal fun testResourcePath(name: String): Path { return Path.of(resource.toURI()) } -internal fun testResourcesRoot(): Path = requireNotNull(testResourcePath("/properties").parent) - private object TestResourceMarker From 0f17c3b245a8b9ccd8452330efccae20395d88e6 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 05:24:31 +0300 Subject: [PATCH 87/97] refactor(ts-tests): remove discarded analysis result wrappers --- usvm-ts/src/test/kotlin/org/usvm/project/DemoCalc.kt | 9 +++------ .../src/test/kotlin/org/usvm/project/DemoPhotos.kt | 11 +++-------- .../src/test/kotlin/org/usvm/project/ProjectRunner.kt | 6 ++---- 3 files changed, 8 insertions(+), 18 deletions(-) diff --git a/usvm-ts/src/test/kotlin/org/usvm/project/DemoCalc.kt b/usvm-ts/src/test/kotlin/org/usvm/project/DemoCalc.kt index 80877725ee..fa03928a30 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/project/DemoCalc.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/project/DemoCalc.kt @@ -63,8 +63,7 @@ class RunOnDemoCalcProject : TsMethodTestRunner() { runCatching { val tsOptions = TsOptions() TsMachine(scene, options, tsOptions).use { machine -> - val states = machine.analyze(methods) - states.let {} + machine.analyze(methods) } }.onFailure { exceptions += it @@ -92,8 +91,7 @@ class RunOnDemoCalcProject : TsMethodTestRunner() { val tsOptions = TsOptions() TsMachine(scene, options, tsOptions).use { machine -> - val states = machine.analyze(methods) - states.let {} + machine.analyze(methods) } } @@ -105,8 +103,7 @@ class RunOnDemoCalcProject : TsMethodTestRunner() { val tsOptions = TsOptions() TsMachine(scene, options.copy(timeout = 120.seconds), tsOptions).use { machine -> - val states = machine.analyze(listOf(method)) - states.let {} + machine.analyze(listOf(method)) } } } diff --git a/usvm-ts/src/test/kotlin/org/usvm/project/DemoPhotos.kt b/usvm-ts/src/test/kotlin/org/usvm/project/DemoPhotos.kt index a8df42eb06..06e10ad176 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/project/DemoPhotos.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/project/DemoPhotos.kt @@ -9,12 +9,10 @@ import org.jacodb.ets.utils.INSTANCE_INIT_METHOD_NAME import org.jacodb.ets.utils.STATIC_INIT_METHOD_NAME import org.jacodb.ets.utils.loadEtsProjectAutoConvert import org.junit.jupiter.api.Tag -import org.junit.jupiter.api.condition.EnabledIf import org.usvm.machine.TsMachine import org.usvm.machine.TsOptions import org.usvm.util.TsMethodTestRunner import org.usvm.util.getResourcePath -import org.usvm.util.getResourcePathOrNull import kotlin.test.Test private val logger = KotlinLogging.logger {} @@ -66,8 +64,7 @@ class RunOnDemoPhotosProject : TsMethodTestRunner() { runCatching { val tsOptions = TsOptions() TsMachine(scene, options, tsOptions).use { machine -> - val states = machine.analyze(methods) - states.let {} + machine.analyze(methods) } }.onFailure { exceptions += it @@ -98,8 +95,7 @@ class RunOnDemoPhotosProject : TsMethodTestRunner() { val tsOptions = TsOptions() TsMachine(scene, options, tsOptions).use { machine -> - val states = machine.analyze(methods) - states.let {} + machine.analyze(methods) } } @@ -112,8 +108,7 @@ class RunOnDemoPhotosProject : TsMethodTestRunner() { val tsOptions = TsOptions() TsMachine(scene, options, tsOptions).use { machine -> - val states = machine.analyze(listOf(method)) - states.let {} + machine.analyze(listOf(method)) } } } diff --git a/usvm-ts/src/test/kotlin/org/usvm/project/ProjectRunner.kt b/usvm-ts/src/test/kotlin/org/usvm/project/ProjectRunner.kt index 306e5e3f2c..217cffae1b 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/project/ProjectRunner.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/project/ProjectRunner.kt @@ -147,8 +147,7 @@ class ProjectRunner { val tsOptions = TsOptions() TsMachine(scene, machineOptions, tsOptions).use { machine -> - val states = machine.analyze(methods) - states.let {} + machine.analyze(methods) } } @@ -198,8 +197,7 @@ class ProjectRunner { val tsOptions = TsOptions() TsMachine(scene, machineOptions, tsOptions).use { machine -> - val states = machine.analyze(methods) - states.let {} + machine.analyze(methods) } } From 26806b26fb302d96141b0d5cbdd9d6da3fc5f132 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 05:35:06 +0300 Subject: [PATCH 88/97] fix(ts): ignore extra arguments in compatibility array join --- .../kotlin/org/usvm/machine/expr/CallApproximations.kt | 10 ++-------- .../kotlin/org/usvm/samples/arrays/ArrayMethods.kt | 10 ++++++++++ .../src/test/resources/samples/arrays/ArrayMethods.ts | 9 +++++++++ 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index f66103cb44..9035f4085c 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -223,7 +223,7 @@ internal fun TsExprResolver.tryApproximateInstanceCall( // Handle `Array.join() method calls if (expr.callee.name == "join") { - return from(handleArrayJoin(expr)) + return from(handleArrayJoin()) } // Handle `Array.slice() method calls @@ -990,13 +990,7 @@ private fun TsExprResolver.handleArrayUnshift( } /** Compatibility approximation used when the source model dispatcher is unavailable. */ -private fun TsExprResolver.handleArrayJoin( - expr: EtsInstanceCallExpr, -): UExpr<*>? = with(ctx) { - check(expr.args.size <= 1) { - "Array.join() should have at most one argument, but got ${expr.args.size}" - } - +private fun TsExprResolver.handleArrayJoin(): UExpr<*>? = with(ctx) { // For simplicity, we return a symbolic string constant // A full implementation would need to handle element conversion and concatenation symbolically logger.warn { diff --git a/usvm-ts/src/test/kotlin/org/usvm/samples/arrays/ArrayMethods.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/arrays/ArrayMethods.kt index 9b0dcc5e11..3660f5329d 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/samples/arrays/ArrayMethods.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/samples/arrays/ArrayMethods.kt @@ -170,6 +170,16 @@ class ArrayMethods : TsMethodTestRunner() { ) } + @Test + fun testArrayJoinEvaluatesExtraArgument() { + val method = getMethod("arrayJoinEvaluatesExtraArgument") + + discoverProperties( + method = method, + { result -> result.number == 1.0 }, + ) + } + @Test fun testArraySlice() { val method = getMethod("arraySlice") diff --git a/usvm-ts/src/test/resources/samples/arrays/ArrayMethods.ts b/usvm-ts/src/test/resources/samples/arrays/ArrayMethods.ts index d86392b791..97be33c47b 100644 --- a/usvm-ts/src/test/resources/samples/arrays/ArrayMethods.ts +++ b/usvm-ts/src/test/resources/samples/arrays/ArrayMethods.ts @@ -107,6 +107,15 @@ class ArrayMethods { } } + arrayJoinEvaluatesExtraArgument() { + const values = [1, 2, 3]; + let extraArgumentEvaluations = 0; + + const joined = values.join("-", extraArgumentEvaluations++); + + return joined === "joined_array_result" ? extraArgumentEvaluations : -1; + } + arraySlice(x: boolean) { const arr = [10, 20, 30, 40, 50]; if (x) { From 57b22918a645f9b4720fdd91552c2f440603bea3 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 10:24:20 +0300 Subject: [PATCH 89/97] Pin the reviewed JacoDB frontend for TypeScript integration --- .../kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt index ba024a3ead..d718cf46fa 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt @@ -329,6 +329,7 @@ internal class CurrentTsCallsSymbolicEngine( cache = verifiedProjects, ) } + verifyNativeFrontend(expectedRevision = request.expectedNativeFrontendRevision) val source = request.sourceRoot.resolve(request.function.sourceFile).normalize() From 198c267bef3ef449e44ead8bc5212c1b271bbb33 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 11:23:59 +0300 Subject: [PATCH 90/97] Fix array metadata initialization and awaited rejection compilation --- .../usvm/machine/expr/CallApproximations.kt | 44 +++++++++++++------ .../org/usvm/machine/expr/TsExprResolver.kt | 2 +- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index 9035f4085c..7613581ff2 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -22,6 +22,8 @@ import org.usvm.api.makeSymbolicPrimitive import org.usvm.api.memcpy import org.usvm.api.readArrayIndex import org.usvm.api.typeStreamOf +import org.usvm.collection.array.UArrayRegion +import org.usvm.collection.array.UArrayRegionId import org.usvm.getIntValue import org.usvm.isAllocatedConcreteHeapRef import org.usvm.machine.TsRuntimeFeatureLimitationReason @@ -823,23 +825,37 @@ private fun TsExprResolver.handleArrayFill( if (hasResolvedElementSort) { initializeTypedArrayOverrides(array) - memory.initializeArray( - tempArray, - TsTypedArrayStorage.HAS_BOXED_VALUE, - boolSort, - sizeSort, - (0 until tempSize).asSequence().map { if (usesBoxedValue) trueExpr else falseExpr }, - ) + + fun initializeTempMetadata( + type: TsTypedArrayStorage, + sort: Sort, + values: List>, + ) { + val regionId = UArrayRegionId(type, sort) + val region = memory.getRegion(regionId) + check(region is UArrayRegion) { + "Cannot initialize typed array metadata in $region" + } + + val initialized = region.initializeAllocatedArray( + address = tempArray.address, + arrayType = type, + sort = sort, + content = values, + operationGuard = trueExpr, + ownership = memory.ownership, + ) + memory.setRegion(regionId, initialized) + } + + val hasBoxedValue = if (usesBoxedValue) trueExpr else falseExpr + val overrideValues = List(tempSize) { hasBoxedValue } + initializeTempMetadata(TsTypedArrayStorage.HAS_BOXED_VALUE, boolSort, overrideValues) if (usesBoxedValue) { val boxedFillValue = checkNotNull(boxedValue) - memory.initializeArray( - tempArray, - TsTypedArrayStorage.BOXED_VALUE, - addressSort, - sizeSort, - (0 until tempSize).asSequence().map { boxedFillValue }, - ) + val boxedValues = List(tempSize) { boxedFillValue } + initializeTempMetadata(TsTypedArrayStorage.BOXED_VALUE, addressSort, boxedValues) } } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt index 6d9c7ea719..b386f8f64f 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt @@ -747,7 +747,7 @@ class TsExprResolver( } scope.doWithState { // TODO: create proper exception - methodResult = TsMethodResult.TsException(reason, EtsStringType) + this.methodResult = TsMethodResult.TsException(reason, EtsStringType) } null } From aa78048948be7790b0dec6dc237742b3bfd87cc0 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 11:36:57 +0300 Subject: [PATCH 91/97] Repair regression compilation and retain PBT failure reports --- .github/workflows/ci.yml | 12 ++++++++++++ .../org/usvm/ts/calls/CallsObjectInputsTest.kt | 2 +- usvm-ts-pbt/build.gradle.kts | 9 +++++++++ .../ts/pbt/usvm/UsvmCollectionDomainProjectorTest.kt | 10 ++++++---- .../ts/pbt/usvm/UsvmScalarDomainProjectorTest.kt | 10 ++++++---- .../kotlin/org/usvm/machine/TsStringEqualityTest.kt | 2 ++ .../org/usvm/reachability/LoopsReachabilityTest.kt | 3 +-- 7 files changed, 37 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f9ac76f35..e204367eb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,6 +154,18 @@ jobs: - name: Run TS PBT integration baseline run: env -u ARKANALYZER_DIR ETS_IR_PROVIDER=ts-frontend ./gradlew :usvm-ts-pbt:check :usvm-ts-fast-check:check + - name: Upload Gradle reports + if: (!cancelled()) + uses: actions/upload-artifact@v4 + with: + name: gradle-reports-ts-pbt + path: | + usvm-ts-pbt/build/test-results/**/*.xml + usvm-ts-pbt/build/reports/tests/ + usvm-ts-fast-check/build/test-results/**/*.xml + usvm-ts-fast-check/build/reports/tests/ + retention-days: 1 + lint: runs-on: ubuntu-latest steps: diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsObjectInputsTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsObjectInputsTest.kt index 2141b1ffef..12afb91ff5 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsObjectInputsTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsObjectInputsTest.kt @@ -99,7 +99,7 @@ internal class CallsObjectInputsTest : CurrentTsCallsSymbolicEngineTestSupport() } """.trimIndent(), exportName = "accepts", - inputs = listOf(PropertyInput(name = "input", domain = ObjectDomain(mapOf("value" to NumberDomain()))), + inputs = listOf(PropertyInput(name = "input", domain = ObjectDomain(mapOf("value" to NumberDomain())))), targetStatement = "return true;", ) diff --git a/usvm-ts-pbt/build.gradle.kts b/usvm-ts-pbt/build.gradle.kts index 17237eba23..83eea41e6c 100644 --- a/usvm-ts-pbt/build.gradle.kts +++ b/usvm-ts-pbt/build.gradle.kts @@ -1,3 +1,5 @@ +import org.gradle.api.tasks.testing.logging.TestExceptionFormat + plugins { id("usvm.kotlin-conventions") kotlin("plugin.serialization") version Versions.kotlin @@ -11,3 +13,10 @@ dependencies { testImplementation(Libs.logback) } + +tasks.withType { + testLogging { + events("failed") + exceptionFormat = TestExceptionFormat.FULL + } +} diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/usvm/UsvmCollectionDomainProjectorTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/usvm/UsvmCollectionDomainProjectorTest.kt index 9f14f6e243..3f2f98d8e2 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/usvm/UsvmCollectionDomainProjectorTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/usvm/UsvmCollectionDomainProjectorTest.kt @@ -205,10 +205,12 @@ class UsvmCollectionDomainProjectorTest { assertFalse(analysis.engineFailed) assertFalse(analysis.unsupportedCall) assertTrue(analysis.unsupportedPaths.isEmpty(), analysis.unsupportedPaths.toString()) - assertTrue(analysis.states.all { state -> - val result = state.methodResult as? TsMethodResult.Success.RegularCall - result?.method == method - }) + assertTrue( + analysis.states.all { state -> + val result = state.methodResult as? TsMethodResult.Success.RegularCall + result?.method == method + }, + ) return analysis.states.isNotEmpty() } diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/usvm/UsvmScalarDomainProjectorTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/usvm/UsvmScalarDomainProjectorTest.kt index 89b1957baf..11f76dcf11 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/usvm/UsvmScalarDomainProjectorTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/usvm/UsvmScalarDomainProjectorTest.kt @@ -229,10 +229,12 @@ class UsvmScalarDomainProjectorTest { assertFalse(analysis.engineFailed) assertFalse(analysis.unsupportedCall) assertTrue(analysis.unsupportedPaths.isEmpty(), analysis.unsupportedPaths.toString()) - assertTrue(analysis.states.all { state -> - val result = state.methodResult as? TsMethodResult.Success.RegularCall - result?.method == method - }) + assertTrue( + analysis.states.all { state -> + val result = state.methodResult as? TsMethodResult.Success.RegularCall + result?.method == method + }, + ) return analysis.states.isNotEmpty() } diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/TsStringEqualityTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/TsStringEqualityTest.kt index eb66184a70..d69f4f9849 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/TsStringEqualityTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/TsStringEqualityTest.kt @@ -157,6 +157,7 @@ class TsStringEqualityTest : TsMethodTestRunner() { val analysis = TsMachine( scene, options = analysisOptions.copy(stateCollectionStrategy = StateCollectionStrategy.ALL), + tsOptions = tsOptions, ).use { machine -> machine.analyzeWithOutcome(listOf(method)) } @@ -177,6 +178,7 @@ class TsStringEqualityTest : TsMethodTestRunner() { val conditionalAnalysis = TsMachine( scene, options = analysisOptions.copy(stateCollectionStrategy = StateCollectionStrategy.ALL), + tsOptions = tsOptions, ).use { machine -> machine.analyzeWithOutcome(listOf(conditional)) } diff --git a/usvm-ts/src/test/kotlin/org/usvm/reachability/LoopsReachabilityTest.kt b/usvm-ts/src/test/kotlin/org/usvm/reachability/LoopsReachabilityTest.kt index b46d0e0d91..b3682d5b7f 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/reachability/LoopsReachabilityTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/reachability/LoopsReachabilityTest.kt @@ -19,7 +19,6 @@ import org.usvm.machine.TsMachineAnalysisResult import org.usvm.machine.TsOptions import org.usvm.machine.state.TsMethodResult import org.usvm.util.getResourcePath -import org.usvm.util.mkRegisterStackLValue import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -129,7 +128,7 @@ class LoopsReachabilityTest { methods = listOf(method), configureInitialState = { _, state -> with(state.ctx) { - val input = state.memory.read(mkRegisterStackLValue(fp64Sort, idx = 1)) + val input = state.memory.stack.readRegister(index = 1, sort = fp64Sort) state.pathConstraints += mkEq(input, mkFp64(start)) } }, From 5f332f8f54e21e7fc1e237e9f168ced1a02d8e85 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 11:55:40 +0300 Subject: [PATCH 92/97] Prune infeasible TypeScript entries and address strict lint --- .../usvm/census/UnknownCallCensusArtifacts.kt | 7 +- .../usvm/census/UnknownCallCensusProcess.kt | 14 +- .../main/kotlin/org/usvm/machine/TsContext.kt | 1 - .../org/usvm/machine/TsInterpreterObserver.kt | 6 +- .../main/kotlin/org/usvm/machine/TsMachine.kt | 5 +- .../usvm/machine/expr/CallApproximations.kt | 468 +++++++++++------- .../machine/expr/CallStaticApproximations.kt | 2 +- .../kotlin/org/usvm/machine/expr/ExprUtil.kt | 31 +- .../org/usvm/machine/expr/TsExprResolver.kt | 96 ++-- .../machine/interpreter/TsInitialState.kt | 25 +- .../usvm/machine/interpreter/TsInterpreter.kt | 17 +- .../org/usvm/machine/interpreter/TsPromise.kt | 29 +- .../usvm/machine/operator/TsBinaryOperator.kt | 67 ++- .../kotlin/org/usvm/util/BuildEtsMethod.kt | 25 +- .../org/usvm/util/InputPropertyDeclaration.kt | 4 + .../kotlin/org/usvm/util/TsStateVisualizer.kt | 2 + .../machine/TsInitialStateFeasibilityTest.kt | 80 +++ 17 files changed, 565 insertions(+), 314 deletions(-) create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/TsInitialStateFeasibilityTest.kt diff --git a/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusArtifacts.kt b/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusArtifacts.kt index d99fce62af..3d894abfb3 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusArtifacts.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusArtifacts.kt @@ -143,7 +143,8 @@ internal object UnknownCallCensusAggregator { val completed = record.optionalString("status") == "completed" val matchingStart = record.optionalString("startedAt") == startedAt && !startedAt.isNullOrBlank() val hasFinish = !record.optionalString("finishedAt").isNullOrBlank() - if (!validSchema || !completed || !matchingStart || !hasFinish) { + val validRunResult = validSchema && completed && matchingStart && hasFinish + if (!validRunResult) { inputIssues += UnknownCallCensusInputIssue.INVALID_RUN_RESULT } runResultSeen = true @@ -177,9 +178,11 @@ internal object UnknownCallCensusAggregator { UnknownCallCensusCompleteness.PARTIAL } val summaries = profiles.toSortedMap().mapValues { (_, accumulator) -> accumulator.toSummary() } + val input = UnknownCallCensusInput(completeness = completeness, issues = inputIssues.toList()) + return UnknownCallCensusSummary( profiles = summaries, - input = UnknownCallCensusInput(completeness = completeness, issues = inputIssues.toList()), + input = input, ) } } diff --git a/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusProcess.kt b/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusProcess.kt index e3eb71107b..8ad293d867 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusProcess.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusProcess.kt @@ -124,12 +124,11 @@ internal fun boundedProcessOutput( completedNormally = true return completedOutput } finally { - if (!completedNormally) { - process?.let { startedProcess -> - val hasLiveObservedDescendants = observedDescendants.any(ProcessHandle::isAlive) - if (startedProcess.isAlive || hasLiveObservedDescendants) { - terminateProcessTreeBestEffort(startedProcess, observedDescendants) - } + val startedProcess = process + if (!completedNormally && startedProcess != null) { + val hasLiveObservedDescendants = observedDescendants.any(ProcessHandle::isAlive) + if (startedProcess.isAlive || hasLiveObservedDescendants) { + terminateProcessTreeBestEffort(startedProcess, observedDescendants) } } process?.let(::closeProcessOutputBestEffort) @@ -157,7 +156,8 @@ private fun waitForProcessWhileObservingDescendants( } val waitMillis = minOf(remaining, PROCESS_DESCENDANT_POLL_INTERVAL) - .inWholeMilliseconds.coerceAtLeast(minimumValue = 1) + .inWholeMilliseconds + .coerceAtLeast(minimumValue = 1) if (process.waitFor(waitMillis, TimeUnit.MILLISECONDS)) { return true } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt index eccacfa03b..96e56b0e26 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt @@ -397,7 +397,6 @@ class TsContext( fun UConcreteHeapRef.extractRef(scope: TsStepScope): UHeapRef { return scope.calcOnState { extractRef(memory) } } - } class Constants { diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt index 49b9919a77..76d2d7ab2a 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt @@ -127,7 +127,11 @@ interface TsInterpreterObserver : UInterpreterObserver { internal fun TsInterpreterObserver.onRuntimeFeatureLimitationSafely(event: TsRuntimeFeatureLimitationEvent) { runCatching { onRuntimeFeatureLimitation(event) } - .onFailure { error -> logger.warn(error) { "Runtime feature limitation observer failed while recording an event" } } + .onFailure { error -> + logger.warn(error) { + "Runtime feature limitation observer failed while recording an event" + } + } } /** A feasible execution path stopped because the runtime model deliberately omits [reason]. */ diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt index 85b3bd587c..3adede295d 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt @@ -373,9 +373,12 @@ class TsMachine( if (unsupportedReason != null) { unsupportedPaths += unsupportedReason } else { - initialStates[method] = interpreter.getInitialState(method, targets) { + val initialState = interpreter.getInitialStateOrNull(method, targets) { configureInitialState(method, this) } + if (initialState != null) { + initialStates[method] = initialState + } } } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index 7613581ff2..bbfb4ef665 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -14,6 +14,7 @@ import org.jacodb.ets.model.EtsUnclearRefType import org.jacodb.ets.model.EtsUnknownType import org.jacodb.ets.utils.CONSTRUCTOR_NAME import org.usvm.UBoolExpr +import org.usvm.UConcreteHeapRef import org.usvm.UExpr import org.usvm.UHeapRef import org.usvm.USort @@ -40,6 +41,7 @@ import org.usvm.machine.interpreter.PromiseState import org.usvm.machine.interpreter.createSettledPromise import org.usvm.machine.interpreter.existingPromiseOrEnsureNonThenable import org.usvm.machine.state.TsMethodResult +import org.usvm.machine.state.TsState import org.usvm.machine.state.lastStmt import org.usvm.machine.state.newStmt import org.usvm.machine.types.TsUnresolvedArrayKind @@ -182,76 +184,7 @@ internal fun TsExprResolver.tryApproximateInstanceCall( val instanceType = scope.calcOnState { arrayStorageType(array, expr.instance.type) } if (instanceType is EtsArrayType) { - val elementSort = typeToSort(instanceType.componentType) - .takeIf { it !is TsUnresolvedSort } - ?: addressSort - - val usesModelDispatcher = unknownCallDispatcher is TsUnknownCallModelDispatcher - val modeledArrayCall = usesModelDispatcher && TsBuiltInUnknownCallModels.hasPartialApproximationModel( - methodName = expr.callee.name, - enclosingClassName = "Array", - allowUnqualifiedTarget = true, - ) - - if (modeledArrayCall) { - dispatchArrayModel(stmt) - return TsExprApproximationResult.ResolveFailure - } - - // Handle 'Array.push()' method calls - if (expr.callee.name == "push") { - return from(handleArrayPush(stmt, instanceType, array)) - } - - // Handle `Array.pop() method calls - if (expr.callee.name == "pop") { - return handleArrayPopCall(stmt, instanceType) - } - - // Handle `Array.fill() method calls - if (expr.callee.name == "fill") { - return from(handleArrayFill(expr, instanceType, elementSort, array)) - } - - // Handle `Array.unshift() method calls - if (expr.callee.name == "unshift") { - return from(handleArrayUnshift(stmt, instanceType, array)) - } - - // Handle `Array.shift() method calls - if (expr.callee.name == "shift") { - return handleArrayShiftCall(stmt, instanceType) - } - - // Handle `Array.join() method calls - if (expr.callee.name == "join") { - return from(handleArrayJoin()) - } - - // Handle `Array.slice() method calls - if (expr.callee.name == "slice") { - return from(handleArraySlice(stmt, instanceType, array)) - } - - // Handle `Array.concat() method calls - if (expr.callee.name == "concat") { - return handleArrayConcat(stmt, instanceType, array) - } - - // Handle Array search and indexed access method calls. - if (expr.callee.name in setOf("indexOf", "lastIndexOf")) { - return handleArrayIndexSearchCall(stmt, instanceType, array) - } - - // Handle `Array.includes() method calls - if (expr.callee.name == "includes") { - return handleArrayIncludesCall(stmt) - } - - // Handle `Array.reverse() method calls - if (expr.callee.name == "reverse") { - return from(handleArrayReverse(instanceType, array)) - } + return tryApproximateArrayCall(stmt, instanceType, array) } val dispatcher = unknownCallDispatcher @@ -277,6 +210,75 @@ internal fun TsExprResolver.tryApproximateInstanceCall( return TsExprApproximationResult.NoApproximation } +private fun TsExprResolver.tryApproximateArrayCall( + stmt: TsVirtualMethodCallStmt, + arrayType: EtsArrayType, + array: UHeapRef, +): TsExprApproximationResult = with(ctx) { + val expr = stmt.call + val elementSort = typeToSort(arrayType.componentType) + .takeIf { it !is TsUnresolvedSort } + ?: addressSort + + val usesModelDispatcher = unknownCallDispatcher is TsUnknownCallModelDispatcher + val modeledArrayCall = usesModelDispatcher && TsBuiltInUnknownCallModels.hasPartialApproximationModel( + methodName = expr.callee.name, + enclosingClassName = "Array", + allowUnqualifiedTarget = true, + ) + + if (modeledArrayCall) { + dispatchArrayModel(stmt) + return TsExprApproximationResult.ResolveFailure + } + + if (expr.callee.name == "push") { + return from(handleArrayPush(stmt, arrayType, array)) + } + + if (expr.callee.name == "pop") { + return handleArrayPopCall(stmt, arrayType) + } + + if (expr.callee.name == "fill") { + return from(handleArrayFill(expr, arrayType, elementSort, array)) + } + + if (expr.callee.name == "unshift") { + return from(handleArrayUnshift(stmt, arrayType, array)) + } + + if (expr.callee.name == "shift") { + return handleArrayShiftCall(stmt, arrayType) + } + + if (expr.callee.name == "join") { + return from(handleArrayJoin()) + } + + if (expr.callee.name == "slice") { + return from(handleArraySlice(stmt, arrayType, array)) + } + + if (expr.callee.name == "concat") { + return handleArrayConcat(stmt, arrayType, array) + } + + if (expr.callee.name in setOf("indexOf", "lastIndexOf")) { + return handleArrayIndexSearchCall(stmt, arrayType, array) + } + + if (expr.callee.name == "includes") { + return handleArrayIncludesCall(stmt) + } + + if (expr.callee.name == "reverse") { + return from(handleArrayReverse(arrayType, array)) + } + + return TsExprApproximationResult.NoApproximation +} + private fun TsExprResolver.hasProjectToStringTarget(stmt: TsVirtualMethodCallStmt): Boolean = with(ctx) { if (stmt.instance.sort != addressSort) return false @@ -738,19 +740,21 @@ private fun TsExprResolver.handleArrayFill( } val startBv: UExpr = if (start == mkUndefinedValue()) { mkBv(0) - } else when (start.sort) { - sizeSort -> start.asExpr(sizeSort) - - fp64Sort -> mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = start.asExpr(fp64Sort), - bvSize = sizeSort.sizeBits.toInt(), - isSigned = true, - ).asExpr(sizeSort) - - else -> { - logger.warn { "Unsupported sort for `start` index in Array.fill(): ${start.sort}" } - return null + } else { + when (start.sort) { + sizeSort -> start.asExpr(sizeSort) + + fp64Sort -> mkFpToBvExpr( + roundingMode = fpRoundingModeSortDefaultValue(), + value = start.asExpr(fp64Sort), + bvSize = sizeSort.sizeBits.toInt(), + isSigned = true, + ).asExpr(sizeSort) + + else -> { + logger.warn { "Unsupported sort for `start` index in Array.fill(): ${start.sort}" } + return null + } } } @@ -763,22 +767,48 @@ private fun TsExprResolver.handleArrayFill( val endBv: UExpr = if (end == mkUndefinedValue()) { val lengthLValue = mkArrayLengthLValue(array, arrayType) scope.calcOnState { memory.read(lengthLValue) } - } else when (end.sort) { - sizeSort -> end.asExpr(sizeSort) - - fp64Sort -> mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = end.asExpr(fp64Sort), - bvSize = sizeSort.sizeBits.toInt(), - isSigned = true, - ).asExpr(sizeSort) - - else -> { - logger.warn { "Unsupported sort for `end` index in Array.fill(): ${end.sort}" } - return null + } else { + when (end.sort) { + sizeSort -> end.asExpr(sizeSort) + + fp64Sort -> mkFpToBvExpr( + roundingMode = fpRoundingModeSortDefaultValue(), + value = end.asExpr(fp64Sort), + bvSize = sizeSort.sizeBits.toInt(), + isSigned = true, + ).asExpr(sizeSort) + + else -> { + logger.warn { "Unsupported sort for `end` index in Array.fill(): ${end.sort}" } + return null + } } } + val fillValue = ArrayFillValue( + nativeValue = nativeValue, + boxedValue = boxedValue, + hasResolvedElementSort = hasResolvedElementSort, + usesBoxedValue = usesBoxedValue, + ) + fillArrayRange(arrayType, elementSort, array, fillValue, startBv, endBv) +} + +private data class ArrayFillValue( + val nativeValue: UExpr<*>, + val boxedValue: UConcreteHeapRef?, + val hasResolvedElementSort: Boolean, + val usesBoxedValue: Boolean, +) + +private fun TsExprResolver.fillArrayRange( + arrayType: EtsArrayType, + elementSort: USort, + array: UHeapRef, + fillValue: ArrayFillValue, + startBv: UExpr, + endBv: UExpr, +): UExpr<*>? = with(ctx) { scope.calcOnState { val descriptor = arrayDescriptorOf(arrayType) val length = memory.read(mkArrayLengthLValue(array, arrayType)) @@ -812,88 +842,25 @@ private fun TsExprResolver.handleArrayFill( } // Allocate a temporary array to hold the filled values - val tempArray = memory.allocConcrete(descriptor) - - // Fill the temporary array with the specified `value` - memory.initializeArray( - tempArray, - descriptor, - elementSort, - sizeSort, - (0 until tempSize).asSequence().map { nativeValue.asExpr(elementSort) }, + val tempArray = initializeArrayFillTemporary( + sourceArray = array, + descriptor = descriptor, + elementSort = elementSort, + fillValue = fillValue, + tempSize = tempSize, ) - if (hasResolvedElementSort) { - initializeTypedArrayOverrides(array) - - fun initializeTempMetadata( - type: TsTypedArrayStorage, - sort: Sort, - values: List>, - ) { - val regionId = UArrayRegionId(type, sort) - val region = memory.getRegion(regionId) - check(region is UArrayRegion) { - "Cannot initialize typed array metadata in $region" - } - - val initialized = region.initializeAllocatedArray( - address = tempArray.address, - arrayType = type, - sort = sort, - content = values, - operationGuard = trueExpr, - ownership = memory.ownership, - ) - memory.setRegion(regionId, initialized) - } - - val hasBoxedValue = if (usesBoxedValue) trueExpr else falseExpr - val overrideValues = List(tempSize) { hasBoxedValue } - initializeTempMetadata(TsTypedArrayStorage.HAS_BOXED_VALUE, boolSort, overrideValues) - - if (usesBoxedValue) { - val boxedFillValue = checkNotNull(boxedValue) - val boxedValues = List(tempSize) { boxedFillValue } - initializeTempMetadata(TsTypedArrayStorage.BOXED_VALUE, addressSort, boxedValues) - } - } - // Copy the filled values to the specified range in the original array - memory.memcpy( + copyArrayFillRange( srcRef = tempArray, dstRef = array, - type = descriptor, + descriptor = descriptor, elementSort = elementSort, - fromSrc = zero, + fillValue = fillValue, fromDst = from, length = fillLength, ) - if (hasResolvedElementSort) { - memory.memcpy( - srcRef = tempArray, - dstRef = array, - type = TsTypedArrayStorage.HAS_BOXED_VALUE, - elementSort = boolSort, - fromSrc = zero, - fromDst = from, - length = fillLength, - ) - - if (usesBoxedValue) { - memory.memcpy( - srcRef = tempArray, - dstRef = array, - type = TsTypedArrayStorage.BOXED_VALUE, - elementSort = addressSort, - fromSrc = zero, - fromDst = from, - length = fillLength, - ) - } - } - if (hasKnownOwnSlots) { val fillCount = checkNotNull(concreteFillLength) val filledIndices = (0 until fillCount).associate { offset -> @@ -910,6 +877,117 @@ private fun TsExprResolver.handleArrayFill( } } +private fun TsState.initializeArrayFillTemporary( + sourceArray: UHeapRef, + descriptor: EtsArrayType, + elementSort: USort, + fillValue: ArrayFillValue, + tempSize: Int, +): UConcreteHeapRef = with(ctx) { + val tempArray = memory.allocConcrete(descriptor) + memory.initializeArray( + tempArray, + descriptor, + elementSort, + sizeSort, + (0 until tempSize).asSequence().map { fillValue.nativeValue.asExpr(elementSort) }, + ) + + if (fillValue.hasResolvedElementSort) { + initializeTypedArrayOverrides(sourceArray) + + val hasBoxedValue = if (fillValue.usesBoxedValue) trueExpr else falseExpr + val overrideValues = List(tempSize) { hasBoxedValue } + initializeArrayFillMetadata( + tempArray = tempArray, + type = TsTypedArrayStorage.HAS_BOXED_VALUE, + sort = boolSort, + values = overrideValues, + ) + + if (fillValue.usesBoxedValue) { + val boxedFillValue = checkNotNull(fillValue.boxedValue) + val boxedValues = List(tempSize) { boxedFillValue } + initializeArrayFillMetadata( + tempArray = tempArray, + type = TsTypedArrayStorage.BOXED_VALUE, + sort = addressSort, + values = boxedValues, + ) + } + } + + tempArray +} + +private fun TsState.initializeArrayFillMetadata( + tempArray: UConcreteHeapRef, + type: TsTypedArrayStorage, + sort: Sort, + values: List>, +) = with(ctx) { + val regionId = UArrayRegionId(type, sort) + val region = memory.getRegion(regionId) + check(region is UArrayRegion) { + "Cannot initialize typed array metadata in $region" + } + + val initialized = region.initializeAllocatedArray( + address = tempArray.address, + arrayType = type, + sort = sort, + content = values, + operationGuard = trueExpr, + ownership = memory.ownership, + ) + memory.setRegion(regionId, initialized) +} + +private fun TsState.copyArrayFillRange( + srcRef: UHeapRef, + dstRef: UHeapRef, + descriptor: EtsArrayType, + elementSort: USort, + fillValue: ArrayFillValue, + fromDst: UExpr, + length: UExpr, +) = with(ctx) { + val fromSrc = mkBv(0) + memory.memcpy( + srcRef = srcRef, + dstRef = dstRef, + type = descriptor, + elementSort = elementSort, + fromSrc = fromSrc, + fromDst = fromDst, + length = length, + ) + + if (fillValue.hasResolvedElementSort) { + memory.memcpy( + srcRef = srcRef, + dstRef = dstRef, + type = TsTypedArrayStorage.HAS_BOXED_VALUE, + elementSort = boolSort, + fromSrc = fromSrc, + fromDst = fromDst, + length = length, + ) + + if (fillValue.usesBoxedValue) { + memory.memcpy( + srcRef = srcRef, + dstRef = dstRef, + type = TsTypedArrayStorage.BOXED_VALUE, + elementSort = addressSort, + fromSrc = fromSrc, + fromDst = fromDst, + length = length, + ) + } + } +} + private const val ARRAY_FILL_MAX_SIZE = 10_000 /** @@ -1210,23 +1288,14 @@ private fun TsExprResolver.handleArrayConcat( from( scope.calcOnState { val resultArray = memory.allocConcrete(arrayDescriptorOf(arrayType)) - copyArrayElements( + copyConcatArraySource( srcRef = array, dstRef = resultArray, arrayType = arrayType, - fromSrc = mkBv(0), fromDst = mkBv(0), length = originalLength, + preserveKnownPresence = preserveKnownPresence, ) - if (preserveKnownPresence) { - copyKnownArrayIndexPresence( - srcRef = array, - dstRef = resultArray, - fromSrc = mkBv(0), - fromDst = mkBv(0), - length = originalLength, - ) - } var copiedLength = originalLength stmt.args.forEachIndexed { index, arg -> @@ -1234,23 +1303,14 @@ private fun TsExprResolver.handleArrayConcat( if (argType != null) { val ref = arg.asExpr(addressSort) val length = argumentLengths[index] - copyArrayElements( + copyConcatArraySource( srcRef = ref, dstRef = resultArray, arrayType = argType, - fromSrc = mkBv(0), fromDst = copiedLength, length = length, + preserveKnownPresence = preserveKnownPresence, ) - if (preserveKnownPresence) { - copyKnownArrayIndexPresence( - srcRef = ref, - dstRef = resultArray, - fromSrc = mkBv(0), - fromDst = copiedLength, - length = length, - ) - } copiedLength = mkBvAddExpr(copiedLength, length) } else { val newLength = mkBvAddExpr(copiedLength, mkBv(1)) @@ -1269,6 +1329,34 @@ private fun TsExprResolver.handleArrayConcat( ) } +private fun TsState.copyConcatArraySource( + srcRef: UHeapRef, + dstRef: UHeapRef, + arrayType: EtsArrayType, + fromDst: UExpr, + length: UExpr, + preserveKnownPresence: Boolean, +) = with(ctx) { + val fromSrc = mkBv(0) + copyArrayElements( + srcRef = srcRef, + dstRef = dstRef, + arrayType = arrayType, + fromSrc = fromSrc, + fromDst = fromDst, + length = length, + ) + if (preserveKnownPresence) { + copyKnownArrayIndexPresence( + srcRef = srcRef, + dstRef = dstRef, + fromSrc = fromSrc, + fromDst = fromDst, + length = length, + ) + } +} + /** Compatibility approximation constrained to -1 or an in-range index. */ private fun TsExprResolver.handleArrayIndexOf( expr: EtsInstanceCallExpr, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallStaticApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallStaticApproximations.kt index f673f934a2..c8161576f4 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallStaticApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallStaticApproximations.kt @@ -34,7 +34,7 @@ internal fun TsExprResolver.tryApproximateStaticCall( internal fun TsExprResolver.tryApproximateResolvedFunctionCall(method: EtsMethod): TsExprApproximationResult { // Match the selected top-level function, not its mutable source binding or alias name. val owner = method.enclosingClass?.signature - val isTopLevelFunction = owner?.name == DEFAULT_ARK_CLASS_NAME && owner?.namespace == null + val isTopLevelFunction = owner?.name == DEFAULT_ARK_CLASS_NAME && owner.namespace == null val isResourceLookup = method.name == "\$r" && isTopLevelFunction if (isResourceLookup) { return from(handleR()) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ExprUtil.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ExprUtil.kt index 458b764ef0..3833b98440 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ExprUtil.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ExprUtil.kt @@ -318,14 +318,19 @@ private fun TsContext.mkNumericExprUnchecked( ): UExpr { if (expr.isFakeObject()) { val type = expr.getFakeType(scope) + val isNumber = type.fpTypeExpr + val number = expr.extractFp(scope) + val isBoolean = type.boolTypeExpr + val boolean = expr.extractBool(scope) + val booleanNumber = mkNumericExprUnchecked(boolean, scope) + val reference = expr.extractRef(scope) + val referenceNumber = mkNumericExprUnchecked(reference, scope) + val nonNumber = mkIte(isBoolean, booleanNumber, referenceNumber) + return mkIte( - condition = type.fpTypeExpr, - trueBranch = expr.extractFp(scope), - falseBranch = mkIte( - condition = type.boolTypeExpr, - trueBranch = mkNumericExprUnchecked(expr.extractBool(scope), scope), - falseBranch = mkNumericExprUnchecked(expr.extractRef(scope), scope) - ) + condition = isNumber, + trueBranch = number, + falseBranch = nonNumber, ) } @@ -372,10 +377,16 @@ private fun TsContext.mkNumericExprUnchecked( // TODO: Model general StringToNumber and ToPrimitive conversion (https://github.com/UnitTestBot/usvm/issues/280). // The public support guard excludes unmodeled reference alternatives from the selected result. + val reference = expr.asExpr(addressSort) + val nullValue = mkTsNullValue() + val isNull = mkEq(reference, nullValue) + val zero = mkFp(0.0, fp64Sort) + val nan = mkFp64NaN() + return mkIte( - condition = mkEq(expr.asExpr(addressSort), mkTsNullValue()), - trueBranch = mkFp(0.0, fp64Sort), - falseBranch = mkFp64NaN(), + condition = isNull, + trueBranch = zero, + falseBranch = nan, ) } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt index b386f8f64f..27f415ae74 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt @@ -45,6 +45,7 @@ import org.jacodb.ets.model.EtsLexicalEnvType import org.jacodb.ets.model.EtsLocal import org.jacodb.ets.model.EtsLtEqExpr import org.jacodb.ets.model.EtsLtExpr +import org.jacodb.ets.model.EtsMethod import org.jacodb.ets.model.EtsMethodSignature import org.jacodb.ets.model.EtsMulExpr import org.jacodb.ets.model.EtsNegExpr @@ -117,7 +118,6 @@ import org.usvm.machine.TsInterpreterObserver import org.usvm.machine.TsOptions import org.usvm.machine.TsRuntimeFeatureLimitationEvent import org.usvm.machine.TsRuntimeFeatureLimitationReason -import org.usvm.machine.onRuntimeFeatureLimitationSafely import org.usvm.machine.call.TsUnknownCallDispatcher import org.usvm.machine.call.TsUnknownCallFailureReason import org.usvm.machine.call.dispatch @@ -130,6 +130,7 @@ import org.usvm.machine.interpreter.isDefinitelyNonThenable import org.usvm.machine.interpreter.isResolved import org.usvm.machine.interpreter.markResolved import org.usvm.machine.interpreter.setResolvedValue +import org.usvm.machine.onRuntimeFeatureLimitationSafely import org.usvm.machine.operator.TsBinaryOperator import org.usvm.machine.operator.TsUnaryOperator import org.usvm.machine.runtimeFunctionType @@ -672,50 +673,7 @@ class TsExprResolver( ?: error("Await expression should have a promise executor, but it is not set for $promise") check(executor.cfg.stmts.isNotEmpty()) - val args: MutableList> = mutableListOf() - - // Executor lambda does not have 'this', so we fill it with 'undefined': - args += mkUndefinedValue() - - val params = executor.parameters.toMutableList() - if (params.isNotEmpty() && params[0].type is EtsLexicalEnvType) { - params.removeFirst() - // TODO: handle closures - args += mkUndefinedValue() - } - if (params.isNotEmpty()) { - val resolveCallback = allocateConcreteRef() - args += resolveCallback - scope.doWithState { - registerPromiseCallback( - resolveCallback, - PromiseCallback(promise = promise, settlement = PromiseState.FULFILLED), - ) - } - if (params.size >= 2) { - val rejectCallback = allocateConcreteRef() - args += rejectCallback - scope.doWithState { - registerPromiseCallback( - rejectCallback, - PromiseCallback(promise = promise, settlement = PromiseState.REJECTED), - ) - } - if (params.size >= 3) { - error( - "Promise executor should have at most 3 parameters" + - " (closures, resolve, reject), but got ${params.size}" - ) - } - } - } - scope.doWithState { - pushSortsForActualArguments(args) - memory.stack.push(args.toTypedArray(), executor.localsCount) - registerCallee(currentStatement, executor.cfg) - callStack.push(executor, currentStatement) - newStmt(executor.cfg.stmts.first()) - } + invokePromiseExecutor(promise, executor) null } else { when (promiseState) { @@ -755,6 +713,54 @@ class TsExprResolver( } } + private fun invokePromiseExecutor(promise: UConcreteHeapRef, executor: EtsMethod) = with(ctx) { + val args: MutableList> = mutableListOf() + + // Executor lambda does not have 'this', so we fill it with 'undefined': + args += mkUndefinedValue() + + val params = executor.parameters.toMutableList() + if (params.isNotEmpty() && params[0].type is EtsLexicalEnvType) { + params.removeFirst() + // TODO: handle closures + args += mkUndefinedValue() + } + if (params.isNotEmpty()) { + val resolveCallback = allocateConcreteRef() + args += resolveCallback + scope.doWithState { + registerPromiseCallback( + resolveCallback, + PromiseCallback(promise = promise, settlement = PromiseState.FULFILLED), + ) + } + if (params.size >= 2) { + val rejectCallback = allocateConcreteRef() + args += rejectCallback + scope.doWithState { + registerPromiseCallback( + rejectCallback, + PromiseCallback(promise = promise, settlement = PromiseState.REJECTED), + ) + } + if (params.size >= 3) { + error( + "Promise executor should have at most 3 parameters" + + " (closures, resolve, reject), but got ${params.size}" + ) + } + } + } + + scope.doWithState { + pushSortsForActualArguments(args) + memory.stack.push(args.toTypedArray(), executor.localsCount) + registerCallee(currentStatement, executor.cfg) + callStack.push(executor, currentStatement) + newStmt(executor.cfg.stmts.first()) + } + } + override fun visit(expr: EtsYieldExpr): UExpr? { logger.warn { "visit(${expr::class.simpleName}) is not implemented yet" } error("Not supported $expr") diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInitialState.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInitialState.kt index debc5bd4e8..617ef1cde0 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInitialState.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInitialState.kt @@ -29,23 +29,24 @@ import org.usvm.machine.types.initialInputType import org.usvm.machine.types.mkFakeValue import org.usvm.machine.types.toAuxiliaryType import org.usvm.sizeSort +import org.usvm.solver.USatResult import org.usvm.solver.UUnknownResult +import org.usvm.solver.UUnsatResult import org.usvm.targets.UTargetsSet import org.usvm.util.mkArrayLengthLValue import org.usvm.util.mkRegisterStackLValue import org.usvm.util.type -import org.usvm.utils.ensureSat private val logger = KotlinLogging.logger {} -internal fun createInitialState( +internal fun createInitialStateOrNull( ctx: TsContext, graph: TsGraph, options: TsOptions, method: EtsMethod, targets: List, configureState: TsState.() -> Unit = {}, -): TsState = with(ctx) { +): TsState? = with(ctx) { val state = TsState( ctx = ctx, ownership = MutabilityOwnership(), @@ -153,18 +154,22 @@ internal fun createInitialState( state.configureState() - state.initializeModel(method) + val feasible = state.initializeModel(method) + if (!feasible) return@with null state } -private fun TsState.initializeModel(method: EtsMethod) { +private fun TsState.initializeModel(method: EtsMethod): Boolean { val solver = ctx.solver() val solverResult = solver.check(pathConstraints) - if (solverResult is UUnknownResult) { - throw TsInitialStateSolverUnknownException(method) - } + return when (solverResult) { + is USatResult -> { + models = listOf(solverResult.model) + true + } - val model = solverResult.ensureSat().model - models = listOf(model) + is UUnsatResult -> false + is UUnknownResult -> throw TsInitialStateSolverUnknownException(method) + } } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt index f1c36fe143..6e79e95517 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt @@ -114,7 +114,9 @@ class TsInterpreter( val result = state.methodResult val isEntryPointGuardResult = stmt is TsEntryPointGuardResultStmt if (result is TsMethodResult.TsException && !isEntryPointGuardResult) { - val catcher = stmt.location.method.cfg.catchers(stmt).singleOrNull() + val catcher = stmt.location.method.cfg + .catchers(stmt) + .singleOrNull() if (catcher != null) { scope.doWithState { caughtException = result.value @@ -800,7 +802,18 @@ class TsInterpreter( method: EtsMethod, targets: List, configureState: TsState.() -> Unit = {}, - ): TsState = createInitialState(ctx, graph, options, method, targets, configureState) + ): TsState { + val initialState = getInitialStateOrNull(method, targets, configureState) + return checkNotNull(initialState) { + "Initial constraints are infeasible for ${method.signature}" + } + } + + internal fun getInitialStateOrNull( + method: EtsMethod, + targets: List, + configureState: TsState.() -> Unit = {}, + ): TsState? = createInitialStateOrNull(ctx, graph, options, method, targets, configureState) // TODO: expand with interpreter implementation private val EtsStmt.nextStmt: EtsStmt? diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsPromise.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsPromise.kt index e8d23fd504..d07af33ce6 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsPromise.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsPromise.kt @@ -13,8 +13,8 @@ import org.usvm.collection.field.UFieldLValue import org.usvm.isAllocatedConcreteHeapRef import org.usvm.isTrue import org.usvm.machine.TsContext -import org.usvm.machine.expr.objectLiteralClass import org.usvm.machine.expr.ensureNoPrototypeMutation +import org.usvm.machine.expr.objectLiteralClass import org.usvm.machine.state.TsState import org.usvm.types.singleOrNull import org.usvm.util.EtsHierarchy @@ -28,6 +28,7 @@ enum class PromiseState { REJECTED; } +/** A settlement callback queued for a modeled Promise. */ data class PromiseCallback( val promise: UConcreteHeapRef, val settlement: PromiseState, @@ -96,21 +97,23 @@ internal fun TsContext.existingPromiseOrEnsureNonThenable( if (actualValue == mkTsNullValue() || actualValue == mkUndefinedValue()) return null val reference = actualValue.asExpr(addressSort) - if (!isAllocatedConcreteHeapRef(reference)) { - throw UnsupportedOperationException("$operation requires unmodeled thenable assimilation") + val concreteReference = isAllocatedConcreteHeapRef(reference) + if (concreteReference) { + val registeredPromise = scope.calcOnState { + promiseState[reference] != null || promiseExecutor[reference] != null + } + if (registeredPromise) return reference + + val definitelyNonThenable = isDefinitelyNonThenable(scope, reference, hierarchy) + if (definitelyNonThenable) return null } - val registeredPromise = scope.calcOnState { - promiseState[reference] != null || promiseExecutor[reference] != null - } - if (registeredPromise) { - return reference - } - if (!isDefinitelyNonThenable(scope, reference, hierarchy)) { - throw UnsupportedOperationException("$operation may require unmodeled thenable assimilation") + val assimilationFailure = if (concreteReference) { + "may require unmodeled thenable assimilation" + } else { + "requires unmodeled thenable assimilation" } - - return null + throw UnsupportedOperationException("$operation $assimilationFailure") } internal const val RESOLVED_FIELD = "__resolved__" diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt index 9391e0d827..5e73b1dee6 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt @@ -128,21 +128,31 @@ private fun TsContext.stringValueLessThan( val comparison = scope.calcOnState { val leftChars = memory.read(mkStringBackingLValue(lhs)) val rightChars = memory.read(mkStringBackingLValue(rhs)) + val leftLengthLocation = mkStringBackingLengthLValue(leftChars) + val leftLength = memory.read(leftLengthLocation) + val rightLengthLocation = mkStringBackingLengthLValue(rightChars) + val rightLength = memory.read(rightLengthLocation) StringComparisonData( lhsChars = leftChars, rhsChars = rightChars, - lhsLength = memory.read(mkStringBackingLengthLValue(leftChars)), - rhsLength = memory.read(mkStringBackingLengthLValue(rightChars)), + lhsLength = leftLength, + rhsLength = rightLength, maxLength = maxOf(leftBound, rightBound), ) } val zero = mkBv(0) + val leftLengthNonnegative = mkBvSignedGreaterOrEqualExpr(comparison.lhsLength, zero) + val leftBoundExpr = mkBv(leftBound) + val leftLengthWithinBound = mkBvSignedLessOrEqualExpr(comparison.lhsLength, leftBoundExpr) + val rightLengthNonnegative = mkBvSignedGreaterOrEqualExpr(comparison.rhsLength, zero) + val rightBoundExpr = mkBv(rightBound) + val rightLengthWithinBound = mkBvSignedLessOrEqualExpr(comparison.rhsLength, rightBoundExpr) val lengthsWithinBounds = mkAnd( - mkBvSignedGreaterOrEqualExpr(comparison.lhsLength, zero), - mkBvSignedLessOrEqualExpr(comparison.lhsLength, mkBv(leftBound)), - mkBvSignedGreaterOrEqualExpr(comparison.rhsLength, zero), - mkBvSignedLessOrEqualExpr(comparison.rhsLength, mkBv(rightBound)), + leftLengthNonnegative, + leftLengthWithinBound, + rightLengthNonnegative, + rightLengthWithinBound, ) val supported = mkOr(mkNot(activeGuard), lengthsWithinBounds) val currentSupported = scope.fork(supported, blockOnFalseState = { @@ -211,14 +221,18 @@ private fun TsContext.relationalComparison( if (lhsReference == null || rhsReference == null) return numericResult val stringResult = when (comparison) { - TsBinaryOperator.Relational.Comparison.LT -> + TsBinaryOperator.Relational.Comparison.LT -> { stringValueLessThan(lhsReference, rhsReference, scope, stringGuard) - TsBinaryOperator.Relational.Comparison.GT -> + } + TsBinaryOperator.Relational.Comparison.GT -> { stringValueLessThan(rhsReference, lhsReference, scope, stringGuard) - TsBinaryOperator.Relational.Comparison.LTE -> + } + TsBinaryOperator.Relational.Comparison.LTE -> { mkNot(stringValueLessThan(rhsReference, lhsReference, scope, stringGuard)) - TsBinaryOperator.Relational.Comparison.GTE -> + } + TsBinaryOperator.Relational.Comparison.GTE -> { mkNot(stringValueLessThan(lhsReference, rhsReference, scope, stringGuard)) + } } return mkIte(bothStrings, stringResult, numericResult) @@ -603,7 +617,8 @@ sealed interface TsBinaryOperator { // fake(ref) + bool val refBoolGuard = mkAnd(activeGuard, lhsType.refTypeExpr) val refBoolExpr = internalResolveWithGuard(lhsRef, rhsBool, scope, refBoolGuard) - ?.asExpr(resultSort) ?: return null + ?.asExpr(resultSort) + ?: return null conjuncts += ExprWithTypeConstraint( constraint = lhsType.refTypeExpr, expr = refBoolExpr @@ -629,7 +644,8 @@ sealed interface TsBinaryOperator { // fake(ref) + fp val refFpGuard = mkAnd(activeGuard, lhsType.refTypeExpr) val refFpExpr = internalResolveWithGuard(lhsRef, rhsFpExpr, scope, refFpGuard) - ?.asExpr(resultSort) ?: return null + ?.asExpr(resultSort) + ?: return null conjuncts += ExprWithTypeConstraint( constraint = lhsType.refTypeExpr, expr = refFpExpr @@ -641,7 +657,8 @@ sealed interface TsBinaryOperator { val rhsRef = rhs.asExpr(addressSort) val boolRefGuard = mkAnd(activeGuard, lhsType.boolTypeExpr) val boolRefExpr = internalResolveWithGuard(lhsBool, rhsRef, scope, boolRefGuard) - ?.asExpr(resultSort) ?: return null + ?.asExpr(resultSort) + ?: return null conjuncts += ExprWithTypeConstraint( constraint = lhsType.boolTypeExpr, expr = boolRefExpr @@ -650,7 +667,8 @@ sealed interface TsBinaryOperator { // fake(fp) + ref val fpRefGuard = mkAnd(activeGuard, lhsType.fpTypeExpr) val fpRefExpr = internalResolveWithGuard(lhsFp, rhsRef, scope, fpRefGuard) - ?.asExpr(resultSort) ?: return null + ?.asExpr(resultSort) + ?: return null conjuncts += ExprWithTypeConstraint( constraint = lhsType.fpTypeExpr, expr = fpRefExpr @@ -701,7 +719,8 @@ sealed interface TsBinaryOperator { // bool + fake(ref) val boolRefGuard = mkAnd(activeGuard, rhsType.refTypeExpr) val boolRefExpr = internalResolveWithGuard(lhsBool, rhsRef, scope, boolRefGuard) - ?.asExpr(resultSort) ?: return null + ?.asExpr(resultSort) + ?: return null conjuncts += ExprWithTypeConstraint( constraint = rhsType.refTypeExpr, expr = boolRefExpr @@ -727,7 +746,8 @@ sealed interface TsBinaryOperator { // fp + fake(ref) val fpRefGuard = mkAnd(activeGuard, rhsType.refTypeExpr) val fpRefExpr = internalResolveWithGuard(lhsFp, rhsRef, scope, fpRefGuard) - ?.asExpr(resultSort) ?: return null + ?.asExpr(resultSort) + ?: return null conjuncts += ExprWithTypeConstraint( constraint = rhsType.refTypeExpr, expr = fpRefExpr @@ -739,7 +759,8 @@ sealed interface TsBinaryOperator { val lhsRef = lhs.asExpr(addressSort) val refBoolGuard = mkAnd(activeGuard, rhsType.boolTypeExpr) val refBoolExpr = internalResolveWithGuard(lhsRef, rhsBool, scope, refBoolGuard) - ?.asExpr(resultSort) ?: return null + ?.asExpr(resultSort) + ?: return null conjuncts += ExprWithTypeConstraint( constraint = rhsType.boolTypeExpr, expr = refBoolExpr @@ -748,7 +769,8 @@ sealed interface TsBinaryOperator { // ref + fake(fp) val refFpGuard = mkAnd(activeGuard, rhsType.fpTypeExpr) val refFpExpr = internalResolveWithGuard(lhsRef, rhsFp, scope, refFpGuard) - ?.asExpr(resultSort) ?: return null + ?.asExpr(resultSort) + ?: return null conjuncts += ExprWithTypeConstraint( constraint = rhsType.fpTypeExpr, expr = refFpExpr @@ -812,7 +834,9 @@ sealed interface TsBinaryOperator { samePrimitiveStringKind, ) scope.fork(supported, blockOnFalseState = { - terminateAsUnsupported(reason = "Loose equality needs a modeled ToPrimitive conversion for object/string pairs") + terminateAsUnsupported( + reason = "Loose equality needs a modeled ToPrimitive conversion for object/string pairs", + ) }) ?: return null return Unit @@ -1497,6 +1521,7 @@ sealed interface TsBinaryOperator { } } + /** TypeScript relational operators for numeric values and primitive UTF-16 strings. */ sealed interface Relational : TsBinaryOperator { enum class Comparison { LT, GT, LTE, GTE } @@ -1571,18 +1596,22 @@ sealed interface TsBinaryOperator { } } + /** TypeScript less-than operator. */ data object Lt : Relational { override val comparison = Relational.Comparison.LT } + /** TypeScript greater-than operator. */ data object Gt : Relational { override val comparison = Relational.Comparison.GT } + /** TypeScript less-than-or-equal operator. */ data object LtEq : Relational { override val comparison = Relational.Comparison.LTE } + /** TypeScript greater-than-or-equal operator. */ data object GtEq : Relational { override val comparison = Relational.Comparison.GTE } diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/BuildEtsMethod.kt b/usvm-ts/src/main/kotlin/org/usvm/util/BuildEtsMethod.kt index 99ef0b2b0d..02696d2791 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/BuildEtsMethod.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/BuildEtsMethod.kt @@ -21,19 +21,20 @@ fun buildEtsMethod( returnType: EtsType, program: ProgramBuilder.() -> Unit, ): EtsMethod { + val signature = EtsMethodSignature( + enclosingClass = enclosingClass.signature, + name = name, + parameters = parameters.mapIndexed { index, (parameterName, type) -> + EtsMethodParameter( + index = index, + name = parameterName, + type = type, + ) + }, + returnType = returnType, + ) val method = EtsMethodImpl( - signature = EtsMethodSignature( - enclosingClass = enclosingClass.signature, - name = name, - parameters = parameters.mapIndexed { index, (parameterName, type) -> - EtsMethodParameter( - index = index, - name = parameterName, - type = type, - ) - }, - returnType = returnType, - ) + signature = signature, ) val dslProgram = program(program) diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/InputPropertyDeclaration.kt b/usvm-ts/src/main/kotlin/org/usvm/util/InputPropertyDeclaration.kt index b3302d7473..f4734cb435 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/InputPropertyDeclaration.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/InputPropertyDeclaration.kt @@ -12,7 +12,11 @@ data class InputPropertyDeclaration(val type: EtsType, val optional: Boolean) /** Keep absent fields distinct from declarations that cannot be resolved consistently. */ sealed interface InputPropertyResolution { data class Declared(val property: InputPropertyDeclaration) : InputPropertyResolution + + /** No matching instance field exists for any resolved receiver candidate. */ data object Absent : InputPropertyResolution + + /** The receiver or its candidate field declarations cannot be resolved consistently. */ data object Unresolved : InputPropertyResolution } diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/TsStateVisualizer.kt b/usvm-ts/src/main/kotlin/org/usvm/util/TsStateVisualizer.kt index 0c4da690c6..bc2dca8c30 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/TsStateVisualizer.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/TsStateVisualizer.kt @@ -18,6 +18,7 @@ import kotlin.io.path.writeText private val logger = KotlinLogging.logger {} class TsStateVisualizer : TsInterpreterObserver, UMachineObserver { + @Suppress("TooGenericExceptionCaught") override fun onStatePeeked(state: TsState) { try { state.renderGraph() @@ -30,6 +31,7 @@ class TsStateVisualizer : TsInterpreterObserver, UMachineObserver { } catch (e: CancellationException) { throw e } catch (e: Exception) { + // Rendering is optional; graph, filesystem, and process failures must not affect symbolic execution. logger.warn(e) { "Could not visualize state at ${state.currentStatement}" } } } diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/TsInitialStateFeasibilityTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/TsInitialStateFeasibilityTest.kt new file mode 100644 index 0000000000..6bfa009dd9 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/TsInitialStateFeasibilityTest.kt @@ -0,0 +1,80 @@ +package org.usvm.machine + +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.junit.jupiter.api.Test +import org.usvm.SolverType +import org.usvm.StateCollectionStrategy +import org.usvm.UMachineOptions +import org.usvm.util.TsMethodTestRunner +import org.usvm.util.getResourcePath +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Checks configured machine entry constraints, not TypeScript source reachability. */ +class TsInitialStateFeasibilityTest : TsMethodTestRunner() { + private val file = loadEtsFileAutoConvert( + getResourcePath("/models/SymbolicStringEngine.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + override val scene = EtsScene(listOf(file)) + + @Test + fun `infeasible configured entry is pruned without an engine failure`() { + val method = getMethod(methodName = "length", className = "SymbolicStringEngine") + + val analysis = analyzeWithInfeasibleEntry(methods = listOf(method), infeasibleMethod = method) + + assertEquals(emptyList(), analysis.states) + assertEquals(TsAnalysisStopReason.EXHAUSTED, analysis.stopReason) + assertFalse(analysis.timedOut) + assertFalse(analysis.unsupportedCall) + assertFalse(analysis.engineFailed) + assertTrue(analysis.unsupportedPaths.isEmpty()) + } + + @Test + fun `infeasible entry does not discard another method`() { + val infeasibleMethod = getMethod(methodName = "length", className = "SymbolicStringEngine") + val feasibleMethod = getMethod(methodName = "anyAliasLength", className = "SymbolicStringEngine") + + val analysis = analyzeWithInfeasibleEntry( + methods = listOf(infeasibleMethod, feasibleMethod), + infeasibleMethod = infeasibleMethod, + ) + + assertTrue(analysis.states.isNotEmpty()) + assertTrue(analysis.states.all { state -> state.entrypoint == feasibleMethod }) + assertEquals(TsAnalysisStopReason.EXHAUSTED, analysis.stopReason) + assertFalse(analysis.timedOut) + assertFalse(analysis.unsupportedCall) + assertFalse(analysis.engineFailed) + assertTrue(analysis.unsupportedPaths.isEmpty()) + } + + private fun analyzeWithInfeasibleEntry( + methods: List, + infeasibleMethod: EtsMethod, + ): TsMachineAnalysisResult { + val options = UMachineOptions( + solverType = SolverType.YICES, + stateCollectionStrategy = StateCollectionStrategy.ALL, + stopOnCoverage = 0, + removeCoveredEntryPoints = false, + ) + + return TsMachine(scene = scene, options = options, tsOptions = TsOptions()).use { machine -> + machine.analyzeWithMetadata( + methods = methods, + configureInitialState = { method, state -> + if (method == infeasibleMethod) { + state.pathConstraints += state.ctx.falseExpr + } + }, + ) + } + } +} From 4e84f6c46132f88791d72929cf29449132a15144 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 12:24:43 +0300 Subject: [PATCH 93/97] Bound legacy array fill and simplify call approximations --- .../usvm/machine/expr/CallApproximations.kt | 163 ++++++++++++------ .../org/usvm/machine/expr/TsExprResolver.kt | 56 +++--- 2 files changed, 142 insertions(+), 77 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index bbfb4ef665..44ac1c78a4 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -1,5 +1,6 @@ package org.usvm.machine.expr +import io.ksmt.expr.KFp64Value import io.ksmt.expr.KFpRoundingMode import io.ksmt.utils.asExpr import mu.KotlinLogging @@ -27,6 +28,7 @@ import org.usvm.collection.array.UArrayRegion import org.usvm.collection.array.UArrayRegionId import org.usvm.getIntValue import org.usvm.isAllocatedConcreteHeapRef +import org.usvm.machine.TsContext import org.usvm.machine.TsRuntimeFeatureLimitationReason import org.usvm.machine.TsSizeSort import org.usvm.machine.TsVirtualMethodCallStmt @@ -38,6 +40,7 @@ import org.usvm.machine.call.hasBuiltinGlobalOwner import org.usvm.machine.call.intrinsic.TsDateEtsIrModelFamily import org.usvm.machine.expr.TsExprApproximationResult.Companion.from import org.usvm.machine.interpreter.PromiseState +import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.interpreter.createSettledPromise import org.usvm.machine.interpreter.existingPromiseOrEnsureNonThenable import org.usvm.machine.state.TsMethodResult @@ -153,19 +156,8 @@ internal fun TsExprResolver.tryApproximateInstanceCall( val iterator = tryApproximateBuiltinIteratorCall(stmt) if (iterator !is TsExprApproximationResult.NoApproximation) return iterator - // The model catalog has no toString target; project methods still need normal dispatch. if (expr.callee.name == "toString") { - if (unknownCallDispatcher is TsUnknownCallModelDispatcher) { - if (hasProjectToStringTarget(stmt)) return TsExprApproximationResult.NoApproximation - - dispatchLegacyInstanceCall(stmt) - return TsExprApproximationResult.ResolveFailure - } - - if (expr.args.isNotEmpty()) { - logger.warn { "toString() should have no arguments, but got ${expr.args.size}" } - } - return from(mkStringConstant("I am a string", scope)) + return handleToStringCall(stmt) } // Handle `.valueOf()` method calls @@ -210,6 +202,22 @@ internal fun TsExprResolver.tryApproximateInstanceCall( return TsExprApproximationResult.NoApproximation } +private fun TsExprResolver.handleToStringCall(stmt: TsVirtualMethodCallStmt): TsExprApproximationResult = with(ctx) { + // The model catalog has no toString target; project methods still need normal dispatch. + if (unknownCallDispatcher is TsUnknownCallModelDispatcher) { + if (hasProjectToStringTarget(stmt)) return TsExprApproximationResult.NoApproximation + + dispatchLegacyInstanceCall(stmt) + return TsExprApproximationResult.ResolveFailure + } + + val expr = stmt.call + if (expr.args.isNotEmpty()) { + logger.warn { "toString() should have no arguments, but got ${expr.args.size}" } + } + return from(mkStringConstant("I am a string", scope)) +} + private fun TsExprResolver.tryApproximateArrayCall( stmt: TsVirtualMethodCallStmt, arrayType: EtsArrayType, @@ -785,13 +793,27 @@ private fun TsExprResolver.handleArrayFill( } } + val nonNegativeEndBound = nonNegativeArrayFillEndBound(end, endBv) + val fillValue = ArrayFillValue( nativeValue = nativeValue, boxedValue = boxedValue, hasResolvedElementSort = hasResolvedElementSort, usesBoxedValue = usesBoxedValue, ) - fillArrayRange(arrayType, elementSort, array, fillValue, startBv, endBv) + fillArrayRange(arrayType, elementSort, array, fillValue, startBv, endBv, nonNegativeEndBound) +} + +private fun TsContext.nonNegativeArrayFillEndBound(end: UExpr<*>, endBv: UExpr): Int? { + val integerEnd = getIntValue(endBv) + if (integerEnd != null) return integerEnd.takeIf { it >= 0 } + + val fpEnd = (end as? KFp64Value)?.value ?: return null + val isExactNonNegativeInteger = fpEnd.isFinite() && fpEnd >= 0.0 && + fpEnd <= Int.MAX_VALUE.toDouble() && fpEnd % 1.0 == 0.0 + if (!isExactNonNegativeInteger) return null + + return fpEnd.toInt() } private data class ArrayFillValue( @@ -808,6 +830,7 @@ private fun TsExprResolver.fillArrayRange( fillValue: ArrayFillValue, startBv: UExpr, endBv: UExpr, + nonNegativeEndBound: Int?, ): UExpr<*>? = with(ctx) { scope.calcOnState { val descriptor = arrayDescriptorOf(arrayType) @@ -832,10 +855,11 @@ private fun TsExprResolver.fillArrayRange( } scope.assert(withinCapacity) ?: return@calcOnState null - val tempSize = getIntValue(fillLength)?.coerceAtLeast(0) ?: ARRAY_FILL_MAX_SIZE + val concreteFillLength = getIntValue(fillLength) + val maximumFillLength = nonNegativeEndBound?.coerceAtMost(ARRAY_FILL_MAX_SIZE) ?: ARRAY_FILL_MAX_SIZE + val tempSize = concreteFillLength?.coerceAtLeast(0) ?: maximumFillLength val hasKnownOwnSlots = array in knownArrayPropertyPresence val concreteStart = getIntValue(from) - val concreteFillLength = getIntValue(fillLength) if (hasKnownOwnSlots && (concreteStart == null || concreteFillLength == null)) { logger.warn { "Array.fill() cannot preserve symbolic own-slot presence in the legacy path" } return@calcOnState null @@ -1233,16 +1257,17 @@ private fun TsExprResolver.handleArrayConcat( val elementSort = typeToSort(arrayType.componentType) val arrayTypes = mutableListOf() for ((index, arg) in stmt.args.withIndex()) { - if (arg.sort == addressSort) { - val ref = arg.asExpr(addressSort) - // A fake or an untyped reference may itself contain an array; spreading requires runtime dispatch. - if (ref.hasFakeValueBranch()) return TsExprApproximationResult.NoApproximation - val type = scope.calcOnState { arrayStorageType(ref, stmt.call.args[index].type) } - if (typeToSort(type) is TsUnresolvedSort) return TsExprApproximationResult.NoApproximation - arrayTypes += type as? EtsArrayType - } else { + if (arg.sort != addressSort) { arrayTypes += null + continue } + + val ref = arg.asExpr(addressSort) + // A fake or an untyped reference may itself contain an array; spreading requires runtime dispatch. + if (ref.hasFakeValueBranch()) return TsExprApproximationResult.NoApproximation + val type = scope.calcOnState { arrayStorageType(ref, stmt.call.args[index].type) } + if (typeToSort(type) is TsUnresolvedSort) return TsExprApproximationResult.NoApproximation + arrayTypes += type as? EtsArrayType } val hasIncompatibleElementSort = arrayTypes.withIndex().any { (index, type) -> if (type != null) { @@ -1268,16 +1293,7 @@ private fun TsExprResolver.handleArrayConcat( if (hasMixedPresence) return TsExprApproximationResult.NoApproximation val originalLength = scope.calcOnState { memory.read(mkArrayLengthLValue(array, arrayType)) } - val argumentLengths = scope.calcOnState { - arrayTypes.mapIndexed { index, type -> - if (type == null) { - mkBv(1) - } else { - val ref = stmt.args[index].asExpr(addressSort) - memory.read(mkArrayLengthLValue(ref, type)) - } - } - } + val argumentLengths = scope.calcOnState { readConcatArgumentLengths(stmt.args, arrayTypes) } var resultLength = originalLength for (length in argumentLengths) { resultLength = mkBvAddExpr(resultLength, length) @@ -1297,28 +1313,17 @@ private fun TsExprResolver.handleArrayConcat( preserveKnownPresence = preserveKnownPresence, ) - var copiedLength = originalLength - stmt.args.forEachIndexed { index, arg -> - val argType = arrayTypes[index] - if (argType != null) { - val ref = arg.asExpr(addressSort) - val length = argumentLengths[index] - copyConcatArraySource( - srcRef = ref, - dstRef = resultArray, - arrayType = argType, - fromDst = copiedLength, - length = length, - preserveKnownPresence = preserveKnownPresence, - ) - copiedLength = mkBvAddExpr(copiedLength, length) - } else { - val newLength = mkBvAddExpr(copiedLength, mkBv(1)) - memory.write(mkArrayLengthLValue(resultArray, arrayType), newLength, guard = trueExpr) - assignToArrayIndex(scope, resultArray, copiedLength, arg, arrayType) ?: return@calcOnState null - copiedLength = newLength - } - } + copyConcatArrayArguments( + scope = scope, + arguments = stmt.args, + arrayTypes = arrayTypes, + argumentLengths = argumentLengths, + resultArray = resultArray, + arrayType = arrayType, + originalLength = originalLength, + preserveKnownPresence = preserveKnownPresence, + ) ?: return@calcOnState null + memory.write(mkArrayLengthLValue(resultArray, arrayType), resultLength, guard = trueExpr) if (preserveKnownPresence) { knownArrayPropertyPresence += resultArray @@ -1329,6 +1334,54 @@ private fun TsExprResolver.handleArrayConcat( ) } +private fun TsState.readConcatArgumentLengths( + arguments: List>, + arrayTypes: List, +): List> = with(ctx) { + arrayTypes.mapIndexed { index, type -> + if (type == null) { + mkBv(1) + } else { + val ref = arguments[index].asExpr(addressSort) + memory.read(mkArrayLengthLValue(ref, type)) + } + } +} + +private fun TsState.copyConcatArrayArguments( + scope: TsStepScope, + arguments: List>, + arrayTypes: List, + argumentLengths: List>, + resultArray: UHeapRef, + arrayType: EtsArrayType, + originalLength: UExpr, + preserveKnownPresence: Boolean, +): Unit? = with(ctx) { + var copiedLength = originalLength + arguments.forEachIndexed { index, arg -> + val argType = arrayTypes[index] + if (argType != null) { + val ref = arg.asExpr(addressSort) + val length = argumentLengths[index] + copyConcatArraySource( + srcRef = ref, + dstRef = resultArray, + arrayType = argType, + fromDst = copiedLength, + length = length, + preserveKnownPresence = preserveKnownPresence, + ) + copiedLength = mkBvAddExpr(copiedLength, length) + } else { + val newLength = mkBvAddExpr(copiedLength, mkBv(1)) + memory.write(mkArrayLengthLValue(resultArray, arrayType), newLength, guard = trueExpr) + assignToArrayIndex(scope, resultArray, copiedLength, arg, arrayType) ?: return null + copiedLength = newLength + } + } +} + private fun TsState.copyConcatArraySource( srcRef: UHeapRef, dstRef: UHeapRef, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt index 27f415ae74..0c6f005906 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt @@ -713,7 +713,22 @@ class TsExprResolver( } } - private fun invokePromiseExecutor(promise: UConcreteHeapRef, executor: EtsMethod) = with(ctx) { + private fun invokePromiseExecutor(promise: UConcreteHeapRef, executor: EtsMethod) { + val args = buildPromiseExecutorArguments(promise, executor) + + scope.doWithState { + pushSortsForActualArguments(args) + memory.stack.push(args.toTypedArray(), executor.localsCount) + registerCallee(currentStatement, executor.cfg) + callStack.push(executor, currentStatement) + newStmt(executor.cfg.stmts.first()) + } + } + + private fun buildPromiseExecutorArguments( + promise: UConcreteHeapRef, + executor: EtsMethod, + ): List> = with(ctx) { val args: MutableList> = mutableListOf() // Executor lambda does not have 'this', so we fill it with 'undefined': @@ -725,6 +740,7 @@ class TsExprResolver( // TODO: handle closures args += mkUndefinedValue() } + if (params.isNotEmpty()) { val resolveCallback = allocateConcreteRef() args += resolveCallback @@ -734,31 +750,27 @@ class TsExprResolver( PromiseCallback(promise = promise, settlement = PromiseState.FULFILLED), ) } - if (params.size >= 2) { - val rejectCallback = allocateConcreteRef() - args += rejectCallback - scope.doWithState { - registerPromiseCallback( - rejectCallback, - PromiseCallback(promise = promise, settlement = PromiseState.REJECTED), - ) - } - if (params.size >= 3) { - error( - "Promise executor should have at most 3 parameters" + - " (closures, resolve, reject), but got ${params.size}" - ) - } + } + + if (params.size >= 2) { + val rejectCallback = allocateConcreteRef() + args += rejectCallback + scope.doWithState { + registerPromiseCallback( + rejectCallback, + PromiseCallback(promise = promise, settlement = PromiseState.REJECTED), + ) } } - scope.doWithState { - pushSortsForActualArguments(args) - memory.stack.push(args.toTypedArray(), executor.localsCount) - registerCallee(currentStatement, executor.cfg) - callStack.push(executor, currentStatement) - newStmt(executor.cfg.stmts.first()) + if (params.size >= 3) { + error( + "Promise executor should have at most 3 parameters" + + " (closures, resolve, reject), but got ${params.size}" + ) } + + args } override fun visit(expr: EtsYieldExpr): UExpr? { From acf4c5ddec2daddd40cae7238de76447723bf902 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 12:29:11 +0300 Subject: [PATCH 94/97] Align TypeScript regression fixtures with supported contracts --- .../org/usvm/ts/calls/CallsExperimentTest.kt | 4 +- .../usvm/ts/calls/CallsObjectInputsTest.kt | 31 ++++++- .../usvm/machine/TsStringRelationalTest.kt | 31 +++++-- .../expr/ExprUtilConditionalValueTest.kt | 35 ++++++-- .../reachability/LoopsReachabilityTest.kt | 15 +++- .../kotlin/org/usvm/samples/operators/Less.kt | 30 ++++++- .../samples/types/RuntimeInstanceofTest.kt | 83 ++++++++++++++----- .../org/usvm/samples/types/RuntimeNewTest.kt | 27 ++++-- .../resources/models/ArrayIteratorSource.ts | 3 +- .../resources/models/BuiltinOwnerBoundary.ts | 4 +- .../models/SourceSdkConstructorShadow.ts | 2 +- .../models/SparseArrayIteratorSource.ts | 4 +- .../test/resources/models/StringRelational.ts | 18 +++- .../samples/operators/DeleteProperty.ts | 2 +- .../resources/samples/operators/InOperator.ts | 2 +- .../test/resources/samples/operators/Less.ts | 7 +- 16 files changed, 236 insertions(+), 62 deletions(-) diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt index 0da912fd5b..d20dc501d4 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt @@ -88,7 +88,7 @@ class CallsExperimentTest { ) } - assertTrue(failure.message.orEmpty().contains("Git diff --quiet failed")) + assertTrue(failure.message.orEmpty().contains("Git diff --quiet HEAD -- failed")) assertFalse(Files.exists(rawOutput)) } @@ -120,7 +120,7 @@ class CallsExperimentTest { ) } - assertTrue(failure.message.orEmpty().contains("Git diff --quiet failed")) + assertTrue(failure.message.orEmpty().contains("Git diff --quiet HEAD -- failed")) assertFalse(Files.exists(rawOutput)) } diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsObjectInputsTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsObjectInputsTest.kt index 12afb91ff5..5e8a8e803a 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsObjectInputsTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsObjectInputsTest.kt @@ -1,5 +1,7 @@ package org.usvm.ts.calls +import org.usvm.machine.call.TsResidualCallPolicy +import org.usvm.machine.call.TsUnknownCallDecision import org.usvm.machine.call.TsUnknownCallEvent import org.usvm.machine.call.TsUnknownCallFailureReason import org.usvm.ts.pbt.model.ArrayDomain @@ -13,6 +15,7 @@ import org.usvm.ts.pbt.model.PropertyDomain import org.usvm.ts.pbt.model.PropertyInput import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -178,13 +181,37 @@ internal class CallsObjectInputsTest : CurrentTsCallsSymbolicEngineTestSupport() inputs = listOf(PropertyInput(name = "input", domain = ObjectDomain(mapOf("x" to NumberDomain())))), targetStatement = "return true;", ) + val plainObjectProperties = mapOf("x" to JsConcreteValue.number(value = 1.0)) + val plainInput = JsConcreteValue.Object(plainObjectProperties) val unknownCalls = mutableListOf() val result = fixture.search(modelIds = emptySet(), unknownCallEventSink = unknownCalls::add) + val replay = OriginalTypeScriptTargetReplayer().replay( + sourceRoots = listOf(fixture.sourceRoot), + entryPoint = fixture.function.entryPoint, + inputs = listOf(plainInput), + target = fixture.target, + timeoutMillis = 10_000L, + ) - assertEquals(CallsSymbolicStatus.UNREACHED, result.status, result.toString()) + // The missing-method STOP_PATH is an unsupported search boundary before the return target. + assertEquals(CallsSymbolicStatus.UNSUPPORTED, result.status, result.toString()) + assertFalse(result.solverReached, result.toString()) assertNull(result.inputs) - assertTrue(unknownCalls.any { it.failureReason == TsUnknownCallFailureReason.NO_SUITABLE_VIRTUAL_TARGET }) + + val unknownCall = unknownCalls.single() + assertEquals("f", unknownCall.callee.name) + assertEquals(TsUnknownCallFailureReason.NO_SUITABLE_VIRTUAL_TARGET, unknownCall.failureReason) + assertEquals( + TsUnknownCallDecision.ResidualFallback(policy = TsResidualCallPolicy.STOP_PATH), + unknownCall.decision, + ) + + assertEquals(CallsReplayStatus.REJECTED, replay.status, replay.toString()) + val invocation = assertNotNull(replay.invocation, replay.toString()) + assertFalse(invocation.targetHit) + assertEquals("threw", invocation.invocation) + assertEquals("TypeError", invocation.errorName) } @Test diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/TsStringRelationalTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/TsStringRelationalTest.kt index 67490a871b..020a2616ac 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/TsStringRelationalTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/TsStringRelationalTest.kt @@ -63,7 +63,7 @@ class TsStringRelationalTest : TsMethodTestRunner() { } @Test - fun `inclusive symbolic string comparisons include equal values`() { + fun `inclusive symbolic string comparisons respect ordering`() { val comparisons = mapOf Boolean>( "symbolicLessOrEqual" to { left, right -> left <= right }, "symbolicGreaterOrEqual" to { left, right -> left >= right }, @@ -74,10 +74,7 @@ class TsStringRelationalTest : TsMethodTestRunner() { discoverProperties( method = method, - { left, right, result -> left.value == right.value && result.number == 1.0 }, - { left, right, result -> - left.value != right.value && expected(left.value, right.value) && result.number == 1.0 - }, + { left, right, result -> expected(left.value, right.value) && result.number == 1.0 }, { left, right, result -> !expected(left.value, right.value) && result.number == 2.0 }, invariants = arrayOf({ left, right, result -> val expectedResult = if (expected(left.value, right.value)) 1.0 else 2.0 @@ -88,6 +85,22 @@ class TsStringRelationalTest : TsMethodTestRunner() { } } + @Test + fun `inclusive comparisons accept equal values from distinct string inputs`() { + val method = getMethod(methodName = "symbolicEqualInclusive", className = "StringRelational") + + discoverProperties( + method = method, + { left, right, result -> left.value == right.value && result.number == 1.0 }, + { left, right, result -> left.value != right.value && result.number == 0.0 }, + invariants = arrayOf({ left, right, result -> + val expected = if (left.value == right.value) 1.0 else 0.0 + + result.number == expected + }), + ) + } + @Test fun `symbolic string pairs compare their source values`() { val method = getMethod(methodName = "symbolicLess", className = "StringRelational") @@ -111,10 +124,14 @@ class TsStringRelationalTest : TsMethodTestRunner() { discoverProperties( method = method, { useString, value, result -> useString.value && value.value < "b" && result.number == 1.0 }, - { useString, value, result -> useString.value && value.value >= "b" && result.number == 2.0 }, + { useString, value, result -> useString.value && value.value >= "b" && result.number == 3.0 }, { useString, _, result -> !useString.value && result.number == 2.0 }, invariants = arrayOf({ useString, value, result -> - val expected = if (useString.value && value.value < "b") 1.0 else 2.0 + val expected = when { + !useString.value -> 2.0 + value.value < "b" -> 1.0 + else -> 3.0 + } result.number == expected }), diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/expr/ExprUtilConditionalValueTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/ExprUtilConditionalValueTest.kt index 6fd8094bc7..ac8e9f97d3 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/expr/ExprUtilConditionalValueTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/ExprUtilConditionalValueTest.kt @@ -22,6 +22,8 @@ import org.usvm.machine.types.TsTypeSystem import org.usvm.machine.types.mkFakeValue import org.usvm.memory.UReadOnlyMemory import org.usvm.memory.URegistersStack +import org.usvm.solver.USatResult +import org.usvm.solver.UUnsatResult import org.usvm.util.EtsHierarchy import org.usvm.util.boolToFp import kotlin.test.assertEquals @@ -136,9 +138,32 @@ class ExprUtilConditionalValueTest { assertFalse(conditional.hasFakeValueBranch()) assertNotNull(supported) - assertEquals(falseExpr, truthy) - assertEquals(trueExpr, nullish) - assertEquals(mkIte(selected, mkFp64(0.0), mkFp64NaN()), numeric) + + val truthyConstraints = state.pathConstraints.clone() + truthyConstraints += truthy + assertIs>(solver().check(truthyConstraints)) + + val nonNullishConstraints = state.pathConstraints.clone() + nonNullishConstraints += mkNot(nullish) + assertIs>(solver().check(nonNullishConstraints)) + + val nullBranchConstraints = state.pathConstraints.clone() + nullBranchConstraints += selected + assertIs>(solver().check(nullBranchConstraints)) + + val wrongNullNumberConstraints = state.pathConstraints.clone() + wrongNullNumberConstraints += selected + wrongNullNumberConstraints += mkNot(mkFpEqualExpr(numeric, mkFp64(0.0))) + assertIs>(solver().check(wrongNullNumberConstraints)) + + val undefinedBranchConstraints = state.pathConstraints.clone() + undefinedBranchConstraints += mkNot(selected) + assertIs>(solver().check(undefinedBranchConstraints)) + + val wrongUndefinedNumberConstraints = state.pathConstraints.clone() + wrongUndefinedNumberConstraints += mkNot(selected) + wrongUndefinedNumberConstraints += mkNot(mkFpIsNaNExpr(numeric)) + assertIs>(solver().check(wrongUndefinedNumberConstraints)) } @Test @@ -306,7 +331,7 @@ class ExprUtilConditionalValueTest { assertEquals(mkFp64(8.0), evaluateSelection(expr = result, selectedValue = true)) assertNull(state.unsupportedReason) assertEquals( - "Numeric conversion needs a modeled StringToNumber or ToPrimitive conversion", + "Dynamic string addition needs a modeled mixed-kind result", unsupported.unsupportedReason, ) } @@ -349,7 +374,7 @@ class ExprUtilConditionalValueTest { val selected = mkRegisterReading(idx = 0, sort = boolSort) val unmodeledString = state.mkInitializedStringConstant(value = "42") - val resolved = with(TsBinaryOperator.Add) { + val resolved = with(TsBinaryOperator.Sub) { ctx.resolve(unmodeledString, mkFp64(1.0), scope, activeGuard = selected) } val result = assertNotNull(resolved) diff --git a/usvm-ts/src/test/kotlin/org/usvm/reachability/LoopsReachabilityTest.kt b/usvm-ts/src/test/kotlin/org/usvm/reachability/LoopsReachabilityTest.kt index b3682d5b7f..f528f6af01 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/reachability/LoopsReachabilityTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/reachability/LoopsReachabilityTest.kt @@ -10,6 +10,7 @@ import org.usvm.PathSelectionStrategy import org.usvm.SolverType import org.usvm.StateCollectionStrategy import org.usvm.UMachineOptions +import org.usvm.api.TsTestValue import org.usvm.api.targets.ReachabilityObserver import org.usvm.api.targets.TsReachabilityTarget import org.usvm.api.targets.TsTarget @@ -18,10 +19,12 @@ import org.usvm.machine.TsMachine import org.usvm.machine.TsMachineAnalysisResult import org.usvm.machine.TsOptions import org.usvm.machine.state.TsMethodResult +import org.usvm.util.TsTestResolver import org.usvm.util.getResourcePath import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertTrue import kotlin.time.Duration @@ -93,12 +96,11 @@ class LoopsReachabilityTest { val method = scene.projectClasses .flatMap { it.methods } .single { it.name == "whileLoopBreakReachable" } - val returnOne = method.cfg.stmts.filterIsInstance().first() val analysis = analyzeWhileLoopBreak(method, start = 0.0) assertCompleteWhileLoopAnalysis(analysis) - assertFalse(returnOne in analysis.states.flatMap { it.pathNode.allStatements }) + assertEquals(setOf(0.0), returnedNumbers(method, analysis)) } @Test @@ -106,14 +108,19 @@ class LoopsReachabilityTest { val method = scene.projectClasses .flatMap { it.methods } .single { it.name == "whileLoopBreakReachable" } - val returnOne = method.cfg.stmts.filterIsInstance().first() val analysis = analyzeWhileLoopBreak(method, start = 50.0) assertCompleteWhileLoopAnalysis(analysis) - assertTrue(returnOne in analysis.states.flatMap { it.pathNode.allStatements }) + assertEquals(setOf(1.0), returnedNumbers(method, analysis)) } + private fun returnedNumbers(method: EtsMethod, analysis: TsMachineAnalysisResult): Set = + analysis.states.map { state -> + val result = TsTestResolver().resolve(method, state).returnValue + assertIs(result).number + }.toSet() + private fun analyzeWhileLoopBreak(method: EtsMethod, start: Double): TsMachineAnalysisResult { val exhaustiveOptions = options.copy( pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), diff --git a/usvm-ts/src/test/kotlin/org/usvm/samples/operators/Less.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/operators/Less.kt index a0f175ee5e..a663cdf1ff 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/samples/operators/Less.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/samples/operators/Less.kt @@ -1,11 +1,18 @@ package org.usvm.samples.operators import org.jacodb.ets.model.EtsScene +import org.usvm.StateCollectionStrategy import org.usvm.api.TsTestValue +import org.usvm.machine.TsAnalysisStopReason +import org.usvm.machine.TsMachine import org.usvm.util.TsMethodTestRunner import org.usvm.util.eq import org.usvm.util.toDouble import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration class Less : TsMethodTestRunner() { private val tsPath = "/samples/operators/Less.ts" @@ -46,11 +53,28 @@ class Less : TsMethodTestRunner() { } @Test - fun testLessRefs() { + fun `object comparison reports unmodeled primitive conversion`() { val method = getMethod("lessRefs") - discoverProperties( - method, + val analysisOptions = options.copy( + stateCollectionStrategy = StateCollectionStrategy.ALL, + stopOnCoverage = 0, + timeout = Duration.INFINITE, + throwExceptionOnStepFailure = false, + ) + + val outcome = TsMachine(scene = scene, options = analysisOptions, tsOptions = tsOptions).use { machine -> + machine.analyzeWithMetadata(methods = listOf(method)) + } + + assertEquals(TsAnalysisStopReason.EXHAUSTED, outcome.stopReason) + assertTrue(outcome.states.isEmpty()) + assertEquals( + expected = listOf("Numeric conversion needs a modeled StringToNumber or ToPrimitive conversion"), + actual = outcome.unsupportedPaths, ) + assertFalse(outcome.timedOut) + assertFalse(outcome.unsupportedCall) + assertFalse(outcome.engineFailed) } @Test diff --git a/usvm-ts/src/test/kotlin/org/usvm/samples/types/RuntimeInstanceofTest.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/types/RuntimeInstanceofTest.kt index acbddaf752..5b5cf11c66 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/samples/types/RuntimeInstanceofTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/samples/types/RuntimeInstanceofTest.kt @@ -19,6 +19,7 @@ import org.usvm.util.getResourcePath import java.nio.file.Path import kotlin.io.path.readText import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds @@ -336,34 +337,55 @@ class RuntimeInstanceofTest : TsMethodTestRunner() { } @Test - fun `custom hasInstance is explicitly unsupported`() { - val custom = analyze("customHasInstance") - val inherited = analyze("inheritedHasInstance") - val customField = analyze(methodName = "customHasInstanceField") - val inheritedField = analyze(methodName = "inheritedHasInstanceField") - - assertEquals(TsAnalysisStopReason.EXHAUSTED, custom.stopReason) - assertTrue(custom.states.isEmpty()) - assertTrue(custom.unsupportedPaths.any { "Symbol.hasInstance" in it }, "${custom.unsupportedPaths}") - - assertEquals(TsAnalysisStopReason.EXHAUSTED, inherited.stopReason) - assertTrue(inherited.states.isEmpty()) - assertTrue(inherited.unsupportedPaths.any { "Symbol.hasInstance" in it }, "${inherited.unsupportedPaths}") + fun `custom hasInstance method class construction is rejected by the frontend`() { + assertFrontendRejectedValue( + methodName = "customHasInstance", + kindName = "NewExpression", + text = "new InstanceCustom()", + ) - assertEquals(TsAnalysisStopReason.EXHAUSTED, customField.stopReason) - assertTrue(customField.states.isEmpty()) - assertTrue(customField.unsupportedPaths.any { "Symbol.hasInstance" in it }, "${customField.unsupportedPaths}") + replay(listOf( + "if (new RuntimeInstanceof().customHasInstance() !== false) throw Error('custom hasInstance');", + )) + } - assertEquals(TsAnalysisStopReason.EXHAUSTED, inheritedField.stopReason) - assertTrue(inheritedField.states.isEmpty()) - assertTrue(inheritedField.unsupportedPaths.any { "Symbol.hasInstance" in it }, "${inheritedField.unsupportedPaths}") + @Test + fun `inherited hasInstance method constructor value is rejected by the frontend`() { + assertFrontendRejectedValue( + methodName = "inheritedHasInstance", + kindName = "Identifier", + text = "InstanceHasInstanceChild", + ) replay(listOf( - "if (new RuntimeInstanceof().customHasInstance() !== false) throw Error('custom hasInstance');", "if (new RuntimeInstanceof().inheritedHasInstance(new InstanceHasInstanceChild()) !== false) " + "throw Error('inherited hasInstance');", + )) + } + + @Test + fun `custom hasInstance field class construction is rejected by the frontend`() { + assertFrontendRejectedValue( + methodName = "customHasInstanceField", + kindName = "NewExpression", + text = "new InstanceCustomField()", + ) + + replay(listOf( "if (new RuntimeInstanceof().customHasInstanceField() !== false) " + "throw Error('field hasInstance');", + )) + } + + @Test + fun `inherited hasInstance field constructor value is rejected by the frontend`() { + assertFrontendRejectedValue( + methodName = "inheritedHasInstanceField", + kindName = "Identifier", + text = "InstanceHasInstanceFieldChild", + ) + + replay(listOf( "if (new RuntimeInstanceof().inheritedHasInstanceField(new InstanceHasInstanceFieldChild()) !== false) " + "throw Error('inherited field hasInstance');", )) @@ -651,6 +673,27 @@ class RuntimeInstanceofTest : TsMethodTestRunner() { outcome.states.forEach { state -> assertIs(state.methodResult) } } + private fun assertFrontendRejectedValue(methodName: String, kindName: String, text: String) { + val method = getMethod(methodName = methodName, className = "RuntimeInstanceof") + + val outcome = TsMachine(scene, options = machineOptions, tsOptions = TsOptions()).use { machine -> + machine.analyzeWithMetadata(methods = listOf(method)) + } + + assertEquals(TsAnalysisStopReason.EXHAUSTED, outcome.stopReason, methodName) + assertTrue(outcome.states.isEmpty(), methodName) + assertFalse(outcome.timedOut, methodName) + assertFalse(outcome.engineFailed, methodName) + assertFalse(outcome.unsupportedCall, methodName) + assertEquals(expected = 1, actual = outcome.unsupportedPaths.size, message = methodName) + + val unsupportedValue = outcome.unsupportedPaths.single() + val rejectedExpectedValue = + unsupportedValue.startsWith("FRONTEND_UNSUPPORTED_VALUE: Unsupported frontend value: UnsupportedValue") && + "\"kindName\":\"$kindName\"" in unsupportedValue && "\"text\":\"$text\"" in unsupportedValue + assertTrue(rejectedExpectedValue, "$methodName: $unsupportedValue") + } + private fun discoverConstant(methodName: String, expected: Double) { discoverProperties( method = getMethod(methodName = methodName, className = "RuntimeInstanceof"), diff --git a/usvm-ts/src/test/kotlin/org/usvm/samples/types/RuntimeNewTest.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/types/RuntimeNewTest.kt index 60e850fd05..7ced3b7d84 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/samples/types/RuntimeNewTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/samples/types/RuntimeNewTest.kt @@ -18,6 +18,7 @@ import org.usvm.util.getResourcePath import java.nio.file.Path import kotlin.io.path.readText import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds @@ -447,17 +448,27 @@ class RuntimeNewTest : TsMethodTestRunner() { } @Test - fun `an object hasInstance method does not make it constructable`() { - val outcome = analyze(methodName = "nonConstructableWithHasInstance") + fun `computed hasInstance class construction is rejected before dynamic new`() { + val method = getMethod(methodName = "nonConstructableWithHasInstance", className = "RuntimeNew") - assertEquals(TsAnalysisStopReason.EXHAUSTED, outcome.stopReason) - assertTrue(outcome.unsupportedPaths.isEmpty(), "${outcome.unsupportedPaths}") - assertTrue(outcome.states.isNotEmpty()) - outcome.states.forEach { state -> - val exception = assertIs(state.methodResult) - assertEquals("TypeError", exception.type.typeName) + val outcome = TsMachine(scene, options = machineOptions, tsOptions = TsOptions()).use { machine -> + machine.analyzeWithMetadata(methods = listOf(method)) } + assertEquals(TsAnalysisStopReason.EXHAUSTED, outcome.stopReason) + assertTrue(outcome.states.isEmpty()) + assertFalse(outcome.timedOut) + assertFalse(outcome.engineFailed) + assertFalse(outcome.unsupportedCall) + assertEquals(expected = 1, actual = outcome.unsupportedPaths.size) + + val unsupportedValue = outcome.unsupportedPaths.single() + val rejectedHasInstanceConstruction = + unsupportedValue.startsWith("FRONTEND_UNSUPPORTED_VALUE: Unsupported frontend value: UnsupportedValue") && + "\"kindName\":\"NewExpression\"" in unsupportedValue && + "\"text\":\"new NewHasInstanceObject()\"" in unsupportedValue + assertTrue(rejectedHasInstanceConstruction, unsupportedValue) + replay(listOf( "let caught = false; try { new RuntimeNew().nonConstructableWithHasInstance(); } " + "catch (error) { caught = error instanceof TypeError; } " + diff --git a/usvm-ts/src/test/resources/models/ArrayIteratorSource.ts b/usvm-ts/src/test/resources/models/ArrayIteratorSource.ts index 53db5c433b..281efd3329 100644 --- a/usvm-ts/src/test/resources/models/ArrayIteratorSource.ts +++ b/usvm-ts/src/test/resources/models/ArrayIteratorSource.ts @@ -113,7 +113,8 @@ export class ArrayIteratorSource { } static stringNamedLookalike(): number { - const values: any = { "Symbol.iterator"() { return [7]; } }; + const values: any = { "Symbol.iterator": function () { return [7]; } }; + return [...values].length; } diff --git a/usvm-ts/src/test/resources/models/BuiltinOwnerBoundary.ts b/usvm-ts/src/test/resources/models/BuiltinOwnerBoundary.ts index 25870a99e5..7713232e16 100644 --- a/usvm-ts/src/test/resources/models/BuiltinOwnerBoundary.ts +++ b/usvm-ts/src/test/resources/models/BuiltinOwnerBoundary.ts @@ -1,12 +1,12 @@ export class BuiltinOwnerBoundary { shadowedMath(): number { - const Math = { abs(value: number): number { return value + 79; } }; + const Math = { abs: function (value: number): number { return value + 79; } }; return Math.abs(-2); } shadowedNumber(): boolean { - const Number = { isInteger(_value: number): boolean { return false; } }; + const Number = { isInteger: function (_value: number): boolean { return false; } }; return Number.isInteger(2); } diff --git a/usvm-ts/src/test/resources/models/SourceSdkConstructorShadow.ts b/usvm-ts/src/test/resources/models/SourceSdkConstructorShadow.ts index b9147e4cb7..5f37633411 100644 --- a/usvm-ts/src/test/resources/models/SourceSdkConstructorShadow.ts +++ b/usvm-ts/src/test/resources/models/SourceSdkConstructorShadow.ts @@ -1,5 +1,5 @@ // @ts-expect-error This source binding implements only the method used below. -const Object: typeof globalThis.Object = { setPrototypeOf(target: any, _prototype: any): any { target[0] = 9; return target; } }; +const Object: typeof globalThis.Object = { setPrototypeOf: function (target: any, _prototype: any): any { target[0] = 9; return target; } }; // @ts-expect-error This source binding implements only the method used below. const Array: typeof globalThis.Array = { isArray: (_value: unknown): boolean => false }; diff --git a/usvm-ts/src/test/resources/models/SparseArrayIteratorSource.ts b/usvm-ts/src/test/resources/models/SparseArrayIteratorSource.ts index cb228cb672..7e14e9ae1c 100644 --- a/usvm-ts/src/test/resources/models/SparseArrayIteratorSource.ts +++ b/usvm-ts/src/test/resources/models/SparseArrayIteratorSource.ts @@ -132,10 +132,12 @@ export class SparseArrayIteratorSource { } static sourceObjectLookalike(): number { - const Object = { setPrototypeOf(values: any[], prototype: any) { return values; } }; + const Object = { setPrototypeOf: function (values: any[], prototype: any) { return values; } }; const values = [, 7]; + Object.setPrototypeOf(values, { 0: 9 }); const copy = [...values]; + return copy[0] === undefined && copy[1] === 7 ? 1 : 0; } diff --git a/usvm-ts/src/test/resources/models/StringRelational.ts b/usvm-ts/src/test/resources/models/StringRelational.ts index b712d2d898..eef173114f 100644 --- a/usvm-ts/src/test/resources/models/StringRelational.ts +++ b/usvm-ts/src/test/resources/models/StringRelational.ts @@ -41,12 +41,28 @@ class StringRelational { return left >= right ? 1 : 2; } + symbolicEqualInclusive(left: string, right: string): number { + if (left !== right) { + return 0; + } + + return left <= right && left >= right ? 1 : 2; + } + symbolicLess(left: string, right: string): number { return left < right ? 1 : 2; } conditionalLeft(useString: boolean, value: string): number { const left: string | number = useString ? value : 1; - return left < "b" ? 1 : 2; + if (left < "b") { + return 1; + } + + if (useString) { + return 3; + } + + return 2; } } diff --git a/usvm-ts/src/test/resources/samples/operators/DeleteProperty.ts b/usvm-ts/src/test/resources/samples/operators/DeleteProperty.ts index e7ebcc54a2..e8b68378da 100644 --- a/usvm-ts/src/test/resources/samples/operators/DeleteProperty.ts +++ b/usvm-ts/src/test/resources/samples/operators/DeleteProperty.ts @@ -173,7 +173,7 @@ class DeleteProperty { } deleteObjectLiteralMethod(): number { - const object = { method(): number { return 7; } }; + const object = { method: function (): number { return 7; } }; delete object.method; return object.method === undefined ? 1 : 0; } diff --git a/usvm-ts/src/test/resources/samples/operators/InOperator.ts b/usvm-ts/src/test/resources/samples/operators/InOperator.ts index 5a9e4f1df8..3c08497009 100644 --- a/usvm-ts/src/test/resources/samples/operators/InOperator.ts +++ b/usvm-ts/src/test/resources/samples/operators/InOperator.ts @@ -37,7 +37,7 @@ class InOperator { } hasOwnConstructorMethod(value: number): number { - const obj = { constructor() {} }; + const obj = { constructor: function () {} }; return "constructor" in obj ? 1 : -1; } diff --git a/usvm-ts/src/test/resources/samples/operators/Less.ts b/usvm-ts/src/test/resources/samples/operators/Less.ts index f11ae55929..51f756749c 100644 --- a/usvm-ts/src/test/resources/samples/operators/Less.ts +++ b/usvm-ts/src/test/resources/samples/operators/Less.ts @@ -39,15 +39,16 @@ class Less { } lessRefs(a: object, b: object): boolean { + // Relational comparison converts objects to primitives before comparing their values. if (a < b) { - return true; // Reference comparison, not value + return true; } if (b < a) { - return false; // Reference comparison, not value + return false; } - return false; // Return false if they are equal + return false; } lessUnknown(a, b): boolean { From 51c8d368d11faf01afe1ccdd403922cac053d4a8 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 12:51:50 +0300 Subject: [PATCH 95/97] Separate replay scenarios and address TypeScript test lint --- .../usvm/baseline/CallFallbackBaselineTest.kt | 2 +- .../census/UnknownCallCensusAggregatorTest.kt | 8 +- .../org/usvm/machine/TsContextFactoryTest.kt | 4 +- .../machine/call/TsArrayShiftReplayCases.kt | 507 +++++++++++++++++ .../machine/call/TsArrayShiftReplayTest.kt | 526 +----------------- .../expr/ExprUtilConditionalValueTest.kt | 2 +- .../expr/RuntimeLimitationBoundariesTest.kt | 5 +- .../usvm/machine/types/TsTypeSystemTest.kt | 4 +- .../kotlin/org/usvm/project/ImportResolver.kt | 106 ++-- 9 files changed, 592 insertions(+), 572 deletions(-) create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayCases.kt diff --git a/usvm-ts/src/test/kotlin/org/usvm/baseline/CallFallbackBaselineTest.kt b/usvm-ts/src/test/kotlin/org/usvm/baseline/CallFallbackBaselineTest.kt index ceeef3f3da..ff921ceeba 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/baseline/CallFallbackBaselineTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/baseline/CallFallbackBaselineTest.kt @@ -8,8 +8,8 @@ import org.jacodb.ets.model.EtsReturnStmt import org.jacodb.ets.model.EtsScene import org.jacodb.ets.model.EtsStaticCallExpr import org.jacodb.ets.model.EtsStmt -import org.jacodb.ets.utils.EtsIrProvider import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME +import org.jacodb.ets.utils.EtsIrProvider import org.jacodb.ets.utils.callExpr import org.jacodb.ets.utils.loadEtsFileAutoConvert import org.junit.jupiter.api.Test diff --git a/usvm-ts/src/test/kotlin/org/usvm/census/UnknownCallCensusAggregatorTest.kt b/usvm-ts/src/test/kotlin/org/usvm/census/UnknownCallCensusAggregatorTest.kt index ca8d9e2b9e..47eba8ed9b 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/census/UnknownCallCensusAggregatorTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/census/UnknownCallCensusAggregatorTest.kt @@ -1,8 +1,8 @@ package org.usvm.census import kotlin.test.Test -import kotlin.test.assertEquals import kotlin.test.assertContains +import kotlin.test.assertEquals import kotlin.test.assertFailsWith class UnknownCallCensusAggregatorTest { @@ -110,7 +110,8 @@ class UnknownCallCensusAggregatorTest { @Test fun `unsupported current method and project statuses report raw line and value`() { val cases = listOf( - methodRecord(functionId = "project:file:fn", status = "future_method") to "method_result status 'future_method'", + methodRecord(functionId = "project:file:fn", status = "future_method") to + "method_result status 'future_method'", projectRecord(projectId = "project", status = "future_project") to "project_result status 'future_project'", ) @@ -128,7 +129,8 @@ class UnknownCallCensusAggregatorTest { """{"kind":"run_start","schemaVersion":$schemaVersion,"startedAt":"2026-10-15T00:00:00Z"}""" private fun runResult(schemaVersion: Int = 2, status: String = "completed"): String = - """{"kind":"run_result","schemaVersion":$schemaVersion,"startedAt":"2026-10-15T00:00:00Z","finishedAt":"2026-10-15T00:01:00Z","status":"$status"}""" + """{"kind":"run_result","schemaVersion":$schemaVersion,"startedAt":"2026-10-15T00:00:00Z",""" + + """"finishedAt":"2026-10-15T00:01:00Z","status":"$status"}""" private fun unknownCallRecord(siteId: String, failureReason: String): String = """ { diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/TsContextFactoryTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/TsContextFactoryTest.kt index ddd510eba0..975e62fc60 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/TsContextFactoryTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/TsContextFactoryTest.kt @@ -247,7 +247,9 @@ class TsContextFactoryTest { @Test fun `primitive intersections share storage and mixed or empty intersections stay unresolved`() { val numberIntersection = EtsIntersectionType(types = listOf(EtsNumberType, EtsNumberLiteralType(value = 7.0))) - val booleanIntersection = EtsIntersectionType(types = listOf(EtsBooleanType, EtsBooleanLiteralType(value = true))) + val booleanIntersection = EtsIntersectionType( + types = listOf(EtsBooleanType, EtsBooleanLiteralType(value = true)), + ) val elementTypes = listOf( numberIntersection, alias(name = "Intersection", originalType = numberIntersection), diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayCases.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayCases.kt new file mode 100644 index 0000000000..9ceb38904e --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayCases.kt @@ -0,0 +1,507 @@ +package org.usvm.machine.call + +internal fun arrayShiftReplayCases(): List = buildList { + for (type in listOf("any", "unknown")) { + add( + ArrayShiftReplayCase( + name = "first $type read", + parameters = "values: $type[]", + maxResult = 6, + body = """ + if (values.length !== 1) return 0; + const removed = values.shift(); + if (removed === 42) return 1; + if (removed === true) return 2; + if (removed === false) return 3; + if (removed === null) return 4; + if (removed === undefined) return 5; + return 6; + """.trimIndent(), + ) + ) + add( + ArrayShiftReplayCase( + name = "repeated mixed $type shifts", + parameters = "values: $type[]", + maxResult = 3, + body = """ + if (values.length !== 2) return 0; + const first = values.shift(); + const second = values.shift(); + if (first === 42 && second === true) return 1; + if (first === false && second === 17) return 2; + return 3; + """.trimIndent(), + ) + ) + add( + ArrayShiftReplayCase( + name = "read shifted $type tail", + parameters = "values: $type[]", + maxResult = 3, + body = """ + if (values.length !== 2) return 0; + values.shift(); + if (values[0] === 42) return 1; + if (values[0] === true) return 2; + return 3; + """.trimIndent(), + ) + ) + add( + ArrayShiftReplayCase( + name = "overwrite $type then shift", + parameters = "values: $type[], index: number", + maxResult = 3, + body = """ + if (values.length !== 2 || (index !== 0 && index !== 1)) return 0; + values[index] = true; + const first = values.shift(); + if (first === 42 && values[0] === true) return 1; + if (first === true && values[0] === 17) return 2; + return 3; + """.trimIndent(), + ) + ) + } + add( + ArrayShiftReplayCase( + name = "typed pop payload remains usable as number and array index", + parameters = "values: number[]", + maxResult = 2, + body = """ + if (values.length !== 1) return 0; + const n = values.pop(); + if (n !== 1) return 1; + const target = [10, 20]; + return Math.floor(n) === 1 && target[n] === 20 ? 2 : -1; + """.trimIndent(), + ) + ) + addAll(assertionAndWriteCases()) + addAll(storageOperationCases()) + addAll(pairCases()) + addAll(typedCases()) +} + +private fun assertionAndWriteCases(): List = buildList { + add( + ArrayShiftReplayCase( + name = "number type assertions preserve runtime values", + parameters = "value: number", + maxResult = 1, + body = """ + if (value !== 42) return 0; + const asserted = value as unknown as string; + return typeof asserted === 'number' && asserted === 42 ? 1 : -1; + """.trimIndent(), + ) + ) + add( + ArrayShiftReplayCase( + name = "boolean type assertions do not convert to numbers", + parameters = "value: boolean", + maxResult = 1, + body = """ + if (!value) return 0; + const asserted = value as unknown as number; + return typeof asserted === 'boolean' && asserted === true ? 1 : -1; + """.trimIndent(), + ) + ) + add( + ArrayShiftReplayCase( + name = "truthy wrapped number retains its runtime kind through assertion and write", + parameters = "values: number[]", + maxResult = 2, + body = """ + if (values.length !== 1) return 0; + const item = values[0]; + if (!item) return 1; + const asserted = item as unknown as number; + const output = [0]; + output[0] = asserted; + return output[0] === item ? 2 : -1; + """.trimIndent(), + ) + ) + add( + ArrayShiftReplayCase( + name = "typed array copies preserve wrapped numbers including NaN", + parameters = "values: number[]", + maxResult = 1, + body = """ + if (values.length !== 2) return 0; + values[0] = values[1]; + return values[0] === values[1] || values[0] !== values[0] ? 1 : -1; + """.trimIndent(), + ) + ) +} + +private fun storageOperationCases(): List = listOf("any", "unknown").flatMap { type -> + copyCases(type) + mutationCases(type) + concatCases(type) +}.map { case -> case.copy(name = "compatibility storage: ${case.name}", compatibility = true) } + +private fun copyCases(type: String): List = listOf( + ArrayShiftReplayCase( + name = "$type slice ignores an evaluated extra argument", + parameters = "values: $type[]", + maxResult = 1, + body = """ + if (values.length !== 1) return 0; + const alias = values; + + const copy = values.slice(0, 1, values.push(true)); + + return copy.length === 1 && alias.length === 2 && alias[1] === true ? 1 : -1; + """.trimIndent(), + ), + ArrayShiftReplayCase( + name = "$type slice preserves a deleted slot at a translated index", + parameters = "values: $type[]", + maxResult = 1, + body = """ + if (values.length !== 1) return 0; + const source = [7, 11, 22]; + delete source[1]; + + const copy = source.slice(1, 3); + + return copy.length === 2 && copy[0] === undefined && copy[1] === 22 && + source[0] === 7 ? 1 : -1; + """.trimIndent(), + ), + ArrayShiftReplayCase( + name = "$type slice() retains all runtime kinds", + parameters = "values: $type[]", + maxResult = 6, + body = """ + if (values.length !== 1) return 0; + const copy = values.slice(); + const value = copy[0]; + if (value === 42) return 1; + if (value === true) return 2; + if (value === false) return 3; + if (value === null) return 4; + if (value === undefined) return 5; + return 6; + """.trimIndent(), + ), + ArrayShiftReplayCase( + name = "$type slice().reverse() retains all runtime kinds", + parameters = "values: $type[]", + maxResult = 6, + body = """ + if (values.length !== 1) return 0; + const copy = values.slice().reverse(); + const value = copy[0]; + if (value === 42) return 1; + if (value === true) return 2; + if (value === false) return 3; + if (value === null) return 4; + if (value === undefined) return 5; + return 6; + """.trimIndent(), + ), + ArrayShiftReplayCase( + name = "$type sliced input mixed with appended wrapper", + parameters = "values: $type[], index: number", + maxResult = 4, + body = """ + if (values.length !== 1) return 0; + const i = Math.floor(index); + if (!(i >= 0 && i <= 1)) return 0; + const copy = values.slice(); + copy.push(true); + const value = copy[i]; + if (i === 1 && value === true) return 1; + if (i === 0 && value === 42) return 2; + if (i === 0 && value === false) return 3; + return 4; + """.trimIndent(), + ), + ArrayShiftReplayCase( + name = "$type copied payload moves through two shifts", + parameters = "values: $type[]", + maxResult = 3, + body = """ + if (values.length !== 2) return 0; + const copy = values.slice(); + const first = copy.shift(); + const second = copy.shift(); + if (first === 42 && second === true) return 1; + if (first === false && second === 17) return 2; + return 3; + """.trimIndent(), + ), +) + +private fun mutationCases(type: String): List = listOf( + ArrayShiftReplayCase( + name = "$type compatibility pop ignores evaluated extra arguments", + parameters = "values: $type[]", + maxResult = 1, + body = """ + if (values.length !== 1) return 0; + const alias = values; + const removed = values.pop(values.push(true)); + return removed === true && alias.length === 1 ? 1 : -1; + """.trimIndent(), + ), + ArrayShiftReplayCase( + name = "$type compatibility push accepts zero and three values", + parameters = "values: $type[]", + maxResult = 1, + body = """ + if (values.length !== 1) return 0; + const alias = values; + const unchangedLength = values.push(); + const newLength = values.push(7, true, null); + return unchangedLength === 1 && newLength === 4 && alias[1] === 7 && + alias[2] === true && alias[3] === null ? 1 : -1; + """.trimIndent(), + ), + ArrayShiftReplayCase( + name = "$type compatibility unshift accepts zero and three values", + parameters = "values: $type[]", + maxResult = 1, + body = """ + if (values.length !== 1) return 0; + const alias = values; + const unchangedLength = values.unshift(); + const newLength = values.unshift(7, true, null); + return unchangedLength === 1 && newLength === 4 && alias[0] === 7 && + alias[1] === true && alias[2] === null ? 1 : -1; + """.trimIndent(), + ), + ArrayShiftReplayCase( + name = "$type unshift preserves unread tail and pop kind", + parameters = "values: $type[]", + maxResult = 3, + body = """ + if (values.length !== 1) return 0; + const alias = values; + values.unshift(true); + const tail = alias.pop(); + if (alias[0] !== true || alias.length !== 1) return -1; + if (tail === 42) return 1; + if (tail === false) return 2; + return 3; + """.trimIndent(), + ), + ArrayShiftReplayCase( + name = "$type reverse permutes payloads and kinds", + parameters = "values: $type[]", + maxResult = 3, + body = """ + if (values.length !== 2) return 0; + const copy = values.slice(); + copy.reverse(); + if (copy[0] === 42 && copy[1] === true) return 1; + if (copy[0] === false && copy[1] === 17) return 2; + return 3; + """.trimIndent(), + ), + ArrayShiftReplayCase( + name = "$type fill overrides input kind selectors", + parameters = "values: $type[], index: number", + maxResult = 4, + body = """ + if (values.length !== 2) return 0; + const i = Math.floor(index); + if (!(i >= 0 && i <= 1)) return 0; + values.fill(true, 1, 2); + const value = values[i]; + if (i === 1 && value === true) return 1; + if (i === 0 && value === 42) return 2; + if (i === 0 && value === false) return 3; + return 4; + """.trimIndent(), + ), +) + +private fun concatCases(type: String): List = listOf( + ArrayShiftReplayCase( + name = "$type concat preserves deleted receiver slot and alias", + parameters = "values: $type[]", + maxResult = 1, + body = """ + if (values.length !== 1) return 0; + const source = [7, 11]; + const alias = source; + delete source[1]; + + const copy = source.concat([22], 33); + + return copy.length === 4 && copy[1] === undefined && copy[2] === 22 && + copy[3] === 33 && alias[1] === undefined && alias.length === 2 ? 1 : -1; + """.trimIndent(), + ), + ArrayShiftReplayCase( + name = "$type concat preserves deleted argument slot at offset", + parameters = "values: $type[]", + maxResult = 1, + body = """ + if (values.length !== 1) return 0; + const source = [7]; + const other = [11, 22]; + delete other[0]; + + const copy = source.concat(other); + + return copy.length === 3 && copy[0] === 7 && copy[1] === undefined && + copy[2] === 22 ? 1 : -1; + """.trimIndent(), + ), + ArrayShiftReplayCase( + name = "$type concat copies unread input arrays", + parameters = "left: $type[], right: $type[]", + maxResult = 3, + body = """ + if (left.length !== 1 || right.length !== 1) return 0; + const copy = left.concat(right); + const first = copy.shift(); + const second = copy.shift(); + if (first === 42 && second === true) return 1; + if (first === false && second === 17) return 2; + return 3; + """.trimIndent(), + ), + ArrayShiftReplayCase( + name = "$type concat wraps scalar primitives", + parameters = "values: $type[]", + maxResult = 3, + body = """ + if (values.length !== 1) return 0; + const copy = values.concat(true); + if (copy[1] !== true) return -1; + if (copy[0] === 42) return 1; + if (copy[0] === false) return 2; + return 3; + """.trimIndent(), + ), + ArrayShiftReplayCase( + name = "$type empty pop returns undefined and retains length", + parameters = "values: $type[]", + maxResult = 1, + body = """ + if (values.length !== 0) return 0; + const result = values.pop(); + return result === undefined && values.length === 0 ? 1 : -1; + """.trimIndent(), + ), +) + +private fun pairCases(): List = buildList { + val literals = listOf("42", "true", "false", "null", "undefined", "'left'") + for (type in listOf("any", "unknown")) { + for (first in literals) { + for (second in literals) { + // Unconstrained string equality is not modeled yet; write string slots before shifting. + val writes = listOf(first, second).mapIndexedNotNull { index, literal -> + if (literal == "'left'") "values[$index] = $literal;" else null + }.joinToString(separator = "\n") + val label = if (writes.isEmpty()) "input pair" else "pair with written string" + val maxResult = if (first == "'left'" && second == "'left'") 1 else 2 + add( + ArrayShiftReplayCase( + name = "$type $label $first then $second", + parameters = "values: $type[]", + maxResult = maxResult, + body = """ + if (values.length !== 2) return 0; + $writes + const first = values.shift(); + const second = values.shift(); + return first === $first && second === $second ? 1 : 2; + """.trimIndent(), + ) + ) + } + } + } +} + +private fun typedCases(): List = buildList { + for ((type, literal) in listOf("number" to "42", "boolean" to "true")) { + add( + ArrayShiftReplayCase( + name = "homogeneous $type", + parameters = "values: $type[]", + maxResult = 2, + body = """ + if (values.length !== 2) return 0; + const first = values.shift(); + return first === $literal && values[0] === $literal ? 1 : 2; + """.trimIndent(), + ) + ) + } + add( + ArrayShiftReplayCase( + name = "written homogeneous string", + parameters = "values: string[]", + maxResult = 1, + body = """ + if (values.length !== 2) return 0; + values[0] = 'left'; + values[1] = 'right'; + return values.shift() === 'left' && values[0] === 'right' ? 1 : 2; + """.trimIndent(), + ) + ) + add( + ArrayShiftReplayCase( + name = "written symbolic union", + parameters = "values: (number | boolean)[]", + maxResult = 1, + body = """ + if (values.length !== 2) return 0; + values[0] = 42; + values[1] = true; + return values.shift() === 42 && values[0] === true ? 1 : 2; + """.trimIndent(), + ) + ) + for (type in listOf("number", "boolean")) { + for (aliasType in listOf("any", "unknown")) { + add( + ArrayShiftReplayCase( + name = "symbolic $type array through $aliasType alias", + parameters = "values: $type[]", + maxResult = if (type == "number") 2 else 1, + body = """ + if (values.length !== 2) return 0; + const alias: $aliasType[] = values; + const first = values[0]; + const second = values[1]; + return alias.shift() === first && values[0] === second && alias.length === 1 ? 1 : 2; + """.trimIndent(), + ) + ) + } + } + add( + ArrayShiftReplayCase( + name = "symbolic object array preserves references", + parameters = "values: ReplayElement[]", + maxResult = 2, + body = """ + if (values.length !== 2) return 0; + const first = values[0]; + const second = values[1]; + if (first == null || second == null) return 2; + return values.shift() === first && values[0] === second ? 1 : 3; + """.trimIndent(), + ) + ) +} + +internal data class ArrayShiftReplayCase( + val name: String, + val parameters: String, + val maxResult: Int, + val body: String, + val compatibility: Boolean = false, +) diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt index 250b3e19e1..37e5682a2b 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt @@ -78,8 +78,12 @@ class TsArrayShiftReplayTest { } """.trimIndent() ) - val scene = EtsScene(projectFiles = listOf(loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND))) - val methods = scene.projectClasses.single { it.name == "ArrayCompatibilityCapacity" }.methods.associateBy { it.name } + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val scene = EtsScene(projectFiles = listOf(file)) + val methods = scene.projectClasses + .single { it.name == "ArrayCompatibilityCapacity" } + .methods + .associateBy { it.name } for (operation in listOf("push", "unshift", "concat")) { val events = mutableListOf() @@ -122,11 +126,15 @@ class TsArrayShiftReplayTest { @TestFactory fun `symbolic shift inputs results and heap changes replay in JavaScript`(): List { - val cases = replayCases() + val cases = arrayShiftReplayCases() val source = directory.resolve("ArrayShiftReplay.ts") source.writeText(renderSource(cases)) - val scene = EtsScene(listOf(loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND))) - val methods = scene.projectClasses.single { it.name == "ArrayShiftReplay" }.methods.associateBy { it.name } + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val scene = EtsScene(projectFiles = listOf(file)) + val methods = scene.projectClasses + .single { it.name == "ArrayShiftReplay" } + .methods + .associateBy { it.name } return cases.mapIndexed { index, case -> DynamicTest.dynamicTest(case.name) { @@ -173,505 +181,7 @@ class TsArrayShiftReplayTest { } } - private fun replayCases(): List = buildList { - for (type in listOf("any", "unknown")) { - add( - ReplayCase( - name = "first $type read", - parameters = "values: $type[]", - maxResult = 6, - body = """ - if (values.length !== 1) return 0; - const removed = values.shift(); - if (removed === 42) return 1; - if (removed === true) return 2; - if (removed === false) return 3; - if (removed === null) return 4; - if (removed === undefined) return 5; - return 6; - """.trimIndent(), - ) - ) - add( - ReplayCase( - name = "repeated mixed $type shifts", - parameters = "values: $type[]", - maxResult = 3, - body = """ - if (values.length !== 2) return 0; - const first = values.shift(); - const second = values.shift(); - if (first === 42 && second === true) return 1; - if (first === false && second === 17) return 2; - return 3; - """.trimIndent(), - ) - ) - add( - ReplayCase( - name = "read shifted $type tail", - parameters = "values: $type[]", - maxResult = 3, - body = """ - if (values.length !== 2) return 0; - values.shift(); - if (values[0] === 42) return 1; - if (values[0] === true) return 2; - return 3; - """.trimIndent(), - ) - ) - add( - ReplayCase( - name = "overwrite $type then shift", - parameters = "values: $type[], index: number", - maxResult = 3, - body = """ - if (values.length !== 2 || (index !== 0 && index !== 1)) return 0; - values[index] = true; - const first = values.shift(); - if (first === 42 && values[0] === true) return 1; - if (first === true && values[0] === 17) return 2; - return 3; - """.trimIndent(), - ) - ) - } - add( - ReplayCase( - name = "typed pop payload remains usable as number and array index", - parameters = "values: number[]", - maxResult = 2, - body = """ - if (values.length !== 1) return 0; - const n = values.pop(); - if (n !== 1) return 1; - const target = [10, 20]; - return Math.floor(n) === 1 && target[n] === 20 ? 2 : -1; - """.trimIndent(), - ) - ) - addAll(assertionAndWriteCases()) - addAll(storageOperationCases()) - addAll(pairCases()) - addAll(typedCases()) - } - - private fun assertionAndWriteCases(): List = buildList { - add( - ReplayCase( - name = "number type assertions preserve runtime values", - parameters = "value: number", - maxResult = 1, - body = """ - if (value !== 42) return 0; - const asserted = value as unknown as string; - return typeof asserted === 'number' && asserted === 42 ? 1 : -1; - """.trimIndent(), - ) - ) - add( - ReplayCase( - name = "boolean type assertions do not convert to numbers", - parameters = "value: boolean", - maxResult = 1, - body = """ - if (!value) return 0; - const asserted = value as unknown as number; - return typeof asserted === 'boolean' && asserted === true ? 1 : -1; - """.trimIndent(), - ) - ) - add( - ReplayCase( - name = "truthy wrapped number retains its runtime kind through assertion and write", - parameters = "values: number[]", - maxResult = 2, - body = """ - if (values.length !== 1) return 0; - const item = values[0]; - if (!item) return 1; - const asserted = item as unknown as number; - const output = [0]; - output[0] = asserted; - return output[0] === item ? 2 : -1; - """.trimIndent(), - ) - ) - add( - ReplayCase( - name = "typed array copies preserve wrapped numbers including NaN", - parameters = "values: number[]", - maxResult = 1, - body = """ - if (values.length !== 2) return 0; - values[0] = values[1]; - return values[0] === values[1] || values[0] !== values[0] ? 1 : -1; - """.trimIndent(), - ) - ) - } - - private fun storageOperationCases(): List = listOf("any", "unknown").flatMap { type -> - copyCases(type) + mutationCases(type) + concatCases(type) - }.map { case -> case.copy(name = "compatibility storage: ${case.name}", compatibility = true) } - - private fun copyCases(type: String): List = listOf( - ReplayCase( - name = "$type slice ignores an evaluated extra argument", - parameters = "values: $type[]", - maxResult = 1, - body = """ - if (values.length !== 1) return 0; - const alias = values; - - const copy = values.slice(0, 1, values.push(true)); - - return copy.length === 1 && alias.length === 2 && alias[1] === true ? 1 : -1; - """.trimIndent(), - ), - ReplayCase( - name = "$type slice preserves a deleted slot at a translated index", - parameters = "values: $type[]", - maxResult = 1, - body = """ - if (values.length !== 1) return 0; - const source = [7, 11, 22]; - delete source[1]; - - const copy = source.slice(1, 3); - - return copy.length === 2 && copy[0] === undefined && copy[1] === 22 && - source[0] === 7 ? 1 : -1; - """.trimIndent(), - ), - ReplayCase( - name = "$type slice() retains all runtime kinds", - parameters = "values: $type[]", - maxResult = 6, - body = """ - if (values.length !== 1) return 0; - const copy = values.slice(); - const value = copy[0]; - if (value === 42) return 1; - if (value === true) return 2; - if (value === false) return 3; - if (value === null) return 4; - if (value === undefined) return 5; - return 6; - """.trimIndent(), - ), - ReplayCase( - name = "$type slice().reverse() retains all runtime kinds", - parameters = "values: $type[]", - maxResult = 6, - body = """ - if (values.length !== 1) return 0; - const copy = values.slice().reverse(); - const value = copy[0]; - if (value === 42) return 1; - if (value === true) return 2; - if (value === false) return 3; - if (value === null) return 4; - if (value === undefined) return 5; - return 6; - """.trimIndent(), - ), - ReplayCase( - name = "$type sliced input mixed with appended wrapper", - parameters = "values: $type[], index: number", - maxResult = 4, - body = """ - if (values.length !== 1) return 0; - const i = Math.floor(index); - if (!(i >= 0 && i <= 1)) return 0; - const copy = values.slice(); - copy.push(true); - const value = copy[i]; - if (i === 1 && value === true) return 1; - if (i === 0 && value === 42) return 2; - if (i === 0 && value === false) return 3; - return 4; - """.trimIndent(), - ), - ReplayCase( - name = "$type copied payload moves through two shifts", - parameters = "values: $type[]", - maxResult = 3, - body = """ - if (values.length !== 2) return 0; - const copy = values.slice(); - const first = copy.shift(); - const second = copy.shift(); - if (first === 42 && second === true) return 1; - if (first === false && second === 17) return 2; - return 3; - """.trimIndent(), - ), - ) - - private fun mutationCases(type: String): List = listOf( - ReplayCase( - name = "$type compatibility pop ignores evaluated extra arguments", - parameters = "values: $type[]", - maxResult = 1, - body = """ - if (values.length !== 1) return 0; - const alias = values; - const removed = values.pop(values.push(true)); - return removed === true && alias.length === 1 ? 1 : -1; - """.trimIndent(), - ), - ReplayCase( - name = "$type compatibility push accepts zero and three values", - parameters = "values: $type[]", - maxResult = 1, - body = """ - if (values.length !== 1) return 0; - const alias = values; - const unchangedLength = values.push(); - const newLength = values.push(7, true, null); - return unchangedLength === 1 && newLength === 4 && alias[1] === 7 && - alias[2] === true && alias[3] === null ? 1 : -1; - """.trimIndent(), - ), - ReplayCase( - name = "$type compatibility unshift accepts zero and three values", - parameters = "values: $type[]", - maxResult = 1, - body = """ - if (values.length !== 1) return 0; - const alias = values; - const unchangedLength = values.unshift(); - const newLength = values.unshift(7, true, null); - return unchangedLength === 1 && newLength === 4 && alias[0] === 7 && - alias[1] === true && alias[2] === null ? 1 : -1; - """.trimIndent(), - ), - ReplayCase( - name = "$type unshift preserves unread tail and pop kind", - parameters = "values: $type[]", - maxResult = 3, - body = """ - if (values.length !== 1) return 0; - const alias = values; - values.unshift(true); - const tail = alias.pop(); - if (alias[0] !== true || alias.length !== 1) return -1; - if (tail === 42) return 1; - if (tail === false) return 2; - return 3; - """.trimIndent(), - ), - ReplayCase( - name = "$type reverse permutes payloads and kinds", - parameters = "values: $type[]", - maxResult = 3, - body = """ - if (values.length !== 2) return 0; - const copy = values.slice(); - copy.reverse(); - if (copy[0] === 42 && copy[1] === true) return 1; - if (copy[0] === false && copy[1] === 17) return 2; - return 3; - """.trimIndent(), - ), - ReplayCase( - name = "$type fill overrides input kind selectors", - parameters = "values: $type[], index: number", - maxResult = 4, - body = """ - if (values.length !== 2) return 0; - const i = Math.floor(index); - if (!(i >= 0 && i <= 1)) return 0; - values.fill(true, 1, 2); - const value = values[i]; - if (i === 1 && value === true) return 1; - if (i === 0 && value === 42) return 2; - if (i === 0 && value === false) return 3; - return 4; - """.trimIndent(), - ), - ) - - private fun concatCases(type: String): List = listOf( - ReplayCase( - name = "$type concat preserves deleted receiver slot and alias", - parameters = "values: $type[]", - maxResult = 1, - body = """ - if (values.length !== 1) return 0; - const source = [7, 11]; - const alias = source; - delete source[1]; - - const copy = source.concat([22], 33); - - return copy.length === 4 && copy[1] === undefined && copy[2] === 22 && - copy[3] === 33 && alias[1] === undefined && alias.length === 2 ? 1 : -1; - """.trimIndent(), - ), - ReplayCase( - name = "$type concat preserves deleted argument slot at offset", - parameters = "values: $type[]", - maxResult = 1, - body = """ - if (values.length !== 1) return 0; - const source = [7]; - const other = [11, 22]; - delete other[0]; - - const copy = source.concat(other); - - return copy.length === 3 && copy[0] === 7 && copy[1] === undefined && - copy[2] === 22 ? 1 : -1; - """.trimIndent(), - ), - ReplayCase( - name = "$type concat copies unread input arrays", - parameters = "left: $type[], right: $type[]", - maxResult = 3, - body = """ - if (left.length !== 1 || right.length !== 1) return 0; - const copy = left.concat(right); - const first = copy.shift(); - const second = copy.shift(); - if (first === 42 && second === true) return 1; - if (first === false && second === 17) return 2; - return 3; - """.trimIndent(), - ), - ReplayCase( - name = "$type concat wraps scalar primitives", - parameters = "values: $type[]", - maxResult = 3, - body = """ - if (values.length !== 1) return 0; - const copy = values.concat(true); - if (copy[1] !== true) return -1; - if (copy[0] === 42) return 1; - if (copy[0] === false) return 2; - return 3; - """.trimIndent(), - ), - ReplayCase( - name = "$type empty pop returns undefined and retains length", - parameters = "values: $type[]", - maxResult = 1, - body = """ - if (values.length !== 0) return 0; - const result = values.pop(); - return result === undefined && values.length === 0 ? 1 : -1; - """.trimIndent(), - ), - ) - - private fun pairCases(): List = buildList { - val literals = listOf("42", "true", "false", "null", "undefined", "'left'") - for (type in listOf("any", "unknown")) { - for (first in literals) { - for (second in literals) { - // Unconstrained string equality is not modeled yet; write string slots before shifting. - val writes = listOf(first, second).mapIndexedNotNull { index, literal -> - if (literal == "'left'") "values[$index] = $literal;" else null - }.joinToString(separator = "\n") - val label = if (writes.isEmpty()) "input pair" else "pair with written string" - val maxResult = if (first == "'left'" && second == "'left'") 1 else 2 - add( - ReplayCase( - name = "$type $label $first then $second", - parameters = "values: $type[]", - maxResult = maxResult, - body = """ - if (values.length !== 2) return 0; - $writes - const first = values.shift(); - const second = values.shift(); - return first === $first && second === $second ? 1 : 2; - """.trimIndent(), - ) - ) - } - } - } - } - - private fun typedCases(): List = buildList { - for ((type, literal) in listOf("number" to "42", "boolean" to "true")) { - add( - ReplayCase( - name = "homogeneous $type", - parameters = "values: $type[]", - maxResult = 2, - body = """ - if (values.length !== 2) return 0; - const first = values.shift(); - return first === $literal && values[0] === $literal ? 1 : 2; - """.trimIndent(), - ) - ) - } - add( - ReplayCase( - name = "written homogeneous string", - parameters = "values: string[]", - maxResult = 1, - body = """ - if (values.length !== 2) return 0; - values[0] = 'left'; - values[1] = 'right'; - return values.shift() === 'left' && values[0] === 'right' ? 1 : 2; - """.trimIndent(), - ) - ) - add( - ReplayCase( - name = "written symbolic union", - parameters = "values: (number | boolean)[]", - maxResult = 1, - body = """ - if (values.length !== 2) return 0; - values[0] = 42; - values[1] = true; - return values.shift() === 42 && values[0] === true ? 1 : 2; - """.trimIndent(), - ) - ) - for (type in listOf("number", "boolean")) { - for (aliasType in listOf("any", "unknown")) { - add( - ReplayCase( - name = "symbolic $type array through $aliasType alias", - parameters = "values: $type[]", - maxResult = if (type == "number") 2 else 1, - body = """ - if (values.length !== 2) return 0; - const alias: $aliasType[] = values; - const first = values[0]; - const second = values[1]; - return alias.shift() === first && values[0] === second && alias.length === 1 ? 1 : 2; - """.trimIndent(), - ) - ) - } - } - add( - ReplayCase( - name = "symbolic object array preserves references", - parameters = "values: ReplayElement[]", - maxResult = 2, - body = """ - if (values.length !== 2) return 0; - const first = values[0]; - const second = values[1]; - if (first == null || second == null) return 2; - return values.shift() === first && values[0] === second ? 1 : 3; - """.trimIndent(), - ) - ) - } - - private fun renderSource(cases: List): String = buildString { + private fun renderSource(cases: List): String = buildString { appendLine("class ReplayElement {}") appendLine("class ArrayShiftReplay {") cases.forEachIndexed { index, case -> @@ -693,14 +203,6 @@ class TsArrayShiftReplayTest { else -> error("Unsupported replay value: $value") } - private data class ReplayCase( - val name: String, - val parameters: String, - val maxResult: Int, - val body: String, - val compatibility: Boolean = false, - ) - private companion object { const val JS_SAME_VALUE = """ function same(a, b) { diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/expr/ExprUtilConditionalValueTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/ExprUtilConditionalValueTest.kt index ac8e9f97d3..0df9909110 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/expr/ExprUtilConditionalValueTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/ExprUtilConditionalValueTest.kt @@ -6,8 +6,8 @@ import org.jacodb.ets.model.EtsScene import org.jacodb.ets.model.EtsType import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test -import org.usvm.UIteExpr import org.usvm.UExpr +import org.usvm.UIteExpr import org.usvm.UMachineOptions import org.usvm.USort import org.usvm.collections.immutable.internal.MutabilityOwnership diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/expr/RuntimeLimitationBoundariesTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/RuntimeLimitationBoundariesTest.kt index 57fe9ed894..5eb1b0366e 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/expr/RuntimeLimitationBoundariesTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/RuntimeLimitationBoundariesTest.kt @@ -158,7 +158,10 @@ class RuntimeLimitationBoundariesTest : TsMethodTestRunner() { @Test fun `unmodeled binary numeric conversion stops while a stored numeric sibling returns`() { - assertUnsupportedNumericSibling(methodName = "unsupportedNumericBinaryConversionOrSupportedSibling", expected = 6.0) + assertUnsupportedNumericSibling( + methodName = "unsupportedNumericBinaryConversionOrSupportedSibling", + expected = 6.0, + ) } @Test diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/types/TsTypeSystemTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/types/TsTypeSystemTest.kt index aab0ab76fd..8a5b922772 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/types/TsTypeSystemTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/types/TsTypeSystemTest.kt @@ -132,7 +132,9 @@ class TsTypeSystemTest { val clazz = EtsClassImpl( signature = classSignature, fields = listOf( - EtsFieldImpl(EtsFieldSignature(enclosingClass = classSignature, name = fieldName, type = EtsNumberType)), + EtsFieldImpl( + EtsFieldSignature(enclosingClass = classSignature, name = fieldName, type = EtsNumberType), + ), ), methods = emptyList(), ) diff --git a/usvm-ts/src/test/kotlin/org/usvm/project/ImportResolver.kt b/usvm-ts/src/test/kotlin/org/usvm/project/ImportResolver.kt index e48f0df378..9a6e546d6b 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/project/ImportResolver.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/project/ImportResolver.kt @@ -38,19 +38,19 @@ class ImportResolverTest { @BeforeAll fun setupScene() { - println("\n--- Setting up scene for import resolution tests ---") + logger.info { "\n--- Setting up scene for import resolution tests ---" } scene = run { val path = TEST_PROJECT_PATH - println("Loading project from resources: $path") + logger.info { "Loading project from resources: $path" } val projectPath = getResourcePathOrNull(path) if (projectPath == null) { logger.warn { "Project '$path' not found in resources. Ensure the project is available." } abort() } - println("Loading project from path: $projectPath") + logger.info { "Loading project from path: $projectPath" } val projectScene = loadEtsProjectAutoConvert(projectPath) - println("Project loaded: ${projectScene.projectName} with ${projectScene.projectFiles.size} files") + logger.info { "Project loaded: ${projectScene.projectName} with ${projectScene.projectFiles.size} files" } assertTrue( projectScene.projectFiles.isNotEmpty(), "No project files found in the project at '$path'. Ensure the project is correctly set up." @@ -63,16 +63,16 @@ class ImportResolverTest { "/sdk/ohos/5.0.1.111/ets/kits", "/sdk/typescript", ).map { - println("Loading SDK from resource: $it") + logger.info { "Loading SDK from resource: $it" } val sdkPath = getResourcePathOrNull(it) assertNotNull( sdkPath, "SDK path '$it' not found in resources. Ensure the SDK is available." ) - println("Loading SDK from path: $sdkPath") + logger.info { "Loading SDK from path: $sdkPath" } val sdkScene = loadEtsProjectAutoConvert(sdkPath, useArkAnalyzerTypeInference = null) - println("SDK loaded: ${sdkScene.projectName} with ${sdkScene.projectFiles.size} files") + logger.info { "SDK loaded: ${sdkScene.projectName} with ${sdkScene.projectFiles.size} files" } assertTrue( sdkScene.projectFiles.isNotEmpty(), "No SDK files found in the SDK at '$it'. Ensure the SDK is correctly set up." @@ -81,7 +81,7 @@ class ImportResolverTest { sdkScene } - println("Merging project and SDK files...") + logger.info { "Merging project and SDK files..." } EtsScene( projectFiles = projectScene.projectFiles, sdkFiles = sdks.flatMap { it.projectFiles }, @@ -89,13 +89,15 @@ class ImportResolverTest { ) } - println("Scene loaded with ${scene.projectFiles.size} project files and ${scene.sdkFiles.size} SDK files") + logger.info { + "Scene loaded with ${scene.projectFiles.size} project files and ${scene.sdkFiles.size} SDK files" + } } @Test @DisplayName("Report file-level import resolution across project files") fun reportFileImportResolution() { - println("\n--- Reporting file-level import resolution ---") + logger.info { "\n--- Reporting file-level import resolution ---" } val allFiles = scene.projectFiles var totalImports = 0 @@ -107,9 +109,9 @@ class ImportResolverTest { val imports = currentFile.importInfos if (imports.isEmpty()) { - println("\n[${index + 1}/${allFiles.size}] File: $fileName (no imports)") + logger.info { "\n[${index + 1}/${allFiles.size}] File: $fileName (no imports)" } } else { - println("\n[${index + 1}/${allFiles.size}] File: $fileName (${imports.size} imports)") + logger.info { "\n[${index + 1}/${allFiles.size}] File: $fileName (${imports.size} imports)" } imports.forEach { importInfo -> totalImports++ @@ -117,31 +119,31 @@ class ImportResolverTest { when (val result = scene.resolveImport(currentFile, importInfo.from)) { is ImportResolutionResult.Success -> { successfulImports++ - println( + logger.info { " ✅ '${importInfo.name}' from '${importInfo.from}' -> '${result.file.signature.fileName}'" + " (type: ${importInfo.type})" - ) + } } is ImportResolutionResult.NotFound -> { failedImports++ - println( + logger.info { " ❌ '${importInfo.name}' from '${importInfo.from}' -> ${result.reason}" + " (type: ${importInfo.type})" - ) + } } } } } } - println("\n--- File Import Resolution Summary ---") - println("Total imports found: $totalImports") - println("Successfully resolved files: $successfulImports") - println("Failed to resolve files: $failedImports") + logger.info { "\n--- File Import Resolution Summary ---" } + logger.info { "Total imports found: $totalImports" } + logger.info { "Successfully resolved files: $successfulImports" } + logger.info { "Failed to resolve files: $failedImports" } if (totalImports > 0) { val successRate = (successfulImports.toDouble() / totalImports * 100).toInt() - println("File resolution success rate: $successRate%") + logger.info { "File resolution success rate: $successRate%" } } assertTrue(totalImports > 0, "Should find at least some imports in the project files") @@ -150,7 +152,7 @@ class ImportResolverTest { @Test @DisplayName("Report symbol resolution across project imports") fun reportSymbolResolution() { - println("\n--- Reporting symbol resolution ---") + logger.info { "\n--- Reporting symbol resolution ---" } val allFiles = scene.projectFiles var totalSymbols = 0 @@ -163,9 +165,9 @@ class ImportResolverTest { val imports = currentFile.importInfos if (imports.isEmpty()) { - println("\n[${index + 1}/${allFiles.size}] File: '$fileName' (no imports)") + logger.info { "\n[${index + 1}/${allFiles.size}] File: '$fileName' (no imports)" } } else { - println("\n[${index + 1}/${allFiles.size}] File: '$fileName' (${imports.size} imports)") + logger.info { "\n[${index + 1}/${allFiles.size}] File: '$fileName' (${imports.size} imports)" } imports.forEach { importInfo -> totalSymbols++ @@ -176,36 +178,36 @@ class ImportResolverTest { is SymbolResolutionResult.Success -> { successfulSymbols++ val exportInfo = result.exportInfo - println( + logger.info { " 🎯 '${importInfo.name}' from '${importInfo.from}' -> '${result.file.signature.fileName}'" + " exports: ${getExportDescription(exportInfo)} (import type: ${importInfo.type})" - ) + } } is SymbolResolutionResult.FileNotFound -> { fileNotFoundSymbols++ - println( + logger.info { " 📁❌ '${importInfo.name}' from '${importInfo.from}' -> ${result.reason}" + " (import type: ${importInfo.type})" - ) + } } is SymbolResolutionResult.SymbolNotFound -> { symbolNotFoundSymbols++ - println( + logger.info { " 🔍❌ '${importInfo.name}' from '${importInfo.from}' -> ${result.reason}" + " (import type: ${importInfo.type})" - ) + } val availableExports = result.file.exportInfos if (availableExports.isNotEmpty()) { val exportNames = availableExports.map { it.name }.take(5) - println( + logger.info { " Available exports: ${ exportNames.joinToString(", ") }${ if (availableExports.size > 5) " ..." else "" }" - ) + } } } } @@ -213,14 +215,14 @@ class ImportResolverTest { } } - println("\n--- Complete Symbol Resolution Summary ---") - println("Total symbols to resolve: $totalSymbols") - println("Successfully resolved symbols: $successfulSymbols") - println("File not found: $fileNotFoundSymbols") - println("Symbol not found in file: $symbolNotFoundSymbols") + logger.info { "\n--- Complete Symbol Resolution Summary ---" } + logger.info { "Total symbols to resolve: $totalSymbols" } + logger.info { "Successfully resolved symbols: $successfulSymbols" } + logger.info { "File not found: $fileNotFoundSymbols" } + logger.info { "Symbol not found in file: $symbolNotFoundSymbols" } if (totalSymbols > 0) { val symbolSuccessRate = (successfulSymbols.toDouble() / totalSymbols * 100).toInt() - println("Complete symbol resolution success rate: $symbolSuccessRate%") + logger.info { "Complete symbol resolution success rate: $symbolSuccessRate%" } } assertTrue(totalSymbols > 0, "Should find at least some symbols to resolve in the project files") @@ -229,7 +231,7 @@ class ImportResolverTest { @Test @DisplayName("Report common import probes and check path normalization") fun reportCommonImportPatterns() { - println("\n--- Reporting common import probes ---") + logger.info { "\n--- Reporting common import probes ---" } Assumptions.assumeTrue( scene.projectFiles.isNotEmpty(), @@ -237,7 +239,7 @@ class ImportResolverTest { ) val testFile = scene.projectFiles.first() - println("Using test file: ${testFile.signature.fileName}") + logger.info { "Using test file: ${testFile.signature.fileName}" } // Probe paths may be absent from the optional demo project. val testCases = mapOf( @@ -269,24 +271,24 @@ class ImportResolverTest { var successfulImports = 0 testCases.forEach { (category, imports) -> - println("\n$category:") + logger.info { "\n$category:" } imports.forEach { importPath -> totalTestedImports++ when (val result = scene.resolveImport(testFile, importPath)) { is ImportResolutionResult.Success -> { successfulImports++ - println(" ✓ '$importPath' resolved to '${result.file.signature.fileName}'") + logger.info { " ✓ '$importPath' resolved to '${result.file.signature.fileName}'" } } is ImportResolutionResult.NotFound -> { - println(" ✗ '$importPath' failed: ${result.reason}") + logger.info { " ✗ '$importPath' failed: ${result.reason}" } } } } } // Test path normalization - println("\nPath Normalization Tests:") + logger.info { "\nPath Normalization Tests:" } val normalizationTests = listOf( "./a/../b" to "b", "a/./b" to "a/b", @@ -300,25 +302,25 @@ class ImportResolverTest { val result = normalizeRelativePath(input) val status = if (result == expected) "✓" else "✗" if (result == expected) correctNormalizations++ - println(" $status '$input' -> '$result' (expected: '$expected')") + logger.info { " $status '$input' -> '$result' (expected: '$expected')" } } // Summary statistics - println("\n--- Summary ---") - println("Project files: ${scene.projectFiles.size}") - println("SDK files: ${scene.sdkFiles.size}") - println("Total files: ${scene.projectAndSdkClasses.size}") - println("Total imports in project files: ${scene.projectFiles.sumOf { it.importInfos.size }}") + logger.info { "\n--- Summary ---" } + logger.info { "Project files: ${scene.projectFiles.size}" } + logger.info { "SDK files: ${scene.sdkFiles.size}" } + logger.info { "Total files: ${scene.projectAndSdkClasses.size}" } + logger.info { "Total imports in project files: ${scene.projectFiles.sumOf { it.importInfos.size }}" } val projectFilesByExtension = scene.projectFiles.groupBy { it.signature.fileName.substringAfterLast(".", "no-ext") } - println("Project files by extension: ${projectFilesByExtension.mapValues { it.value.size }}") + logger.info { "Project files by extension: ${projectFilesByExtension.mapValues { it.value.size }}" } val sdkFilesByExtension = scene.sdkFiles.groupBy { it.signature.fileName.substringAfterLast(".", "no-ext") } - println("SDK files by extension: ${sdkFilesByExtension.mapValues { it.value.size }}") + logger.info { "SDK files by extension: ${sdkFilesByExtension.mapValues { it.value.size }}" } // Assertions assertTrue(scene.projectFiles.isNotEmpty(), "Should have at least one project file") From ad5669f14579a85de1b7f714a045d75de9bc904c Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 13:00:46 +0300 Subject: [PATCH 96/97] Align replay case file name with its declaration --- .../call/{TsArrayShiftReplayCases.kt => ArrayShiftReplayCase.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename usvm-ts/src/test/kotlin/org/usvm/machine/call/{TsArrayShiftReplayCases.kt => ArrayShiftReplayCase.kt} (100%) diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayCases.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/ArrayShiftReplayCase.kt similarity index 100% rename from usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayCases.kt rename to usvm-ts/src/test/kotlin/org/usvm/machine/call/ArrayShiftReplayCase.kt From b92f23a6aa24d54c0cb7ea0493e9e9a4321545fb Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 15 Oct 2026 15:45:26 +0300 Subject: [PATCH 97/97] Resolve Calls Detekt warnings and split experiment tests --- .../ts/calls/CallsInputTypeCompatibility.kt | 31 +- .../ts/calls/CurrentTsCallsSymbolicEngine.kt | 41 +- .../calls/CallsExperimentSerializationTest.kt | 81 +++ .../ts/calls/CallsExperimentSummaryTest.kt | 243 +++++++ .../org/usvm/ts/calls/CallsExperimentTest.kt | 655 +----------------- .../ts/calls/CallsExperimentTestSupport.kt | 101 +++ .../usvm/ts/calls/CallsWitnessReplayTest.kt | 280 ++++++++ 7 files changed, 763 insertions(+), 669 deletions(-) create mode 100644 usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentSerializationTest.kt create mode 100644 usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentSummaryTest.kt create mode 100644 usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTestSupport.kt create mode 100644 usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsWitnessReplayTest.kt diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputTypeCompatibility.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputTypeCompatibility.kt index 90dc66964e..fddfacb89d 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputTypeCompatibility.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputTypeCompatibility.kt @@ -93,12 +93,7 @@ private fun PropertyDomain.inputTypeLimitation( this is StringDomain || constant is JsConcreteValue.String } is EtsNumberLiteralType -> { - when { - this is NumberDomain -> !allowNaN && min.toDouble() == type.value && max.toDouble() == type.value - this is IntegerDomain -> min.toDouble() == type.value && max.toDouble() == type.value - constant is JsConcreteValue.Number -> constant.toDouble() == type.value - else -> false - } + matchesNumberLiteral(type, constant) } is EtsStringLiteralType -> { val matchesEmptyStringDomain = this is StringDomain && @@ -177,6 +172,16 @@ private fun PropertyDomain.inputTypeLimitation( return if (compatible) null else "$path: frozen domain $this is not supported for declared type $type" } +private fun PropertyDomain.matchesNumberLiteral( + type: EtsNumberLiteralType, + constant: JsConcreteValue?, +): Boolean = when { + this is NumberDomain -> !allowNaN && min.toDouble() == type.value && max.toDouble() == type.value + this is IntegerDomain -> min.toDouble() == type.value && max.toDouble() == type.value + constant is JsConcreteValue.Number -> constant.toDouble() == type.value + else -> false +} + private fun PropertyDomain.intersectionTypeLimitation( type: EtsIntersectionType, genericParameters: Map, @@ -239,10 +244,14 @@ private fun EtsType.intersectionMembers( genericParameters: Map, visitedGenerics: Set, ): List = when (this) { - is EtsIntersectionType -> types.flatMap { member -> - member.intersectionMembers(genericParameters, visitedGenerics) + is EtsIntersectionType -> { + types.flatMap { member -> + member.intersectionMembers(genericParameters, visitedGenerics) + } + } + is EtsAliasType -> { + originalType.intersectionMembers(genericParameters, visitedGenerics) } - is EtsAliasType -> originalType.intersectionMembers(genericParameters, visitedGenerics) is EtsGenericType -> { if (typeName in visitedGenerics) { listOf(this) @@ -251,7 +260,9 @@ private fun EtsType.intersectionMembers( upperBound.intersectionMembers(genericParameters, visitedGenerics + typeName) } } - else -> listOf(this) + else -> { + listOf(this) + } } private fun PropertyDomain.objectFieldTypeLimitation( diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt index d718cf46fa..f60a3f5454 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt @@ -227,9 +227,17 @@ internal class CurrentTsCallsSymbolicEngine( val status = when (analysis.stopReason) { TsAnalysisStopReason.EXHAUSTED -> { when { - interpreterObserver.runtimeLimitations.isNotEmpty() -> CallsSymbolicStatus.RUNTIME_LIMITATION - analysis.unsupportedPaths.isNotEmpty() || analysis.unsupportedCall -> CallsSymbolicStatus.UNSUPPORTED - else -> CallsSymbolicStatus.UNREACHED + interpreterObserver.runtimeLimitations.isNotEmpty() -> { + CallsSymbolicStatus.RUNTIME_LIMITATION + } + + analysis.unsupportedPaths.isNotEmpty() || analysis.unsupportedCall -> { + CallsSymbolicStatus.UNSUPPORTED + } + + else -> { + CallsSymbolicStatus.UNREACHED + } } } // The machine options above disable every stop condition except the per-target timeout. @@ -880,14 +888,10 @@ private fun callsExternalFrontendRuntimeDigest(frontendDirectory: Path): String require(Files.isDirectory(dist)) { "External native frontend dist directory is missing: $dist" } val files = Files.walk(dist).use { paths -> - paths.iterator().asSequence().filter { path -> - require(!Files.isSymbolicLink(path)) { "External native frontend dist contains a symlink: $path" } - val isDirectory = Files.isDirectory(path) - if (!isDirectory) { - require(Files.isRegularFile(path)) { "External native frontend dist contains a non-file: $path" } - } - !isDirectory - }.sortedBy { path -> dist.relativize(path).toString().replace('\\', '/') }.toList() + paths.iterator().asSequence() + .filter(::isCallsExternalFrontendRuntimeFile) + .sortedBy { path -> dist.relativize(path).toString().replace('\\', '/') } + .toList() } require(dist.resolve("index.js") in files) { "External native frontend dist is missing index.js: $dist" } @@ -913,9 +917,22 @@ private fun callsExternalFrontendRuntimeDigest(frontendDirectory: Path): String require(bytesRead == fileSize) { "External native frontend dist changed while hashing: $file" } } - return digest.digest().joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) } + return digest.digest().joinToString(separator = "") { byte -> + "%02x".format(byte.toInt() and UNSIGNED_BYTE_MASK) + } +} + +private fun isCallsExternalFrontendRuntimeFile(path: Path): Boolean { + require(!Files.isSymbolicLink(path)) { "External native frontend dist contains a symlink: $path" } + val isDirectory = Files.isDirectory(path) + if (!isDirectory) { + require(Files.isRegularFile(path)) { "External native frontend dist contains a non-file: $path" } + } + + return !isDirectory } +private const val UNSIGNED_BYTE_MASK = 0xff private val EXTERNAL_FRONTEND_IDENTITY = Regex("([0-9a-f]{40})@sha256:([0-9a-f]{64})") internal fun runCallsGit(checkout: Path, vararg arguments: String): String { diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentSerializationTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentSerializationTest.kt new file mode 100644 index 0000000000..42054aff27 --- /dev/null +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentSerializationTest.kt @@ -0,0 +1,81 @@ +package org.usvm.ts.calls + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import org.junit.jupiter.api.Test +import org.usvm.ts.calls.CallsExperimentTestSupport.manifest +import org.usvm.ts.calls.CallsExperimentTestSupport.targetResult +import org.usvm.ts.pbt.model.JsConcreteValue +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CallsExperimentSerializationTest { + @Test + fun `legacy source target defaults to entry mode`() { + val encoded = """ + { + "targetId": "target", + "siteId": "site", + "sourcePath": "fixture.ts", + "startOffset": 0, + "endOffset": 6, + "start": { "line": 0, "column": 0 }, + "end": { "line": 0, "column": 6 } + } + """.trimIndent() + + val target = CallsExperimentJson.json.decodeFromString(encoded) + + assertEquals(CallsSourceTargetMode.ENTRY, target.mode) + assertNull(target.returnExpressionStartOffset) + assertNull(target.returnExpressionEndOffset) + } + + @Test + fun `completed return target preserves explicit expression offsets`() { + val entryTarget = manifest(sourceRoot = ".", seeds = listOf(17L)) + .projects.single().functions.single().targets.single() + val completedReturn = entryTarget.copy( + mode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpressionStartOffset = 7, + returnExpressionEndOffset = 10, + ) + + val encoded = CallsExperimentJson.json.encodeToString(completedReturn) + val decoded = CallsExperimentJson.json.decodeFromString(encoded) + + assertEquals(completedReturn, decoded) + } + + @Test + fun `manifest rejects breadth first search`() { + val accepted = manifest(sourceRoot = ".", seeds = listOf(17L)) + + assertEquals(CALLS_PATH_SELECTION_STRATEGY.name, accepted.searchPolicy) + val error = assertFailsWith { + accepted.copy(searchPolicy = "BFS") + } + + assertTrue(error.message.orEmpty().contains(CALLS_PATH_SELECTION_STRATEGY.name)) + } + + @Test + fun `target result witness uses lossless concrete value serialization`() { + val witness = listOf( + JsConcreteValue.number(-0.0), + JsConcreteValue.number(Double.NaN), + JsConcreteValue.Array( + elements = listOf(JsConcreteValue.Undefined, JsConcreteValue.Null, JsConcreteValue.String("value")), + ), + ) + val result = targetResult(inputs = witness) + + val encoded = CallsExperimentJson.json.encodeToString(result) + val decoded = CallsExperimentJson.json.decodeFromString(encoded) as CallsTargetResult + + assertEquals(result, decoded) + assertEquals(witness, decoded.inputs) + } +} diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentSummaryTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentSummaryTest.kt new file mode 100644 index 0000000000..4cc8b82754 --- /dev/null +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentSummaryTest.kt @@ -0,0 +1,243 @@ +package org.usvm.ts.calls + +import kotlinx.serialization.decodeFromString +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.usvm.ts.calls.CallsExperimentTestSupport.FIXTURE_TOOL_REVISION +import org.usvm.ts.calls.CallsExperimentTestSupport.manifest +import org.usvm.ts.calls.CallsExperimentTestSupport.readRecords +import org.usvm.ts.calls.CallsExperimentTestSupport.result +import org.usvm.ts.calls.CallsExperimentTestSupport.targetResult +import org.usvm.ts.calls.CallsExperimentTestSupport.writeRecords +import org.usvm.ts.pbt.model.JsConcreteValue +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull + +class CallsExperimentSummaryTest { + @Test + fun `aggregator rejects an interrupted raw prefix without completion`(@TempDir directory: Path) { + val rawOutput = directory.resolve("results.jsonl") + val engine = CallsSymbolicEngine { result(status = CallsSymbolicStatus.UNREACHED) } + + CallsExperimentRunner( + symbolicEngine = engine, + targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> error("Replay must not run") }, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + sourceCheckoutVerifier = { _, _ -> }, + ).run( + manifest = manifest(sourceRoot = ".", seeds = listOf(0L)), + manifestDirectory = directory, + rawOutput = rawOutput, + ) + val interrupted = directory.resolve("interrupted.jsonl") + Files.write(interrupted, Files.readAllLines(rawOutput).dropLast(1)) + + assertFailsWith { + CallsExperimentAggregator.summarize(interrupted) + } + } + + @Test + fun `summarize rejects its raw input as output without changing it`(@TempDir directory: Path) { + val rawInput = directory.resolve("results.jsonl") + writeCompletedRawFixture(rawInput) + val originalBytes = Files.readAllBytes(rawInput) + + val failure = assertFailsWith { + main(arrayOf("summarize", rawInput.toString(), rawInput.toString())) + } + + assertEquals("Summary output must differ from raw input", failure.message) + assertContentEquals(originalBytes, Files.readAllBytes(rawInput)) + } + + @Test + fun `summarize rejects a link to its raw input without changing it`(@TempDir directory: Path) { + val rawInput = directory.resolve("results.jsonl") + writeCompletedRawFixture(rawInput) + val alias = directory.resolve("raw-alias.jsonl") + val linkCreated = try { + Files.createSymbolicLink(alias, rawInput) + true + } catch (_: UnsupportedOperationException) { + false + } catch (_: IOException) { + false + } catch (_: SecurityException) { + false + } + + assumeTrue(linkCreated, "Symbolic links are unavailable") + val originalBytes = Files.readAllBytes(rawInput) + + val failure = assertFailsWith { + main(arrayOf("summarize", rawInput.toString(), alias.toString())) + } + + assertEquals("Summary output must differ from raw input", failure.message) + assertContentEquals(originalBytes, Files.readAllBytes(rawInput)) + } + + @Test + fun `summarize replaces a distinct existing summary`(@TempDir directory: Path) { + val rawInput = directory.resolve("results.jsonl") + writeCompletedRawFixture(rawInput) + val originalBytes = Files.readAllBytes(rawInput) + val output = directory.resolve("summary.json") + Files.writeString(output, "stale summary") + + main(arrayOf("summarize", rawInput.toString(), output.toString())) + + val summary = CallsExperimentJson.json.decodeFromString(Files.readString(output)) + assertEquals("fixture", summary.experimentId) + assertEquals(4, summary.resultRows) + assertContentEquals(originalBytes, Files.readAllBytes(rawInput)) + } + + @Test + fun `aggregator counts each profile from raw rows without collapsing outcome stages`(@TempDir directory: Path) { + val rawOutput = directory.resolve("results.jsonl") + val statuses = mapOf( + CallsExperimentProfile.EMPTY_STOP to CallsSymbolicStatus.TIMEOUT, + CallsExperimentProfile.EMPTY_FRESH to CallsSymbolicStatus.REACHED, + CallsExperimentProfile.FROZEN_STOP to CallsSymbolicStatus.REACHED, + CallsExperimentProfile.FROZEN_FRESH to CallsSymbolicStatus.TOOL_ERROR, + ) + val engine = CallsSymbolicEngine { request -> + val status = statuses.getValue(request.profile) + result( + status = status, + inputs = if (status == CallsSymbolicStatus.REACHED) { + listOf(JsConcreteValue.Boolean(request.profile.usesFrozenModels)) + } else { + null + }, + ) + } + val replayer = CallsTargetReplayer { _, _, inputs, _, _ -> + val input = inputs.single() as JsConcreteValue.Boolean + CallsSourceReplayResult( + status = if (input.value) CallsReplayStatus.CONFIRMED else CallsReplayStatus.REJECTED, + ) + } + + CallsExperimentRunner( + symbolicEngine = engine, + targetReplayer = replayer, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + sourceCheckoutVerifier = { _, _ -> }, + ).run( + manifest = manifest(sourceRoot = ".", seeds = listOf(0L, 3L)), + manifestDirectory = directory, + rawOutput = rawOutput, + ) + val summary = CallsExperimentAggregator.summarize(rawOutput) + + assertEquals("fixture", summary.experimentId) + assertEquals(1, summary.commonEligibleTargets) + assertEquals(8, summary.resultRows) + assertEquals( + CallsProfileSummary( + runs = 2, + solverReached = 0, + inputExtracted = 0, + replayConfirmed = 0, + replayRejected = 0, + unsupported = 0, + timeouts = 2, + toolErrors = 0, + symbolicStatuses = CallsSymbolicStatus.entries.associateWith { status -> + if (status == CallsSymbolicStatus.TIMEOUT) 2 else 0 + }, + replayStatuses = CallsReplayStatus.entries.associateWith { 0 }, + replayNotRun = 2, + ), + summary.byProfile.getValue(CallsExperimentProfile.EMPTY_STOP), + ) + assertEquals(2, summary.byProfile.getValue(CallsExperimentProfile.EMPTY_FRESH).replayRejected) + assertEquals(2, summary.byProfile.getValue(CallsExperimentProfile.FROZEN_STOP).replayConfirmed) + assertEquals(2, summary.byProfile.getValue(CallsExperimentProfile.FROZEN_FRESH).toolErrors) + } + + @Test + fun `solver unknown survives raw serialization and summary without becoming unreached`(@TempDir directory: Path) { + val rawOutput = directory.resolve("results.jsonl") + val diagnostic = "Solver returned UNKNOWN while checking initial constraints" + val engine = CallsSymbolicEngine { + CallsSymbolicSearchResult( + status = CallsSymbolicStatus.UNKNOWN, + elapsedMillis = 7L, + diagnostic = diagnostic, + ) + } + + CallsExperimentRunner( + symbolicEngine = engine, + targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> error("No witness to replay") }, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + sourceCheckoutVerifier = { _, _ -> }, + ).run( + manifest = manifest(sourceRoot = ".", seeds = listOf(0L)), + manifestDirectory = directory, + rawOutput = rawOutput, + ) + val records = readRecords(rawOutput).filterIsInstance() + val summary = CallsExperimentAggregator.summarize(rawOutput) + + assertEquals(4, records.size) + records.forEach { record -> + assertEquals(CallsSymbolicStatus.UNKNOWN, record.symbolicStatus) + assertFalse(record.solverReached) + assertFalse(record.inputExtracted) + assertNull(record.replayStatus) + assertEquals(diagnostic, record.diagnostic) + } + summary.byProfile.values.forEach { profile -> + assertEquals(1, profile.symbolicStatuses.getValue(CallsSymbolicStatus.UNKNOWN)) + assertEquals(0, profile.symbolicStatuses.getValue(CallsSymbolicStatus.UNREACHED)) + assertEquals(0, profile.timeouts) + assertEquals(0, profile.toolErrors) + } + } + + private fun writeCompletedRawFixture(path: Path) { + val row = targetResult(inputs = listOf(JsConcreteValue.Boolean(value = true))) + val target = CallsRunTargetIdentity( + projectId = row.projectId, + revision = row.revision, + development = row.development, + functionId = row.functionId, + targetId = row.targetId, + siteId = row.siteId, + targetMode = row.targetMode, + ) + val metadata = CallsRunMetadata( + experimentId = row.experimentId, + toolRevision = FIXTURE_TOOL_REVISION, + nativeFrontendRevision = "bundled:test", + modelSet = CallsModelSetIdentity(ids = emptySet(), toolRevision = FIXTURE_TOOL_REVISION), + profiles = CallsExperimentProfile.entries, + seeds = listOf(row.seed), + commonEligibleTargets = 1, + targets = listOf(target), + ) + val completion = CallsRunCompletion( + experimentId = row.experimentId, + resultRows = CallsExperimentProfile.entries.size, + ) + val records: List = buildList { + add(metadata) + CallsExperimentProfile.entries.forEach { profile -> add(row.copy(profile = profile)) } + add(completion) + } + + writeRecords(path, records) + } +} diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt index d20dc501d4..c30e7c79b0 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt @@ -1,10 +1,7 @@ package org.usvm.ts.calls -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString import org.jacodb.ets.utils.EtsIrProvider import org.jacodb.ets.utils.loadEtsFileAutoConvert -import org.junit.jupiter.api.Assumptions.assumeTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import org.usvm.SolverType @@ -12,14 +9,13 @@ import org.usvm.machine.call.TsResidualCallPolicy import org.usvm.machine.call.TsUnknownCallDecision import org.usvm.machine.call.TsUnknownCallEvent import org.usvm.machine.call.TsUnknownCallFailureReason -import org.usvm.ts.pbt.model.BooleanDomain +import org.usvm.ts.calls.CallsExperimentTestSupport.FIXTURE_TOOL_REVISION +import org.usvm.ts.calls.CallsExperimentTestSupport.manifest +import org.usvm.ts.calls.CallsExperimentTestSupport.readRecords +import org.usvm.ts.calls.CallsExperimentTestSupport.result import org.usvm.ts.pbt.model.JsConcreteValue -import org.usvm.ts.pbt.model.PropertyInput -import org.usvm.ts.pbt.model.TypeScriptEntryPoint -import java.io.IOException import java.nio.file.Files import java.nio.file.Path -import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse @@ -224,55 +220,6 @@ class CallsExperimentTest { assertFalse(Files.exists(rawOutput)) } - @Test - fun `legacy source target defaults to entry mode`() { - val encoded = """ - { - "targetId": "target", - "siteId": "site", - "sourcePath": "fixture.ts", - "startOffset": 0, - "endOffset": 6, - "start": { "line": 0, "column": 0 }, - "end": { "line": 0, "column": 6 } - } - """.trimIndent() - - val target = CallsExperimentJson.json.decodeFromString(encoded) - - assertEquals(CallsSourceTargetMode.ENTRY, target.mode) - assertNull(target.returnExpressionStartOffset) - assertNull(target.returnExpressionEndOffset) - } - - @Test - fun `completed return target preserves explicit expression offsets`() { - val entryTarget = manifest(sourceRoot = ".", seeds = listOf(17L)) - .projects.single().functions.single().targets.single() - val completedReturn = entryTarget.copy( - mode = CallsSourceTargetMode.COMPLETED_RETURN, - returnExpressionStartOffset = 7, - returnExpressionEndOffset = 10, - ) - - val encoded = CallsExperimentJson.json.encodeToString(completedReturn) - val decoded = CallsExperimentJson.json.decodeFromString(encoded) - - assertEquals(completedReturn, decoded) - } - - @Test - fun `manifest rejects breadth first search`() { - val accepted = manifest(sourceRoot = ".", seeds = listOf(17L)) - - assertEquals(CALLS_PATH_SELECTION_STRATEGY.name, accepted.searchPolicy) - val error = assertFailsWith { - accepted.copy(searchPolicy = "BFS") - } - - assertTrue(error.message.orEmpty().contains(CALLS_PATH_SELECTION_STRATEGY.name)) - } - @Test fun `runner rotates profiles and records symbolic and replay outcomes separately`(@TempDir directory: Path) { val requests = mutableListOf() @@ -361,468 +308,12 @@ class CallsExperimentTest { assertNull(frozenFresh.replayStatus) } - @Test - fun `target result witness uses lossless concrete value serialization`() { - val witness = listOf( - JsConcreteValue.number(-0.0), - JsConcreteValue.number(Double.NaN), - JsConcreteValue.Array( - elements = listOf(JsConcreteValue.Undefined, JsConcreteValue.Null, JsConcreteValue.String("value")), - ), - ) - val result = targetResult(inputs = witness) - - val encoded = CallsExperimentJson.json.encodeToString(result) - val decoded = CallsExperimentJson.json.decodeFromString(encoded) as CallsTargetResult - - assertEquals(result, decoded) - assertEquals(witness, decoded.inputs) - } - - @Test - fun `single witness replay uses selected stored inputs without symbolic search`(@TempDir directory: Path) { - val rawOutput = directory.resolve("results.jsonl") - val frozenManifest = manifest(sourceRoot = ".", seeds = listOf(11L)) - CallsExperimentRunner( - symbolicEngine = CallsSymbolicEngine { - result( - status = CallsSymbolicStatus.REACHED, - inputs = listOf(JsConcreteValue.Boolean(true)), - ) - }, - targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> - CallsSourceReplayResult(status = CallsReplayStatus.CONFIRMED) - }, - runtimeToolRevision = FIXTURE_TOOL_REVISION, - sourceCheckoutVerifier = { _, _ -> }, - ).run( - manifest = frozenManifest, - manifestDirectory = directory, - rawOutput = rawOutput, - ) - var replayedInputs: List? = null - var replayedTarget: CallsSourceTarget? = null - var replayedTimeout: Long? = null - var verifiedCheckout: Path? = null - val witnessReplayer = CallsWitnessReplayer( - targetReplayer = CallsTargetReplayer { _, _, inputs, target, timeoutMillis -> - replayedInputs = inputs - replayedTarget = target - replayedTimeout = timeoutMillis - - CallsSourceReplayResult(status = CallsReplayStatus.REJECTED) - }, - runtimeToolRevision = FIXTURE_TOOL_REVISION, - verifyProjectCheckout = { checkout, expectedRevision -> - assertEquals("project-revision", expectedRevision) - verifiedCheckout = checkout - }, - ) - - val replay = witnessReplayer.replay( - manifest = frozenManifest, - manifestDirectory = directory, - rawInput = rawOutput, - selector = selector(seed = 11L, profile = CallsExperimentProfile.FROZEN_STOP), - ) - - val expectedTarget = frozenManifest.projects - .single() - .functions - .single() - .targets - .single() - assertEquals(CallsReplayStatus.REJECTED, replay.status) - assertEquals(listOf(JsConcreteValue.Boolean(true)), replayedInputs) - assertEquals(expectedTarget, replayedTarget) - assertEquals(1_000L, replayedTimeout) - assertEquals(directory.toRealPath(), verifiedCheckout) - } - - @Test - fun `single witness replay accepts stored completed return target`(@TempDir directory: Path) { - val baseManifest = manifest(sourceRoot = ".", seeds = listOf(13L)) - val project = baseManifest.projects.single() - val function = project.functions.single() - val completedReturn = function.targets.single().copy( - mode = CallsSourceTargetMode.COMPLETED_RETURN, - returnExpressionStartOffset = 7, - returnExpressionEndOffset = 10, - ) - val frozenManifest = baseManifest.copy( - projects = listOf(project.copy(functions = listOf(function.copy(targets = listOf(completedReturn))))), - ) - val rawOutput = directory.resolve("results.jsonl") - val storedInputs = listOf(JsConcreteValue.Boolean(true)) - CallsExperimentRunner( - symbolicEngine = CallsSymbolicEngine { - result(status = CallsSymbolicStatus.REACHED, inputs = storedInputs) - }, - targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> - CallsSourceReplayResult(status = CallsReplayStatus.CONFIRMED) - }, - runtimeToolRevision = FIXTURE_TOOL_REVISION, - sourceCheckoutVerifier = { _, _ -> }, - ).run( - manifest = frozenManifest, - manifestDirectory = directory, - rawOutput = rawOutput, - ) - - val raw = CallsRawResultsReader.read(rawOutput) - val storedRow = raw.results.single { row -> row.profile == CallsExperimentProfile.FROZEN_STOP } - var replayedInputs: List? = null - var replayedTarget: CallsSourceTarget? = null - val replay = CallsWitnessReplayer( - targetReplayer = CallsTargetReplayer { _, _, inputs, target, _ -> - replayedInputs = inputs - replayedTarget = target - CallsSourceReplayResult(status = CallsReplayStatus.REJECTED) - }, - runtimeToolRevision = FIXTURE_TOOL_REVISION, - verifyProjectCheckout = { _, _ -> }, - ).replay( - manifest = frozenManifest, - manifestDirectory = directory, - rawInput = rawOutput, - selector = selector(seed = 13L, profile = CallsExperimentProfile.FROZEN_STOP), - ) - - assertEquals(CallsSourceTargetMode.COMPLETED_RETURN, raw.metadata.targets.single().targetMode) - assertEquals(CallsSourceTargetMode.COMPLETED_RETURN, storedRow.targetMode) - assertEquals(storedInputs, storedRow.inputs) - assertEquals(storedInputs, replayedInputs) - assertEquals(completedReturn, replayedTarget) - assertEquals(CallsReplayStatus.REJECTED, replay.status) - } - - @Test - fun `single witness replay selects function when projects share an ID`(@TempDir directory: Path) { - val baseManifest = manifest(sourceRoot = ".", seeds = listOf(19L)) - val firstProject = baseManifest.projects.single() - val firstFunction = firstProject.functions.single() - val secondTarget = firstFunction.targets.single().copy( - targetId = "fixture.ts::other/1#return", - siteId = "fixture.ts:2:1-2:12::other/1", - ) - val secondFunction = firstFunction.copy( - functionId = "fixture.ts::other/1", - targets = listOf(secondTarget), - ) - val secondProject = firstProject.copy( - revision = "second-project-revision", - functions = listOf(secondFunction), - ) - val frozenManifest = baseManifest.copy(projects = listOf(firstProject, secondProject)) - val rawOutput = directory.resolve("results.jsonl") - val selectedWitness = CallsWitnessSelector( - projectId = secondProject.projectId, - functionId = secondFunction.functionId, - targetId = secondTarget.targetId, - profile = CallsExperimentProfile.FROZEN_STOP, - seed = 19L, - ) - - CallsExperimentRunner( - symbolicEngine = CallsSymbolicEngine { - result( - status = CallsSymbolicStatus.REACHED, - inputs = listOf(JsConcreteValue.Boolean(true)), - ) - }, - targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> - CallsSourceReplayResult(status = CallsReplayStatus.CONFIRMED) - }, - runtimeToolRevision = FIXTURE_TOOL_REVISION, - sourceCheckoutVerifier = { _, _ -> }, - ).run( - manifest = frozenManifest, - manifestDirectory = directory, - rawOutput = rawOutput, - ) - - var verifiedRevision: String? = null - var replayedTarget: CallsSourceTarget? = null - val replay = CallsWitnessReplayer( - targetReplayer = CallsTargetReplayer { _, _, _, target, _ -> - replayedTarget = target - CallsSourceReplayResult(status = CallsReplayStatus.CONFIRMED) - }, - runtimeToolRevision = FIXTURE_TOOL_REVISION, - verifyProjectCheckout = { _, revision -> verifiedRevision = revision }, - ).replay( - manifest = frozenManifest, - manifestDirectory = directory, - rawInput = rawOutput, - selector = selectedWitness, - ) - - assertEquals(CallsReplayStatus.CONFIRMED, replay.status) - assertEquals("second-project-revision", verifiedRevision) - assertEquals(secondTarget, replayedTarget) - } - - @Test - fun `historical extracted row without stored witness reports replay unavailable first`(@TempDir directory: Path) { - val rawOutput = directory.resolve("results.jsonl") - val frozenManifest = manifest(sourceRoot = ".", seeds = listOf(5L)) - CallsExperimentRunner( - symbolicEngine = CallsSymbolicEngine { - result( - status = CallsSymbolicStatus.REACHED, - inputs = listOf(JsConcreteValue.Boolean(true)), - ) - }, - targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> - CallsSourceReplayResult(status = CallsReplayStatus.CONFIRMED) - }, - runtimeToolRevision = FIXTURE_TOOL_REVISION, - sourceCheckoutVerifier = { _, _ -> }, - ).run( - manifest = frozenManifest, - manifestDirectory = directory, - rawOutput = rawOutput, - ) - val historicalRecords = readRecords(rawOutput).map { record -> - when { - record is CallsTargetResult && record.profile == CallsExperimentProfile.EMPTY_FRESH -> { - record.copy(inputs = null) - } - - else -> { - record - } - } - } - writeRecords(rawOutput, historicalRecords) - val witnessReplayer = CallsWitnessReplayer( - targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> error("Replay must not run") }, - runtimeToolRevision = "different-runtime-revision", - verifyProjectCheckout = { _, _ -> error("Checkout verification must not run") }, - ) - - val error = assertFailsWith { - witnessReplayer.replay( - manifest = frozenManifest, - manifestDirectory = directory, - rawInput = rawOutput, - selector = selector(seed = 5L, profile = CallsExperimentProfile.EMPTY_FRESH), - ) - } - - assertTrue(error.message.orEmpty().contains("historical raw artifact")) - assertTrue(error.message.orEmpty().contains("single-witness replay is unavailable")) - - val manifestPath = directory.resolve("historical-manifest.json") - Files.writeString(manifestPath, CallsExperimentJson.encodeManifest(frozenManifest)) - - val cliError = assertFailsWith { - replayWitness( - listOf( - manifestPath.toString(), - rawOutput.toString(), - "fixture/project", - "fixture.ts::predicate/1", - "fixture.ts::predicate/1#return", - CallsExperimentProfile.EMPTY_FRESH.name, - "5", - ), - ) - } - - assertTrue(cliError.message.orEmpty().contains("historical raw artifact")) - assertTrue(cliError.message.orEmpty().contains("single-witness replay is unavailable")) - } - - @Test - fun `aggregator rejects an interrupted raw prefix without completion`(@TempDir directory: Path) { - val rawOutput = directory.resolve("results.jsonl") - val engine = CallsSymbolicEngine { result(status = CallsSymbolicStatus.UNREACHED) } - - CallsExperimentRunner( - symbolicEngine = engine, - targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> error("Replay must not run") }, - runtimeToolRevision = FIXTURE_TOOL_REVISION, - sourceCheckoutVerifier = { _, _ -> }, - ).run( - manifest = manifest(sourceRoot = ".", seeds = listOf(0L)), - manifestDirectory = directory, - rawOutput = rawOutput, - ) - val interrupted = directory.resolve("interrupted.jsonl") - Files.write(interrupted, Files.readAllLines(rawOutput).dropLast(1)) - - assertFailsWith { - CallsExperimentAggregator.summarize(interrupted) - } - } - - @Test - fun `summarize rejects its raw input as output without changing it`(@TempDir directory: Path) { - val rawInput = directory.resolve("results.jsonl") - writeCompletedRawFixture(rawInput) - val originalBytes = Files.readAllBytes(rawInput) - - val failure = assertFailsWith { - main(arrayOf("summarize", rawInput.toString(), rawInput.toString())) - } - - assertEquals("Summary output must differ from raw input", failure.message) - assertContentEquals(originalBytes, Files.readAllBytes(rawInput)) - } - - @Test - fun `summarize rejects a link to its raw input without changing it`(@TempDir directory: Path) { - val rawInput = directory.resolve("results.jsonl") - writeCompletedRawFixture(rawInput) - val alias = directory.resolve("raw-alias.jsonl") - val linkCreated = try { - Files.createSymbolicLink(alias, rawInput) - true - } catch (_: UnsupportedOperationException) { - false - } catch (_: IOException) { - false - } catch (_: SecurityException) { - false - } - - assumeTrue(linkCreated, "Symbolic links are unavailable") - val originalBytes = Files.readAllBytes(rawInput) - - val failure = assertFailsWith { - main(arrayOf("summarize", rawInput.toString(), alias.toString())) - } - - assertEquals("Summary output must differ from raw input", failure.message) - assertContentEquals(originalBytes, Files.readAllBytes(rawInput)) - } - - @Test - fun `summarize replaces a distinct existing summary`(@TempDir directory: Path) { - val rawInput = directory.resolve("results.jsonl") - writeCompletedRawFixture(rawInput) - val originalBytes = Files.readAllBytes(rawInput) - val output = directory.resolve("summary.json") - Files.writeString(output, "stale summary") - - main(arrayOf("summarize", rawInput.toString(), output.toString())) - - val summary = CallsExperimentJson.json.decodeFromString(Files.readString(output)) - assertEquals("fixture", summary.experimentId) - assertEquals(4, summary.resultRows) - assertContentEquals(originalBytes, Files.readAllBytes(rawInput)) - } - - @Test - fun `aggregator counts each profile from raw rows without collapsing outcome stages`(@TempDir directory: Path) { - val rawOutput = directory.resolve("results.jsonl") - val statuses = mapOf( - CallsExperimentProfile.EMPTY_STOP to CallsSymbolicStatus.TIMEOUT, - CallsExperimentProfile.EMPTY_FRESH to CallsSymbolicStatus.REACHED, - CallsExperimentProfile.FROZEN_STOP to CallsSymbolicStatus.REACHED, - CallsExperimentProfile.FROZEN_FRESH to CallsSymbolicStatus.TOOL_ERROR, - ) - val engine = CallsSymbolicEngine { request -> - val status = statuses.getValue(request.profile) - result( - status = status, - inputs = if (status == CallsSymbolicStatus.REACHED) { - listOf(JsConcreteValue.Boolean(request.profile.usesFrozenModels)) - } else { - null - }, - ) - } - val replayer = CallsTargetReplayer { _, _, inputs, _, _ -> - val input = inputs.single() as JsConcreteValue.Boolean - CallsSourceReplayResult( - status = if (input.value) CallsReplayStatus.CONFIRMED else CallsReplayStatus.REJECTED, - ) - } - - CallsExperimentRunner( - symbolicEngine = engine, - targetReplayer = replayer, - runtimeToolRevision = FIXTURE_TOOL_REVISION, - sourceCheckoutVerifier = { _, _ -> }, - ).run( - manifest = manifest(sourceRoot = ".", seeds = listOf(0L, 3L)), - manifestDirectory = directory, - rawOutput = rawOutput, - ) - val summary = CallsExperimentAggregator.summarize(rawOutput) - - assertEquals("fixture", summary.experimentId) - assertEquals(1, summary.commonEligibleTargets) - assertEquals(8, summary.resultRows) - assertEquals( - CallsProfileSummary( - runs = 2, - solverReached = 0, - inputExtracted = 0, - replayConfirmed = 0, - replayRejected = 0, - unsupported = 0, - timeouts = 2, - toolErrors = 0, - symbolicStatuses = CallsSymbolicStatus.entries.associateWith { status -> - if (status == CallsSymbolicStatus.TIMEOUT) 2 else 0 - }, - replayStatuses = CallsReplayStatus.entries.associateWith { 0 }, - replayNotRun = 2, - ), - summary.byProfile.getValue(CallsExperimentProfile.EMPTY_STOP), - ) - assertEquals(2, summary.byProfile.getValue(CallsExperimentProfile.EMPTY_FRESH).replayRejected) - assertEquals(2, summary.byProfile.getValue(CallsExperimentProfile.FROZEN_STOP).replayConfirmed) - assertEquals(2, summary.byProfile.getValue(CallsExperimentProfile.FROZEN_FRESH).toolErrors) - } - - @Test - fun `solver unknown survives raw serialization and summary without becoming unreached`(@TempDir directory: Path) { - val rawOutput = directory.resolve("results.jsonl") - val diagnostic = "Solver returned UNKNOWN while checking initial constraints" - val engine = CallsSymbolicEngine { - CallsSymbolicSearchResult( - status = CallsSymbolicStatus.UNKNOWN, - elapsedMillis = 7L, - diagnostic = diagnostic, - ) - } - - CallsExperimentRunner( - symbolicEngine = engine, - targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> error("No witness to replay") }, - runtimeToolRevision = FIXTURE_TOOL_REVISION, - sourceCheckoutVerifier = { _, _ -> }, - ).run( - manifest = manifest(sourceRoot = ".", seeds = listOf(0L)), - manifestDirectory = directory, - rawOutput = rawOutput, - ) - val records = readRecords(rawOutput).filterIsInstance() - val summary = CallsExperimentAggregator.summarize(rawOutput) - - assertEquals(4, records.size) - records.forEach { record -> - assertEquals(CallsSymbolicStatus.UNKNOWN, record.symbolicStatus) - assertFalse(record.solverReached) - assertFalse(record.inputExtracted) - assertNull(record.replayStatus) - assertEquals(diagnostic, record.diagnostic) - } - summary.byProfile.values.forEach { profile -> - assertEquals(1, profile.symbolicStatuses.getValue(CallsSymbolicStatus.UNKNOWN)) - assertEquals(0, profile.symbolicStatuses.getValue(CallsSymbolicStatus.UNREACHED)) - assertEquals(0, profile.timeouts) - assertEquals(0, profile.toolErrors) - } - } - private fun frozenGitManifest(directory: Path): CallsExperimentManifest { val sourceRoot = Files.createDirectory(directory.resolve("source")) - Files.writeString(sourceRoot.resolve("fixture.ts"), "export function predicate(value: boolean): boolean { return value; }") + Files.writeString( + sourceRoot.resolve("fixture.ts"), + "export function predicate(value: boolean): boolean { return value; }", + ) runCallsGit(sourceRoot, "init") runCallsGit(sourceRoot, "config", "user.name", "USVM Tests") runCallsGit(sourceRoot, "config", "user.email", "usvm@example.test") @@ -833,134 +324,4 @@ class CallsExperimentTest { return base.copy(projects = listOf(base.projects.single().copy(revision = revision))) } - - private fun manifest(sourceRoot: String, seeds: List) = CallsExperimentManifest( - schemaVersion = CallsExperimentManifest.SCHEMA_VERSION, - experimentId = "fixture", - toolRevision = FIXTURE_TOOL_REVISION, - nativeFrontendRevision = "bundled:test", - solver = "YICES", - searchPolicy = CALLS_PATH_SELECTION_STRATEGY.name, - modelSet = CallsModelSetIdentity( - ids = setOf("ts.array.pop", "ts.array.shift"), - toolRevision = FIXTURE_TOOL_REVISION, - ), - seeds = seeds, - perTargetBudgetMillis = 1_000L, - projects = listOf( - CallsProjectCase( - projectId = "fixture/project", - revision = "project-revision", - sourceRoot = sourceRoot, - development = true, - functions = listOf( - CallsFunctionCase( - functionId = "fixture.ts::predicate/1", - sourceFile = "fixture.ts", - entryPoint = TypeScriptEntryPoint( - module = "fixture.ts", - exportName = "predicate", - ), - inputs = listOf(PropertyInput(name = "value", domain = BooleanDomain)), - targets = listOf( - CallsSourceTarget( - targetId = "fixture.ts::predicate/1#return", - siteId = "fixture.ts:1:1-1:12::predicate/1", - sourcePath = "fixture.ts", - startOffset = 0, - endOffset = 11, - start = CallsSourcePosition(line = 0, column = 0), - end = CallsSourcePosition(line = 0, column = 11), - ), - ), - ), - ), - ), - ), - ) - - private fun result( - status: CallsSymbolicStatus, - solverReached: Boolean = status == CallsSymbolicStatus.REACHED, - inputs: List? = null, - ) = CallsSymbolicSearchResult( - status = status, - solverReached = solverReached, - inputs = inputs, - elapsedMillis = 7L, - ) - - private fun readRecords(path: Path): List = Files.readAllLines(path) - .filter(String::isNotBlank) - .map { line -> CallsExperimentJson.json.decodeFromString(line) } - - private fun writeRecords(path: Path, records: List) { - Files.writeString( - path, - records.joinToString(separator = "\n", postfix = "\n") { record -> - CallsExperimentJson.json.encodeToString(record) - }, - ) - } - - private fun writeCompletedRawFixture(path: Path) { - val row = targetResult(inputs = listOf(JsConcreteValue.Boolean(value = true))) - val target = CallsRunTargetIdentity( - projectId = row.projectId, - revision = row.revision, - development = row.development, - functionId = row.functionId, - targetId = row.targetId, - siteId = row.siteId, - targetMode = row.targetMode, - ) - val metadata = CallsRunMetadata( - experimentId = row.experimentId, - toolRevision = FIXTURE_TOOL_REVISION, - nativeFrontendRevision = "bundled:test", - modelSet = CallsModelSetIdentity(ids = emptySet(), toolRevision = FIXTURE_TOOL_REVISION), - profiles = CallsExperimentProfile.entries, - seeds = listOf(row.seed), - commonEligibleTargets = 1, - targets = listOf(target), - ) - val completion = CallsRunCompletion(experimentId = row.experimentId, resultRows = CallsExperimentProfile.entries.size) - val records: List = buildList { - add(metadata) - CallsExperimentProfile.entries.forEach { profile -> add(row.copy(profile = profile)) } - add(completion) - } - - writeRecords(path, records) - } - - private fun selector(seed: Long, profile: CallsExperimentProfile) = CallsWitnessSelector( - projectId = "fixture/project", - functionId = "fixture.ts::predicate/1", - targetId = "fixture.ts::predicate/1#return", - profile = profile, - seed = seed, - ) - - private fun targetResult(inputs: List) = CallsTargetResult( - experimentId = "fixture", - projectId = "fixture/project", - revision = "project-revision", - development = true, - functionId = "fixture.ts::predicate/1", - targetId = "fixture.ts::predicate/1#return", - siteId = "fixture.ts:1:1-1:12::predicate/1", - profile = CallsExperimentProfile.FROZEN_STOP, - seed = 1L, - symbolicStatus = CallsSymbolicStatus.REACHED, - solverReached = true, - inputExtracted = true, - inputs = inputs, - replayStatus = CallsReplayStatus.CONFIRMED, - symbolicElapsedMillis = 7L, - ) - - private companion object { - const val FIXTURE_TOOL_REVISION = "0000000000000000000000000000000000000001" - } } diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTestSupport.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTestSupport.kt new file mode 100644 index 0000000000..b2ba807089 --- /dev/null +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTestSupport.kt @@ -0,0 +1,101 @@ +package org.usvm.ts.calls + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import org.usvm.ts.pbt.model.BooleanDomain +import org.usvm.ts.pbt.model.JsConcreteValue +import org.usvm.ts.pbt.model.PropertyInput +import org.usvm.ts.pbt.model.TypeScriptEntryPoint +import java.nio.file.Files +import java.nio.file.Path + +internal object CallsExperimentTestSupport { + const val FIXTURE_TOOL_REVISION = "0000000000000000000000000000000000000001" + + fun manifest(sourceRoot: String, seeds: List) = CallsExperimentManifest( + schemaVersion = CallsExperimentManifest.SCHEMA_VERSION, + experimentId = "fixture", + toolRevision = FIXTURE_TOOL_REVISION, + nativeFrontendRevision = "bundled:test", + solver = "YICES", + searchPolicy = CALLS_PATH_SELECTION_STRATEGY.name, + modelSet = CallsModelSetIdentity( + ids = setOf("ts.array.pop", "ts.array.shift"), + toolRevision = FIXTURE_TOOL_REVISION, + ), + seeds = seeds, + perTargetBudgetMillis = 1_000L, + projects = listOf( + CallsProjectCase( + projectId = "fixture/project", + revision = "project-revision", + sourceRoot = sourceRoot, + development = true, + functions = listOf( + CallsFunctionCase( + functionId = "fixture.ts::predicate/1", + sourceFile = "fixture.ts", + entryPoint = TypeScriptEntryPoint( + module = "fixture.ts", + exportName = "predicate", + ), + inputs = listOf(PropertyInput(name = "value", domain = BooleanDomain)), + targets = listOf( + CallsSourceTarget( + targetId = "fixture.ts::predicate/1#return", + siteId = "fixture.ts:1:1-1:12::predicate/1", + sourcePath = "fixture.ts", + startOffset = 0, + endOffset = 11, + start = CallsSourcePosition(line = 0, column = 0), + end = CallsSourcePosition(line = 0, column = 11), + ), + ), + ), + ), + ), + ), + ) + + fun result( + status: CallsSymbolicStatus, + solverReached: Boolean = status == CallsSymbolicStatus.REACHED, + inputs: List? = null, + ) = CallsSymbolicSearchResult( + status = status, + solverReached = solverReached, + inputs = inputs, + elapsedMillis = 7L, + ) + + fun readRecords(path: Path): List = Files.readAllLines(path) + .filter(String::isNotBlank) + .map { line -> CallsExperimentJson.json.decodeFromString(line) } + + fun writeRecords(path: Path, records: List) { + Files.writeString( + path, + records.joinToString(separator = "\n", postfix = "\n") { record -> + CallsExperimentJson.json.encodeToString(record) + }, + ) + } + + fun targetResult(inputs: List) = CallsTargetResult( + experimentId = "fixture", + projectId = "fixture/project", + revision = "project-revision", + development = true, + functionId = "fixture.ts::predicate/1", + targetId = "fixture.ts::predicate/1#return", + siteId = "fixture.ts:1:1-1:12::predicate/1", + profile = CallsExperimentProfile.FROZEN_STOP, + seed = 1L, + symbolicStatus = CallsSymbolicStatus.REACHED, + solverReached = true, + inputExtracted = true, + inputs = inputs, + replayStatus = CallsReplayStatus.CONFIRMED, + symbolicElapsedMillis = 7L, + ) +} diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsWitnessReplayTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsWitnessReplayTest.kt new file mode 100644 index 0000000000..8a59fc8222 --- /dev/null +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsWitnessReplayTest.kt @@ -0,0 +1,280 @@ +package org.usvm.ts.calls + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.usvm.ts.calls.CallsExperimentTestSupport.FIXTURE_TOOL_REVISION +import org.usvm.ts.calls.CallsExperimentTestSupport.manifest +import org.usvm.ts.calls.CallsExperimentTestSupport.readRecords +import org.usvm.ts.calls.CallsExperimentTestSupport.result +import org.usvm.ts.calls.CallsExperimentTestSupport.writeRecords +import org.usvm.ts.pbt.model.JsConcreteValue +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class CallsWitnessReplayTest { + @Test + fun `single witness replay uses selected stored inputs without symbolic search`(@TempDir directory: Path) { + val rawOutput = directory.resolve("results.jsonl") + val frozenManifest = manifest(sourceRoot = ".", seeds = listOf(11L)) + CallsExperimentRunner( + symbolicEngine = CallsSymbolicEngine { + result( + status = CallsSymbolicStatus.REACHED, + inputs = listOf(JsConcreteValue.Boolean(true)), + ) + }, + targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> + CallsSourceReplayResult(status = CallsReplayStatus.CONFIRMED) + }, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + sourceCheckoutVerifier = { _, _ -> }, + ).run( + manifest = frozenManifest, + manifestDirectory = directory, + rawOutput = rawOutput, + ) + var replayedInputs: List? = null + var replayedTarget: CallsSourceTarget? = null + var replayedTimeout: Long? = null + var verifiedCheckout: Path? = null + val witnessReplayer = CallsWitnessReplayer( + targetReplayer = CallsTargetReplayer { _, _, inputs, target, timeoutMillis -> + replayedInputs = inputs + replayedTarget = target + replayedTimeout = timeoutMillis + + CallsSourceReplayResult(status = CallsReplayStatus.REJECTED) + }, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + verifyProjectCheckout = { checkout, expectedRevision -> + assertEquals("project-revision", expectedRevision) + verifiedCheckout = checkout + }, + ) + + val replay = witnessReplayer.replay( + manifest = frozenManifest, + manifestDirectory = directory, + rawInput = rawOutput, + selector = selector(seed = 11L, profile = CallsExperimentProfile.FROZEN_STOP), + ) + + val expectedTarget = frozenManifest.projects + .single() + .functions + .single() + .targets + .single() + assertEquals(CallsReplayStatus.REJECTED, replay.status) + assertEquals(listOf(JsConcreteValue.Boolean(true)), replayedInputs) + assertEquals(expectedTarget, replayedTarget) + assertEquals(1_000L, replayedTimeout) + assertEquals(directory.toRealPath(), verifiedCheckout) + } + + @Test + fun `single witness replay accepts stored completed return target`(@TempDir directory: Path) { + val baseManifest = manifest(sourceRoot = ".", seeds = listOf(13L)) + val project = baseManifest.projects.single() + val function = project.functions.single() + val completedReturn = function.targets.single().copy( + mode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpressionStartOffset = 7, + returnExpressionEndOffset = 10, + ) + val frozenManifest = baseManifest.copy( + projects = listOf(project.copy(functions = listOf(function.copy(targets = listOf(completedReturn))))), + ) + val rawOutput = directory.resolve("results.jsonl") + val storedInputs = listOf(JsConcreteValue.Boolean(true)) + CallsExperimentRunner( + symbolicEngine = CallsSymbolicEngine { + result(status = CallsSymbolicStatus.REACHED, inputs = storedInputs) + }, + targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> + CallsSourceReplayResult(status = CallsReplayStatus.CONFIRMED) + }, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + sourceCheckoutVerifier = { _, _ -> }, + ).run( + manifest = frozenManifest, + manifestDirectory = directory, + rawOutput = rawOutput, + ) + + val raw = CallsRawResultsReader.read(rawOutput) + val storedRow = raw.results.single { row -> row.profile == CallsExperimentProfile.FROZEN_STOP } + var replayedInputs: List? = null + var replayedTarget: CallsSourceTarget? = null + val replay = CallsWitnessReplayer( + targetReplayer = CallsTargetReplayer { _, _, inputs, target, _ -> + replayedInputs = inputs + replayedTarget = target + CallsSourceReplayResult(status = CallsReplayStatus.REJECTED) + }, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + verifyProjectCheckout = { _, _ -> }, + ).replay( + manifest = frozenManifest, + manifestDirectory = directory, + rawInput = rawOutput, + selector = selector(seed = 13L, profile = CallsExperimentProfile.FROZEN_STOP), + ) + + assertEquals(CallsSourceTargetMode.COMPLETED_RETURN, raw.metadata.targets.single().targetMode) + assertEquals(CallsSourceTargetMode.COMPLETED_RETURN, storedRow.targetMode) + assertEquals(storedInputs, storedRow.inputs) + assertEquals(storedInputs, replayedInputs) + assertEquals(completedReturn, replayedTarget) + assertEquals(CallsReplayStatus.REJECTED, replay.status) + } + + @Test + fun `single witness replay selects function when projects share an ID`(@TempDir directory: Path) { + val baseManifest = manifest(sourceRoot = ".", seeds = listOf(19L)) + val firstProject = baseManifest.projects.single() + val firstFunction = firstProject.functions.single() + val secondTarget = firstFunction.targets.single().copy( + targetId = "fixture.ts::other/1#return", + siteId = "fixture.ts:2:1-2:12::other/1", + ) + val secondFunction = firstFunction.copy( + functionId = "fixture.ts::other/1", + targets = listOf(secondTarget), + ) + val secondProject = firstProject.copy( + revision = "second-project-revision", + functions = listOf(secondFunction), + ) + val frozenManifest = baseManifest.copy(projects = listOf(firstProject, secondProject)) + val rawOutput = directory.resolve("results.jsonl") + val selectedWitness = CallsWitnessSelector( + projectId = secondProject.projectId, + functionId = secondFunction.functionId, + targetId = secondTarget.targetId, + profile = CallsExperimentProfile.FROZEN_STOP, + seed = 19L, + ) + + CallsExperimentRunner( + symbolicEngine = CallsSymbolicEngine { + result( + status = CallsSymbolicStatus.REACHED, + inputs = listOf(JsConcreteValue.Boolean(true)), + ) + }, + targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> + CallsSourceReplayResult(status = CallsReplayStatus.CONFIRMED) + }, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + sourceCheckoutVerifier = { _, _ -> }, + ).run( + manifest = frozenManifest, + manifestDirectory = directory, + rawOutput = rawOutput, + ) + + var verifiedRevision: String? = null + var replayedTarget: CallsSourceTarget? = null + val replay = CallsWitnessReplayer( + targetReplayer = CallsTargetReplayer { _, _, _, target, _ -> + replayedTarget = target + CallsSourceReplayResult(status = CallsReplayStatus.CONFIRMED) + }, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + verifyProjectCheckout = { _, revision -> verifiedRevision = revision }, + ).replay( + manifest = frozenManifest, + manifestDirectory = directory, + rawInput = rawOutput, + selector = selectedWitness, + ) + + assertEquals(CallsReplayStatus.CONFIRMED, replay.status) + assertEquals("second-project-revision", verifiedRevision) + assertEquals(secondTarget, replayedTarget) + } + + @Test + fun `historical extracted row without stored witness reports replay unavailable first`(@TempDir directory: Path) { + val rawOutput = directory.resolve("results.jsonl") + val frozenManifest = manifest(sourceRoot = ".", seeds = listOf(5L)) + CallsExperimentRunner( + symbolicEngine = CallsSymbolicEngine { + result( + status = CallsSymbolicStatus.REACHED, + inputs = listOf(JsConcreteValue.Boolean(true)), + ) + }, + targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> + CallsSourceReplayResult(status = CallsReplayStatus.CONFIRMED) + }, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + sourceCheckoutVerifier = { _, _ -> }, + ).run( + manifest = frozenManifest, + manifestDirectory = directory, + rawOutput = rawOutput, + ) + val historicalRecords = readRecords(rawOutput).map { record -> + when { + record is CallsTargetResult && record.profile == CallsExperimentProfile.EMPTY_FRESH -> { + record.copy(inputs = null) + } + + else -> { + record + } + } + } + writeRecords(rawOutput, historicalRecords) + val witnessReplayer = CallsWitnessReplayer( + targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> error("Replay must not run") }, + runtimeToolRevision = "different-runtime-revision", + verifyProjectCheckout = { _, _ -> error("Checkout verification must not run") }, + ) + + val error = assertFailsWith { + witnessReplayer.replay( + manifest = frozenManifest, + manifestDirectory = directory, + rawInput = rawOutput, + selector = selector(seed = 5L, profile = CallsExperimentProfile.EMPTY_FRESH), + ) + } + + assertTrue(error.message.orEmpty().contains("historical raw artifact")) + assertTrue(error.message.orEmpty().contains("single-witness replay is unavailable")) + + val manifestPath = directory.resolve("historical-manifest.json") + Files.writeString(manifestPath, CallsExperimentJson.encodeManifest(frozenManifest)) + + val cliError = assertFailsWith { + replayWitness( + listOf( + manifestPath.toString(), + rawOutput.toString(), + "fixture/project", + "fixture.ts::predicate/1", + "fixture.ts::predicate/1#return", + CallsExperimentProfile.EMPTY_FRESH.name, + "5", + ), + ) + } + + assertTrue(cliError.message.orEmpty().contains("historical raw artifact")) + assertTrue(cliError.message.orEmpty().contains("single-witness replay is unavailable")) + } + + private fun selector(seed: Long, profile: CallsExperimentProfile) = CallsWitnessSelector( + projectId = "fixture/project", + functionId = "fixture.ts::predicate/1", + targetId = "fixture.ts::predicate/1#return", + profile = profile, + seed = seed, + ) +}