From 1d21189a83161d3338c2a5ceea5ffa1a761d8d2f Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 00:08:16 +0300 Subject: [PATCH 1/3] [TS] Support class coverage in symbolic analysis --- .../main/kotlin/org/usvm/machine/TsMachine.kt | 10 +- .../org/usvm/machine/TsClassCoverageTest.kt | 147 ++++++++++++++++++ .../samples/coverage/ClassCoverage.ts | 54 +++++++ 3 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/TsClassCoverageTest.kt create mode 100644 usvm-ts/src/test/resources/samples/coverage/ClassCoverage.ts 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 9be48cfad6..630486d406 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt @@ -169,7 +169,15 @@ class TsMachine( val methodsToTrackCoverage = when (options.coverageZone) { CoverageZone.METHOD, CoverageZone.TRANSITIVE -> initialStates.keys.toHashSet() - CoverageZone.CLASS -> TODO("Unsupported yet") + CoverageZone.CLASS -> initialStates.keys.flatMapTo(hashSetOf()) { method -> + val owner = requireNotNull(method.enclosingClass) { + "Class coverage requires a declaring class for ${method.signature}" + } + + owner.methods.filter { candidate -> + candidate.signature.enclosingClass == owner.signature && candidate.cfg.stmts.isNotEmpty() + } + } + initialStates.keys } val coverageStatistics = CoverageStatistics( diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/TsClassCoverageTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/TsClassCoverageTest.kt new file mode 100644 index 0000000000..a507a1f9ca --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/TsClassCoverageTest.kt @@ -0,0 +1,147 @@ +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.CoverageZone +import org.usvm.StateCollectionStrategy +import org.usvm.api.TsTestValue +import org.usvm.util.TsMethodTestRunner +import org.usvm.util.eq +import org.usvm.util.getResourcePath +import kotlin.test.assertEquals +import kotlin.time.Duration +import org.usvm.test.util.checkers.eq as exactly + +class TsClassCoverageTest : TsMethodTestRunner() { + override val scene = EtsScene( + projectFiles = listOf( + loadEtsFileAutoConvert( + getResourcePath("/samples/coverage/ClassCoverage.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ), + ), + ) + + @Test + fun `class coverage retains new paths in a private helper`() { + val method = getMethod(methodName = "entry", className = "HelperCoverage") + val classOptions = options.copy( + coverageZone = CoverageZone.CLASS, + stateCollectionStrategy = StateCollectionStrategy.COVERED_NEW, + stopOnCoverage = 0, + ) + + withOptions(options = classOptions) { + checkMatches( + method = method, + analysisResultNumberMatches = exactly(2), + { value, result -> value.number > 0 && result eq 1 }, + { value, result -> !(value.number > 0) && result eq 2 }, + ) + } + } + + @Test + fun `method coverage still ignores new paths outside the entry method`() { + val method = getMethod(methodName = "entry", className = "HelperCoverage") + val methodOptions = options.copy( + coverageZone = CoverageZone.METHOD, + stateCollectionStrategy = StateCollectionStrategy.COVERED_NEW, + stopOnCoverage = 0, + ) + + withOptions(options = methodOptions) { + checkMatches( + method = method, + analysisResultNumberMatches = exactly(1), + { _, result -> result eq 1 || result eq 2 }, + ) + } + } + + @Test + fun `class coverage retains new paths in the constructor`() { + val method = getMethod(methodName = "entry", className = "ConstructorCoverage") + val classOptions = options.copy( + coverageZone = CoverageZone.CLASS, + stateCollectionStrategy = StateCollectionStrategy.COVERED_NEW, + stopOnCoverage = 0, + ) + + withOptions(options = classOptions) { + checkMatches( + method = method, + analysisResultNumberMatches = exactly(2), + { value, result -> value.number > 0 && result eq 0 }, + { value, result -> !(value.number > 0) && result eq 0 }, + ) + } + } + + @Test + fun `class coverage excludes helper methods belonging to another class`() { + val method = getMethod(methodName = "entry", className = "ForeignCaller") + val classOptions = options.copy( + coverageZone = CoverageZone.CLASS, + stateCollectionStrategy = StateCollectionStrategy.COVERED_NEW, + stopOnCoverage = 0, + ) + + withOptions(options = classOptions) { + checkMatches( + method = method, + analysisResultNumberMatches = exactly(1), + { _, result -> result eq 10 || result eq 20 }, + ) + } + } + + @Test + fun `unvisited class methods prevent the method coverage stop`() { + val method = getMethod(methodName = "entry", className = "HelperCoverage") + val classOptions = options.copy( + coverageZone = CoverageZone.CLASS, + stopOnCoverage = 100, + timeout = Duration.INFINITE, + ) + + val result = TsMachine( + scene = scene, + options = classOptions, + tsOptions = TsOptions(), + ).use { machine -> + machine.analyzeWithOutcome(methods = listOf(method)) + } + + assertEquals(expected = TsAnalysisStopReason.EXHAUSTED, actual = result.stopReason) + assertEquals(expected = 2, actual = result.states.size) + } + + @Test + fun `class coverage combines the declaring classes of all entry points`() { + val helperEntry = getMethod(methodName = "entry", className = "HelperCoverage") + val constructorEntry = getMethod(methodName = "entry", className = "ConstructorCoverage") + val classOptions = options.copy( + coverageZone = CoverageZone.CLASS, + stateCollectionStrategy = StateCollectionStrategy.COVERED_NEW, + stopOnCoverage = 0, + timeout = Duration.INFINITE, + ) + + val result = TsMachine( + scene = scene, + options = classOptions, + tsOptions = TsOptions(), + ).use { machine -> + machine.analyzeWithOutcome(methods = listOf(helperEntry, constructorEntry)) + } + + val statesByEntryPoint = result.states.groupingBy { state -> state.entrypoint.signature }.eachCount() + assertEquals( + expected = mapOf(helperEntry.signature to 2, constructorEntry.signature to 2), + actual = statesByEntryPoint, + ) + } +} diff --git a/usvm-ts/src/test/resources/samples/coverage/ClassCoverage.ts b/usvm-ts/src/test/resources/samples/coverage/ClassCoverage.ts new file mode 100644 index 0000000000..d5f07af3be --- /dev/null +++ b/usvm-ts/src/test/resources/samples/coverage/ClassCoverage.ts @@ -0,0 +1,54 @@ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols + +class HelperCoverage { + static entry(value: number): number { + return HelperCoverage.choose(value); + } + + private static choose(value: number): number { + if (value > 0) { + return 1; + } + + return 2; + } + + static unvisited(): number { + return 3; + } +} + +class ConstructorCoverage { + private value: number; + + constructor(value: number) { + if (value > 0) { + this.value = 1; + } else { + this.value = 2; + } + } + + static entry(value: number): number { + const instance = new ConstructorCoverage(value); + + return 0; + } +} + +class ForeignCaller { + static entry(value: number): number { + return ForeignHelper.choose(value); + } +} + +class ForeignHelper { + static choose(value: number): number { + if (value > 0) { + return 10; + } + + return 20; + } +} From c07b0d6ea60f6408aa012bcd70447f502c5bf803 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 00:34:20 +0300 Subject: [PATCH 2/3] [TS] Bound entry point turns and preserve pending states --- .../usvm/ps/ConstantTimeFairPathSelector.kt | 26 ++-- .../kotlin/org/usvm/ps/PathSelectorFactory.kt | 22 ++- ...ConstantTimeFairPathSelectorQuantumTest.kt | 130 ++++++++++++++++++ .../org/usvm/machine/TsClassCoverageTest.kt | 42 ++++++ .../main/kotlin/org/usvm/UMachineOptions.kt | 10 ++ 5 files changed, 214 insertions(+), 16 deletions(-) create mode 100644 usvm-core/src/test/kotlin/org/usvm/ps/ConstantTimeFairPathSelectorQuantumTest.kt diff --git a/usvm-core/src/main/kotlin/org/usvm/ps/ConstantTimeFairPathSelector.kt b/usvm-core/src/main/kotlin/org/usvm/ps/ConstantTimeFairPathSelector.kt index cee40d42a0..8013846c87 100644 --- a/usvm-core/src/main/kotlin/org/usvm/ps/ConstantTimeFairPathSelector.kt +++ b/usvm-core/src/main/kotlin/org/usvm/ps/ConstantTimeFairPathSelector.kt @@ -9,9 +9,9 @@ import kotlin.time.Duration * [UPathSelector] implementation which uses strategy similar to Linux O(1) scheduler * to switch between states with different [Key]s (for example, different entry point methods). * Keys are switched in round-robin fashion (so, all keys are guaranteed to be selected). Each key is given - * an equal time quantum which is calculated using the value returned by [getRemainingTime]. If such quantum is - * exceeded by some key, quantum for the remaining keys in round is recalculated (decreased equally). From other side, - * if some key takes less time than the quantum, another round is likely to happen. + * an equal time quantum calculated from [getRemainingTime] and bounded by [maxTimeQuantum]. An uncapped overrun is + * redistributed over the remaining keys; capped turns recompute their share of the actual remaining budget. + * If a key finishes before its quantum ends, there may be time for another round. * Spent time is estimated as timespan between successive peeks. Keys in queue are sorted by [KeyPriority] and total time spent. * * @param initialKeys complete set of [Key]s which will be used with this instance. Operations with states having other keys @@ -22,6 +22,7 @@ import kotlin.time.Duration * @param getKeyPriority returns priority by key. Priority can change over time. Smaller values have greater priority. * @param basePathSelectorFactory function to create a [UPathSelector] associated with specific key. States * with the same key are maintained in path selector created by this function. + * @param maxTimeQuantum upper bound for each turn. It cannot interrupt an in-flight interpreter step. */ class ConstantTimeFairPathSelector>( initialKeys: Set, @@ -29,7 +30,8 @@ class ConstantTimeFairPathSelector Duration, getKey: (State) -> Key, private val getKeyPriority: (Key) -> KeyPriority, - basePathSelectorFactory: (Key) -> UPathSelector + basePathSelectorFactory: (Key) -> UPathSelector, + private val maxTimeQuantum: Duration = Duration.INFINITE, ) : KeyedPathSelector(getKey) { private data class KeysQueueElement(val key: Key, var priority: KeyPriority, var elapsed: Duration) @@ -44,16 +46,19 @@ class ConstantTimeFairPathSelector Duration.ZERO) { "maxTimeQuantum must be positive" } stopwatch.reset() initialKeys.forEach { pathSelectors[it] = basePathSelectorFactory(it) activeQueue.add(KeysQueueElement(it, getKeyPriority(it), Duration.ZERO)) } val pathSelectorsCount = pathSelectors.size - val remainingTime = getRemainingTime() - currentTimeQuantum = remainingTime / pathSelectorsCount + currentTimeQuantum = calculateTimeQuantum(pathSelectorsCount) } + private fun calculateTimeQuantum(keysCount: Int): Duration = + minOf(getRemainingTime() / keysCount, maxTimeQuantum) + private fun peekFromQueues(): KeysQueueElement { check(activeQueue.isNotEmpty() || expiredList.isNotEmpty()) { "Trying to peek from empty path selector" } if (activeQueue.isEmpty()) { @@ -62,8 +67,7 @@ class ConstantTimeFairPathSelector currentTimeQuantum && activeQueue.isNotEmpty()) { - currentTimeQuantum -= (stopwatch.elapsed - currentTimeQuantum) / activeQueue.size + currentTimeQuantum = if (maxTimeQuantum.isFinite()) { + calculateTimeQuantum(activeQueue.size) + } else { + currentTimeQuantum - (stopwatch.elapsed - currentTimeQuantum) / activeQueue.size + } } currentKey = peekFromQueues() currentPathSelector = pathSelectors[currentKey.key] diff --git a/usvm-core/src/main/kotlin/org/usvm/ps/PathSelectorFactory.kt b/usvm-core/src/main/kotlin/org/usvm/ps/PathSelectorFactory.kt index 2f4c528063..4d31d340c7 100644 --- a/usvm-core/src/main/kotlin/org/usvm/ps/PathSelectorFactory.kt +++ b/usvm-core/src/main/kotlin/org/usvm/ps/PathSelectorFactory.kt @@ -174,7 +174,10 @@ fun createPathSelector( callGraphStatisticsFactory: () -> CallGraphStatistics? = { null }, loopStatisticFactory: () -> StateLoopTracker<*, Statement, State>? = { null }, ): UPathSelector where Target : UTarget, State : UState<*, Method, Statement, *, Target, State> { - if (options.timeout == Duration.INFINITE || initialStates.size == 1) { + val hasSingleEntryPoint = initialStates.size == 1 + val hasUnlimitedScheduling = options.timeout == Duration.INFINITE && + options.maxEntryPointTimeQuantum == Duration.INFINITE + if (hasSingleEntryPoint || hasUnlimitedScheduling) { return createPathSelector( initialStates.values.toList(), options, @@ -213,12 +216,13 @@ fun createPathSelector( } ?: { 0f } ConstantTimeFairPathSelector( - initialStates.keys, - RealTimeStopwatch(), - ::getRemainingTimeMs, - { it.entrypoint }, - getMethodCoverage, - ::createBasePathSelector + initialKeys = initialStates.keys, + stopwatch = RealTimeStopwatch(), + getRemainingTime = ::getRemainingTimeMs, + getKey = { it.entrypoint }, + getKeyPriority = getMethodCoverage, + basePathSelectorFactory = ::createBasePathSelector, + maxTimeQuantum = options.maxEntryPointTimeQuantum, ) } @@ -231,6 +235,10 @@ fun createPathSelector( ) } + if (!options.removeCoveredEntryPoints) { + return pathSelector + } + val totalCoveragePercents = 100f when (options.coverageZone) { CoverageZone.METHOD -> { diff --git a/usvm-core/src/test/kotlin/org/usvm/ps/ConstantTimeFairPathSelectorQuantumTest.kt b/usvm-core/src/test/kotlin/org/usvm/ps/ConstantTimeFairPathSelectorQuantumTest.kt new file mode 100644 index 0000000000..f5e7a5bddb --- /dev/null +++ b/usvm-core/src/test/kotlin/org/usvm/ps/ConstantTimeFairPathSelectorQuantumTest.kt @@ -0,0 +1,130 @@ +package org.usvm.ps + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class ConstantTimeFairPathSelectorQuantumTest { + @Test + fun `bounded turns revisit every key and retain its pending states`() { + val stopwatch = TestStopwatch() + var remainingSeconds = 60L + val selector = selector( + stopwatch = stopwatch, + remainingTime = { remainingSeconds.seconds }, + maxTimeQuantum = 2.seconds, + ) + selector.add(states = listOf("a1", "a2", "b1", "c1")) + + val visited = List(size = 12) { index -> + val state = selector.peek() + if (index == 1) { + selector.remove(state = state) + } + stopwatch.elapsedRaw++ + remainingSeconds-- + state + } + + assertEquals( + expected = listOf("a1", "a1", "b1", "b1", "c1", "c1", "a2", "a2", "b1", "b1", "c1", "c1"), + actual = visited, + ) + } + + @Test + fun `an overrun preserves bounded turns for the remaining keys`() { + val stopwatch = TestStopwatch() + var remainingSeconds = 100L + val selector = selector( + stopwatch = stopwatch, + remainingTime = { remainingSeconds.seconds }, + maxTimeQuantum = 2.seconds, + ) + selector.add(states = listOf("a1", "b1", "c1")) + assertEquals(expected = "a1", actual = selector.peek()) + + stopwatch.elapsedRaw = 20L + remainingSeconds = 80L + val afterOverrun = selector.peek() + stopwatch.elapsedRaw = 1L + remainingSeconds = 79L + val withinNextTurn = selector.peek() + stopwatch.elapsedRaw = 2L + remainingSeconds = 78L + val afterNextTurn = selector.peek() + + assertEquals(expected = "b1", actual = afterOverrun) + assertEquals(expected = "b1", actual = withinNextTurn) + assertEquals(expected = "c1", actual = afterNextTurn) + } + + @Test + fun `the remaining budget can shorten the configured turn`() { + val stopwatch = TestStopwatch() + var remainingSeconds = 3L + val selector = selector( + stopwatch = stopwatch, + remainingTime = { remainingSeconds.seconds }, + maxTimeQuantum = 2.seconds, + ) + selector.add(states = listOf("a1", "b1", "c1")) + + val visited = List(size = 3) { + val state = selector.peek() + stopwatch.elapsedRaw++ + remainingSeconds-- + state + } + + assertEquals(expected = listOf("a1", "b1", "c1"), actual = visited) + } + + @Test + fun `a finite turn also works with an unlimited total budget`() { + val stopwatch = TestStopwatch() + val selector = selector( + stopwatch = stopwatch, + remainingTime = { Duration.INFINITE }, + maxTimeQuantum = 2.seconds, + ) + selector.add(states = listOf("a1", "b1", "c1")) + assertEquals(expected = "a1", actual = selector.peek()) + + stopwatch.elapsedRaw = 2L + val nextState = selector.peek() + + assertEquals(expected = "b1", actual = nextState) + } + + @Test + fun `nonpositive turn bounds are rejected`() { + for (quantum in listOf(Duration.ZERO, -1.seconds)) { + val stopwatch = TestStopwatch() + + assertFailsWith { + selector( + stopwatch = stopwatch, + remainingTime = { 30.seconds }, + maxTimeQuantum = quantum, + ) + } + } + } + + private fun selector( + stopwatch: TestStopwatch, + remainingTime: () -> Duration, + maxTimeQuantum: Duration, + ): ConstantTimeFairPathSelector = ConstantTimeFairPathSelector( + initialKeys = setOf('a', 'b', 'c'), + stopwatch = stopwatch, + getRemainingTime = remainingTime, + getKey = { state -> state.first() }, + getKeyPriority = { key -> key }, + basePathSelectorFactory = { BfsPathSelector() }, + maxTimeQuantum = maxTimeQuantum, + ) +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/TsClassCoverageTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/TsClassCoverageTest.kt index a507a1f9ca..442963cf50 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/TsClassCoverageTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/TsClassCoverageTest.kt @@ -5,6 +5,7 @@ import org.jacodb.ets.utils.EtsIrProvider import org.jacodb.ets.utils.loadEtsFileAutoConvert import org.junit.jupiter.api.Test import org.usvm.CoverageZone +import org.usvm.PathSelectionStrategy import org.usvm.StateCollectionStrategy import org.usvm.api.TsTestValue import org.usvm.util.TsMethodTestRunner @@ -12,6 +13,8 @@ import org.usvm.util.eq import org.usvm.util.getResourcePath import kotlin.test.assertEquals import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds import org.usvm.test.util.checkers.eq as exactly class TsClassCoverageTest : TsMethodTestRunner() { @@ -144,4 +147,43 @@ class TsClassCoverageTest : TsMethodTestRunner() { actual = statesByEntryPoint, ) } + + @Test + fun `budget search can retain pending states after entry method coverage`() { + val counts = analyzePendingEntryPoints(removeCoveredEntryPoints = false) + + assertEquals(expected = mapOf("ForeignCaller" to 2, "HelperCoverage" to 1), actual = counts) + } + + @Test + fun `covered entry point pruning retains its default behavior`() { + val counts = analyzePendingEntryPoints(removeCoveredEntryPoints = true) + + assertEquals(expected = mapOf("ForeignCaller" to 1, "HelperCoverage" to 1), actual = counts) + } + + private fun analyzePendingEntryPoints(removeCoveredEntryPoints: Boolean): Map { + val caller = getMethod(methodName = "entry", className = "ForeignCaller") + val sibling = getMethod(methodName = "unvisited", className = "HelperCoverage") + val schedulingOptions = options.copy( + pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), + coverageZone = CoverageZone.METHOD, + stateCollectionStrategy = StateCollectionStrategy.ALL, + stopOnCoverage = 0, + timeout = 10.seconds, + maxEntryPointTimeQuantum = 1.milliseconds, + removeCoveredEntryPoints = removeCoveredEntryPoints, + ) + + val result = TsMachine( + scene = scene, + options = schedulingOptions, + tsOptions = TsOptions(), + ).use { machine -> + machine.analyzeWithOutcome(methods = listOf(caller, sibling)) + } + + assertEquals(expected = TsAnalysisStopReason.EXHAUSTED, actual = result.stopReason) + return result.states.groupingBy { state -> state.entrypoint.signature.enclosingClass.name }.eachCount() + } } diff --git a/usvm-util/src/main/kotlin/org/usvm/UMachineOptions.kt b/usvm-util/src/main/kotlin/org/usvm/UMachineOptions.kt index 9964f00aef..0784b8d18f 100644 --- a/usvm-util/src/main/kotlin/org/usvm/UMachineOptions.kt +++ b/usvm-util/src/main/kotlin/org/usvm/UMachineOptions.kt @@ -263,4 +263,14 @@ data class UMachineOptions( * If set to false, exception will be suppressed and the failed state will be killed. * */ val throwExceptionOnStepFailure: Boolean = false, + /** + * Upper bound for a CONSTANT_TIME entry point turn. Switching happens between interpreter steps; + * an in-flight solver operation can exceed this quantum. INFINITE keeps the existing budget-based turns. + */ + val maxEntryPointTimeQuantum: Duration = Duration.INFINITE, + /** + * Remove an entry point's pending states after its configured coverage scope is fully covered. + * Disable this for budget/exhaustion experiments that must retain those states between turns. + */ + val removeCoveredEntryPoints: Boolean = true, ) From 106e4a918f378e8678d2a0d1340bcae44d8dc46f Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 14 Oct 2026 00:51:57 +0300 Subject: [PATCH 3/3] [TS] Target class coverage independently of wrapper entries --- .../kotlin/org/usvm/ps/PathSelectorFactory.kt | 7 +- .../statistics/StatisticsByMethodPrinter.kt | 9 ++- .../main/kotlin/org/usvm/machine/TsMachine.kt | 42 +++++++++--- .../main/kotlin/org/usvm/machine/TsOptions.kt | 6 ++ .../org/usvm/machine/TsClassCoverageTest.kt | 68 +++++++++++++++++++ .../samples/coverage/ClassCoverage.ts | 10 +++ 6 files changed, 131 insertions(+), 11 deletions(-) diff --git a/usvm-core/src/main/kotlin/org/usvm/ps/PathSelectorFactory.kt b/usvm-core/src/main/kotlin/org/usvm/ps/PathSelectorFactory.kt index 4d31d340c7..80e5f175a8 100644 --- a/usvm-core/src/main/kotlin/org/usvm/ps/PathSelectorFactory.kt +++ b/usvm-core/src/main/kotlin/org/usvm/ps/PathSelectorFactory.kt @@ -212,7 +212,12 @@ fun createPathSelector( val pathSelector = when (options.pathSelectorFairnessStrategy) { PathSelectorFairnessStrategy.CONSTANT_TIME -> { val getMethodCoverage = coverageStatistics?.let { - { m: Method -> coverageStatistics.getMethodCoverage(m) } + val trackedMethods = coverageStatistics.coverageZone.toHashSet() + val entryPointCoverage: (Method) -> Float = { method -> + if (method in trackedMethods) coverageStatistics.getMethodCoverage(method) else 0f + } + + entryPointCoverage } ?: { 0f } ConstantTimeFairPathSelector( diff --git a/usvm-core/src/main/kotlin/org/usvm/statistics/StatisticsByMethodPrinter.kt b/usvm-core/src/main/kotlin/org/usvm/statistics/StatisticsByMethodPrinter.kt index 4ae34e2d33..14893480d7 100644 --- a/usvm-core/src/main/kotlin/org/usvm/statistics/StatisticsByMethodPrinter.kt +++ b/usvm-core/src/main/kotlin/org/usvm/statistics/StatisticsByMethodPrinter.kt @@ -20,10 +20,17 @@ class StatisticsByMethodPrinter() { + init { + val hasCompatibleCoverageScope = tsOptions.classCoverageTargets == null || + options.coverageZone == CoverageZone.CLASS + require(hasCompatibleCoverageScope) { "Explicit class coverage targets require CoverageZone.CLASS" } + } + private val resolvedUnknownCallModels = when { unknownCallDispatcher != null -> null unknownCallModels != null -> unknownCallModels @@ -169,15 +175,7 @@ class TsMachine( val methodsToTrackCoverage = when (options.coverageZone) { CoverageZone.METHOD, CoverageZone.TRANSITIVE -> initialStates.keys.toHashSet() - CoverageZone.CLASS -> initialStates.keys.flatMapTo(hashSetOf()) { method -> - val owner = requireNotNull(method.enclosingClass) { - "Class coverage requires a declaring class for ${method.signature}" - } - - owner.methods.filter { candidate -> - candidate.signature.enclosingClass == owner.signature && candidate.cfg.stmts.isNotEmpty() - } - } + initialStates.keys + CoverageZone.CLASS -> classCoverageMethods(entryMethods = initialStates.keys) } val coverageStatistics = CoverageStatistics( @@ -298,6 +296,32 @@ class TsMachine( ) } + private fun classCoverageMethods(entryMethods: Set): Set { + val targets = tsOptions.classCoverageTargets + val classes = if (targets == null) { + entryMethods.map { method -> + requireNotNull(method.enclosingClass) { + "Class coverage requires a declaring class for ${method.signature}" + } + } + } else { + val classesBySignature = runtimeScene.projectAndSdkClasses.groupBy { owner -> owner.signature } + val unresolvedTargets = targets.filter { target -> classesBySignature[target]?.size != 1 } + require(unresolvedTargets.isEmpty()) { + "Class coverage targets must identify one scene class each: $unresolvedTargets" + } + + targets.map { target -> classesBySignature.getValue(target).single() } + } + val coveredMethods = classes.flatMapTo(hashSetOf()) { owner -> + owner.methods.filter { candidate -> + candidate.signature.enclosingClass == owner.signature && candidate.cfg.stmts.isNotEmpty() + } + } + + return if (targets == null) coveredMethods + entryMethods else coveredMethods + } + private fun emptyAnalysisResult(unsupportedPaths: Set) = TsMachineAnalysisResult( states = emptyList(), stopReason = TsAnalysisStopReason.EXHAUSTED, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt index 7075187b1f..6334667b8e 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt @@ -1,5 +1,6 @@ package org.usvm.machine +import org.jacodb.ets.model.EtsClassSignature import org.usvm.machine.call.TsResidualCallPolicy import org.usvm.machine.call.TsUnknownCallModelSelection @@ -29,8 +30,13 @@ data class TsOptions( val dateNowMilliseconds: Double? = null, /** Erase TypeScript assertions; false retains the existing ETS cast constraints and unsupported casts. */ val eraseTypeAssertions: Boolean = false, + /** CLASS coverage targets for wrapper entry points; null uses the entry points' declaring classes. */ + val classCoverageTargets: Set? = null, ) { init { + val hasCoverageTargets = classCoverageTargets == null || classCoverageTargets.isNotEmpty() + require(hasCoverageTargets) { "Explicit class coverage targets must not be empty" } + val isValidDateNow = dateNowMilliseconds == null || dateNowMilliseconds.isFinite() && dateNowMilliseconds % 1.0 == 0.0 && diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/TsClassCoverageTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/TsClassCoverageTest.kt index 442963cf50..ab0bf48b8e 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/TsClassCoverageTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/TsClassCoverageTest.kt @@ -12,6 +12,7 @@ import org.usvm.util.TsMethodTestRunner import org.usvm.util.eq import org.usvm.util.getResourcePath import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -162,6 +163,73 @@ class TsClassCoverageTest : TsMethodTestRunner() { assertEquals(expected = mapOf("ForeignCaller" to 1, "HelperCoverage" to 1), actual = counts) } + @Test + fun `explicit class targets collect private and constructor paths through wrappers`() { + val helper = getMethod(methodName = "helperCase", className = "CoverageHarness") + val constructor = getMethod(methodName = "constructorCase", className = "CoverageHarness") + val targets = scene.projectClasses.filter { owner -> + owner.name == "HelperCoverage" || owner.name == "ConstructorCoverage" + }.mapTo(hashSetOf()) { owner -> owner.signature } + val classOptions = options.copy( + pathSelectionStrategies = listOf(PathSelectionStrategy.CLOSEST_TO_UNCOVERED_RANDOM), + coverageZone = CoverageZone.CLASS, + stateCollectionStrategy = StateCollectionStrategy.COVERED_NEW, + stopOnCoverage = 0, + timeout = 10.seconds, + maxEntryPointTimeQuantum = 1.milliseconds, + removeCoveredEntryPoints = false, + ) + + val result = TsMachine( + scene = scene, + options = classOptions, + tsOptions = TsOptions(classCoverageTargets = targets), + ).use { machine -> + machine.analyzeWithOutcome(methods = listOf(helper, constructor)) + } + + val statesByEntryPoint = result.states.groupingBy { state -> state.entrypoint.name }.eachCount() + assertEquals(expected = mapOf("helperCase" to 2, "constructorCase" to 2), actual = statesByEntryPoint) + } + + @Test + fun `an unknown class target is rejected instead of falling back to the wrapper`() { + val method = getMethod(methodName = "entry", className = "HelperCoverage") + val missingTarget = method.signature.enclosingClass.copy(name = "MissingCoverageClass") + + assertFailsWith { + TsMachine( + scene = scene, + options = options.copy(coverageZone = CoverageZone.CLASS), + tsOptions = TsOptions(classCoverageTargets = setOf(missingTarget)), + ).use { machine -> + machine.analyzeWithOutcome(methods = listOf(method)) + } + } + } + + @Test + fun `an explicit empty class target set is rejected`() { + assertFailsWith { + TsOptions(classCoverageTargets = emptySet()) + } + } + + @Test + fun `explicit class targets cannot silently use method coverage`() { + val method = getMethod(methodName = "entry", className = "HelperCoverage") + + assertFailsWith { + TsMachine( + scene = scene, + options = options.copy(coverageZone = CoverageZone.METHOD), + tsOptions = TsOptions(classCoverageTargets = setOf(method.signature.enclosingClass)), + ).use { machine -> + machine.analyzeWithOutcome(methods = listOf(method)) + } + } + } + private fun analyzePendingEntryPoints(removeCoveredEntryPoints: Boolean): Map { val caller = getMethod(methodName = "entry", className = "ForeignCaller") val sibling = getMethod(methodName = "unvisited", className = "HelperCoverage") diff --git a/usvm-ts/src/test/resources/samples/coverage/ClassCoverage.ts b/usvm-ts/src/test/resources/samples/coverage/ClassCoverage.ts index d5f07af3be..c0b80be768 100644 --- a/usvm-ts/src/test/resources/samples/coverage/ClassCoverage.ts +++ b/usvm-ts/src/test/resources/samples/coverage/ClassCoverage.ts @@ -52,3 +52,13 @@ class ForeignHelper { return 20; } } + +class CoverageHarness { + static helperCase(value: number): number { + return HelperCoverage.entry(value); + } + + static constructorCase(value: number): number { + return ConstructorCoverage.entry(value); + } +}