Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -345,8 +345,13 @@ private fun coverageCheckpoints(
search.candidates[index].emittedAtMillis <= checkpoint
}
val replayed = indices.mapNotNull { index -> events[index].replay }
val coveredStatements = replayed.flatMap { it.coveredStatementKeys }.toSet()
val coveredIfArms = replayed.flatMap { it.coveredIfArmKeys }.toSet()
val accepted = indices.mapNotNull { index ->
val event = events[index]
val expectedCompletion = search.candidates[index].completion
event.replay?.takeIf { replay -> event.error == null && replay.completion == expectedCompletion }
}
val coveredStatements = accepted.flatMap { it.coveredStatementKeys }.toSet()
val coveredIfArms = accepted.flatMap { it.coveredIfArmKeys }.toSet()
require(universe.supportedStatementKeys.containsAll(coveredStatements)) {
"Replayed statements extend beyond the frozen coverage universe"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package org.usvm.ts.calls

import kotlinx.serialization.Serializable
import org.jacodb.ets.model.EtsClassSignature
import org.jacodb.ets.model.EtsStmt
import java.nio.file.Path

/** Selects symbolic candidate novelty; original-source coverage still uses its separate frozen universe. */
@Serializable
internal data class CallsSymbolicCoverageClass(
val sourceFile: String,
val className: String,
) {
init {
val sourcePath = Path.of(sourceFile)
val isRelativeSource = sourceFile.isNotBlank() && !sourcePath.isAbsolute &&
sourcePath.normalize() == sourcePath && !sourcePath.startsWith("..") && sourceFile != "."
require(isRelativeSource) { "Symbolic coverage class source must be a normalized relative path" }
require(className.isNotBlank()) { "Symbolic coverage class name must not be blank" }
}
}

internal sealed interface CallsSymbolicCoverageScope {
data class Selected(
val classSignature: EtsClassSignature?,
val statements: Set<EtsStmt>,
) : CallsSymbolicCoverageScope

data class Rejected(
val status: CallsCoverageSearchStatus,
val diagnostic: String,
) : CallsSymbolicCoverageScope
}

internal fun resolveCallsSymbolicCoverageScope(
preparation: CallsFunctionPreparation.Eligible,
target: CallsSymbolicCoverageClass?,
): CallsSymbolicCoverageScope {
if (target == null) {
return CallsSymbolicCoverageScope.Selected(
classSignature = null,
statements = preparation.method.cfg.stmts.toSet(),
)
}

val targetPath = target.sourceFile.replace('\\', '/')
val classes = preparation.scene.projectClasses.filter { owner ->
owner.name == target.className && preparation.sourcePaths[owner.signature.file] == targetPath
}
if (classes.size != 1) {
val status = if (classes.isEmpty()) CallsCoverageSearchStatus.UNMAPPED else CallsCoverageSearchStatus.AMBIGUOUS
return CallsSymbolicCoverageScope.Rejected(
status = status,
diagnostic = "Symbolic coverage class $targetPath:${target.className} resolves to ${classes.size} classes",
)
}

val owner = classes.single()
val methods = owner.methods.filter { method -> method.signature.enclosingClass == owner.signature }
val statements = methods.flatMapTo(hashSetOf()) { method -> method.cfg.stmts }
if (statements.isEmpty()) {
return CallsSymbolicCoverageScope.Rejected(
status = CallsCoverageSearchStatus.UNSUPPORTED,
diagnostic = "Symbolic coverage class $targetPath:${target.className} has no executable statements",
)
}

return CallsSymbolicCoverageScope.Selected(classSignature = owner.signature, statements = statements)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package org.usvm.ts.calls
import kotlinx.serialization.Serializable
import org.jacodb.ets.model.EtsMethod
import org.jacodb.ets.model.EtsStmt
import org.usvm.CoverageZone
import org.usvm.SolverType
import org.usvm.StateCollectionStrategy
import org.usvm.UMachineOptions
Expand Down Expand Up @@ -90,26 +91,26 @@ internal data class CallsCoverageSearchRequest(
}
}

/** Runs one function without a return target and records completed coverage-novel paths. */
/** Runs one function and records completed paths adding coverage in its declared symbolic scope. */
internal class CurrentTsCallsCoverageEngine(
private val sourceEngine: CurrentTsCallsSymbolicEngine = CurrentTsCallsSymbolicEngine(),
) {
fun search(request: CallsCoverageSearchRequest): CallsCoverageSearchResult {
val preparationStarted = TimeSource.Monotonic.markNow()
val preparation = sourceEngine.prepareFunction(
CallsFunctionPreflightRequest(
sourceRoot = request.sourceRoot,
project = request.project,
function = request.function,
expectedNativeFrontendRevision = request.expectedNativeFrontendRevision,
),
)
val preparationElapsedMillis = preparationStarted.elapsedNow().inWholeMilliseconds
val preparation = sourceEngine.prepareFunction(request.toFunctionPreflightRequest())
if (preparation is CallsFunctionPreparation.Rejected) {
return preparation.coverageResult(preparationElapsedMillis = preparationElapsedMillis)
return preparation.coverageResult(
preparationElapsedMillis = preparationStarted.elapsedNow().inWholeMilliseconds,
)
}

preparation as CallsFunctionPreparation.Eligible
val scope = resolveCallsSymbolicCoverageScope(preparation, target = request.function.symbolicCoverageClass)
val preparationElapsedMillis = preparationStarted.elapsedNow().inWholeMilliseconds
if (scope is CallsSymbolicCoverageScope.Rejected) {
return emptyCoverageResult(scope.status, preparationElapsedMillis, scope.diagnostic)
}
scope as CallsSymbolicCoverageScope.Selected
val symbolicInputs = CallsSymbolicInputs(
inputs = request.function.inputs,
bindings = preparation.inputBindings,
Expand All @@ -122,6 +123,7 @@ internal class CurrentTsCallsCoverageEngine(
val machineCallStarted = TimeSource.Monotonic.markNow()
val observer = CompletedCoverageCandidateObserver(
method = preparation.method,
coverageStatements = scope.statements,
inputDomains = request.function.inputs,
symbolicInputs = symbolicInputs,
candidateCap = request.candidateCap,
Expand All @@ -145,7 +147,7 @@ internal class CurrentTsCallsCoverageEngine(
}
}
val inputCapacity = request.function.inputs.maxOfOrNull { it.domain.callsInputCapacity() } ?: 0
val machineOptions = coverageMachineOptions(request)
val machineOptions = coverageMachineOptions(request, scope)
val machineResult = runCatching {
TsMachine(
scene = symbolicInputs.scene,
Expand All @@ -154,6 +156,7 @@ internal class CurrentTsCallsCoverageEngine(
maxArraySize = maxOf(TsOptions().maxArraySize, inputCapacity),
unknownCallModelSelection = modelSelection,
unknownCallFallback = request.profile.fallback,
classCoverageTargets = scope.classSignature?.let { signature -> setOf(signature) },
),
machineObserver = observer,
observer = interpreterObserver,
Expand Down Expand Up @@ -197,7 +200,10 @@ internal class CurrentTsCallsCoverageEngine(
)
}

private fun coverageMachineOptions(request: CallsCoverageSearchRequest): UMachineOptions =
private fun coverageMachineOptions(
request: CallsCoverageSearchRequest,
scope: CallsSymbolicCoverageScope.Selected,
): UMachineOptions =
UMachineOptions(
pathSelectionStrategies = listOf(CALLS_PATH_SELECTION_STRATEGY),
stateCollectionStrategy = StateCollectionStrategy.COVERED_NEW,
Expand All @@ -206,16 +212,36 @@ internal class CurrentTsCallsCoverageEngine(
solverTimeout = request.solverQueryLimit,
solverType = request.solverType,
stopOnCoverage = CALLS_STOP_ON_COVERAGE,
coverageZone = if (scope.classSignature == null) CoverageZone.METHOD else CoverageZone.CLASS,
removeCoveredEntryPoints = scope.classSignature == null,
stopOnTargetsReached = false,
throwExceptionOnStepFailure = true,
)
}

private fun CallsCoverageSearchRequest.toFunctionPreflightRequest(): CallsFunctionPreflightRequest =
CallsFunctionPreflightRequest(
sourceRoot = sourceRoot,
project = project,
function = function,
expectedNativeFrontendRevision = expectedNativeFrontendRevision,
)

private fun CallsFunctionPreparation.Rejected.coverageResult(
preparationElapsedMillis: Long,
): CallsCoverageSearchResult = emptyCoverageResult(
status = status.toCoverageStatus(),
preparationElapsedMillis = preparationElapsedMillis,
diagnostic = "$reasonCode: $diagnostic",
)

private fun emptyCoverageResult(
status: CallsCoverageSearchStatus,
preparationElapsedMillis: Long,
diagnostic: String,
): CallsCoverageSearchResult {
return CallsCoverageSearchResult(
status = status.toCoverageStatus(),
status = status,
candidates = emptyList(),
selectedStates = 0,
executedSteps = 0,
Expand All @@ -226,12 +252,13 @@ private fun CallsFunctionPreparation.Rejected.coverageResult(
machineSetupElapsedMillis = 0,
searchElapsedMillis = 0,
machineTeardownElapsedMillis = 0,
diagnostic = "$reasonCode: $diagnostic",
diagnostic = diagnostic,
)
}

private class CompletedCoverageCandidateObserver(
private val method: EtsMethod,
private val coverageStatements: Set<EtsStmt>,
private val inputDomains: List<org.usvm.ts.pbt.model.PropertyInput>,
private val symbolicInputs: CallsSymbolicInputs,
private val candidateCap: Int,
Expand All @@ -240,7 +267,6 @@ private class CompletedCoverageCandidateObserver(
private val onCandidate: (CallsCoverageCandidate) -> Unit,
private val onExtractionFailure: (String) -> Unit,
) : UMachineObserver<TsState> {
private val entryStatements: Set<EtsStmt> = method.cfg.stmts.toSet()
private val coveredStatements = hashSetOf<EtsStmt>()
private val inputKeys = hashSetOf<String>()
private var machineStarted: TimeSource.Monotonic.ValueTimeMark? = null
Expand Down Expand Up @@ -295,7 +321,7 @@ private class CompletedCoverageCandidateObserver(
}
}
val pathStatements = state.pathNode.allStatements.filterTo(hashSetOf()) { statement ->
statement in entryStatements
statement in coverageStatements
}
val newStatements = pathStatements.count { statement -> statement !in coveredStatements }
if (newStatements == 0) return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ internal data class CallsFunctionCase(
val entryPoint: TypeScriptEntryPoint,
val inputs: List<PropertyInput>,
val targets: List<CallsSourceTarget>,
/** Optional target class for coverage-novel symbolic candidates through this function. */
val symbolicCoverageClass: CallsSymbolicCoverageClass? = null,
)

@Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ internal sealed interface CallsSourceProject {
val scene: EtsScene,
val entryModule: String,
val sourceByFile: Map<EtsFileSignature, String>,
val sourcePaths: Map<EtsFileSignature, String>,
) : CallsSourceProject

data class Unsupported(val issue: CallsIrReadinessIssue) : CallsSourceProject
Expand All @@ -42,6 +43,7 @@ internal fun loadCallsSourceProject(sourceRoot: Path, source: Path): CallsSource
scene = EtsScene(projectFiles = listOf(entry)),
entryModule = source.fileName.toString(),
sourceByFile = mapOf(entry.signature to Files.readString(source)),
sourcePaths = mapOf(entry.signature to entryModule.replace('\\', '/')),
)
}

Expand Down Expand Up @@ -72,20 +74,26 @@ internal fun loadCallsSourceProject(sourceRoot: Path, source: Path): CallsSource
} finally {
generatedIr.toFile().deleteRecursively()
}
val sourceByFile = scene.projectFiles.associate { file ->
val sourcePaths = scene.projectFiles.associate { file ->
val filePath = Path.of(file.signature.fileName)
val relative = if (filePath.isAbsolute) {
temporaryProject.relativize(filePath).toString()
} else {
filePath.toString()
}
file.signature to requireNotNull(sources[relative]) {
require(relative in sources) {
"Frontend source identity is outside the inspected closure: ${file.signature}"
}
file.signature to relative
}
require(sourceByFile.size == closure.files.size) { "Frontend omitted or duplicated a dependency source" }
require(sourcePaths.size == closure.files.size) { "Frontend omitted or duplicated a dependency source" }

return CallsSourceProject.Loaded(scene = scene, entryModule = entryModule, sourceByFile = sourceByFile)
return CallsSourceProject.Loaded(
scene = scene,
entryModule = entryModule,
sourceByFile = sourcePaths.mapValues { (_, relative) -> sources.getValue(relative) },
sourcePaths = sourcePaths.mapValues { (_, relative) -> relative.replace('\\', '/') },
)
} finally {
temporaryProject.toFile().deleteRecursively()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.usvm.ts.calls

import mu.KotlinLogging
import org.jacodb.ets.model.EtsFileSignature
import org.jacodb.ets.model.EtsLexicalEnvType
import org.jacodb.ets.model.EtsMethod
import org.jacodb.ets.model.EtsReturnStmt
Expand Down Expand Up @@ -56,6 +57,7 @@ internal sealed interface CallsFunctionPreparation {
val inputBindings: List<EtsInputBinding>,
val lexicalEnvironment: EtsLexicalEnvironmentBinding?,
val captures: List<TypeScriptSourceCapture>,
val sourcePaths: Map<EtsFileSignature, String>,
) : CallsFunctionPreparation

data class Rejected(
Expand Down Expand Up @@ -409,6 +411,7 @@ internal class CurrentTsCallsSymbolicEngine(
inputBindings = mappedTarget.bindings.inputs,
lexicalEnvironment = lexicalEnvironment,
captures = sourceCaptures,
sourcePaths = loaded.sourcePaths,
)
}

Expand Down
Loading
Loading