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/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, 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'); 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..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 @@ -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") } } @@ -282,6 +288,8 @@ 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, + private val frontendRuntimeVerifier: (String) -> Unit = ::verifyCallsExternalFrontendRuntime, ) { fun run( manifest: CallsExperimentManifest, @@ -348,6 +356,15 @@ internal class CallsExperimentRunner( resultRows = resultRows, ), ) + + manifest.projects.forEach { project -> + val sourceRoot = manifestDirectory.resolve(project.sourceRoot).normalize().toRealPath() + sourceCheckoutVerifier(sourceRoot, project.revision) + } + if (!manifest.nativeFrontendRevision.startsWith("bundled:")) { + frontendRuntimeVerifier(manifest.nativeFrontendRevision) + } + Files.move( partialOutput, rawOutput, @@ -415,6 +432,8 @@ internal class CallsExperimentRunner( profile: CallsExperimentProfile, appendUnknownCall: (CallsRawRecord) -> Unit, ): CallsTargetResult { + sourceCheckoutVerifier(sourceRoot, project.revision) + val request = CallsSymbolicSearchRequest( sourceRoot = sourceRoot, project = project, @@ -443,14 +462,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( @@ -543,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, 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..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 @@ -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()) } } @@ -82,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 @@ -91,6 +95,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 +112,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/CallsInputTypeCompatibility.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputTypeCompatibility.kt index efb979aea2..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 @@ -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( @@ -91,16 +93,14 @@ 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 -> { - (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 +156,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 +172,99 @@ 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, + 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/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/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) 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/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( 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..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 @@ -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" } @@ -126,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/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt index 1e1375a164..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 @@ -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() @@ -209,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) }, ) @@ -217,6 +219,7 @@ internal class CurrentTsCallsSymbolicEngine( states = targetObserver.reachedStates, stopReason = outcome.stopReason, unsupportedPaths = outcome.unsupportedPaths, + unsupportedCall = outcome.unsupportedCall, ) } val states = analysis.states @@ -224,9 +227,17 @@ internal class CurrentTsCallsSymbolicEngine( val status = when (analysis.stopReason) { TsAnalysisStopReason.EXHAUSTED -> { when { - interpreterObserver.runtimeLimitations.isNotEmpty() -> CallsSymbolicStatus.RUNTIME_LIMITATION - analysis.unsupportedPaths.isNotEmpty() -> 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. @@ -278,9 +289,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, @@ -305,11 +321,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, ) } @@ -320,7 +337,8 @@ 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" } @@ -441,11 +459,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, ) } @@ -525,8 +544,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 && @@ -553,8 +572,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) @@ -571,8 +590,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) @@ -621,7 +640,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" @@ -630,11 +649,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 } @@ -642,13 +663,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( @@ -753,10 +768,12 @@ internal class CurrentTsCallsSymbolicEngine( val states: List, val stopReason: TsAnalysisStopReason, val unsupportedPaths: List, + val unsupportedCall: Boolean, ) private companion object { const val BUNDLED_FRONTEND_PREFIX: String = "bundled:" + const val ETS_FRONTEND_DIR_PROPERTY: String = "ets.frontend.dir" } } @@ -783,8 +800,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()) { @@ -800,8 +817,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 @@ -837,6 +854,87 @@ 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(::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" } + + 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 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 { val process = ProcessBuilder(listOf("git", "-C", checkout.toString()) + arguments) .redirectErrorStream(true) 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 371417f10e..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,7 +1,5 @@ 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.Test @@ -11,10 +9,11 @@ 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.nio.file.Files import java.nio.file.Path import kotlin.test.assertEquals @@ -24,6 +23,103 @@ 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) + 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 HEAD -- 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 HEAD -- failed")) + assertFalse(Files.exists(rawOutput)) + } + @Test fun `runner forwards an explicitly configured solver`(@TempDir directory: Path) { val solvers = mutableListOf() @@ -37,6 +133,7 @@ class CallsExperimentTest { symbolicEngine = engine, targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> error("No witness to replay") }, runtimeToolRevision = FIXTURE_TOOL_REVISION, + sourceCheckoutVerifier = { _, _ -> }, ).run( manifest = z3Manifest, manifestDirectory = directory, @@ -74,6 +171,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 +208,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, @@ -121,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() @@ -207,6 +257,7 @@ class CallsExperimentTest { symbolicEngine = engine, targetReplayer = replayer, runtimeToolRevision = FIXTURE_TOOL_REVISION, + sourceCheckoutVerifier = { _, _ -> }, ).run( manifest = manifest(sourceRoot = ".", seeds = listOf(1L)), manifestDirectory = directory, @@ -257,376 +308,20 @@ 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, - ).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 `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, - ).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, - ).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 `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, - ).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, - ).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 manifest(sourceRoot: String, seeds: List) = CallsExperimentManifest( - schemaVersion = CallsExperimentManifest.SCHEMA_VERSION, - experimentId = "fixture", - toolRevision = FIXTURE_TOOL_REVISION, - nativeFrontendRevision = "frontend-revision", - 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) { + private fun frozenGitManifest(directory: Path): CallsExperimentManifest { + val sourceRoot = Files.createDirectory(directory.resolve("source")) Files.writeString( - path, - records.joinToString(separator = "\n", postfix = "\n") { record -> - CallsExperimentJson.json.encodeToString(record) - }, + sourceRoot.resolve("fixture.ts"), + "export function predicate(value: boolean): boolean { return value; }", ) - } - - 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" + 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))) } } 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/CallsObjectInputsTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsObjectInputsTest.kt index 077e66ee0d..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 @@ -54,6 +57,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( @@ -122,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-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( 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/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, + ) +} 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..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,17 +1,21 @@ 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 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 import kotlin.test.assertIs import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue internal class CurrentTsCallsSymbolicEngineTest : CurrentTsCallsSymbolicEngineTestSupport() { @@ -22,10 +26,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 +42,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( @@ -395,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( 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 } 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; 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-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/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/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/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/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) } 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/PropertyDomain.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDomain.kt index 3f18a4c3b5..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. @@ -113,6 +115,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/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-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) } 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/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, + ) } } 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/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 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..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 @@ -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) @@ -115,6 +117,165 @@ 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 `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") + 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 `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") + 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") @@ -259,12 +420,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 } 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..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 @@ -54,8 +54,21 @@ 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 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/usvm/UsvmCollectionDomainProjectorTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/usvm/UsvmCollectionDomainProjectorTest.kt index 8c2ca6d260..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 @@ -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,39 @@ 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/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", 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..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 @@ -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,37 @@ 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", 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)), 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: 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") } } } 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/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/main/kotlin/org/usvm/census/UnknownCallCensusArtifacts.kt b/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusArtifacts.kt index c6aa0b3c1a..3d894abfb3 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,51 @@ 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() + val validRunResult = validSchema && completed && matchingStart && hasFinish + if (!validRunResult) { + 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 +169,21 @@ 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) + val input = UnknownCallCensusInput(completeness = completeness, issues = inputIssues.toList()) + + return UnknownCallCensusSummary( + profiles = summaries, + input = input, + ) } } @@ -150,7 +224,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 +239,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 +262,7 @@ private class ProfileAccumulator { projects += projectId when (status) { + "completed" -> Unit "timeout" -> timeouts += issue "tool_error" -> errors += issue } @@ -204,6 +286,8 @@ private class ProfileAccumulator { ) } +private val projectResultStatuses = setOf("completed", "timeout", "tool_error") + private val issueComparator = compareBy( UnknownCallCensusIssue::projectId, { it.functionId.orEmpty() }, @@ -217,6 +301,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/main/kotlin/org/usvm/census/UnknownCallCensusProcess.kt b/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusProcess.kt index fe870c3174..8ad293d867 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,28 @@ 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) + val startedProcess = process + if (!completedNormally && startedProcess != null) { + 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 +138,32 @@ 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/main/kotlin/org/usvm/census/UnknownCallCensusRunner.kt b/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusRunner.kt index 970a67e3aa..9b28442a02 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) @@ -172,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") } } ) @@ -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/main/kotlin/org/usvm/machine/TsContext.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt index 9c260c84d0..96e56b0e26 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt @@ -46,15 +46,16 @@ 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 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.uctx import org.usvm.util.componentType import org.usvm.util.mkFieldLValue import kotlin.contracts.ExperimentalContracts @@ -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 { @@ -224,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 } /** @@ -243,6 +259,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 { @@ -308,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) } @@ -364,19 +397,14 @@ class TsContext( fun UConcreteHeapRef.extractRef(scope: TsStepScope): UHeapRef { 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 { 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/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt index 566d040c00..76d2d7ab2a 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,15 @@ 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/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/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 } 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/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/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( 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/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/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index 120776dd90..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,26 +1,34 @@ package org.usvm.machine.expr +import io.ksmt.expr.KFp64Value import io.ksmt.expr.KFpRoundingMode 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 +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.initializeArray 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.TsContext import org.usvm.machine.TsRuntimeFeatureLimitationReason import org.usvm.machine.TsSizeSort import org.usvm.machine.TsVirtualMethodCallStmt @@ -32,16 +40,22 @@ 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.TsStepScope +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 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 import org.usvm.util.forEachArrayPayloadRegion import org.usvm.util.initializeArrayKind import org.usvm.util.initializeTypedArrayOverrides @@ -142,17 +156,8 @@ internal fun TsExprResolver.tryApproximateInstanceCall( val iterator = tryApproximateBuiltinIteratorCall(stmt) if (iterator !is TsExprApproximationResult.NoApproximation) return iterator - // Mock `.toString()` method calls if (expr.callee.name == "toString") { - if (unknownCallDispatcher is TsUnknownCallModelDispatcher) { - 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 @@ -171,76 +176,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(expr, instanceType, elementSort, 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(expr, 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(expr)) - } - - // Handle `Array.slice() method calls - if (expr.callee.name == "slice") { - return from(handleArraySlice(expr, 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, elementSort, 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(expr, instanceType, array)) - } + return tryApproximateArrayCall(stmt, instanceType, array) } val dispatcher = unknownCallDispatcher @@ -266,10 +202,128 @@ 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, + 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 + + 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, - elementSort: USort, array: UHeapRef, ): TsExprApproximationResult { val dispatcher = unknownCallDispatcher @@ -278,7 +332,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) @@ -448,7 +502,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 @@ -458,13 +511,18 @@ 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) + + if (newState == PromiseState.FULFILLED) { + val existingPromise = existingPromiseOrEnsureNonThenable( + scope = scope, + value = value, + hierarchy = hierarchy, + operation = "Promise.resolve", + ) + if (existingPromise != null) return existingPromise } - promise + + createSettledPromise(scope = scope, value = value, settlement = newState) } private fun TsExprResolver.handleMathFloor(expr: EtsInstanceCallExpr): UExpr<*>? = with(ctx) { @@ -507,42 +565,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) } /** @@ -572,10 +642,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) } @@ -602,9 +668,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) @@ -643,35 +717,52 @@ 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) { - 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 + 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 + } } } @@ -681,58 +772,128 @@ private fun TsExprResolver.handleArrayFill( val lengthLValue = mkArrayLengthLValue(array, arrayType) scope.calcOnState { memory.read(lengthLValue) } } - val endBv: UExpr = 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 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 + } } } + val nonNegativeEndBound = nonNegativeArrayFillEndBound(end, endBv) + + val fillValue = ArrayFillValue( + nativeValue = nativeValue, + boxedValue = boxedValue, + hasResolvedElementSort = hasResolvedElementSort, + usesBoxedValue = usesBoxedValue, + ) + 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( + 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, + nonNegativeEndBound: Int?, +): UExpr<*>? = with(ctx) { scope.calcOnState { val descriptor = arrayDescriptorOf(arrayType) + val length = memory.read(mkArrayLengthLValue(array, arrayType)) + val zero = mkBv(0) - // Calculate the length of the range to fill - val fillLength = mkBvSubExpr(endBv, startBv) + 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) + } + + 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 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) + 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) - - // Fill the temporary array with the specified `value` - memory.initializeArray( - tempArray, - descriptor, - elementSort, - sizeSort, - (0 until tempSize).asSequence().map { value.asExpr(elementSort) } + val tempArray = initializeArrayFillTemporary( + sourceArray = array, + descriptor = descriptor, + elementSort = elementSort, + fillValue = fillValue, + tempSize = tempSize, ) // 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 = mkBv(0), - fromDst = startBv, + fillValue = fillValue, + fromDst = from, length = fillLength, ) - // This legacy fill path does not preserve boxed overrides or sparse-slot metadata. - knownArrayPropertyPresence -= array - initializedArrayIndices -= array + 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 @@ -740,6 +901,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 /** @@ -791,78 +1063,52 @@ 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 } -} -/** - * 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 - */ -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}" - } + mkBvToFpExpr( + sort = fp64Sort, + roundingMode = fpRoundingModeSortDefaultValue(), + value = newLength.asExpr(sizeSort), + signed = true, + ) +} +/** Compatibility approximation used when the source model dispatcher is unavailable. */ +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 { @@ -897,19 +1143,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) @@ -926,9 +1164,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) } } @@ -950,6 +1186,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) @@ -976,6 +1213,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) @@ -993,7 +1240,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 * @@ -1008,95 +1255,165 @@ private fun TsExprResolver.handleArrayConcat( array: UHeapRef, ): TsExprApproximationResult = with(ctx) { val elementSort = typeToSort(arrayType.componentType) - val arrayTypes = stmt.args.mapIndexed { index, arg -> - 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 - } else { - null + val arrayTypes = mutableListOf() + for ((index, arg) in stmt.args.withIndex()) { + 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 } - 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 { readConcatArgumentLengths(stmt.args, arrayTypes) } + 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( + copyConcatArraySource( srcRef = array, dstRef = resultArray, arrayType = arrayType, - fromSrc = mkBv(0), fromDst = mkBv(0), length = originalLength, + preserveKnownPresence = preserveKnownPresence, ) - var totalLength = 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)) - copyArrayElements( - srcRef = ref, - dstRef = resultArray, - arrayType = argType, - fromSrc = mkBv(0), - fromDst = totalLength, - length = length, - ) - totalLength = mkBvAddExpr(totalLength, length) - } else { - val newLength = mkBvAddExpr(totalLength, mkBv(1)) - memory.write(mkArrayLengthLValue(resultArray, arrayType), newLength, guard = trueExpr) - assignToArrayIndex(scope, resultArray, totalLength, arg, arrayType) ?: return@calcOnState null - totalLength = 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 } - memory.write(mkArrayLengthLValue(resultArray, arrayType), totalLength, guard = trueExpr) resultArray } ) } -/** - * 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 - */ +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, + 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, arrayType: EtsArrayType, - elementSort: USort, array: UHeapRef, ): UExpr<*>? = with(ctx) { check(expr.args.size == 1) { @@ -1135,25 +1452,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) { @@ -1201,14 +1500,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/main/kotlin/org/usvm/machine/expr/CallStatic.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallStatic.kt index 154d9555ac..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 @@ -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,25 +90,39 @@ 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) } - // Unknown signature: - val methods = ctx.applicationAndSdkClasses - .flatMap { it.methods } - .filter { it.name == method.name } + // 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() 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, ) { - 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!!) } @@ -136,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..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 @@ -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/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" 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..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 @@ -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,20 +243,94 @@ 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) + 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 = mkNumericExpr(expr.extractBool(scope), scope), - falseBranch = mkNumericExpr(expr.extractRef(scope), scope) - ) + condition = isNumber, + trueBranch = number, + falseBranch = nonNumber, ) } @@ -274,8 +364,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 +375,18 @@ 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: 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() - // TODO incorrect implementation, returns some number that is not equal to 0 and NaN - // https://github.com/UnitTestBot/usvm/issues/280 return mkIte( - condition = mkEq(expr.asExpr(addressSort), mkTsNullValue()), - trueBranch = mkFp(0.0, fp64Sort), - falseBranch = mkIte( - mkEq(expr.asExpr(addressSort), mkUndefinedValue()), - mkFp64NaN(), - mkFp64NaN() - ) + condition = isNull, + trueBranch = zero, + falseBranch = nan, ) } @@ -305,6 +394,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 +421,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/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/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/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 3bc38202a8..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 @@ -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 @@ -120,13 +121,16 @@ import org.usvm.machine.TsRuntimeFeatureLimitationReason 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 +import org.usvm.machine.onRuntimeFeatureLimitationSafely import org.usvm.machine.operator.TsBinaryOperator import org.usvm.machine.operator.TsUnaryOperator import org.usvm.machine.runtimeFunctionType @@ -212,7 +216,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), ) } @@ -395,9 +399,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 @@ -612,72 +618,62 @@ 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() - - // 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()) { - args += resolveFunctionRef - scope.doWithState { - setBoundThis(resolveFunctionRef, promise) - } - if (params.size >= 2) { - args += rejectFunctionRef - scope.doWithState { - setBoundThis(rejectFunctionRef, promise) - } - 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) { @@ -709,7 +705,7 @@ class TsExprResolver( } scope.doWithState { // TODO: create proper exception - methodResult = TsMethodResult.TsException(reason, EtsStringType) + this.methodResult = TsMethodResult.TsException(reason, EtsStringType) } null } @@ -717,6 +713,66 @@ class TsExprResolver( } } + 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': + 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}" + ) + } + + args + } + override fun visit(expr: EtsYieldExpr): UExpr? { logger.warn { "visit(${expr::class.simpleName}) is not implemented yet" } error("Not supported $expr") @@ -798,6 +854,7 @@ class TsExprResolver( } } } + return resolveBinaryOperator(TsBinaryOperator.Add, expr) } @@ -828,7 +885,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", @@ -1061,17 +1119,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) { @@ -1292,34 +1344,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() } @@ -1333,9 +1370,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)) { @@ -1592,6 +1631,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" @@ -1664,7 +1720,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) } @@ -1700,11 +1757,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/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/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" 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 aa82ca3837..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 @@ -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 @@ -19,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 @@ -112,16 +114,29 @@ 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()) { @@ -129,6 +144,10 @@ class TsInterpreter( popLocalToSortStack() } + if (rejectedPromise != null) { + methodResult = TsMethodResult.Success.RegularCall(rejectedPromise, throwingMethod) + } + if (returnSite != null) { newStmt(returnSite) } @@ -160,6 +179,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 { @@ -199,6 +221,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 +255,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 +302,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.name == callee.name } - concreteMethods += suitableMethods - } - } else { - val cls = classes.single() - val suitableMethods = cls.methods.filter { it.name == callee.name } - concreteMethods += suitableMethods } + concreteMethods += classes.mapNotNull(::selectedMethod) } else { logger.warn { "Could not resolve method: $callee on type: $type" @@ -287,7 +312,12 @@ class TsInterpreter( return } } else { - val methods = resolveEtsMethods(callee) + 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 +357,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.name == callee.name } - } - } - .map { ancestor -> clazz to ancestor.methods.first { method -> 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 +368,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 @@ -523,9 +544,32 @@ 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) { + 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, + settlement = PromiseState.FULFILLED, + ) + } else { + value } + + scope.doWithState { returnValue(returnedValue) } } private fun assignTo( @@ -558,6 +602,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) @@ -569,7 +617,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 { @@ -583,7 +631,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 { @@ -603,7 +651,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 { @@ -734,7 +782,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") } @@ -754,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 f91f2dcc6b..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 @@ -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.ensureNoPrototypeMutation +import org.usvm.machine.expr.objectLiteralClass 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,94 @@ enum class PromiseState { REJECTED; } +/** A settlement callback queued for a modeled Promise. */ +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 +} + +/** 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("$operation has a symbolic runtime kind") + } else { + value + } + if (actualValue.sort != addressSort) return null + if (actualValue == mkTsNullValue() || actualValue == mkUndefinedValue()) return null + + val reference = actualValue.asExpr(addressSort) + 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 assimilationFailure = if (concreteReference) { + "may require unmodeled thenable assimilation" + } else { + "requires unmodeled thenable assimilation" + } + throw UnsupportedOperationException("$operation $assimilationFailure") +} + internal const val RESOLVED_FIELD = "__resolved__" internal fun TsContext.mkResolvedField(ref: UHeapRef): UFieldLValue { 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/machine/operator/TsBinaryOperator.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt index bb2a01a457..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 @@ -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 @@ -30,6 +31,213 @@ 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) +} + +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, + 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)) + val leftLengthLocation = mkStringBackingLengthLValue(leftChars) + val leftLength = memory.read(leftLengthLocation) + val rightLengthLocation = mkStringBackingLengthLValue(rightChars) + val rightLength = memory.read(rightLengthLocation) + + StringComparisonData( + lhsChars = leftChars, + rhsChars = 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( + leftLengthNonnegative, + leftLengthWithinBound, + rightLengthNonnegative, + rightLengthWithinBound, + ) + 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, + 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, comparison) + val falseResult = relationalComparison(lhs.falseBranch, rhs, scope, falseGuard, comparison) + + 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, comparison) + val falseResult = relationalComparison(lhs, rhs.falseBranch, scope, falseGuard, comparison) + + 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 = 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 = 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) +} + private val logger = KotlinLogging.logger {} /** Strings are primitive values even though the TS heap stores their UTF-16 contents behind references. */ @@ -221,6 +429,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<*>, @@ -273,7 +488,7 @@ sealed interface TsBinaryOperator { } } - return internalResolve(lhsValue, rhsValue, scope) + return internalResolveWithGuard(lhsValue, rhsValue, scope, activeGuard) } @Suppress("LongMethod") @@ -311,14 +526,16 @@ 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 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 @@ -339,21 +556,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 @@ -392,7 +615,10 @@ 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 @@ -416,7 +642,10 @@ 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 @@ -426,14 +655,20 @@ 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 @@ -482,7 +717,10 @@ 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 @@ -506,7 +744,10 @@ 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 @@ -516,14 +757,20 @@ 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 @@ -554,6 +801,67 @@ 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, + 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, @@ -587,6 +895,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), @@ -620,6 +930,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()) @@ -660,30 +977,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}") } @@ -752,9 +1067,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() } } } @@ -925,6 +1247,46 @@ sealed interface TsBinaryOperator { } data object Add : TsBinaryOperator { + override fun TsContext.resolve( + lhs: UExpr<*>, + rhs: UExpr<*>, + 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) + + 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 +1324,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( @@ -1168,71 +1521,41 @@ sealed interface TsBinaryOperator { } } - data object Lt : TsBinaryOperator { - override fun TsContext.onBool( - lhs: UBoolExpr, - rhs: UBoolExpr, - scope: TsStepScope, - ): UBoolExpr { - return mkAnd(lhs.not(), rhs) - } + /** TypeScript relational operators for numeric values and primitive UTF-16 strings. */ + sealed interface Relational : TsBinaryOperator { + enum class Comparison { LT, GT, LTE, GTE } - override fun TsContext.onFp( - lhs: UExpr, - rhs: UExpr, + val comparison: Comparison + + override fun TsContext.resolve( + lhs: UExpr<*>, + rhs: UExpr<*>, scope: TsStepScope, + activeGuard: UBoolExpr, ): UBoolExpr { - return mkFpLessExpr(lhs, rhs) + return relationalComparison(lhs, rhs, scope, activeGuard, comparison) } - override fun TsContext.onRef( + override fun TsContext.onRefWithGuard( lhs: UHeapRef, rhs: UHeapRef, scope: TsStepScope, - ): UBoolExpr { - val lhsNumeric = mkNumericExpr(lhs, scope) - val rhsNumeric = mkNumericExpr(rhs, scope) - return mkFpLessExpr(lhsNumeric, rhsNumeric) - } - - override fun TsContext.resolveFakeObject( - lhs: UExpr<*>, - 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") - } + activeGuard: UBoolExpr, + ): UBoolExpr = resolve(lhs, rhs, scope, activeGuard) - override fun TsContext.internalResolve( + override fun TsContext.resolveFakeObjectWithGuard( lhs: UExpr<*>, 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) - } - } + activeGuard: UBoolExpr, + ): UBoolExpr = resolve(lhs, rhs, scope, activeGuard) - data object Gt : TsBinaryOperator { override fun TsContext.onBool( lhs: UBoolExpr, rhs: UBoolExpr, scope: TsStepScope, ): UBoolExpr { - return mkAnd(lhs, rhs.not()) + return onFp(boolToFp(lhs), boolToFp(rhs), scope) } override fun TsContext.onFp( @@ -1240,7 +1563,12 @@ sealed interface TsBinaryOperator { rhs: UExpr, scope: TsStepScope, ): UBoolExpr { - return mkFpGreaterExpr(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( @@ -1248,9 +1576,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( @@ -1258,16 +1584,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( @@ -1275,18 +1592,62 @@ 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) } } + /** 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 + } + sealed interface TsArithmeticOperator : TsBinaryOperator { fun TsContext.apply( lhs: UExpr, 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/main/kotlin/org/usvm/machine/state/TsState.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/state/TsState.kt index 39874af0cd..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, @@ -272,8 +278,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 +300,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,11 +354,12 @@ class TsState( targets = targets.clone(), localToSortStack = localToSortStack.toMutableList(), staticStorage = staticStorage, - addedArtificialLocals = addedArtificialLocals, + addedArtificialLocals = addedArtificialLocals.toMutableSet(), lValuesToAllocatedFakeObjects = lValuesToAllocatedFakeObjects.toMutableList(), discoveredCallees = discoveredCallees, promiseState = promiseState, promiseExecutor = promiseExecutor, + promiseCallbacks = promiseCallbacks, methodToRef = methodToRef, associatedFunction = associatedFunction, capturedInstanceCalls = capturedInstanceCalls, @@ -397,7 +395,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/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/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/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/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), 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..112f1d6505 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))) @@ -59,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/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/main/kotlin/org/usvm/util/BuildEtsMethod.kt b/usvm-ts/src/main/kotlin/org/usvm/util/BuildEtsMethod.kt index de11cc07ad..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,24 @@ 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, (name, type) -> - EtsMethodParameter(index, name, type) - }, - returnType = returnType, - ) + signature = signature, ) - 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 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..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 @@ -32,6 +34,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 +47,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" } } } @@ -90,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/main/kotlin/org/usvm/util/InputPropertyDeclaration.kt b/usvm-ts/src/main/kotlin/org/usvm/util/InputPropertyDeclaration.kt index 07dbcd5890..f4734cb435 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,24 @@ 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 + + /** 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 +} + /** 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 +40,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 } 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( 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/TsImports.kt b/usvm-ts/src/main/kotlin/org/usvm/util/TsImports.kt index 51bc7f56ab..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) { @@ -130,7 +125,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/main/kotlin/org/usvm/util/TsStateVisualizer.kt b/usvm-ts/src/main/kotlin/org/usvm/util/TsStateVisualizer.kt index 430b9cdc02..bc2dca8c30 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/TsStateVisualizer.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/TsStateVisualizer.kt @@ -7,15 +7,33 @@ 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 private val logger = KotlinLogging.logger {} class TsStateVisualizer : TsInterpreterObserver, UMachineObserver { + @Suppress("TooGenericExceptionCaught") 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) { + // Rendering is optional; graph, filesystem, and process failures must not affect symbolic execution. + logger.warn(e) { "Could not visualize state at ${state.currentStatement}" } + } } } @@ -33,7 +51,6 @@ fun TsState.renderGraph(view: Boolean = true) { } } -@Suppress("DEPRECATION") fun myRenderDot( dot: String, outDir: Path = createTempDirectory(), @@ -46,12 +63,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/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/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/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; } 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 = ""; 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..ff921ceeba 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/baseline/CallFallbackBaselineTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/baseline/CallFallbackBaselineTest.kt @@ -6,7 +6,9 @@ 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.DEFAULT_ARK_CLASS_NAME import org.jacodb.ets.utils.EtsIrProvider import org.jacodb.ets.utils.callExpr import org.jacodb.ets.utils.loadEtsFileAutoConvert @@ -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/census/UnknownCallCensusAggregatorTest.kt b/usvm-ts/src/test/kotlin/org/usvm/census/UnknownCallCensusAggregatorTest.kt index 6807cdaffa..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,7 +1,9 @@ package org.usvm.census import kotlin.test.Test +import kotlin.test.assertContains import kotlin.test.assertEquals +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,89 @@ 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 +168,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", 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..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( @@ -151,6 +190,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") 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 + } +} 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() { 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) } } 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/TsContextFactoryTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/TsContextFactoryTest.kt new file mode 100644 index 0000000000..975e62fc60 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/TsContextFactoryTest.kt @@ -0,0 +1,354 @@ +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.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. */ +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 `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) + 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) } } } 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 + } + }, + ) + } + } +} 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/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) + } +} 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..d69f4f9849 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,74 @@ 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 `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), + tsOptions = tsOptions, + ).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), + tsOptions = tsOptions, + ).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/kotlin/org/usvm/machine/TsStringRelationalTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/TsStringRelationalTest.kt new file mode 100644 index 0000000000..020a2616ac --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/TsStringRelationalTest.kt @@ -0,0 +1,140 @@ +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 `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 respect ordering`() { + 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 -> 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 `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") + + 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 == 3.0 }, + { useString, _, result -> !useString.value && result.number == 2.0 }, + invariants = arrayOf({ useString, value, result -> + 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/call/ArrayShiftReplayCase.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/ArrayShiftReplayCase.kt new file mode 100644 index 0000000000..9ceb38904e --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/ArrayShiftReplayCase.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/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/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/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt index 867e20712a..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 @@ -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,13 +31,110 @@ class TsArrayShiftReplayTest { @TempDir lateinit var directory: Path + @Test + fun `compatibility array growth rejects 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); + } + + 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 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() + 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() + 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) { @@ -80,408 +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() 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 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 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 -> @@ -503,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/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/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/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 + } + } +} 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..0df9909110 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/ExprUtilConditionalValueTest.kt @@ -0,0 +1,402 @@ +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.UExpr +import org.usvm.UIteExpr +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.solver.USatResult +import org.usvm.solver.UUnsatResult +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) + + 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 + 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( + "Dynamic string addition needs a modeled mixed-kind result", + unsupported.unsupportedReason, + ) + } + + @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) + val unmodeledString = state.mkInitializedStringConstant(value = "42") + + val resolved = with(TsBinaryOperator.Sub) { + 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..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 @@ -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 @@ -18,8 +20,14 @@ 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 = + "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( listOf( @@ -57,6 +65,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() @@ -109,6 +151,68 @@ 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, + ) + } + + @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), + 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(unsupportedReason 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/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/machine/types/TsTypeSystemTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/types/TsTypeSystemTest.kt index a44475a890..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 @@ -1,35 +1,218 @@ 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 +271,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 +315,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/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/ImportResolver.kt b/usvm-ts/src/test/kotlin/org/usvm/project/ImportResolver.kt index 03efb4f2c8..9a6e546d6b 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 { @@ -35,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." @@ -60,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." @@ -78,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 }, @@ -86,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("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() { + logger.info { "\n--- Reporting file-level import resolution ---" } val allFiles = scene.projectFiles var totalImports = 0 @@ -104,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++ @@ -114,42 +119,40 @@ 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%" } } - // 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() { + logger.info { "\n--- Reporting symbol resolution ---" } val allFiles = scene.projectFiles var totalSymbols = 0 @@ -162,53 +165,49 @@ 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++ - 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 - 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 "" }" - ) + } } } } @@ -216,25 +215,23 @@ 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%" } } - // 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() { + logger.info { "\n--- Reporting common import probes ---" } Assumptions.assumeTrue( scene.projectFiles.isNotEmpty(), @@ -242,9 +239,9 @@ class ImportResolverTest { ) val testFile = scene.projectFiles.first() - println("Using test file: ${testFile.signature.fileName}") + logger.info { "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", @@ -274,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", @@ -305,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") 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) } } 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..f528f6af01 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,30 @@ 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.TsTestValue 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.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 @@ -82,34 +92,65 @@ 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 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) + assertEquals(setOf(0.0), returnedNumbers(method, analysis)) + } - // 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" } - // 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) + 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), + 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.stack.readRegister(index = 1, sort = fp64Sort) + 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/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/arrays/ArrayMethods.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/arrays/ArrayMethods.kt index a765ce98f3..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 @@ -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") @@ -120,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") @@ -202,4 +262,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/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/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/kotlin/org/usvm/samples/lang/Async.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/lang/Async.kt index 42abe144d3..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 @@ -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") @@ -47,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/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/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/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/kotlin/org/usvm/samples/lang/StaticOverloads.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/lang/StaticOverloads.kt index 3255d41d5f..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 @@ -25,6 +25,61 @@ 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 `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/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/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/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/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/kotlin/org/usvm/samples/types/RuntimeInstanceofTest.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/types/RuntimeInstanceofTest.kt index 76d3dcd6de..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');", )) @@ -497,6 +519,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") @@ -625,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 d85e220e69..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 @@ -384,6 +385,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") @@ -414,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/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/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/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) } 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/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) + } +} 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..ecc3a2f44a 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) } @@ -606,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) } } } @@ -617,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/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) + } +} 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/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/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 diff --git a/usvm-ts/src/test/resources/models/BuiltinOwnerBoundary.ts b/usvm-ts/src/test/resources/models/BuiltinOwnerBoundary.ts index 67af2e6f3e..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); } @@ -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; + } } 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(); + } } 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; +} diff --git a/usvm-ts/src/test/resources/models/RuntimeLimitationBoundaries.ts b/usvm-ts/src/test/resources/models/RuntimeLimitationBoundaries.ts index 8c734dbc94..9bd4240338 100644 --- a/usvm-ts/src/test/resources/models/RuntimeLimitationBoundaries.ts +++ b/usvm-ts/src/test/resources/models/RuntimeLimitationBoundaries.ts @@ -17,4 +17,39 @@ 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; + } + + 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/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/StringEquality.ts b/usvm-ts/src/test/resources/models/StringEquality.ts index b545014e41..3d905396f7 100644 --- a/usvm-ts/src/test/resources/models/StringEquality.ts +++ b/usvm-ts/src/test/resources/models/StringEquality.ts @@ -103,4 +103,61 @@ 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; + } + + 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; + } } 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..eef173114f --- /dev/null +++ b/usvm-ts/src/test/resources/models/StringRelational.ts @@ -0,0 +1,68 @@ +// @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; + } + + 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; + } + + 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; + if (left < "b") { + return 1; + } + + if (useString) { + return 3; + } + + return 2; + } +} 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"); } 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; diff --git a/usvm-ts/src/test/resources/samples/arrays/ArrayMethods.ts b/usvm-ts/src/test/resources/samples/arrays/ArrayMethods.ts index cc46af5c98..97be33c47b 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(); @@ -71,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) { @@ -118,4 +163,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]; + } } 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; +} 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; + } } diff --git a/usvm-ts/src/test/resources/samples/lang/Async.ts b/usvm-ts/src/test/resources/samples/lang/Async.ts index 695a619896..fe8cfb3180 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); @@ -27,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 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; 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; + } +} 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; + } +} diff --git a/usvm-ts/src/test/resources/samples/lang/StaticOverloads.ts b/usvm-ts/src/test/resources/samples/lang/StaticOverloads.ts index 8cec979a1d..cfaf265f76 100644 --- a/usvm-ts/src/test/resources/samples/lang/StaticOverloads.ts +++ b/usvm-ts/src/test/resources/samples/lang/StaticOverloads.ts @@ -7,8 +7,54 @@ 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 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); } + + callStaticWithSameNameInstance(): number { + return InstanceDeclarationFirst.method(); + } + + callSuperWithSameNameStatic(): number { + return new MethodKindsChild().callParentMethod(); + } + + callStaticNamedNumber(): number { + return NamedBuiltinStatics.Number(2); + } + + callStaticNamedBoolean(): number { + return NamedBuiltinStatics.Boolean(3); + } + + callStaticNamedResourceLookup(): number { + return NamedBuiltinStatics.$r(4); + } } 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; } 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 { 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; 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]; } 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(); 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 }