diff --git a/build.gradle.kts b/build.gradle.kts index 362cef936..0c4158164 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,6 +10,7 @@ tasks.register("validateProjectList") { // Define the expected subprojects here. val expectedProjects = setOf( project(":usvm-core"), + project(":usvm-detekt-rules"), project(":usvm-util"), project(":usvm-dataflow"), project(":usvm-sample-language"), diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 9e465a8ec..700801642 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -6,7 +6,7 @@ object Versions { const val clikt = "5.0.0" const val detekt = "1.23.7" const val ini4j = "0.5.4" - const val jacodb = "9ea33879c9" + const val jacodb = "ddb127d9ef" const val juliet = "1.3.2" const val junit = "5.9.3" const val kotlin = "2.1.0" diff --git a/buildSrc/src/main/kotlin/DetektConfiguration.kt b/buildSrc/src/main/kotlin/DetektConfiguration.kt index 926b917ef..d57b4b677 100644 --- a/buildSrc/src/main/kotlin/DetektConfiguration.kt +++ b/buildSrc/src/main/kotlin/DetektConfiguration.kt @@ -11,8 +11,13 @@ import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.withType fun Project.configureDetekt() { + val usesStrictTsRules = path in STRICT_TS_DETEKT_PROJECT_PATHS + dependencies { detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:${detekt.toolVersion}") + if (usesStrictTsRules) { + detektPlugins(project(USVM_DETEKT_RULES_PROJECT_PATH)) + } } val includes = listOf( @@ -37,7 +42,6 @@ fun Project.configureDetekt() { val configFile = rootDir.resolve("detekt").resolve("config.yml") val tsConfigFile = rootDir.resolve("detekt").resolve("ts-config.yml") val reportFile = rootProject.layout.buildDirectory.file("reports/detekt/detekt.sarif") - val usesStrictTsRules = name in STRICT_TS_DETEKT_PROJECTS val configFiles = if (usesStrictTsRules) listOf(configFile, tsConfigFile) else listOf(configFile) detekt { @@ -89,4 +93,5 @@ fun Project.configureDetekt() { } } -private val STRICT_TS_DETEKT_PROJECTS = setOf("usvm-ts", "usvm-ts-pbt") +private val STRICT_TS_DETEKT_PROJECT_PATHS = setOf(":usvm-ts", ":usvm-ts-pbt") +private const val USVM_DETEKT_RULES_PROJECT_PATH = ":usvm-detekt-rules" diff --git a/detekt/baselines/usvm-ts-Main.yml b/detekt/baselines/usvm-ts-Main.yml index 24a7b7ba1..6192a8891 100644 --- a/detekt/baselines/usvm-ts-Main.yml +++ b/detekt/baselines/usvm-ts-Main.yml @@ -44,6 +44,24 @@ NestedBlockDepth:TsInterpreter.kt$TsInterpreter$private fun assignToInDfltDflt( scope: TsStepScope, lhv: EtsLValue, expr: UExpr<*>, ): Unit? NestedBlockDepth:TsInterpreter.kt$TsInterpreter$private fun visitVirtualMethodCall(scope: TsStepScope, stmt: TsVirtualMethodCallStmt) NestedBlockDepth:WriteField.kt$internal fun TsExprResolver.handleAssignToInstanceField( lhv: EtsInstanceFieldRef, expr: UExpr<*>, ): Unit? + NestedCallArguments:BuildEtsMethod.kt$EtsMethodImpl( signature = EtsMethodSignature( enclosingClass = enclosingClass.signature, name = name, parameters = parameters.mapIndexed { index, (name, type) -> EtsMethodParameter(index, name, type) }, returnType = returnType, ) ) + NestedCallArguments:CallApproximations.kt$mkAnd( mkBvSignedGreaterOrEqualExpr(symbolicResult, mkBv(0)), mkBvSignedLessExpr(symbolicResult, length) ) + NestedCallArguments:CallApproximations.kt$resolveEtsMethods( EtsMethodSignature( enclosingClass = EtsClassSignature.UNKNOWN, name = executorLocal.name, parameters = emptyList(), returnType = EtsUnknownType, ) ) + NestedCallArguments:EtsFakeType.kt$EtsFakeType$mkAnd( mkImplies(boolTypeExpr, mkNot(fpTypeExpr)), mkImplies(boolTypeExpr, mkNot(refTypeExpr)), mkImplies(fpTypeExpr, mkNot(refTypeExpr)), mkOr(boolTypeExpr, fpTypeExpr, refTypeExpr), ) + NestedCallArguments:EtsHierarchy.kt$EtsHierarchy.Companion$EtsClassType( signature = EtsClassSignature( name = "Object", file = EtsFileSignature(projectName = "ES2015", fileName = "BuiltinClass") ), ) + NestedCallArguments:ExprUtil.kt$ExprWithTypeConstraint( constraint = possibleType.boolTypeExpr, expr = memory.read(getIntermediateBoolLValue(expr.address)) ) + NestedCallArguments:ExprUtil.kt$ExprWithTypeConstraint( constraint = possibleType.refTypeExpr, expr = mkAnd( mkHeapRefEq(value, mkTsNullValue()).not(), mkHeapRefEq(value, mkUndefinedValue()).not(), ) ) + NestedCallArguments:ExprUtil.kt$mkAnd( mkBvSignedGreaterOrEqualExpr(length, mkBv(0)), mkBvSignedLessOrEqualExpr(length, mkBv(maxLength)) ) + NestedCallArguments:ExprUtil.kt$mkIte( condition = fakeType.refTypeExpr, trueBranch = mkOr( mkHeapRefEq(ref, mkTsNullValue()), mkHeapRefEq(ref, mkUndefinedValue()) ), falseBranch = mkFalse(), ) + NestedCallArguments:ExprUtil.kt$mkIte( condition = mkEq(expr.asExpr(addressSort), mkTsNullValue()), trueBranch = mkFp(0.0, fp64Sort), falseBranch = mkIte( mkEq(expr.asExpr(addressSort), mkUndefinedValue()), mkFp64NaN(), mkFp64NaN() ) ) + NestedCallArguments:ExprUtil.kt$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) ) ) + NestedCallArguments:ExprUtil.kt$mkNot( mkOr( mkHeapRefEq(ref, mkTsNullValue()), mkHeapRefEq(ref, mkUndefinedValue()) ) ) + NestedCallArguments:ExprUtil.kt$mkOr( mkHeapRefEq(ref, mkTsNullValue()), mkHeapRefEq(ref, mkUndefinedValue()) ) + NestedCallArguments:TsExprResolver.kt$TsExprResolver$mkAnd( mkEq( mkBvToFpExpr( sort = fp64Sort, roundingMode = fpRoundingModeSortDefaultValue(), value = bvSize, signed = true, ), size.asExpr(fp64Sort) ), mkAnd( mkBvSignedLessOrEqualExpr(mkBv(0), bvSize.asExpr(bv32Sort)), mkBvSignedLessOrEqualExpr(bvSize.asExpr(bv32Sort), mkBv(Int.MAX_VALUE)) ) ) + NestedCallArguments:TsExprResolver.kt$TsExprResolver$mkBvAndExpr( rightBv, mkBv(ECMASCRIPT_BITWISE_SHIFT_MASK, ECMASCRIPT_BITWISE_INTEGER_SIZE.toUInt()) ) + NestedCallArguments:TsExprResolver.kt$TsExprResolver$mkIte( condition = mkHeapRefEq(ref, mkTsNullValue()), trueBranch = mkStringConstant("object", scope), falseBranch = mkIte( condition = mkHeapRefEq(ref, mkUndefinedValue()), trueBranch = mkStringConstant("undefined", scope), falseBranch = mkIte( condition = scope.calcOnState { val unwrappedRef = ref.unwrapRefWithPathConstraint(scope) // TODO: adhoc: "expand" ITE if (unwrappedRef is UIteExpr<*>) { val trueBranch = unwrappedRef.trueBranch val falseBranch = unwrappedRef.falseBranch if (trueBranch.isFakeObject() || falseBranch.isFakeObject()) { val unwrappedTrueExpr = trueBranch.asExpr(addressSort).unwrapRefWithPathConstraint(scope) val unwrappedFalseExpr = falseBranch.asExpr(addressSort).unwrapRefWithPathConstraint(scope) return@calcOnState mkIte( condition = unwrappedRef.condition, trueBranch = memory.types.evalTypeEquals(unwrappedTrueExpr, EtsStringType), falseBranch = memory.types.evalTypeEquals(unwrappedFalseExpr, EtsStringType), ) } } memory.types.evalTypeEquals(unwrappedRef, EtsStringType) }, trueBranch = mkStringConstant("string", scope), falseBranch = mkStringConstant("object", scope), ) ) ) + NestedCallArguments:TsUnknownCall.kt$dispatch( scope, TsUnknownCall( callee = callee, receiver = receiverSource?.let { TsUnknownCallValue(it, resolvedReceiver) }, arguments = call.args.zip(resolvedArguments) { source, resolved -> TsUnknownCallValue(source, resolved) }, resultType = call.type, callSite = callSite, failureReason = failureReason, ), ) + NestedCallArguments:TsUnknownCallProfile.kt$TsProfileUnknownCallDispatcher$TsUnknownCallEvent( callSite = call.callSite, callee = call.callee, failureReason = call.failureReason, profile = profile.copy(residualOverrides = profile.residualOverrides.toMap()), outcome = outcome, decision = decision, ) NoBlankLineInList:TsState.kt$TsState$ NoEmptyFirstLineInMethodBlock:TsInterpreter.kt$TsInterpreter$ NoMultipleSpaces:TsTypeSystem.kt$TsTypeSystem$ diff --git a/detekt/ts-config.yml b/detekt/ts-config.yml index 003c5075c..da5bb4959 100644 --- a/detekt/ts-config.yml +++ b/detekt/ts-config.yml @@ -11,5 +11,15 @@ complexity: threshold: 4 style: + BracesOnWhenStatements: + active: false CascadingCallWrapping: active: true + +usvm: + active: true + MultilineWhenBranchBraces: + active: true + NestedCallArguments: + active: true + excludes: [ '**/test/**' ] diff --git a/settings.gradle.kts b/settings.gradle.kts index 4d0fb4c82..6e8186190 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -28,6 +28,7 @@ develocity { } include("usvm-core") +include("usvm-detekt-rules") include("usvm-jvm") include("usvm-jvm:usvm-jvm-api") include("usvm-jvm:usvm-jvm-test-api") diff --git a/usvm-detekt-rules/build.gradle.kts b/usvm-detekt-rules/build.gradle.kts new file mode 100644 index 000000000..2fb87364d --- /dev/null +++ b/usvm-detekt-rules/build.gradle.kts @@ -0,0 +1,38 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin("jvm") +} + +group = "org.usvm" + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +repositories { + mavenCentral() +} + +val detektVersion = "1.23.5" + +dependencies { + compileOnly("io.gitlab.arturbosch.detekt:detekt-api:$detektVersion") + + testImplementation("io.gitlab.arturbosch.detekt:detekt-test:$detektVersion") + testImplementation(Libs.junit_jupiter_api) + testRuntimeOnly(Libs.junit_jupiter_engine) +} + +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_1_8) + allWarningsAsErrors.set(true) + } +} + +tasks.withType().configureEach { + useJUnitPlatform() +} diff --git a/usvm-detekt-rules/src/main/kotlin/org/usvm/detekt/MultilineWhenBranchBraces.kt b/usvm-detekt-rules/src/main/kotlin/org/usvm/detekt/MultilineWhenBranchBraces.kt new file mode 100644 index 000000000..a0d92768a --- /dev/null +++ b/usvm-detekt-rules/src/main/kotlin/org/usvm/detekt/MultilineWhenBranchBraces.kt @@ -0,0 +1,48 @@ +package org.usvm.detekt + +import io.gitlab.arturbosch.detekt.api.CodeSmell +import io.gitlab.arturbosch.detekt.api.Config +import io.gitlab.arturbosch.detekt.api.Debt +import io.gitlab.arturbosch.detekt.api.Entity +import io.gitlab.arturbosch.detekt.api.Issue +import io.gitlab.arturbosch.detekt.api.Rule +import io.gitlab.arturbosch.detekt.api.Severity +import org.jetbrains.kotlin.psi.KtBlockExpression +import org.jetbrains.kotlin.psi.KtWhenEntry + +/** Requires a block when a `when` branch body starts below its arrow. */ +internal class MultilineWhenBranchBraces(config: Config = Config.empty) : Rule(config) { + override val issue = Issue( + id = "MultilineWhenBranchBraces", + severity = Severity.Style, + description = "Requires braces when a when branch body starts on a new line", + debt = Debt.FIVE_MINS, + ) + + override fun visitWhenEntry(entry: KtWhenEntry) { + super.visitWhenEntry(entry) + + val expression = entry.expression ?: return + if (expression is KtBlockExpression) { + return + } + + val arrow = entry.arrow ?: return + val fileText = entry.containingFile.text + val textBeforeExpression = fileText.substring( + startIndex = arrow.textRange.endOffset, + endIndex = expression.textRange.startOffset, + ) + if (!textBeforeExpression.contains('\n') && !textBeforeExpression.contains('\r')) { + return + } + + report( + CodeSmell( + issue = issue, + entity = Entity.from(expression), + message = "A when branch body that starts on a new line must be enclosed in braces", + ), + ) + } +} diff --git a/usvm-detekt-rules/src/main/kotlin/org/usvm/detekt/NestedCallArguments.kt b/usvm-detekt-rules/src/main/kotlin/org/usvm/detekt/NestedCallArguments.kt new file mode 100644 index 000000000..493c9c507 --- /dev/null +++ b/usvm-detekt-rules/src/main/kotlin/org/usvm/detekt/NestedCallArguments.kt @@ -0,0 +1,86 @@ +package org.usvm.detekt + +import io.gitlab.arturbosch.detekt.api.CodeSmell +import io.gitlab.arturbosch.detekt.api.Config +import io.gitlab.arturbosch.detekt.api.Debt +import io.gitlab.arturbosch.detekt.api.Entity +import io.gitlab.arturbosch.detekt.api.Issue +import io.gitlab.arturbosch.detekt.api.Rule +import io.gitlab.arturbosch.detekt.api.Severity +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtExpression +import org.jetbrains.kotlin.psi.KtParenthesizedExpression +import org.jetbrains.kotlin.psi.KtQualifiedExpression +import org.jetbrains.kotlin.psi.KtValueArgument + +/** Prevents deeply nested calls such as `outer(middle(inner()))`. */ +internal class NestedCallArguments(config: Config = Config.empty) : Rule(config) { + override val issue = Issue( + id = "NestedCallArguments", + severity = Severity.Style, + description = "Nested call arguments should be extracted into intermediate variables", + debt = Debt.FIVE_MINS, + ) + + override fun visitCallExpression(expression: KtCallExpression) { + super.visitCallExpression(expression) + + if (expression.isArgumentRootCall()) { + return + } + if (!expression.textContains('\n')) { + return + } + + val depth = expression.argumentCallDepth() + if (depth <= MAX_ARGUMENT_CALL_DEPTH) { + return + } + + report( + CodeSmell( + issue = issue, + entity = Entity.from(expression), + message = "Call arguments are nested $depth levels deep; extract an inner call into a variable", + ), + ) + } +} + +private fun KtCallExpression.argumentCallDepth(): Int { + val nestedDepth = valueArguments + .asSequence() + .mapNotNull { argument -> argument.getArgumentExpression()?.rootCallOrNull() } + .maxOfOrNull { nestedCall -> nestedCall.argumentCallDepth() } + ?: 0 + + return nestedDepth + 1 +} + +private fun KtExpression.rootCallOrNull(): KtCallExpression? = when (this) { + is KtCallExpression -> this + is KtParenthesizedExpression -> expression?.rootCallOrNull() + is KtQualifiedExpression -> selectorExpression?.rootCallOrNull() + else -> null +} + +private fun KtCallExpression.isArgumentRootCall(): Boolean { + var current: KtExpression = this + while (true) { + val parentExpression = current.parent as? KtExpression ?: break + val wrapsCurrent = when (parentExpression) { + is KtParenthesizedExpression -> parentExpression.expression == current + is KtQualifiedExpression -> parentExpression.selectorExpression == current + else -> false + } + if (!wrapsCurrent) { + break + } + + current = parentExpression + } + + return current.parent is KtValueArgument +} + +private const val MAX_ARGUMENT_CALL_DEPTH = 2 diff --git a/usvm-detekt-rules/src/main/kotlin/org/usvm/detekt/UsvmRuleSetProvider.kt b/usvm-detekt-rules/src/main/kotlin/org/usvm/detekt/UsvmRuleSetProvider.kt new file mode 100644 index 000000000..c1cecb161 --- /dev/null +++ b/usvm-detekt-rules/src/main/kotlin/org/usvm/detekt/UsvmRuleSetProvider.kt @@ -0,0 +1,18 @@ +package org.usvm.detekt + +import io.gitlab.arturbosch.detekt.api.Config +import io.gitlab.arturbosch.detekt.api.RuleSet +import io.gitlab.arturbosch.detekt.api.RuleSetProvider + +/** Makes USVM-specific rules discoverable by Detekt. */ +class UsvmRuleSetProvider : RuleSetProvider { + override val ruleSetId: String = "usvm" + + override fun instance(config: Config): RuleSet = RuleSet( + id = ruleSetId, + rules = listOf( + MultilineWhenBranchBraces(config), + NestedCallArguments(config), + ), + ) +} diff --git a/usvm-detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider b/usvm-detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider new file mode 100644 index 000000000..9371354e7 --- /dev/null +++ b/usvm-detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider @@ -0,0 +1 @@ +org.usvm.detekt.UsvmRuleSetProvider diff --git a/usvm-detekt-rules/src/test/kotlin/org/usvm/detekt/MultilineWhenBranchBracesTest.kt b/usvm-detekt-rules/src/test/kotlin/org/usvm/detekt/MultilineWhenBranchBracesTest.kt new file mode 100644 index 000000000..ef61fe18a --- /dev/null +++ b/usvm-detekt-rules/src/test/kotlin/org/usvm/detekt/MultilineWhenBranchBracesTest.kt @@ -0,0 +1,86 @@ +package org.usvm.detekt + +import io.gitlab.arturbosch.detekt.test.compileAndLint +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class MultilineWhenBranchBracesTest { + private val rule = MultilineWhenBranchBraces() + + @Test + fun `allows branch body on the same line`() { + val code = """ + fun choose(condition: Boolean): Int = when { + condition -> 1 + else -> 0 + } + """.trimIndent() + + val findings = rule.compileAndLint(code) + + assertEquals(0, findings.size) + } + + @Test + fun `reports branch body moved to a new line without braces`() { + val code = """ + fun choose(condition: Boolean): Int = when { + condition -> + 1 + else -> 0 + } + """.trimIndent() + + val findings = rule.compileAndLint(code) + + assertEquals(1, findings.size) + } + + @Test + fun `allows branch body moved to a new line inside braces`() { + val code = """ + fun choose(condition: Boolean): Int = when { + condition -> { + 1 + } + else -> 0 + } + """.trimIndent() + + val findings = rule.compileAndLint(code) + + assertEquals(0, findings.size) + } + + @Test + fun `reports else body moved to a new line without braces`() { + val code = """ + fun choose(condition: Boolean): Int = when { + condition -> 1 + else -> + 0 + } + """.trimIndent() + + val findings = rule.compileAndLint(code) + + assertEquals(1, findings.size) + } + + @Test + fun `allows multiline body inside braces`() { + val code = """ + fun choose(condition: Boolean): Int = when { + condition -> { + println("selected") + 1 + } + else -> 0 + } + """.trimIndent() + + val findings = rule.compileAndLint(code) + + assertEquals(0, findings.size) + } +} diff --git a/usvm-detekt-rules/src/test/kotlin/org/usvm/detekt/NestedCallArgumentsTest.kt b/usvm-detekt-rules/src/test/kotlin/org/usvm/detekt/NestedCallArgumentsTest.kt new file mode 100644 index 000000000..f836513ea --- /dev/null +++ b/usvm-detekt-rules/src/test/kotlin/org/usvm/detekt/NestedCallArgumentsTest.kt @@ -0,0 +1,91 @@ +package org.usvm.detekt + +import io.gitlab.arturbosch.detekt.test.compileAndLint +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class NestedCallArgumentsTest { + private val rule = NestedCallArguments() + + @Test + fun `allows two nested call levels`() { + val code = """ + fun result(diagnostic: Diagnostic): Result = Result( + diagnostics = listOf(diagnostic), + ) + """.trimIndent() + + val findings = rule.compileAndLint(code) + + assertEquals(0, findings.size) + } + + @Test + fun `reports three nested call levels`() { + val code = """ + fun result(): Result = Result( + diagnostics = listOf( + Diagnostic(), + ), + ) + """.trimIndent() + + val findings = rule.compileAndLint(code) + + assertEquals(1, findings.size) + } + + @Test + fun `reports only the outer call for deeper nesting`() { + val code = """ + fun result(): String = outer( + middle( + inner( + leaf(), + ), + ), + ) + """.trimIndent() + + val findings = rule.compileAndLint(code) + + assertEquals(1, findings.size) + } + + @Test + fun `counts a qualified call used as an argument`() { + val code = """ + fun result(factory: Factory): List = resultOf( + listOf( + factory.create(), + ), + ) + """.trimIndent() + + val findings = rule.compileAndLint(code) + + assertEquals(1, findings.size) + } + + @Test + fun `does not count fluent receiver calls as nested arguments`() { + val code = """ + fun result(source: Source): Value = source.read().parse().normalize() + """.trimIndent() + + val findings = rule.compileAndLint(code) + + assertEquals(0, findings.size) + } + + @Test + fun `allows compact nested calls`() { + val code = """ + fun result(): Result = Result(listOf(Diagnostic())) + """.trimIndent() + + val findings = rule.compileAndLint(code) + + assertEquals(0, findings.size) + } +} diff --git a/usvm-ts-pbt/DESIGN.md b/usvm-ts-pbt/DESIGN.md index 82dfe6214..0e40b6057 100644 --- a/usvm-ts-pbt/DESIGN.md +++ b/usvm-ts-pbt/DESIGN.md @@ -8,75 +8,31 @@ API and CLI examples, see [README.md](README.md). - Kotlin owns property definitions, validation, registries, orchestration, and public results. - Node is a thin adapter around fast-check and direct TypeScript loading. - Per-property source coverage is an optional backend capability collected by Kotlin through an isolated c8 run. +- A backend-neutral Kotlin mapping layer connects manifests and source coverage to EtsIR without changing the + declarative property model. - The JSON exchange is one request and one response from the same packaged distribution; it has no persistence or compatibility negotiation. - Failures are typed without exposing runtime-dependent Node stack traces. - A blocked or noisy child process cannot hang the JVM or exhaust unbounded memory. -## Components and dependencies - -```mermaid -flowchart LR - subgraph Kotlin - Caller[Backend caller] - CLI[FastCheckCli] - Registry[PropertyRegistry] - Model[Property model and validation] - Backend[FastCheckBackend] - Process[FastCheckProcessClient] - Projection[FastCheckProjectionClient] - end - - subgraph Node_adapter[Private Node adapter] - ExecutionCLI[execution-cli.ts] - ProjectionCLI[projection-cli.ts] - Execute[execute-property.ts] - Domains[project-domain.ts] - EntryPoints[entry-point.ts] - Values[js-value.ts] - Diagnostics[diagnostics.ts] - end - - FastCheck[fast-check] - Tsx[tsx] - C8[c8 and Istanbul JSON] - UserTS[User TypeScript source] - - CLI --> Registry - CLI --> Backend - Caller --> Backend - Registry --> Model - Backend --> Model - Backend --> Process - Process --> ExecutionCLI - Process --> C8 - C8 --> ExecutionCLI - Projection --> ProjectionCLI - ExecutionCLI --> Execute - Execute --> Domains - Execute --> EntryPoints - Execute --> Values - ProjectionCLI --> Domains - Diagnostics --> ExecutionCLI - Diagnostics --> Domains - Diagnostics --> EntryPoints - Domains --> FastCheck - Execute --> FastCheck - EntryPoints --> Tsx - Tsx --> UserTS -``` - -| Component | Responsibility | -| --- | --- | -| Kotlin model and validation | Define one backend-neutral property and reject invalid structure before execution. | -| Registry and CLI | Select Kotlin-defined properties and turn user options into a run configuration. | -| `FastCheckBackend` | Validate examples, resolve source roots, and create the adapter request. | -| `FastCheckProcessClient` | Supervise Node with coroutines and optionally decode one isolated c8 report. | -| `execution-cli.ts` | Read one JSON request, protect protocol stdout from user logging, and write one response. | -| `execute-property.ts` | Build the fast-check property, run it, and translate `RunDetails` into the common result. | -| `project-domain.ts` | Translate domain descriptors into real `fc.Arbitrary` instances. | -| `entry-point.ts` | Resolve exactly one module below a source root and invoke its typed export through `tsx`. | -| Value and diagnostic modules | Preserve JavaScript values losslessly and define adapter-emitted diagnostic identifiers. | +## Component responsibilities + +| Component | Responsibility | +| ------------------------------- | -------------------------------------------------------------------------------------------------- | +| Kotlin model and validation | Define one backend-neutral property and reject invalid structure before execution. | +| Registry and CLI | Select Kotlin-defined properties and turn user options into a run configuration. | +| `FastCheckBackend` | Validate examples, resolve source roots, and create the adapter request. | +| `FastCheckProcessClient` | Encode one execution request and validate the adapter response. | +| `FastCheckProjectionClient` | Encode one projection request and validate sampled values. | +| `FastCheckProcessTransport` | Run one bounded supervised process exchange for either Kotlin client. | +| `FastCheckCoverageSession` | Prepare c8, own its temporary workspace, and attach decoded coverage to the result. | +| `PropertyEtsMapper` | Resolve property entry points and backend-neutral coverage to explicit EtsIR targets. | +| `process-supervisor.ts` | Own the adapter process group and terminate its descendants on exit or cancellation. | +| `execution-cli.ts` | Read one JSON request, protect protocol stdout from user logging, and write one response. | +| `execute-property.ts` | Build the fast-check property, run it, and translate `RunDetails` into the common result. | +| `project-domain.ts` | Translate domain descriptors into real `fc.Arbitrary` instances. | +| `entry-point.ts` | Resolve exactly one module below a source root and invoke its typed export through `tsx`. | +| Value and diagnostic modules | Preserve JavaScript values losslessly and define adapter-emitted diagnostic identifiers. | `projection-cli.ts` is the smaller sampling path used by `FastCheckProjectionClient`. It shares domain and value translation with property execution but does not load or call user predicates. @@ -158,6 +114,9 @@ sequenceDiagram Input order is preserved from `PropertyDefinition.inputs` to the positional TypeScript arguments. If either the predicate or precondition is asynchronous, the adapter uses `fc.asyncProperty`; otherwise it uses `fc.property`. A false precondition becomes `fc.pre(false)`, leaving skip accounting to fast-check. +Each callback receives its own recursive clone of the generated arguments. This keeps predicate and precondition +mutations from changing fast-check's retained sample or leaking from one callback into the other during shrinking +and replay, while preserving aliases and cycles within one invocation. ## Results, errors, and timeouts @@ -213,11 +172,97 @@ A successful or falsified property exits the bridge normally, allowing c8 to flu invalid protocol responses, and hard kills do not produce a completed property result. The workspace is removed in all cases, and a new workspace is used for every property. -The execution client starts stdout, stderr, and stdin work concurrently on the coroutine I/O dispatcher. Requests -and stdout are limited to 4 MiB; stderr is limited to 64 KiB. These are transport safety bounds, not property-policy -limits. The hard deadline is the property timeout plus two seconds for transport, followed by a 250 ms graceful -shutdown before force-kill. The only run-control maximum is `2^31 - 1` milliseconds because Node timers use signed -32-bit delays; runs, examples, and replay paths have no arbitrary count or length caps. +## Property-to-EtsIR mapping + +The mapping layer consumes common Kotlin artifacts only: `PropertyManifest`, optional `PropertyCoverageArtifact`, +an `EtsScene`, and source roots. It does not depend on `FastCheckBackend` or its private runtime representation. +The result is a `PropertyEtsMappingArtifact` that keeps the manifest property ID, backend coverage provenance, +mapping coordinate and branch-order provenance, resolved predicate and precondition targets, coverage targets, and +stable diagnostic reasons. + +```mermaid +flowchart LR + subgraph Inputs + direction TB + Manifest[PropertyManifest] + Coverage[Optional PropertyCoverageArtifact] + Scene[EtsScene with EtsSourceSpan origins] + Roots[Source roots] + end + + Mapper[PropertyEtsMapper] + Artifact[PropertyEtsMappingArtifact] + + Manifest --> Mapper + Coverage --> Mapper + Scene --> Mapper + Roots --> Mapper + Mapper --> Artifact +``` + +Entry-point resolution starts from the manifest module/export pair and follows named or bare-star TypeScript +re-exports. Direct function exports resolve only in the file-level `%dflt` class. Namespace-star exports are not +callable methods, bare-star traversal excludes `default`, explicit runtime exports take precedence over bare-star +exports, and duplicate paths to one EtsIR method are deduplicated. Type-alias exports do not mask bare-star runtime +exports. The pinned EtsIR model preserves `isTypeOnly` independently of declaration kind, so type-only named and +star re-exports do not mask a bare-star runtime fallback. +Module candidates mirror the frontend's `.ts`, `.ets`, `.d.ts`, and directory-index suffix rules. +Predicate and precondition resolution are independent. A resolved method carries `EtsEntryPointBindings`: receiver +slot zero, ordered input-to-parameter bindings in subsequent slots, and the result type. A mismatch between +manifest inputs and EtsIR parameters is unsupported, as is coverage carrying another property ID. A candidate set +with mixed parameter counts is unsupported as a whole: discarding its unbindable candidates would turn an +ambiguous resolution into a guess. + +Existing source roots and files are canonicalized with real paths; an unresolvable root makes entry-point mapping +unsupported. Istanbul lines are converted from one-based to zero-based, columns stay zero-based, and offsets are +calculated in UTF-16 code units. TypeScript line terminators are LF, CRLF, CR, Unicode line separator (`U+2028`), +and Unicode paragraph separator (`U+2029`). Statement mapping first looks for an exact `EtsSourceSpan`; if +normalized EtsIR statements share that span, all remain exact targets. A containing coverage range with one +distinct origin is also exact, several distinct origins are ambiguous, and no origin match is unmapped. Missing +source text, invalid coordinates, or an EtsIR file whose statements have no origins are unsupported. + +Branch mapping currently accepts an Istanbul `if` with exactly two ordered arms and resolves conditions to +`EtsIfStmt`. The first CFG successor is recorded as true and the second as false. Several EtsIR conditions with one +shared origin are still ambiguous because short-circuit expressions can lower to conditions with different CFG +semantics. Only one condition from one source candidate is exact. Other branch types, non-binary arm shapes, and +EtsIR conditions without two ordered successors are unsupported rather than inferred. + +An invalid arm is reported independently while a successfully resolved condition remains available, and aggregate +coverage status includes both conditions and arms. + +The pinned c8/V8 collector emits backend-specific `branch` records with one arm, so those records remain +unsupported for CFG-edge mapping; their statement coverage is still mapped. A backend that supplies binary +Istanbul `if` branches can use the exact edge mapping above. + +An empty but valid coverage artifact has no failed mapping decisions and therefore has `EXACT` aggregate status. +Backend coverage diagnostics are preserved separately and do not alter that aggregate: they can describe omitted +files outside the requested coverage scopes. + +The JVM taint-analysis `PositionResolver` and `ConditionResolver` were reviewed as architectural prior art. Their +useful separation is preserved: declarative receiver/argument/result positions are distinct from runtime-bound +values, and condition interpretation is distinct from position resolution. The TypeScript mapper expresses this +with EtsIR-specific binding and mapping records and has no dependency on `usvm-jvm` or the taint-analysis module. + +## Process supervision + +`FastCheckProcessTransport` writes stdin and drains stdout and stderr concurrently. This is necessary because each +OS pipe has a finite buffer: reading either output only after process exit can deadlock a child that fills the other +pipe. Requests and stdout are limited to 4 MiB; stderr is limited to 64 KiB. Crossing an output limit fails promptly +and starts cleanup instead of continuing to buffer data. These are transport safety bounds, not property-policy +limits. The execution deadline is the property timeout plus two seconds for transport; projection has its own +bounded wall-clock timeout. Shutdown gets up to 250 ms before force-kill, within the same absolute deadline. The +private process tree is: + +```text +Kotlin client -> process supervisor -> detached group owner -> adapter command -> descendants +``` + +The supervisor stays outside the owned process group so it can escalate shutdown. It installs signal handlers before +spawning the group owner, so an immediate cancellation is remembered until the process-group ID becomes available. +The stable group owner reports command exit over IPC; the supervisor then force-removes remaining descendants. If the +supervisor disappears first, the IPC disconnect handler performs the same cleanup. The only run-control maximum is +`2^31 - 1` milliseconds because Node timers use signed 32-bit delays; runs, examples, and replay paths have no +arbitrary count or length caps. ## Runtime packaging @@ -238,6 +283,9 @@ classifier because `tsx` depends on a native esbuild package. shrinking, explicit examples, preconditions, async predicates, and timeouts. - Coverage golden tests assert literal TypeScript statement and branch outcomes for successful and falsified runs, cross-property isolation, scope and glob filtering, and source-map/report diagnostics. +- Mapping golden tests load stable TypeScript fixtures through the native frontend and cover predicate, + precondition, re-export, UTF-16 normalization, shared spans, exact/ambiguous/unmapped branches, unsupported + source data, and backend-without-coverage behavior. ## Non-goals @@ -245,5 +293,5 @@ classifier because `tsx` depends on a native esbuild package. - Discovering properties by scanning TypeScript source roots. - Compiling user TypeScript as part of the PBT workflow. - Reimplementing generation, replay, skip accounting, or shrinking in Kotlin. -- Mapping Node source locations to EtsIR or constructing symbolic targets from coverage. -- Combining Node source coverage with future EtsIR replay coverage. +- Constructing symbolic inputs or executing mapped properties in USVM. +- Combining backend source coverage with future EtsIR replay coverage. diff --git a/usvm-ts-pbt/README.md b/usvm-ts-pbt/README.md index 14a942a9c..5f01f441a 100644 --- a/usvm-ts-pbt/README.md +++ b/usvm-ts-pbt/README.md @@ -107,12 +107,12 @@ val result = backend.run( The default scope is `SOURCE_UNDER_TEST`. Available scopes are: -| Scope | Files retained after source-map remapping | -| --- | --- | -| `SOURCE_UNDER_TEST` | Files below a source root except exact predicate and precondition modules | -| `PROPERTY_ENTRY_POINTS` | Exact predicate and optional precondition modules | -| `GENERATED_BACKEND_WRAPPERS` | Files in the private adapter runtime outside `node_modules` | -| `DEPENDENCIES` | Executed files below `node_modules` | +| Scope | Files retained after source-map remapping | +| ---------------------------- | ------------------------------------------------------------------------- | +| `SOURCE_UNDER_TEST` | Files below a source root except exact predicate and precondition modules | +| `PROPERTY_ENTRY_POINTS` | Exact predicate and optional precondition modules | +| `GENERATED_BACKEND_WRAPPERS` | Files in the private adapter runtime outside `node_modules` | +| `DEPENDENCIES` | Executed files below `node_modules` | Include and exclude globs operate on original remapped paths, use `/` separators, and support `*`, `?`, and `**`. An empty include list retains every file in a selected scope; exclude rules always win. @@ -137,6 +137,69 @@ Missing or malformed reports use `coverage.report.missing` and `coverage.report. remap produces `coverage.source-map.missing` or `coverage.source-map.invalid`; a missing packaged c8 runtime produces `coverage.collector.not-found`. +## Property-to-EtsIR mapping + +`PropertyEtsMapper` combines a backend-neutral `PropertyManifest`, an `EtsScene`, and optional +`PropertyCoverageArtifact` into one `PropertyEtsMappingArtifact` per property: + +```kotlin +val mapping = PropertyEtsMapper( + scene = etsScene, + sourceRoots = sourceRoots, +).map( + manifest = property.toManifest(), + coverage = result.coverage, +) +``` + +Predicate and optional precondition exports are resolved independently, including named and bare-star TypeScript +re-exports and extensionless `.ts`, `.ets`, `.d.ts`, and directory-index module paths. Direct function exports map +only to file-level EtsIR methods; namespace-star exports are not treated as functions, bare-star exports do not +forward `default`, explicit runtime exports take precedence over bare-star exports, and duplicate re-export paths +to the same method collapse to one target. Type-alias exports do not mask bare-star runtime exports. The current +EtsIR export model preserves `isTypeOnly` independently of the declaration kind, so type-only named and star +re-exports do not mask a bare-star runtime fallback. +Every resolved entry point has explicit receiver, ordered input, and result bindings. The receiver uses stack slot +zero and property inputs follow it in manifest order. A coverage artifact for another property is rejected rather +than combined with the manifest. + +Existing source roots and files are canonicalized through real paths, so symlinked frontend inputs align with +backend coverage; an unresolvable root is `UNSUPPORTED`. Istanbul's one-based lines and zero-based columns become +zero-based half-open ranges with UTF-16 offsets, matching TypeScript and EtsIR source spans. Recognized TypeScript +line terminators are CRLF, lone CR, LF, Unicode line separator (`U+2028`), and Unicode paragraph separator +(`U+2029`). +Statement ranges are compared with `EtsSourceSpan` origins. Several normalized EtsIR statements sharing one exact +origin remain one `EXACT` mapping with several targets; several distinct origins inside a covered range are +`AMBIGUOUS`. + +Binary Istanbul branches map to `EtsIfStmt`. Arm zero is the true CFG successor and arm one is the false successor, +as recorded by `EtsMappingProvenance`. Other branch shapes are `UNSUPPORTED`; the mapper does not guess switch, +logical-expression, or backend-specific arm semantics. + +Only one EtsIR condition from one source candidate is an `EXACT` branch mapping. Multiple conditions are +`AMBIGUOUS` even when they share one source origin, because a short-circuit expression can lower to conditions with +different CFG semantics. + +The pinned c8/V8 collector reports one-arm `branch` records, which therefore remain unsupported for CFG-edge +mapping; statement coverage from the same artifact remains usable. + +| Status | Meaning | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `EXACT` | One source identity was established; normalized statements may produce several EtsIR targets with that shared identity. | +| `AMBIGUOUS` | Several entry points, source origins, source candidates, or branch conditions match, and every candidate is preserved. | +| `UNMAPPED` | The input is supported, but no EtsIR target matches it. | +| `UNSUPPORTED` | The input cannot be interpreted safely, for example because coverage, source text, origins, coordinates, bindings, or branch shape are unsupported. | + +Stable mapping diagnostics include `mapping.entry-point.unmapped`, `mapping.entry-point.ambiguous`, +`mapping.entry-point.bindings.unsupported`, `mapping.coverage.unavailable`, +`mapping.coverage.property-id.mismatch`, `mapping.statement.unmapped`, `mapping.statement.ambiguous`, +`mapping.branch.unmapped`, `mapping.branch.ambiguous`, `mapping.branch.shape.unsupported`, +`mapping.branch.cfg.unsupported`, +`mapping.source.unavailable`, `mapping.source.location.unsupported`, and +`mapping.source-origins.unsupported`, and `mapping.source-root.unsupported`. Backend provenance is preserved +separately from mapping provenance and +backend diagnostics are copied without reinterpretation. + ## Registries and CLI The CLI loads Kotlin property registries through `ServiceLoader`: diff --git a/usvm-ts-pbt/fast-check-adapter/package.json b/usvm-ts-pbt/fast-check-adapter/package.json index de025f782..fdac67984 100644 --- a/usvm-ts-pbt/fast-check-adapter/package.json +++ b/usvm-ts-pbt/fast-check-adapter/package.json @@ -9,7 +9,7 @@ "build": "tsc --project tsconfig.json", "pretest": "npm run build", "test": "npm run test:compiled", - "test:compiled": "node --test dist/test/entry-point.test.js dist/test/execute-property.test.js dist/test/execution-cli.test.js dist/test/js-value.test.js dist/test/project-domain.test.js dist/test/projection-cli.test.js" + "test:compiled": "node --test dist/test/entry-point.test.js dist/test/execute-property.test.js dist/test/execution-cli.test.js dist/test/js-value.test.js dist/test/process-group-shutdown.test.js dist/test/process-supervisor.test.js dist/test/project-domain.test.js dist/test/projection-cli.test.js" }, "dependencies": { "c8": "10.1.3", diff --git a/usvm-ts-pbt/fast-check-adapter/src/execute-property.ts b/usvm-ts-pbt/fast-check-adapter/src/execute-property.ts index a21ea2379..0b562d5cb 100644 --- a/usvm-ts-pbt/fast-check-adapter/src/execute-property.ts +++ b/usvm-ts-pbt/fast-check-adapter/src/execute-property.ts @@ -83,7 +83,7 @@ export async function executeProperty(requestValue: unknown): Promise, + arbitrary: fc.Arbitrary, predicate: LoadedEntryPoint, precondition: LoadedEntryPoint | undefined, -): fc.IProperty<[unknown[]]> | fc.IAsyncProperty<[unknown[]]> { +): fc.IProperty<[JsConcreteValue[]]> | fc.IAsyncProperty<[JsConcreteValue[]]> { const asynchronous = predicate.executionKind === 'async' || precondition?.executionKind === 'async'; if (asynchronous) { - return fc.asyncProperty(arbitrary, async (values: unknown[]): Promise => { - const argumentsList = values as JsConcreteValue[]; + return fc.asyncProperty(arbitrary, async (values: JsConcreteValue[]): Promise => { + if (precondition !== undefined && !(await precondition.invoke(cloneArguments(values)))) fc.pre(false); - if (precondition !== undefined && !(await precondition.invoke(argumentsList))) fc.pre(false); - - return await predicate.invoke(argumentsList); + return await predicate.invoke(cloneArguments(values)); }); } - return fc.property(arbitrary, (values: unknown[]): boolean => { - const argumentsList = values as JsConcreteValue[]; - - if (precondition !== undefined && !precondition.invoke(argumentsList)) fc.pre(false); + return fc.property(arbitrary, (values: JsConcreteValue[]): boolean => { + if (precondition !== undefined && !precondition.invoke(cloneArguments(values))) fc.pre(false); - return predicate.invoke(argumentsList) as boolean; + return predicate.invoke(cloneArguments(values)) as boolean; }); } -function buildParameters(request: FastCheckExecutionRequest): Parameters<[unknown[]]> { - const decodedExamples = request.examples.map((example, exampleIndex) => { +async function checkProperty( + property: fc.IProperty<[JsConcreteValue[]]> | fc.IAsyncProperty<[JsConcreteValue[]]>, + parameters: Parameters<[JsConcreteValue[]]>, + replayPath: string | undefined, +): Promise> { + try { + return await Promise.resolve(fc.check(property, parameters)); + } catch (error: unknown) { + if (replayPath !== undefined && isFastCheckReplayFailure(error)) { + throw protocolError( + adapterDiagnostic.protocolReplayPathInvalid, + 'Replay path cannot be applied to this property run', + 'replayPath', + ); + } + + throw error; + } +} + +/** + * User callbacks must not mutate fast-check's sample, which it retains for shrinking and replay. + * A shared clone map preserves aliases and cycles within one invocation while isolating separate invocations. + */ +function cloneArguments(values: JsConcreteValue[]): JsConcreteValue[] { + return cloneArray(values, new Map()); +} + +function cloneArray( + value: JsConcreteValue[], + clones: Map, +): JsConcreteValue[] { + const existing = clones.get(value); + if (existing !== undefined) return existing; + + const clone: JsConcreteValue[] = []; + clones.set(value, clone); + for (const element of value) { + clone.push(Array.isArray(element) ? cloneArray(element, clones) : element); + } + + return clone; +} + +function buildParameters(request: FastCheckExecutionRequest): Parameters<[JsConcreteValue[]]> { + const decodedExamples = request.examples.map((example, exampleIndex): [JsConcreteValue[]] => { if (example.length !== request.manifest.inputs.length) { throw protocolError( adapterDiagnostic.protocolExamplesArity, @@ -136,10 +176,10 @@ function buildParameters(request: FastCheckExecutionRequest): Parameters<[unknow const values = example.map((value, valueIndex) => decodeJsValue(value, `examples[${exampleIndex}][${valueIndex}]`)); - return [values] as [unknown[]]; + return [values]; }); - const parameters: Parameters<[unknown[]]> = { + const parameters: Parameters<[JsConcreteValue[]]> = { numRuns: request.numRuns, timeout: request.timeoutMillis, interruptAfterTimeLimit: request.timeoutMillis, @@ -155,7 +195,7 @@ function buildParameters(request: FastCheckExecutionRequest): Parameters<[unknow function toRunResult( propertyId: string, - details: RunDetails<[unknown[]]>, + details: RunDetails<[JsConcreteValue[]]>, executionTimeMillis: number, ): FastCheckRunResult { const counterexampleValues = details.counterexample?.[0]; @@ -178,7 +218,7 @@ function toRunResult( }; } -function failureDetails(details: RunDetails<[unknown[]]>): FastCheckFailureDetails { +function failureDetails(details: RunDetails<[JsConcreteValue[]]>): FastCheckFailureDetails { const error = details.errorInstance; const timeout = (details.interrupted && details.counterexample === null) || isFastCheckTimeout(error); @@ -198,17 +238,32 @@ function failureDetails(details: RunDetails<[unknown[]]>): FastCheckFailureDetai }; } + if (details.counterexample === null) { + return { + kind: 'property', + errorName: 'PropertyFailure', + message: 'Property could not satisfy its precondition within the skip limit', + }; + } + return { kind: 'property', - errorName: 'PropertyFailure', - message: details.counterexample === null - ? 'Property could not satisfy its precondition within the skip limit' - : 'Property predicate returned false', + errorName: 'ThrownValue', + message: String(error), }; } function isFastCheckTimeout(error: unknown): boolean { - return error instanceof Error && error.message.startsWith('Property timeout:'); + return hasFastCheckMessagePrefix(error, FAST_CHECK_TIMEOUT_PREFIX); +} + +function isFastCheckReplayFailure(error: unknown): boolean { + return hasFastCheckMessagePrefix(error, FAST_CHECK_REPLAY_FAILURE_PREFIX); +} + +/** The pinned fast-check version exposes these two failure categories only through stable message prefixes. */ +function hasFastCheckMessagePrefix(error: unknown, prefix: string): boolean { + return error instanceof Error && error.message.startsWith(prefix); } function validateRequest(value: unknown): FastCheckExecutionRequest { @@ -248,7 +303,8 @@ function validateRequest(value: unknown): FastCheckExecutionRequest { ); } - const invalidReplayPath = request.replayPath !== undefined && typeof request.replayPath !== 'string'; + const invalidReplayPath = request.replayPath !== undefined + && (typeof request.replayPath !== 'string' || !REPLAY_PATH_PATTERN.test(request.replayPath)); if (invalidReplayPath) { throw protocolError( adapterDiagnostic.protocolReplayPathInvalid, @@ -389,3 +445,6 @@ function isSignedInt(value: unknown): value is number { // Node timers use signed 32-bit millisecond delays; larger values are clamped to one millisecond. const MAX_TIMER_DELAY_MILLIS = 2 ** 31 - 1; +const REPLAY_PATH_PATTERN = /^\d+(?::\d+)*$/; +const FAST_CHECK_REPLAY_FAILURE_PREFIX = 'Unable to replay,'; +const FAST_CHECK_TIMEOUT_PREFIX = 'Property timeout:'; diff --git a/usvm-ts-pbt/fast-check-adapter/src/process-group-shutdown.ts b/usvm-ts-pbt/fast-check-adapter/src/process-group-shutdown.ts new file mode 100644 index 000000000..5ff8d6039 --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/src/process-group-shutdown.ts @@ -0,0 +1,77 @@ +import { spawnSync } from 'node:child_process'; + +export type ProcessGroupTermination = 'graceful' | 'forceful'; +export type ProcessGroupTerminator = (pid: number, termination: ProcessGroupTermination) => void; + +/** Coordinates a two-phase shutdown even when the signal arrives before spawn returns a PID. */ +export class ProcessGroupShutdown { + private processGroupPid: number | undefined; + private shutdownRequested = false; + private shutdownStarted = false; + private forceKillTimer: NodeJS.Timeout | undefined; + + constructor( + private readonly forceKillDelayMillis: number, + private readonly terminate: ProcessGroupTerminator, + ) {} + + attach(processGroupPid: number): void { + if (this.processGroupPid !== undefined) throw new Error('Process group is already attached'); + + this.processGroupPid = processGroupPid; + this.startIfReady(); + } + + request(): void { + this.shutdownRequested = true; + this.startIfReady(); + } + + cancel(): void { + if (this.forceKillTimer !== undefined) clearTimeout(this.forceKillTimer); + } + + private startIfReady(): void { + if (!this.shutdownRequested || this.shutdownStarted || this.processGroupPid === undefined) return; + + const processGroupPid = this.processGroupPid; + this.shutdownStarted = true; + this.terminate(processGroupPid, 'graceful'); + this.forceKillTimer = setTimeout(() => { + this.terminate(processGroupPid, 'forceful'); + }, this.forceKillDelayMillis); + } +} + +/** Terminates a detached worker together with every process that it owns. */ +export function terminateOwnedProcessGroup(pid: number, termination: ProcessGroupTermination): void { + const force = termination === 'forceful'; + + if (process.platform === 'win32') { + const arguments_ = ['/PID', String(pid), '/T']; + if (force) arguments_.push('/F'); + + spawnSync('taskkill', arguments_, { + stdio: 'ignore', + windowsHide: true, + }); + + return; + } + + try { + process.kill(-pid, force ? 'SIGKILL' : 'SIGTERM'); + } catch (error: unknown) { + if (!isMissingProcess(error)) throw error; + } +} + +export function terminateOwnProcessGroup(): void { + terminateOwnedProcessGroup(process.pid, 'forceful'); +} + +function isMissingProcess(error: unknown): boolean { + return error instanceof Error + && 'code' in error + && error.code === 'ESRCH'; +} diff --git a/usvm-ts-pbt/fast-check-adapter/src/process-supervisor.ts b/usvm-ts-pbt/fast-check-adapter/src/process-supervisor.ts new file mode 100644 index 000000000..71771e963 --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/src/process-supervisor.ts @@ -0,0 +1,197 @@ +import { spawn } from 'node:child_process'; +import { unlinkSync, writeFileSync } from 'node:fs'; +import { + ProcessGroupShutdown, + terminateOwnProcessGroup, + terminateOwnedProcessGroup, +} from './process-group-shutdown.js'; + +interface CommandExitMessage { + type: 'command-exit'; + code: number; +} + +type Command = [string, ...string[]]; + +/** + * Process tree: + * Kotlin client -> supervisor -> detached group owner -> command -> any descendants. + * The supervisor stays outside the owned group so it can escalate shutdown. The group owner stays alive over IPC + * until the command reports its exit, then the supervisor removes every remaining descendant at once. + */ + +const commandModeFlag = '--command'; +const groupOwnerFlag = '--group-owner'; +const MAX_TIMER_DELAY_MILLIS = 2 ** 31 - 1; + +runProcess(process.argv.slice(2)); + +function runProcess(arguments_: string[]): void { + const mode = requireArgument(arguments_[0], 'supervisor mode'); + + if (mode === groupOwnerFlag) { + runGroupOwner(requireCommand(arguments_.slice(1))); + return; + } + + if (mode !== commandModeFlag) fail(`Unknown supervisor mode: ${mode}`); + + const forceKillDelayMillis = requireTimerDelay(arguments_[1], 'force-kill delay'); + const processGroupFile = requireArgument(arguments_[2], 'process-group file'); + const command = requireCommand(arguments_.slice(3)); + + runSupervisor(command, forceKillDelayMillis, processGroupFile); +} + +function runSupervisor( + command: Command, + forceKillDelayMillis: number, + processGroupFile: string, +): void { + const shutdown = new ProcessGroupShutdown(forceKillDelayMillis, terminateOwnedProcessGroup); + installSupervisorSignalHandlers(shutdown); + + const supervisorEntryPoint = requireArgument(process.argv[1], 'supervisor entry point'); + const groupOwner = spawn( + process.execPath, + [supervisorEntryPoint, groupOwnerFlag, ...command], + { + detached: true, + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + }, + ); + const groupOwnerPid = requirePid(groupOwner.pid, 'group owner'); + const groupOwnerStdin = requireStream(groupOwner.stdin, 'group owner stdin'); + const groupOwnerStdout = requireStream(groupOwner.stdout, 'group owner stdout'); + const groupOwnerStderr = requireStream(groupOwner.stderr, 'group owner stderr'); + let reportedExitCode: number | undefined; + + shutdown.attach(groupOwnerPid); + writeFileSync(processGroupFile, String(groupOwnerPid)); + + process.stdin.pipe(groupOwnerStdin); + groupOwnerStdout.pipe(process.stdout); + groupOwnerStderr.pipe(process.stderr); + + groupOwner.on('message', (message: unknown) => { + if (!isCommandExitMessage(message)) return; + + reportedExitCode = message.code; + terminateOwnedProcessGroup(groupOwnerPid, 'forceful'); + }); + groupOwner.on('error', (error: Error) => { + process.stderr.write(`Failed to start process-group owner: ${error.message}\n`); + reportedExitCode = 1; + }); + groupOwner.on('close', (code: number | null) => { + shutdown.cancel(); + removeProcessGroupFile(processGroupFile); + + process.exitCode = reportedExitCode ?? code ?? 1; + }); +} + +function installSupervisorSignalHandlers(shutdown: ProcessGroupShutdown): void { + process.on('SIGINT', () => shutdown.request()); + process.on('SIGTERM', () => shutdown.request()); +} + +function runGroupOwner(command: Command): void { + installProcessGroupOwnerHandlers(); + + const reportExit = createCommandExitReporter(); + const child = spawn(command[0], command.slice(1), { + // Direct inheritance avoids a user-space forwarding buffer that could be truncated when the group is removed. + stdio: 'inherit', + }); + + child.on('error', (error: Error) => { + process.stderr.write(`Failed to start supervised command: ${error.message}\n`); + reportExit(1); + }); + child.on('exit', (code: number | null) => reportExit(code ?? 1)); +} + +function installProcessGroupOwnerHandlers(): void { + // Keep the process-group identity stable while shutdown propagates through the group. If the supervisor disappears, + // the IPC disconnect is the last reliable opportunity to remove the entire owned group. + process.on('SIGINT', () => undefined); + process.on('SIGTERM', () => undefined); + process.on('disconnect', terminateOwnProcessGroup); +} + +function createCommandExitReporter(): (code: number) => void { + let reported = false; + + return (code: number): void => { + if (reported) return; + + reported = true; + const message: CommandExitMessage = { type: 'command-exit', code }; + process.send?.(message); + }; +} + +function isCommandExitMessage(value: unknown): value is CommandExitMessage { + if (value === null || typeof value !== 'object') return false; + + const record = value as Record; + + return record.type === 'command-exit' + && typeof record.code === 'number' + && Number.isInteger(record.code); +} + +function removeProcessGroupFile(processGroupFile: string): void { + try { + unlinkSync(processGroupFile); + } catch (error: unknown) { + if (!isMissingFile(error)) throw error; + } +} + +function isMissingFile(error: unknown): boolean { + if (!(error instanceof Error) || !('code' in error)) return false; + + return error.code === 'ENOENT'; +} + +function requireArgument(value: string | undefined, name: string): string { + if (value === undefined || value.length === 0) fail(`Missing ${name}`); + + return value; +} + +function requireCommand(command: string[]): Command { + const executable = requireArgument(command[0], 'command executable'); + + return [executable, ...command.slice(1)]; +} + +function requireTimerDelay(value: string | undefined, name: string): number { + const parsed = value === undefined ? Number.NaN : Number(value); + const isInteger = Number.isInteger(parsed); + const isPositive = parsed > 0; + const fitsNodeTimer = parsed <= MAX_TIMER_DELAY_MILLIS; + const valid = isInteger && isPositive && fitsNodeTimer; + if (!valid) fail(`Invalid ${name}: ${value ?? ''}`); + + return parsed; +} + +function requirePid(value: number | undefined, name: string): number { + if (value === undefined) fail(`Missing ${name} PID`); + + return value; +} + +function requireStream(value: T | null, name: string): T { + if (value === null) fail(`Missing ${name}`); + + return value; +} + +function fail(message: string): never { + process.stderr.write(`${message}\n`); + process.exit(1); +} diff --git a/usvm-ts-pbt/fast-check-adapter/src/project-domain.ts b/usvm-ts-pbt/fast-check-adapter/src/project-domain.ts index 0e421c764..391993901 100644 --- a/usvm-ts-pbt/fast-check-adapter/src/project-domain.ts +++ b/usvm-ts-pbt/fast-check-adapter/src/project-domain.ts @@ -3,6 +3,7 @@ import { adapterDiagnostic } from './diagnostics.js'; import { decodeJsNumber, decodeJsValue, + type JsConcreteValue, ProtocolError, protocolError, } from './js-value.js'; @@ -20,7 +21,7 @@ export interface ProjectionCapability { type DomainRecord = Record; -export function projectDomain(domain: unknown, path = 'domain'): fc.Arbitrary { +export function projectDomain(domain: unknown, path = 'domain'): fc.Arbitrary { requireDomainObject(domain, path); switch (domain.kind) { diff --git a/usvm-ts-pbt/fast-check-adapter/test/execute-property.test.ts b/usvm-ts-pbt/fast-check-adapter/test/execute-property.test.ts index ab1f69877..802922207 100644 --- a/usvm-ts-pbt/fast-check-adapter/test/execute-property.test.ts +++ b/usvm-ts-pbt/fast-check-adapter/test/execute-property.test.ts @@ -66,6 +66,30 @@ test('supports asynchronous predicates and preconditions', async () => { }); }); +test('reports exhausted preconditions as a property failure without a counterexample', async () => { + await withPropertyModule(async (sourceRoot) => { + const request = executionRequest(sourceRoot, 'alwaysTrue', { + precondition: { + module: 'properties.ts', + exportName: 'neverAccepts', + executionKind: 'sync', + }, + }); + request.numRuns = 1; + + const response = await executeProperty(request); + + assert.equal(response.result.status, 'failure'); + assert.equal(response.result.counterexample, null); + assert.equal(response.result.failure?.kind, 'property'); + assert.equal(response.result.failure?.errorName, 'PropertyFailure'); + assert.equal( + response.result.failure?.message, + 'Property could not satisfy its precondition within the skip limit', + ); + }); +}); + test('executes explicit examples through the same predicate', async () => { await withPropertyModule(async (sourceRoot) => { const request = executionRequest(sourceRoot, 'isNotSeven'); @@ -114,6 +138,95 @@ test('keeps a counterexample classified as a property failure when shrinking is }); }); +test('reports the original nested array when the predicate mutates its invocation to an object', async () => { + await withPropertyModule(async (sourceRoot) => { + const originalValue = [[1]]; + const request = executionRequest(sourceRoot, 'mutatesNestedArrayToObject', { + inputDomain: { kind: 'constant', value: encodeJsValue(originalValue) }, + }); + + const response = await executeProperty(request); + + assert.equal(response.result.status, 'failure'); + assert.deepEqual(response.result.counterexample, [encodeJsValue(originalValue)]); + }); +}); + +test('reports and replays the original array when the predicate creates a cycle', async () => { + await withPropertyModule(async (sourceRoot) => { + const originalValue = [1]; + const request = executionRequest(sourceRoot, 'mutatesArrayToCycle', { + inputDomain: { kind: 'constant', value: encodeJsValue(originalValue) }, + }); + + const first = await executeProperty(request); + assert.ok(first.result.replayPath); + + const replay = await executeProperty({ + ...request, + replayPath: first.result.replayPath, + seed: first.result.seed, + }); + + assert.equal(first.result.status, 'failure'); + assert.deepEqual(first.result.counterexample, [encodeJsValue(originalValue)]); + assert.deepEqual(replay.result.counterexample, first.result.counterexample); + }); +}); + +test('isolates predicate input from recursive array mutation in the precondition', async () => { + await withPropertyModule(async (sourceRoot) => { + const request = executionRequest(sourceRoot, 'receivesOriginalNestedArray', { + precondition: { + module: 'properties.ts', + exportName: 'mutatesNestedArrayAndAccepts', + executionKind: 'sync', + }, + inputDomain: { kind: 'constant', value: encodeJsValue([[1]]) }, + }); + + const response = await executeProperty(request); + + assert.equal(response.result.status, 'success'); + }); +}); + +test('isolates asynchronous predicate input from recursive array mutation in the precondition', async () => { + await withPropertyModule(async (sourceRoot) => { + const request = executionRequest(sourceRoot, 'asyncReceivesOriginalNestedArray', { + predicateExecutionKind: 'async', + precondition: { + module: 'properties.ts', + exportName: 'asyncMutatesNestedArrayAndAccepts', + executionKind: 'async', + }, + inputDomain: { kind: 'constant', value: encodeJsValue([[1]]) }, + }); + + const response = await executeProperty(request); + + assert.equal(response.result.status, 'success'); + }); +}); + +test('preserves non-Error thrown values including falsy primitives', async () => { + await withPropertyModule(async (sourceRoot) => { + const cases = ['boom', '', 0, false, null, undefined] as const; + + for (const thrownValue of cases) { + const request = executionRequest(sourceRoot, 'throwsInput', { + inputDomain: { kind: 'constant', value: encodeJsValue(thrownValue) }, + }); + + const response = await executeProperty(request); + + assert.equal(response.result.status, 'failure'); + assert.equal(response.result.failure?.errorName, 'ThrownValue'); + assert.equal(response.result.failure?.message, String(thrownValue)); + } + }); +}); + interface RequestOverrides { predicateExecutionKind?: 'sync' | 'async'; precondition?: FastCheckExecutionRequest['manifest']['precondition']; @@ -155,25 +268,7 @@ async function withPropertyModule(block: (sourceRoot: string) => Promise): const sourceRoot = path.join(workspace, 'src'); await mkdir(sourceRoot); - await writeFile( - path.join(sourceRoot, 'properties.ts'), - [ - 'export function alwaysTrue(_value: number): boolean { return true; }', - 'export function isNegative(value: number): boolean { return value < 0; }', - 'export async function asyncAlwaysTrue(_value: number): Promise { return true; }', - 'export async function asyncIsOne(value: number): Promise { return value === 1; }', - 'export function isNotSeven(value: number): boolean { return value !== 7; }', - 'export function slowFailure(_value: number[]): boolean {', - ' const deadline = Date.now() + 10;', - ' while (Date.now() < deadline) {}', - ' return false;', - '}', - 'export async function neverCompletes(_value: number): Promise {', - ' await new Promise(() => undefined);', - ' return true;', - '}', - ].join('\n'), - ); + await writeFile(path.join(sourceRoot, 'properties.ts'), PROPERTY_MODULE_SOURCE); try { await block(sourceRoot); @@ -187,3 +282,61 @@ function semanticResult(result: FastCheckRunResult): Omit { return true; } +export async function asyncIsOne(value: number): Promise { return value === 1; } +export function neverAccepts(_value: number): boolean { return false; } +export function isNotSeven(value: number): boolean { return value !== 7; } + +export function slowFailure(_value: number[]): boolean { + const deadline = Date.now() + 10; + while (Date.now() < deadline) {} + + return false; +} + +export async function neverCompletes(_value: number): Promise { + await new Promise(() => undefined); + + return true; +} + +export function mutatesNestedArrayToObject(value: unknown[][]): boolean { + value[0]![0] = {}; + + return false; +} + +export function mutatesArrayToCycle(value: unknown[]): boolean { + value[0] = value; + + return false; +} + +export function mutatesNestedArrayAndAccepts(value: unknown[][]): boolean { + value[0]![0] = {}; + + return true; +} + +export function receivesOriginalNestedArray(value: unknown[][]): boolean { + return value[0]?.[0] === 1; +} + +export async function asyncMutatesNestedArrayAndAccepts(value: unknown[][]): Promise { + value[0]![0] = {}; + + return true; +} + +export async function asyncReceivesOriginalNestedArray(value: unknown[][]): Promise { + return value[0]?.[0] === 1; +} + +export function throwsInput(value: unknown): never { + throw value; +} +`.trimStart(); diff --git a/usvm-ts-pbt/fast-check-adapter/test/execution-cli.test.ts b/usvm-ts-pbt/fast-check-adapter/test/execution-cli.test.ts index 3c147cce0..1128155ae 100644 --- a/usvm-ts-pbt/fast-check-adapter/test/execution-cli.test.ts +++ b/usvm-ts-pbt/fast-check-adapter/test/execution-cli.test.ts @@ -80,6 +80,30 @@ test('execution CLI exits after writing a response when user code leaves an open } }); +for (const replayPath of ['garbage', '0:999999:0']) { + test(`execution CLI reports replay path ${replayPath} as a typed protocol error`, async () => { + const sourceRoot = await realpath(await mkdtemp(path.join(tmpdir(), 'usvm-execution-cli-'))); + try { + await writeFile(sourceRoot + '/property.ts', 'export function predicate(value: boolean) { return value; }\n'); + const request = executionRequest(sourceRoot); + request.replayPath = replayPath; + + const invocation = await invokeCli(JSON.stringify(request)); + const response = JSON.parse(invocation.stdout) as ExecutionErrorResponse; + + assert.equal(invocation.timedOut, false); + assert.equal(invocation.exitCode, 0); + assert.equal(invocation.stderr, ''); + assert.equal(response.status, 'error'); + assert.equal(response.diagnostics[0]?.kind, 'invalid-request'); + assert.equal(response.diagnostics[0]?.code, 'protocol.replay-path.invalid'); + assert.equal(response.diagnostics[0]?.path, 'replayPath'); + } finally { + await rm(sourceRoot, { recursive: true, force: true }); + } + }); +} + interface ExecutionErrorResponse { status: string; diagnostics: ProtocolDiagnostic[]; diff --git a/usvm-ts-pbt/fast-check-adapter/test/process-group-shutdown.test.ts b/usvm-ts-pbt/fast-check-adapter/test/process-group-shutdown.test.ts new file mode 100644 index 000000000..5c2ef4648 --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/test/process-group-shutdown.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { ProcessGroupShutdown } from '../src/process-group-shutdown.js'; + +test('starts a shutdown requested before the process group is attached', () => { + const terminations: Array<{ pid: number; termination: string }> = []; + const shutdown = new ProcessGroupShutdown( + 1_000, + (pid, termination) => terminations.push({ pid, termination }), + ); + + shutdown.request(); + assert.deepEqual(terminations, []); + + shutdown.attach(42); + assert.deepEqual(terminations, [{ pid: 42, termination: 'graceful' }]); + + shutdown.cancel(); +}); diff --git a/usvm-ts-pbt/fast-check-adapter/test/process-supervisor.test.ts b/usvm-ts-pbt/fast-check-adapter/test/process-supervisor.test.ts new file mode 100644 index 000000000..41fc18af0 --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/test/process-supervisor.test.ts @@ -0,0 +1,179 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import type { Readable } from 'node:stream'; +import { setTimeout as delay } from 'node:timers/promises'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const supervisorPath = fileURLToPath(new URL('../src/process-supervisor.js', import.meta.url)); + +test('command runs below the stable process-group owner', { timeout: 3_000 }, async () => { + const workspace = await mkdtemp(path.join(tmpdir(), 'usvm-projection-supervisor-')); + const adapterPath = path.join(workspace, 'adapter.mjs'); + const adapterPidFile = path.join(workspace, 'adapter.pid'); + const processGroupFile = path.join(workspace, 'process-group.pid'); + await writeFile( + adapterPath, + `import { writeFileSync } from 'node:fs';\n` + + `writeFileSync(${JSON.stringify(adapterPidFile)}, String(process.pid));\n` + + `setInterval(() => undefined, 1000);\n`, + ); + const supervisor = spawn( + process.execPath, + [supervisorPath, '--command', '25', processGroupFile, process.execPath, adapterPath], + { stdio: 'ignore' }, + ); + const supervisorExit = new Promise((resolve) => supervisor.once('close', () => resolve())); + + try { + const [adapterPid, processGroupPid] = await Promise.all([ + readTextEventually(adapterPidFile), + readTextEventually(processGroupFile), + ]); + + assert.notEqual(adapterPid, processGroupPid); + } finally { + supervisor.kill('SIGTERM'); + await Promise.race([ + supervisorExit, + delay(2_000).then(() => supervisor.kill('SIGKILL')), + ]); + await rm(workspace, { recursive: true, force: true }); + } +}); + +test('command exit removes descendants that retain inherited pipes', { timeout: 10_000 }, async () => { + const workspace = await mkdtemp(path.join(tmpdir(), 'usvm-command-supervisor-')); + const adapterPath = path.join(workspace, 'adapter.mjs'); + const childPidFile = path.join(workspace, 'child.pid'); + const processGroupFile = path.join(workspace, 'process-group.pid'); + await writeFile( + adapterPath, + `import { spawn } from 'node:child_process';\n` + + `import { writeFileSync } from 'node:fs';\n` + + `const child = spawn(process.execPath, ['-e', 'setInterval(() => undefined, 1000)'], ` + + `{ stdio: ['ignore', 'inherit', 'inherit'] });\n` + + `writeFileSync(${JSON.stringify(childPidFile)}, String(child.pid));\n` + + `child.unref();\n` + + `setTimeout(() => undefined, 100);\n`, + ); + const supervisor = spawn( + process.execPath, + [supervisorPath, '--command', '25', processGroupFile, process.execPath, adapterPath], + { stdio: 'ignore' }, + ); + const supervisorExit = new Promise((resolve, reject) => { + supervisor.once('error', reject); + supervisor.once('close', resolve); + }); + let childPid: number | undefined; + + try { + const [childPidText, processGroupPidText] = await Promise.all([ + readTextEventually(childPidFile), + readTextEventually(processGroupFile), + ]); + childPid = Number(childPidText); + + assert.notEqual(childPidText, processGroupPidText); + assert.equal(await supervisorExit, 0); + + assert.equal(isProcessAlive(childPid), false); + } finally { + supervisor.kill('SIGKILL'); + if (childPid !== undefined) terminateProcess(childPid); + await rm(workspace, { recursive: true, force: true }); + } +}); + +test('rejects a force-kill delay outside the Node timer range', async () => { + const workspace = await mkdtemp(path.join(tmpdir(), 'usvm-command-supervisor-')); + const processGroupFile = path.join(workspace, 'process-group.pid'); + const supervisor = spawn( + process.execPath, + [ + supervisorPath, + '--command', + String(2 ** 31), + processGroupFile, + process.execPath, + '-e', + 'process.exit(0)', + ], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + + try { + const [exitCode, stdout, stderr] = await Promise.all([ + new Promise((resolve, reject) => { + supervisor.once('error', reject); + supervisor.once('close', resolve); + }), + collectText(supervisor.stdout), + collectText(supervisor.stderr), + ]); + + assert.equal(exitCode, 1); + assert.equal(stdout, ''); + assert.match(stderr, /Invalid force-kill delay/); + } finally { + supervisor.kill('SIGKILL'); + await rm(workspace, { recursive: true, force: true }); + } +}); + +async function readTextEventually(file: string): Promise { + const deadline = Date.now() + 2_000; + + while (true) { + try { + return await readFile(file, 'utf8'); + } catch (error: unknown) { + if (!isMissingFile(error) || Date.now() >= deadline) throw error; + } + + await delay(10); + } +} + +function isMissingFile(error: unknown): boolean { + return error instanceof Error + && 'code' in error + && error.code === 'ENOENT'; +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + + return true; + } catch (error: unknown) { + if (isMissingProcess(error)) return false; + + throw error; + } +} + +function terminateProcess(pid: number): void { + try { + process.kill(pid, 'SIGKILL'); + } catch (error: unknown) { + if (!isMissingProcess(error)) throw error; + } +} + +function isMissingProcess(error: unknown): boolean { + return error instanceof Error + && 'code' in error + && error.code === 'ESRCH'; +} + +async function collectText(stream: Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(Buffer.from(chunk)); + + return Buffer.concat(chunks).toString('utf8'); +} diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/PbtDiagnosticCode.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/PbtDiagnosticCode.kt index 7ac238b1e..65dbf5170 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/PbtDiagnosticCode.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/PbtDiagnosticCode.kt @@ -45,6 +45,22 @@ internal object PbtDiagnosticCode { const val COVERAGE_SOURCE_MAP_INVALID = "coverage.source-map.invalid" const val COVERAGE_SOURCE_MAP_MISSING = "coverage.source-map.missing" + const val MAPPING_BRANCH_AMBIGUOUS = "mapping.branch.ambiguous" + const val MAPPING_BRANCH_CFG_UNSUPPORTED = "mapping.branch.cfg.unsupported" + const val MAPPING_BRANCH_SHAPE_UNSUPPORTED = "mapping.branch.shape.unsupported" + const val MAPPING_BRANCH_UNMAPPED = "mapping.branch.unmapped" + const val MAPPING_COVERAGE_PROPERTY_ID_MISMATCH = "mapping.coverage.property-id.mismatch" + const val MAPPING_COVERAGE_UNAVAILABLE = "mapping.coverage.unavailable" + const val MAPPING_ENTRY_POINT_AMBIGUOUS = "mapping.entry-point.ambiguous" + const val MAPPING_ENTRY_POINT_BINDINGS_UNSUPPORTED = "mapping.entry-point.bindings.unsupported" + const val MAPPING_ENTRY_POINT_UNMAPPED = "mapping.entry-point.unmapped" + const val MAPPING_SOURCE_LOCATION_UNSUPPORTED = "mapping.source.location.unsupported" + const val MAPPING_SOURCE_ORIGINS_UNSUPPORTED = "mapping.source-origins.unsupported" + const val MAPPING_SOURCE_ROOT_UNSUPPORTED = "mapping.source-root.unsupported" + const val MAPPING_SOURCE_UNAVAILABLE = "mapping.source.unavailable" + const val MAPPING_STATEMENT_AMBIGUOUS = "mapping.statement.ambiguous" + const val MAPPING_STATEMENT_UNMAPPED = "mapping.statement.unmapped" + const val PROTOCOL_REQUEST_INVALID = "protocol.request.invalid" const val SOURCE_ROOT_INVALID = "source-root.invalid" diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/backend/ProjectionCapability.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/backend/ProjectionCapability.kt index a35007c37..d16949243 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/backend/ProjectionCapability.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/backend/ProjectionCapability.kt @@ -79,8 +79,9 @@ fun classifyPropertyCapability( ): PropertyCapabilityLevel = when { concrete.level == ProjectionLevel.UNSUPPORTED -> PropertyCapabilityLevel.UNSUPPORTED symbolic.level == ProjectionLevel.UNSUPPORTED -> PropertyCapabilityLevel.CONCRETE_ONLY - concrete.level == ProjectionLevel.APPROXIMATE || symbolic.level == ProjectionLevel.APPROXIMATE -> + concrete.level == ProjectionLevel.APPROXIMATE || symbolic.level == ProjectionLevel.APPROXIMATE -> { PropertyCapabilityLevel.APPROXIMATE + } else -> PropertyCapabilityLevel.EXACT } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/backend/PropertyBasedTestingBackend.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/backend/PropertyBasedTestingBackend.kt index c3df828e1..4e666df08 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/backend/PropertyBasedTestingBackend.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/backend/PropertyBasedTestingBackend.kt @@ -73,7 +73,6 @@ data class PropertyFailureDetails( ) { init { require(errorName.isNotBlank()) { "Failure error name must not be blank" } - require(message.isNotBlank()) { "Failure message must not be blank" } } } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/cli/FastCheckOptions.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/cli/FastCheckOptions.kt index 17b2e1e32..5df77fcf5 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/cli/FastCheckOptions.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/cli/FastCheckOptions.kt @@ -187,9 +187,12 @@ private class FastCheckOptionsParser : CliktCommand(name = "usvm-ts-pbt") { private fun buildCoverageRequest(): PropertyCoverageRequest? { if (!coverageEnabled) return null + val scopes = coverageScopes + .mapTo(hashSetOf(), ::parseCoverageScope) + .ifEmpty { setOf(CoverageScope.SOURCE_UNDER_TEST) } + return PropertyCoverageRequest( - scopes = coverageScopes.mapTo(hashSetOf(), ::parseCoverageScope) - .ifEmpty { setOf(CoverageScope.SOURCE_UNDER_TEST) }, + scopes = scopes, includePatterns = coverageIncludePatterns, excludePatterns = coverageExcludePatterns, ) diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/CoveragePathFilter.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/CoveragePathFilter.kt index 7643b439f..bb15a4e92 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/CoveragePathFilter.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/CoveragePathFilter.kt @@ -1,5 +1,13 @@ package org.usvm.ts.pbt.coverage +import java.nio.file.Path +import kotlin.io.path.invariantSeparatorsPathString + +internal fun normalizeCoveragePath(path: String): String = Path.of(path) + .toAbsolutePath() + .normalize() + .invariantSeparatorsPathString + internal fun matchesCoveragePath( path: String, patterns: List, @@ -10,7 +18,7 @@ internal fun matchesCoveragePath( add(path) sourceRoots.forEach { sourceRoot -> if (isWithin(path, sourceRoot) && path != sourceRoot) { - add(path.removePrefix("$sourceRoot/")) + add(path.removePrefix(rootPrefix(sourceRoot))) } } } @@ -74,7 +82,9 @@ private fun coverageGlobToRegex(pattern: String): Regex { return Regex(expression.toString()) } -internal fun isWithin(path: String, root: String): Boolean = path == root || path.startsWith("$root/") +internal fun isWithin(path: String, root: String): Boolean = path == root || path.startsWith(rootPrefix(root)) + +private fun rootPrefix(root: String): String = if (root.endsWith('/')) root else "$root/" private const val REGEX_SPECIAL_CHARACTERS = ".+()^$|{}[]" private const val DOUBLE_WILDCARD_LENGTH = 2 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 c2a64f707..1722c9468 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,7 +2,6 @@ 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 @@ -68,32 +67,22 @@ internal class IstanbulCoverageDecoder( return null } - return IstanbulSourceFileDecoder( + val decoder = IstanbulSourceFileDecoder( file = fileObject, path = path, reportKey = reportKey, - ).decode() + ) + + return decoder.decode() } private fun sourceMapDiagnostic(path: String): CoverageDiagnostic { val sourceMapPath = Path.of("$path.map") val sourceMapExists = Files.exists(sourceMapPath) - val diagnosticCode = if (sourceMapExists) { - PbtDiagnosticCode.COVERAGE_SOURCE_MAP_INVALID - } else { - PbtDiagnosticCode.COVERAGE_SOURCE_MAP_MISSING - } - val diagnosticMessage = if (sourceMapExists) { - "Executed JavaScript has a source map that c8 could not remap to its original source" - } else { - "Executed JavaScript below a TypeScript source root has no source map" - } - - return CoverageDiagnostic( - code = diagnosticCode, - message = diagnosticMessage, + return buildSourceMapDiagnostic( path = path, + sourceMapExists = sourceMapExists, ) } @@ -155,11 +144,5 @@ internal class IstanbulCoverageDecoder( private companion object { val GENERATED_JAVASCRIPT_EXTENSIONS = hashSetOf("js", "mjs", "cjs") - - fun normalizeCoveragePath(path: String): String = Path.of(path) - .toAbsolutePath() - .normalize() - .toString() - .replace('\\', '/') } } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportReader.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportReader.kt index e401833fa..a43b97bb9 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportReader.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportReader.kt @@ -71,6 +71,6 @@ internal object IstanbulCoverageReportReader { path = reportPath.toString(), cause = error, ) - - private const val MAX_COVERAGE_REPORT_BYTES = 64L * 1024 * 1024 } + +internal const val MAX_COVERAGE_REPORT_BYTES = 64L * 1024 * 1024 diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspector.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspector.kt new file mode 100644 index 000000000..d19176a68 --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspector.kt @@ -0,0 +1,320 @@ +package org.usvm.ts.pbt.coverage + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.usvm.ts.pbt.PbtDiagnosticCode +import org.usvm.ts.pbt.backend.CoverageDiagnostic +import org.usvm.ts.pbt.manifest.PropertyManifestJson +import java.io.IOException +import java.io.UncheckedIOException +import java.net.URI +import java.nio.ByteBuffer +import java.nio.charset.CharacterCodingException +import java.nio.file.Files +import java.nio.file.Path +import java.util.stream.Collectors +import kotlin.io.path.invariantSeparatorsPathString + +/** Reads bounded raw V8 source-map caches that c8 does not retain in its final Istanbul report. */ +internal fun inspectRawV8SourceMapDiagnostics( + rawDirectory: Path, + sourceRoots: List, + maxReportFiles: Int = MAX_RAW_V8_REPORT_FILES, + maxReportBytes: Long = MAX_COVERAGE_REPORT_BYTES, +): List { + require(maxReportFiles > 0) { "Raw V8 report file limit must be positive" } + require(maxReportBytes in 1 until Int.MAX_VALUE.toLong()) { + "Raw V8 report byte limit must be positive and fit in one byte array" + } + + val reports = listRawReports(rawDirectory, maxReportFiles) + val roots = sourceRoots.map { sourceRoot -> Path.of(sourceRoot).toAbsolutePath().normalize() } + val reader = RawV8ReportReader(maxReportBytes = maxReportBytes) + val diagnostics = reports.flatMap { report -> inspectRawReport(report, roots, reader) } + + return coalesceSourceMapDiagnostics(diagnostics) +} + +/** Reads several reports under one aggregate byte budget. */ +internal class RawV8ReportReader(maxReportBytes: Long) { + private var remainingBytes = maxReportBytes + + init { + require(maxReportBytes in 1 until Int.MAX_VALUE.toLong()) { + "Raw V8 report byte limit must be positive and fit in one byte array" + } + } + + fun readText(reportPath: Path): String { + // One byte past the remaining budget detects growth without a separate size preflight or manual read loop. + val readLimit = remainingBytes.toInt() + LIMIT_OVERFLOW_SENTINEL_BYTES + val bytes = try { + Files.newInputStream(reportPath).use { input -> + input.readNBytes(readLimit) + } + } catch (error: IOException) { + failInvalidRawReport( + message = "Cannot read raw V8 coverage report: ${error.message}", + path = reportPath, + cause = error, + ) + } + if (bytes.size > remainingBytes) { + failInvalidRawReport( + message = "Raw V8 coverage reports exceed the byte limit", + path = reportPath, + ) + } + remainingBytes -= bytes.size + + return decodeUtf8(bytes, reportPath) + } +} + +private data class UnappliedSourceMap( + val reportPath: Path, + val scriptUrl: String, + val sourceMapUrl: String, +) { + val entryPath: String = "$reportPath.source-map-cache[$scriptUrl]" +} + +private fun listRawReports(rawDirectory: Path, maxReportFiles: Int): List { + if (!Files.isDirectory(rawDirectory)) { + failMissingRawReports( + message = "c8 did not produce the expected raw V8 coverage directory: $rawDirectory", + path = rawDirectory, + ) + } + + val listingLimit = maxReportFiles.toLong() + LIMIT_OVERFLOW_SENTINEL_FILES + val reports = try { + Files.list(rawDirectory).use { entries -> + entries + .filter { path -> path.fileName.toString().endsWith(".json") } + .limit(listingLimit) + .collect(Collectors.toList()) + } + } catch (error: IOException) { + failCannotListRawReports(rawDirectory, error) + } catch (error: UncheckedIOException) { + failCannotListRawReports(rawDirectory, error.cause ?: error) + } + if (reports.size > maxReportFiles) { + failInvalidRawReport( + message = "Raw V8 coverage contains more than $maxReportFiles report files", + path = rawDirectory, + ) + } + if (reports.isEmpty()) { + failMissingRawReports( + message = "c8 did not produce any raw V8 coverage reports in $rawDirectory", + path = rawDirectory, + ) + } + + return reports.sortedBy { path -> path.fileName.toString() } +} + +private fun decodeUtf8(bytes: ByteArray, reportPath: Path): String = try { + Charsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString() +} catch (error: CharacterCodingException) { + failInvalidRawReport( + message = "Cannot decode raw V8 coverage report as UTF-8: ${error.message}", + path = reportPath, + cause = error, + ) +} + +private fun inspectRawReport( + reportPath: Path, + sourceRoots: List, + reader: RawV8ReportReader, +): List = readUnappliedSourceMaps(reportPath, reader).mapNotNull { sourceMap -> + sourceMap.inspect(sourceRoots) +} + +/** + * Extracts only cache entries for which c8 saw a source-map URL but could not load its data. + * Entries with object-valued `data` are already usable by c8 and need no diagnostic. + */ +private fun readUnappliedSourceMaps(reportPath: Path, reader: RawV8ReportReader): List { + val report = parseRawReport(reader.readText(reportPath), reportPath) + val sourceMapCache = report["source-map-cache"] ?: return emptyList() + if (sourceMapCache !is JsonObject) { + failInvalidRawReport( + message = "Raw V8 source-map-cache must be a JSON object", + path = "$reportPath.source-map-cache", + ) + } + + return sourceMapCache.mapNotNull { (scriptUrl, entry) -> + parseUnappliedSourceMap(reportPath, scriptUrl, entry) + } +} + +private fun parseRawReport(text: String, reportPath: Path): JsonObject { + val report = try { + PropertyManifestJson.json.parseToJsonElement(text) + } catch (error: IllegalArgumentException) { + failInvalidRawReport( + message = "Raw V8 coverage report is not valid JSON: ${error.message}", + path = reportPath, + cause = error, + ) + } + + if (report !is JsonObject) { + failInvalidRawReport( + message = "Raw V8 coverage report must be a JSON object", + path = reportPath, + ) + } + + return report +} + +private fun parseUnappliedSourceMap( + reportPath: Path, + scriptUrl: String, + element: JsonElement, +): UnappliedSourceMap? { + val entryPath = "$reportPath.source-map-cache[$scriptUrl]" + if (element !is JsonObject) { + failInvalidRawReport( + message = "Raw V8 source-map cache entry must be a JSON object", + path = entryPath, + ) + } + val data = element["data"] + if (data == null) { + failInvalidRawReport( + message = "Raw V8 source-map cache entry is missing data", + path = "$entryPath.data", + ) + } + + if (data is JsonObject) return null + if (data != JsonNull) { + failInvalidRawReport( + message = "Raw V8 source-map cache entry data must be a JSON object when non-null", + path = "$entryPath.data", + ) + } + + val sourceMapUrl = readSourceMapUrl(element, entryPath) + + return UnappliedSourceMap( + reportPath = reportPath, + scriptUrl = scriptUrl, + sourceMapUrl = sourceMapUrl, + ) +} + +private fun readSourceMapUrl(entry: JsonObject, entryPath: String): String { + val url = entry["url"] as? JsonPrimitive + if (url == null || !url.isString || url.content.isBlank()) { + failInvalidRawReport( + message = "Raw V8 source-map cache entry must contain a source-map URL", + path = "$entryPath.url", + ) + } + + return url.content +} + +private fun UnappliedSourceMap.inspect(sourceRoots: List): CoverageDiagnostic? { + val scriptUri = try { + URI.create(scriptUrl) + } catch (error: IllegalArgumentException) { + failInvalidRawReport( + message = "Raw V8 source-map cache key is not a valid script URL: ${error.message}", + path = entryPath, + cause = error, + ) + } + if (scriptUri.scheme != "file") return null + + val scriptPath = try { + Path.of(scriptUri.withoutQueryOrFragment()).toAbsolutePath().normalize() + } catch (error: IllegalArgumentException) { + failInvalidRawReport( + message = "Raw V8 script URL cannot be converted to a path: ${error.message}", + path = entryPath, + cause = error, + ) + } + if (!scriptPath.isGeneratedJavaScriptBelow(sourceRoots)) return null + + val sourceMapPath = resolveSourceMapPath(scriptUri, scriptPath, sourceMapUrl) + val sourceMapExists = sourceMapPath == null || Files.exists(sourceMapPath) + + return buildSourceMapDiagnostic( + path = scriptPath.invariantSeparatorsPathString, + sourceMapExists = sourceMapExists, + ) +} + +private fun resolveSourceMapPath(scriptUri: URI, scriptPath: Path, sourceMapUrl: String): Path? { + val referenceUri = try { + URI.create(sourceMapUrl) + } catch (_: IllegalArgumentException) { + return runCatching { scriptPath.resolveSibling(sourceMapUrl).normalize() }.getOrNull() + } + val resolvedUri = scriptUri.resolve(referenceUri) + if (resolvedUri.scheme != "file" || !resolvedUri.authority.isNullOrEmpty()) return null + + return runCatching { Path.of(resolvedUri.withoutQueryOrFragment()).normalize() }.getOrNull() +} + +private fun URI.withoutQueryOrFragment(): URI = if (rawQuery == null && rawFragment == null) { + this +} else { + URI(scheme, authority, path, null, null) +} + +private fun Path.isGeneratedJavaScriptBelow(sourceRoots: List): Boolean { + val extension = fileName.toString().substringAfterLast('.', missingDelimiterValue = "").lowercase() + + return extension in GENERATED_JAVASCRIPT_EXTENSIONS && sourceRoots.any(::startsWith) +} + +private fun failCannotListRawReports(rawDirectory: Path, cause: Throwable): Nothing = failInvalidRawReport( + message = "Cannot list raw V8 coverage reports: ${cause.message}", + path = rawDirectory, + cause = cause, +) + +private fun failMissingRawReports(message: String, path: Path): Nothing = throw CoverageArtifactException.create( + code = PbtDiagnosticCode.COVERAGE_REPORT_MISSING, + message = message, + path = path.toString(), +) + +private fun failInvalidRawReport( + message: String, + path: Path, + cause: Throwable? = null, +): Nothing = failInvalidRawReport( + message = message, + path = path.toString(), + cause = cause, +) + +private fun failInvalidRawReport( + message: String, + path: String, + cause: Throwable? = null, +): Nothing = throw CoverageArtifactException.create( + code = PbtDiagnosticCode.COVERAGE_REPORT_INVALID, + message = message, + path = path, + cause = cause, +) + +private const val MAX_RAW_V8_REPORT_FILES = 1_024 +private const val LIMIT_OVERFLOW_SENTINEL_BYTES = 1 +private const val LIMIT_OVERFLOW_SENTINEL_FILES = 1 +private val GENERATED_JAVASCRIPT_EXTENSIONS = hashSetOf("js", "mjs", "cjs") diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/SourceMapDiagnostics.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/SourceMapDiagnostics.kt new file mode 100644 index 000000000..863911a0f --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/SourceMapDiagnostics.kt @@ -0,0 +1,73 @@ +package org.usvm.ts.pbt.coverage + +import org.usvm.ts.pbt.PbtDiagnosticCode +import org.usvm.ts.pbt.backend.CoverageDiagnostic + +/** Raw V8 evidence replaces the less precise source-map guesses made from the final Istanbul report. */ +internal fun mergeCoverageDiagnostics( + finalDiagnostics: List, + rawDiagnostics: List, +): List { + val rawSourceMapPaths = rawDiagnostics + .filter(CoverageDiagnostic::isSourceMapDiagnostic) + .mapNotNullTo(hashSetOf(), CoverageDiagnostic::path) + val retainedFinalDiagnostics = finalDiagnostics.filterNot { diagnostic -> + diagnostic.isSourceMapDiagnostic() && diagnostic.path in rawSourceMapPaths + } + val combinedDiagnostics = retainedFinalDiagnostics + rawDiagnostics + + return coalesceSourceMapDiagnostics(combinedDiagnostics) +} + +internal fun buildSourceMapDiagnostic(path: String, sourceMapExists: Boolean): CoverageDiagnostic { + val diagnosticCode = if (sourceMapExists) { + PbtDiagnosticCode.COVERAGE_SOURCE_MAP_INVALID + } else { + PbtDiagnosticCode.COVERAGE_SOURCE_MAP_MISSING + } + val diagnosticMessage = if (sourceMapExists) { + "Executed JavaScript has a source map that c8 could not remap to its original source" + } else { + "Executed JavaScript below a TypeScript source root has no source map" + } + + return CoverageDiagnostic( + code = diagnosticCode, + message = diagnosticMessage, + path = path, + ) +} + +internal fun coalesceSourceMapDiagnostics(diagnostics: List): List { + val diagnosticsByScriptPath = hashMapOf() + val unkeyedDiagnostics = mutableListOf() + + for (diagnostic in diagnostics) { + val scriptPath = diagnostic.path + if (!diagnostic.isSourceMapDiagnostic() || scriptPath == null) { + unkeyedDiagnostics += diagnostic + continue + } + + val previous = diagnosticsByScriptPath[scriptPath] + if (previous == null || diagnostic.isInvalidInsteadOfMissing(previous)) { + diagnosticsByScriptPath[scriptPath] = diagnostic + } + } + + val combinedDiagnostics = unkeyedDiagnostics + diagnosticsByScriptPath.values + + return combinedDiagnostics + .distinct() + .sortedWith(COVERAGE_DIAGNOSTIC_ORDER) +} + +private fun CoverageDiagnostic.isSourceMapDiagnostic(): Boolean = + code == PbtDiagnosticCode.COVERAGE_SOURCE_MAP_MISSING || + code == PbtDiagnosticCode.COVERAGE_SOURCE_MAP_INVALID + +private fun CoverageDiagnostic.isInvalidInsteadOfMissing(other: CoverageDiagnostic): Boolean = + code == PbtDiagnosticCode.COVERAGE_SOURCE_MAP_INVALID && + other.code == PbtDiagnosticCode.COVERAGE_SOURCE_MAP_MISSING + +private val COVERAGE_DIAGNOSTIC_ORDER = compareBy(CoverageDiagnostic::path, CoverageDiagnostic::code) diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackend.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackend.kt index e17ddf93c..a1afb8c29 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackend.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackend.kt @@ -41,18 +41,20 @@ class FastCheckBackend( requireValid(validatePropertyDefinition(property)) validateConfiguration(property, configuration) - return client.check( - FastCheckExecutionRequest( - manifest = property.toManifest(), - sourceRoots = sourceRoots.map(Path::toString), - seed = configuration.seed, - replayPath = configuration.replayPath, - numRuns = configuration.numRuns, - timeoutMillis = configuration.timeoutMillis, - examples = configuration.examples, - coverageRequest = configuration.coverageRequest, - ), + val manifest = property.toManifest() + val sourceRootPaths = sourceRoots.map(Path::toString) + val request = FastCheckExecutionRequest( + manifest = manifest, + sourceRoots = sourceRootPaths, + seed = configuration.seed, + replayPath = configuration.replayPath, + numRuns = configuration.numRuns, + timeoutMillis = configuration.timeoutMillis, + examples = configuration.examples, + coverageRequest = configuration.coverageRequest, ) + + return client.check(request) } private fun validateConfiguration( diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageSession.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageSession.kt new file mode 100644 index 000000000..2191f3f42 --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageSession.kt @@ -0,0 +1,286 @@ +package org.usvm.ts.pbt.fastcheck + +import org.usvm.ts.pbt.PbtDiagnosticCode +import org.usvm.ts.pbt.backend.CoverageScope +import org.usvm.ts.pbt.backend.PropertyRunResult +import org.usvm.ts.pbt.coverage.CoverageArtifactException +import org.usvm.ts.pbt.coverage.IstanbulCoverageContext +import org.usvm.ts.pbt.coverage.decodeIstanbulCoverageReport +import org.usvm.ts.pbt.coverage.inspectRawV8SourceMapDiagnostics +import org.usvm.ts.pbt.coverage.mergeCoverageDiagnostics +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.TimeUnit + +/** Prepared c8 invocation and the temporary artifacts produced by one property run. */ +internal class FastCheckCoverageSession private constructor( + private val nodeExecutable: String, + private val adapterEntryPoint: Path, + private val request: FastCheckExecutionRequest, + private val runtimeVersion: String, + private val workspace: CoverageWorkspace, +) : AutoCloseable { + val adapterCommand: List + get() = buildList { + add(nodeExecutable) + add(workspace.c8EntryPoint.toString()) + add("--config=${workspace.configPath}") + add("--reporter=json") + add("--reports-dir=${workspace.reportDirectory}") + add("--temp-directory=${workspace.rawDirectory}") + add("--exclude-after-remap") + add("--allowExternal") + add("--exclude=__usvm_no_default_excludes__") + if (CoverageScope.DEPENDENCIES in requireNotNull(request.coverageRequest).scopes) { + add("--exclude-node-modules=false") + } + add(nodeExecutable) + add(adapterEntryPoint.toString()) + } + + fun attachTo(result: PropertyRunResult): PropertyRunResult { + val coverageRequest = requireNotNull(request.coverageRequest) + val entryPointPaths = propertyEntryPointPaths() + + val artifact = try { + val context = IstanbulCoverageContext( + backendId = FastCheckBackend.FAST_CHECK_BACKEND_ID, + backendVersion = FastCheckRuntimeMetadata.fastCheckVersion, + propertyId = result.propertyId, + sourceRoots = request.sourceRoots, + propertyEntryPointPaths = entryPointPaths, + adapterRoot = workspace.adapterRoot.toString(), + runtimeVersion = runtimeVersion, + collector = FastCheckRuntimeMetadata.coverageCollector, + request = coverageRequest, + ) + val finalArtifact = decodeIstanbulCoverageReport( + reportPath = workspace.reportDirectory.resolve("coverage-final.json"), + context = context, + ) + val rawDiagnostics = inspectRawV8SourceMapDiagnostics( + rawDirectory = workspace.rawDirectory, + sourceRoots = request.sourceRoots, + ) + val diagnostics = mergeCoverageDiagnostics( + finalDiagnostics = finalArtifact.diagnostics, + rawDiagnostics = rawDiagnostics, + ) + + finalArtifact.copy(diagnostics = diagnostics) + } catch (error: CoverageArtifactException) { + fail( + code = error.diagnostic.code, + message = error.diagnostic.message, + path = error.diagnostic.path, + cause = error, + ) + } + + return result.copy(coverage = artifact) + } + + private fun propertyEntryPointPaths(): Set { + val paths = hashSetOf() + val entryPoints = listOfNotNull(request.manifest.predicate, request.manifest.precondition) + + for (sourceRoot in request.sourceRoots) { + val root = Path.of(sourceRoot) + for (entryPoint in entryPoints) { + val entryPointPath = root.resolve(entryPoint.module).normalize() + paths += canonicalizeExistingEntryPoint(entryPointPath) + } + } + + return paths + } + + override fun close() { + workspace.root.toFile().deleteRecursively() + } + + private fun canonicalizeExistingEntryPoint(candidate: Path): String = + if (Files.exists(candidate)) candidate.toRealPath().toString() else candidate.toString() + + private fun fail( + code: String, + message: String, + path: String? = null, + cause: Throwable? = null, + ): Nothing = throw PbtBackendException( + kind = BackendErrorKind.COVERAGE, + code = code, + message = message, + propertyId = request.manifest.propertyId, + path = path, + cause = cause, + ) + + internal companion object { + fun prepare( + nodeExecutable: String, + adapterEntryPoint: Path, + request: FastCheckExecutionRequest, + ): FastCheckCoverageSession { + val runtimeVersion = readSupportedNodeVersion(nodeExecutable, request) + val adapterRoot = adapterEntryPoint.parent?.parent?.parent + ?: throw IllegalArgumentException("Adapter entry point has no runtime root: $adapterEntryPoint") + val c8EntryPoint = adapterRoot.resolve("node_modules/c8/bin/c8.js") + if (!Files.isRegularFile(c8EntryPoint)) { + failPreparation( + request = request, + code = PbtDiagnosticCode.COVERAGE_COLLECTOR_NOT_FOUND, + message = "Cannot locate c8 ${FastCheckRuntimeMetadata.coverageCollector.version} " + + "in the fast-check adapter runtime", + path = c8EntryPoint.toString(), + ) + } + + val workspace = createWorkspace(c8EntryPoint, adapterRoot) + + return FastCheckCoverageSession( + nodeExecutable = nodeExecutable, + adapterEntryPoint = adapterEntryPoint, + request = request, + runtimeVersion = runtimeVersion, + workspace = workspace, + ) + } + + private fun createWorkspace(c8EntryPoint: Path, adapterRoot: Path): CoverageWorkspace { + val root = Files.createTempDirectory("usvm-ts-pbt-coverage-") + + try { + val configPath = Files.writeString(root.resolve("c8-config.json"), "{}") + + return CoverageWorkspace( + root = root, + configPath = configPath, + rawDirectory = root.resolve("raw"), + reportDirectory = root.resolve("report"), + c8EntryPoint = c8EntryPoint, + adapterRoot = adapterRoot, + ) + } catch (error: IOException) { + root.toFile().deleteRecursively() + throw error + } + } + + private fun readSupportedNodeVersion( + nodeExecutable: String, + request: FastCheckExecutionRequest, + ): String { + val process = try { + ProcessBuilder(nodeExecutable, "--version").start() + } catch (error: IOException) { + failPreparation( + request = request, + code = PbtDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE, + message = "Cannot query the Node.js runtime version: ${error.message}", + cause = error, + ) + } + + try { + awaitNodeVersion(process, request) + val version = process.inputStream.bufferedReader(Charsets.UTF_8).use { input -> + input.readLine().orEmpty().trim() + } + if (process.exitValue() != 0 || version.isBlank()) { + failPreparation( + request = request, + code = PbtDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE, + message = "Cannot query the Node.js runtime version", + ) + } + + return requireSupportedNodeVersion(version, request) + } finally { + runCatching { process.outputStream.close() } + runCatching { process.inputStream.close() } + runCatching { process.errorStream.close() } + } + } + + private fun awaitNodeVersion(process: Process, request: FastCheckExecutionRequest) { + val completed = try { + process.waitFor(NODE_VERSION_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) + } catch (error: InterruptedException) { + Thread.currentThread().interrupt() + failPreparation( + request = request, + code = PbtDiagnosticCode.BACKEND_PROCESS_INTERRUPTED, + message = "Interrupted while querying the Node.js runtime version", + kind = BackendErrorKind.PROCESS_FAILURE, + cause = error, + ) + } + if (!completed) { + process.destroyForcibly() + failPreparation( + request = request, + code = PbtDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE, + message = "Timed out while querying the Node.js runtime version", + ) + } + } + + private fun requireSupportedNodeVersion(version: String, request: FastCheckExecutionRequest): String { + val match = NODE_VERSION_PATTERN.matchEntire(version) + val major = match?.groupValues?.get(1)?.toIntOrNull() + val minor = match?.groupValues?.get(2)?.toIntOrNull() + if (major == null || minor == null) { + failPreparation( + request = request, + code = PbtDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE, + message = "Cannot parse the Node.js runtime version: $version", + ) + } + + val hasNewerMajorVersion = major > MINIMUM_NODE_MAJOR_VERSION + val hasMinimumVersion = major == MINIMUM_NODE_MAJOR_VERSION && minor >= MINIMUM_NODE_MINOR_VERSION + val supported = hasNewerMajorVersion || hasMinimumVersion + if (!supported) { + failPreparation( + request = request, + code = PbtDiagnosticCode.COVERAGE_RUNTIME_UNSUPPORTED, + message = "Coverage requires Node.js 18.18 or newer; found $version", + ) + } + + return version + } + + private fun failPreparation( + request: FastCheckExecutionRequest, + code: String, + message: String, + kind: BackendErrorKind = BackendErrorKind.COVERAGE, + path: String? = null, + cause: Throwable? = null, + ): Nothing = throw PbtBackendException( + kind = kind, + code = code, + message = message, + propertyId = request.manifest.propertyId, + path = path, + cause = cause, + ) + + private const val NODE_VERSION_TIMEOUT_MILLIS = 5_000L + private const val MINIMUM_NODE_MAJOR_VERSION = 18 + private const val MINIMUM_NODE_MINOR_VERSION = 18 + private val NODE_VERSION_PATTERN = Regex("""^v(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$""") + } +} + +private data class CoverageWorkspace( + val root: Path, + val configPath: Path, + val rawDirectory: Path, + val reportDirectory: Path, + val c8EntryPoint: Path, + val adapterRoot: Path, +) diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClient.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClient.kt index 85085343b..ff759dc98 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClient.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClient.kt @@ -1,115 +1,78 @@ package org.usvm.ts.pbt.fastcheck -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.runInterruptible -import kotlinx.coroutines.supervisorScope -import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import org.usvm.ts.pbt.PbtDiagnosticCode -import org.usvm.ts.pbt.backend.CoverageScope import org.usvm.ts.pbt.backend.PropertyRunResult -import org.usvm.ts.pbt.coverage.CoverageArtifactException -import org.usvm.ts.pbt.coverage.IstanbulCoverageContext -import org.usvm.ts.pbt.coverage.decodeIstanbulCoverageReport import org.usvm.ts.pbt.manifest.PropertyManifestJson import org.usvm.ts.pbt.model.PropertyId -import java.io.ByteArrayOutputStream -import java.io.IOException -import java.io.InputStream -import java.nio.file.Files import java.nio.file.Path -import java.util.concurrent.TimeUnit -/** Supervised one-shot transport for the private fast-check execution bridge. */ +/** Encodes one execution request and validates the private fast-check adapter response. */ internal class FastCheckProcessClient( private val nodeExecutable: String = "node", private val adapterEntryPoint: Path, private val transportGraceMillis: Long = DEFAULT_TRANSPORT_GRACE_MILLIS, private val shutdownGraceMillis: Long = DEFAULT_SHUTDOWN_GRACE_MILLIS, - private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { - /** Executes one request and exposes only a fully validated common result. */ - fun check(request: FastCheckExecutionRequest): PropertyRunResult = try { - runBlocking { checkSuspending(request) } - } catch (error: InterruptedException) { - Thread.currentThread().interrupt() - - throw backendError( - kind = BackendErrorKind.PROCESS_FAILURE, - code = PbtDiagnosticCode.BACKEND_PROCESS_INTERRUPTED, - message = "Interrupted while waiting for the fast-check adapter", - request = request, - cause = error, - ) - } + private val transport = FastCheckProcessTransport( + nodeExecutable = nodeExecutable, + maxRequestBytes = MAX_REQUEST_BYTES, + maxStdoutBytes = MAX_STDOUT_BYTES, + maxStderrBytes = MAX_STDERR_BYTES, + shutdownGraceMillis = shutdownGraceMillis, + ) - private suspend fun checkSuspending(request: FastCheckExecutionRequest): PropertyRunResult = supervisorScope { + /** Executes one request and exposes only a fully validated common result. */ + fun check(request: FastCheckExecutionRequest): PropertyRunResult { val encodedRequest = encodeRequest(request) - - val coverageRuntimeVersion = request.coverageRequest?.let { nodeVersion(request) } - val coverageWorkspace = request.coverageRequest?.let { createCoverageWorkspace(request) } - var process: Process? = null - - try { - val startedProcess = startAdapter(request, coverageWorkspace) - process = startedProcess - val stdout = async(ioDispatcher) { startedProcess.inputStream.readBounded(MAX_STDOUT_BYTES) } - val stderr = async(ioDispatcher) { startedProcess.errorStream.readBounded(MAX_STDERR_BYTES) } - val writer = async(ioDispatcher) { - startedProcess.outputStream.bufferedWriter(Charsets.UTF_8).use { output -> - output.write(encodedRequest) - } - } - - awaitProcess(startedProcess, request) - - awaitIo( - task = writer, - operation = "writing the fast-check request", - failureCode = PbtDiagnosticCode.BACKEND_PROCESS_WRITE_FAILED, + val coverageSession = request.coverageRequest?.let { + FastCheckCoverageSession.prepare( + nodeExecutable = nodeExecutable, + adapterEntryPoint = adapterEntryPoint, request = request, ) + } - val stdoutText = awaitIo( - task = stdout, - operation = "reading fast-check stdout", - failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, - request = request, - ) - val stderrText = awaitIo( - task = stderr, - operation = "reading fast-check stderr", - failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + try { + val output = invokeAdapter( + encodedRequest = encodedRequest, request = request, + command = coverageSession?.adapterCommand + ?: listOf(nodeExecutable, adapterEntryPoint.toString()), ) - - validateProcessExit(startedProcess, stderrText, request) - validateStdout(stdoutText, request) - - val response = decodeResponse(stdoutText.text, request) - + validateProcessOutput(output, request) + val response = decodeResponse(output.stdout, request) val result = decodeSuccessfulResponse(response, request) - coverageWorkspace?.let { workspace -> - collectCoverage( - result = result, - request = request, - workspace = workspace, - runtimeVersion = requireNotNull(coverageRuntimeVersion), - ) - } ?: result + return coverageSession?.attachTo(result) ?: result } finally { - process?.takeIf(Process::isAlive)?.let(::terminate) - coverageWorkspace?.root?.toFile()?.deleteRecursively() + coverageSession?.close() } } + private fun invokeAdapter( + encodedRequest: String, + request: FastCheckExecutionRequest, + command: List, + ): FastCheckProcessOutput = try { + transport.invoke( + command = command, + request = encodedRequest, + timeoutMillis = saturatedAdd(request.timeoutMillis, transportGraceMillis), + reportedTimeoutMillis = request.timeoutMillis, + description = "fast-check adapter", + ) + } catch (error: FastCheckTransportException) { + throw backendError( + kind = error.backendErrorKind(), + code = error.code, + message = error.message.orEmpty(), + request = request, + cause = error, + ) + } + private fun encodeRequest(request: FastCheckExecutionRequest): String { val encodedRequest = PropertyManifestJson.json.encodeToString(request) @@ -125,52 +88,22 @@ internal class FastCheckProcessClient( return encodedRequest } - private suspend fun awaitProcess(process: Process, request: FastCheckExecutionRequest) { - val hardTimeoutMillis = safeAdd(request.timeoutMillis, transportGraceMillis) - val exitCode = withTimeoutOrNull(hardTimeoutMillis) { - runInterruptible(ioDispatcher) { process.waitFor() } - } - - if (exitCode == null) { - terminate(process) - - throw backendError( - kind = BackendErrorKind.TIMEOUT, - code = PbtDiagnosticCode.BACKEND_PROCESS_TIMEOUT, - message = "fast-check adapter exceeded the ${request.timeoutMillis} ms timeout", - request = request, - ) - } - } - - private fun validateProcessExit( - process: Process, - stderr: BoundedText, + private fun validateProcessOutput( + output: FastCheckProcessOutput, request: FastCheckExecutionRequest, ) { - if (process.exitValue() != 0) { - val detail = stderr.text.trim().ifEmpty { "no stderr" } + if (output.exitCode != 0) { + val detail = output.stderr.trim().ifEmpty { "no stderr" } throw backendError( kind = BackendErrorKind.PROCESS_FAILURE, code = PbtDiagnosticCode.BACKEND_PROCESS_FAILED, - message = "fast-check adapter exited with code ${process.exitValue()}: $detail", - request = request, - ) - } - } - - private fun validateStdout(stdout: BoundedText, request: FastCheckExecutionRequest) { - if (stdout.exceeded) { - throw backendError( - kind = BackendErrorKind.PROTOCOL_ERROR, - code = PbtDiagnosticCode.BACKEND_RESPONSE_TOO_LARGE, - message = "fast-check adapter stdout exceeds $MAX_STDOUT_BYTES bytes", + message = "fast-check adapter exited with code ${output.exitCode}: $detail", request = request, ) } - if (stdout.text.isBlank()) { + if (output.stdout.isBlank()) { throw backendError( kind = BackendErrorKind.PROTOCOL_ERROR, code = PbtDiagnosticCode.BACKEND_RESPONSE_EMPTY, @@ -180,232 +113,6 @@ internal class FastCheckProcessClient( } } - private fun startAdapter( - request: FastCheckExecutionRequest, - coverageWorkspace: CoverageWorkspace?, - ): Process = try { - ProcessBuilder(adapterCommand(request, coverageWorkspace)).start() - } catch (error: IOException) { - throw backendError( - kind = BackendErrorKind.PROCESS_FAILURE, - code = PbtDiagnosticCode.BACKEND_PROCESS_START_FAILED, - message = "Failed to start fast-check adapter: ${error.message}", - request = request, - cause = error, - ) - } - - private fun adapterCommand( - request: FastCheckExecutionRequest, - coverageWorkspace: CoverageWorkspace?, - ): List { - if (coverageWorkspace == null) return listOf(nodeExecutable, adapterEntryPoint.toString()) - - val command = mutableListOf( - nodeExecutable, - coverageWorkspace.c8EntryPoint.toString(), - "--config=${coverageWorkspace.configPath}", - "--reporter=json", - "--reports-dir=${coverageWorkspace.reportDirectory}", - "--temp-directory=${coverageWorkspace.rawDirectory}", - "--exclude-after-remap", - "--allowExternal", - "--exclude=__usvm_no_default_excludes__", - ) - if (CoverageScope.DEPENDENCIES in requireNotNull(request.coverageRequest).scopes) { - command += "--exclude-node-modules=false" - } - command += nodeExecutable - command += adapterEntryPoint.toString() - - return command - } - - private fun createCoverageWorkspace(request: FastCheckExecutionRequest): CoverageWorkspace { - val adapterRoot = adapterRoot() - val c8EntryPoint = adapterRoot.resolve("node_modules/c8/bin/c8.js") - if (!Files.isRegularFile(c8EntryPoint)) { - throw backendError( - kind = BackendErrorKind.COVERAGE, - code = PbtDiagnosticCode.COVERAGE_COLLECTOR_NOT_FOUND, - message = "Cannot locate c8 ${FastCheckRuntimeMetadata.coverageCollector.version} " + - "in the fast-check adapter runtime", - request = request, - path = c8EntryPoint.toString(), - ) - } - - val root = Files.createTempDirectory("usvm-ts-pbt-coverage-") - val configPath = Files.writeString(root.resolve("c8-config.json"), "{}") - return CoverageWorkspace( - root = root, - configPath = configPath, - rawDirectory = root.resolve("raw"), - reportDirectory = root.resolve("report"), - c8EntryPoint = c8EntryPoint, - adapterRoot = adapterRoot, - ) - } - - private fun collectCoverage( - result: PropertyRunResult, - request: FastCheckExecutionRequest, - workspace: CoverageWorkspace, - runtimeVersion: String, - ): PropertyRunResult { - val coverageRequest = requireNotNull(request.coverageRequest) - val entryPointPaths = hashSetOf() - request.sourceRoots.forEach { sourceRoot -> - val root = Path.of(sourceRoot) - entryPointPaths += root.resolve(request.manifest.predicate.module).normalize().toString() - request.manifest.precondition?.let { precondition -> - entryPointPaths += root.resolve(precondition.module).normalize().toString() - } - } - val artifact = try { - decodeIstanbulCoverageReport( - reportPath = workspace.reportDirectory.resolve("coverage-final.json"), - context = IstanbulCoverageContext( - backendId = FastCheckBackend.FAST_CHECK_BACKEND_ID, - backendVersion = FastCheckRuntimeMetadata.fastCheckVersion, - propertyId = result.propertyId, - sourceRoots = request.sourceRoots, - propertyEntryPointPaths = entryPointPaths, - adapterRoot = workspace.adapterRoot.toString(), - runtimeVersion = runtimeVersion, - collector = FastCheckRuntimeMetadata.coverageCollector, - request = coverageRequest, - ), - ) - } catch (error: CoverageArtifactException) { - throw backendError( - kind = BackendErrorKind.COVERAGE, - code = error.diagnostic.code, - message = error.diagnostic.message, - request = request, - path = error.diagnostic.path, - cause = error, - ) - } - - return result.copy(coverage = artifact) - } - - private suspend fun nodeVersion(request: FastCheckExecutionRequest): String { - val process = startNodeVersionProcess(request) - - awaitNodeVersionProcess(process, request) - - return readNodeVersion(process, request) - } - - private fun startNodeVersionProcess(request: FastCheckExecutionRequest): Process = try { - ProcessBuilder(nodeExecutable, "--version").start() - } catch (error: IOException) { - throw coverageRuntimeVersionError(request, error) - } - - private suspend fun awaitNodeVersionProcess( - process: Process, - request: FastCheckExecutionRequest, - ) { - val completed = runInterruptible(ioDispatcher) { - process.waitFor(NODE_VERSION_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) - } - if (!completed) { - process.destroyForcibly() - - throw backendError( - kind = BackendErrorKind.COVERAGE, - code = PbtDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE, - message = "Timed out while querying the Node.js runtime version", - request = request, - ) - } - } - - private fun readNodeVersion( - process: Process, - request: FastCheckExecutionRequest, - ): String { - val version = process.inputStream.bufferedReader(Charsets.UTF_8).use { input -> - input.readLine().orEmpty().trim() - } - if (process.exitValue() != 0 || version.isBlank()) { - throw backendError( - kind = BackendErrorKind.COVERAGE, - code = PbtDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE, - message = "Cannot query the Node.js runtime version", - request = request, - ) - } - - return requireSupportedNodeVersion(version, request) - } - - private fun requireSupportedNodeVersion( - version: String, - request: FastCheckExecutionRequest, - ): String { - val match = NODE_VERSION_PATTERN.matchEntire(version) - val major = match?.groupValues?.get(1)?.toIntOrNull() - val minor = match?.groupValues?.get(2)?.toIntOrNull() - if (major == null || minor == null) { - throw backendError( - kind = BackendErrorKind.COVERAGE, - code = PbtDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE, - message = "Cannot parse the Node.js runtime version: $version", - request = request, - ) - } - - val isSupported = major > MINIMUM_NODE_MAJOR_VERSION || - major == MINIMUM_NODE_MAJOR_VERSION && minor >= MINIMUM_NODE_MINOR_VERSION - if (!isSupported) { - throw backendError( - kind = BackendErrorKind.COVERAGE, - code = PbtDiagnosticCode.COVERAGE_RUNTIME_UNSUPPORTED, - message = "Coverage requires Node.js 18.18 or newer; found $version", - request = request, - ) - } - - return version - } - - private fun coverageRuntimeVersionError( - request: FastCheckExecutionRequest, - error: IOException, - ) = backendError( - kind = BackendErrorKind.COVERAGE, - code = PbtDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE, - message = "Cannot query the Node.js runtime version: ${error.message}", - request = request, - cause = error, - ) - - private fun adapterRoot(): Path = adapterEntryPoint.parent?.parent?.parent - ?: throw IllegalArgumentException("Adapter entry point has no runtime root: $adapterEntryPoint") - - private suspend fun awaitIo( - task: Deferred, - operation: String, - failureCode: String, - request: FastCheckExecutionRequest, - ): T = try { - task.await() - } catch (error: CancellationException) { - throw error - } catch (error: IOException) { - throw backendError( - kind = BackendErrorKind.PROCESS_FAILURE, - code = failureCode, - message = "Failed while $operation: ${error.message}", - request = request, - cause = error, - ) - } - private fun decodeResponse( stdout: String, request: FastCheckExecutionRequest, @@ -513,62 +220,18 @@ internal class FastCheckProcessClient( cause = cause, ) - private fun terminate(process: Process) { - process.destroy() - - if (!process.waitFor(shutdownGraceMillis, TimeUnit.MILLISECONDS)) { - process.destroyForcibly() - process.waitFor() - } - } - private companion object { const val MAX_REQUEST_BYTES = 4 * 1024 * 1024 const val MAX_STDOUT_BYTES = 4 * 1024 * 1024 const val MAX_STDERR_BYTES = 64 * 1024 const val DEFAULT_TRANSPORT_GRACE_MILLIS = 2_000L const val DEFAULT_SHUTDOWN_GRACE_MILLIS = 250L - const val NODE_VERSION_TIMEOUT_MILLIS = 5_000L - const val MINIMUM_NODE_MAJOR_VERSION = 18 - const val MINIMUM_NODE_MINOR_VERSION = 18 - val NODE_VERSION_PATTERN = Regex("""^v(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$""") - } -} - -private data class CoverageWorkspace( - val root: Path, - val configPath: Path, - val rawDirectory: Path, - val reportDirectory: Path, - val c8EntryPoint: Path, - val adapterRoot: Path, -) - -private data class BoundedText(val text: String, val exceeded: Boolean) - -private fun InputStream.readBounded(limit: Int): BoundedText { - val output = ByteArrayOutputStream(minOf(limit, DEFAULT_BUFFER_SIZE)) - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var exceeded = false - - while (true) { - val read = read(buffer) - if (read < 0) break - - val remaining = limit - output.size() - - if (remaining > 0) output.write(buffer, 0, minOf(read, remaining)) - if (read > remaining) exceeded = true } - - return BoundedText( - text = output.toString(Charsets.UTF_8), - exceeded = exceeded, - ) } -private fun safeAdd(left: Long, right: Long): Long = if (left > Long.MAX_VALUE - right) { - Long.MAX_VALUE -} else { - left + right +private fun FastCheckTransportException.backendErrorKind(): BackendErrorKind = when (code) { + PbtDiagnosticCode.BACKEND_REQUEST_TOO_LARGE -> BackendErrorKind.INVALID_REQUEST + PbtDiagnosticCode.BACKEND_RESPONSE_TOO_LARGE -> BackendErrorKind.PROTOCOL_ERROR + PbtDiagnosticCode.BACKEND_PROCESS_TIMEOUT -> BackendErrorKind.TIMEOUT + else -> BackendErrorKind.PROCESS_FAILURE } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessTransport.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessTransport.kt new file mode 100644 index 000000000..f7b2be7d5 --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessTransport.kt @@ -0,0 +1,428 @@ +package org.usvm.ts.pbt.fastcheck + +import org.usvm.ts.pbt.PbtDiagnosticCode +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.ExecutionException +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +/** Completed output of one supervised request-response process. */ +internal data class FastCheckProcessOutput( + val exitCode: Int, + val stdout: String, + val stderr: String, +) + +/** Transport failure before a response can be interpreted by a protocol client. */ +internal class FastCheckTransportException( + val code: String, + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) + +/** + * Runs one bounded request-response exchange through the shared Node process supervisor. + * + * Three I/O tasks are intentional: draining stdout and stderr concurrently prevents pipe deadlocks, while writing + * stdin separately lets the same wall-clock deadline cover a child that never reads its request. + */ +internal class FastCheckProcessTransport( + private val nodeExecutable: String, + private val maxRequestBytes: Int, + private val maxStdoutBytes: Int, + private val maxStderrBytes: Int, + private val shutdownGraceMillis: Long, +) { + init { + require(maxRequestBytes > 0) { "Maximum request size must be positive" } + require(maxStdoutBytes > 0) { "Maximum stdout size must be positive" } + require(maxStderrBytes > 0) { "Maximum stderr size must be positive" } + require(shutdownGraceMillis in 1..Int.MAX_VALUE.toLong()) { + "Shutdown grace period must fit the positive delay range supported by Node timers" + } + } + + fun invoke( + command: List, + request: String, + timeoutMillis: Long, + reportedTimeoutMillis: Long, + description: String, + ): FastCheckProcessOutput { + require(timeoutMillis > 0) { "Process timeout must be positive" } + require(command.isNotEmpty()) { "Supervised command must not be empty" } + requireRequestWithinLimit(request, description) + + val deadlineNanos = deadlineAfter(timeoutMillis) + val managedProcess = startProcess(command, description) + val executor = Executors.newFixedThreadPool(IO_TASK_COUNT) + val tasks = startIoTasks(managedProcess.process, request, description, executor) + + try { + awaitProcess( + process = managedProcess.process, + tasks = tasks.all, + deadlineNanos = deadlineNanos, + reportedTimeoutMillis = reportedTimeoutMillis, + description = description, + ) + awaitIo( + tasks = tasks, + deadlineNanos = deadlineNanos, + reportedTimeoutMillis = reportedTimeoutMillis, + description = description, + ) + val stdout = tasks.stdout.completedValue(description) + val stderr = tasks.stderr.completedValue(description) + + return FastCheckProcessOutput( + exitCode = managedProcess.process.exitValue(), + stdout = stdout, + stderr = stderr, + ) + } finally { + tasks.all.forEach { task -> task.cancel() } + terminate(managedProcess, deadlineNanos) + closeStreams(managedProcess.process) + runCatching { Files.deleteIfExists(managedProcess.processGroupFile) } + executor.shutdownNow() + } + } + + private fun requireRequestWithinLimit(request: String, description: String) { + if (request.toByteArray(Charsets.UTF_8).size > maxRequestBytes) { + fail( + code = PbtDiagnosticCode.BACKEND_REQUEST_TOO_LARGE, + message = "$description request exceeds $maxRequestBytes bytes", + ) + } + } + + private fun startIoTasks( + process: Process, + request: String, + description: String, + executor: ExecutorService, + ): ProcessIoTasks { + val stdout = ProcessIoTask( + future = executor.submit { + process.inputStream.readBounded(maxStdoutBytes, stream = "stdout") + }, + operation = "reading $description stdout", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + ) + val stderr = ProcessIoTask( + future = executor.submit { + process.errorStream.readBounded(maxStderrBytes, stream = "stderr") + }, + operation = "reading $description stderr", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + ) + val writer = ProcessIoTask( + future = executor.submit { + process.outputStream.bufferedWriter(Charsets.UTF_8).use { output -> + output.write(request) + } + }, + operation = "writing the $description request", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_WRITE_FAILED, + ) + + return ProcessIoTasks(stdout = stdout, stderr = stderr, writer = writer) + } + + private fun awaitProcess( + process: Process, + tasks: List>, + deadlineNanos: Long, + reportedTimeoutMillis: Long, + description: String, + ) { + while (true) { + tasks.forEach { task -> task.throwIfFailed(description) } + + val remainingMillis = remainingMillis(deadlineNanos) + if (remainingMillis <= FORCED_TERMINATION_RESERVE_MILLIS) { + timeout(description, reportedTimeoutMillis) + } + + val completed = try { + process.waitFor(minOf(remainingMillis, PROCESS_POLL_MILLIS), TimeUnit.MILLISECONDS) + } catch (error: InterruptedException) { + Thread.currentThread().interrupt() + fail( + code = PbtDiagnosticCode.BACKEND_PROCESS_INTERRUPTED, + message = "Interrupted while waiting for the $description", + cause = error, + ) + } + if (completed) return + } + } + + private fun awaitIo( + tasks: ProcessIoTasks, + deadlineNanos: Long, + reportedTimeoutMillis: Long, + description: String, + ) { + while (true) { + tasks.all.forEach { task -> task.throwIfFailed(description) } + + val pendingTask = tasks.all.firstOrNull { task -> !task.isDone } ?: break + + val remainingMillis = remainingMillis(deadlineNanos) + if (remainingMillis <= FORCED_TERMINATION_RESERVE_MILLIS) { + timeout(description, reportedTimeoutMillis) + } + + pendingTask.await(minOf(remainingMillis, IO_POLL_MILLIS), description) + } + + tasks.all.forEach { task -> task.completedValue(description) } + } + + private fun startProcess( + supervisedCommand: List, + description: String, + ): SupervisedProcessHandle { + val processGroupFile = try { + Files.createTempFile(PROCESS_GROUP_FILE_PREFIX, ".pid") + } catch (error: IOException) { + processStartFailure(description, error) + } + var processStarted = false + + try { + val command = buildList { + add(nodeExecutable) + add(FastCheckRuntime.processSupervisorEntryPoint().toString()) + add(PROCESS_SUPERVISOR_COMMAND) + add(shutdownGraceMillis.toString()) + add(processGroupFile.toString()) + addAll(supervisedCommand) + } + val process = ProcessBuilder(command).start() + processStarted = true + + return SupervisedProcessHandle(process = process, processGroupFile = processGroupFile) + } catch (error: IOException) { + processStartFailure(description, error) + } finally { + if (!processStarted) runCatching { Files.deleteIfExists(processGroupFile) } + } + } + + private fun processStartFailure(description: String, error: IOException): Nothing = fail( + code = PbtDiagnosticCode.BACKEND_PROCESS_START_FAILED, + message = "Failed to start $description: ${error.message}", + cause = error, + ) + + private fun terminate(managedProcess: SupervisedProcessHandle, deadlineNanos: Long) { + val process = managedProcess.process + if (!process.isAlive) { + forceTerminateOwnedProcessGroup(managedProcess.processGroupFile, deadlineNanos) + return + } + + process.destroy() + val gracefulDeadlineNanos = minOf( + deadlineBefore( + deadlineNanos = deadlineNanos, + durationMillis = FORCED_TERMINATION_RESERVE_MILLIS, + ), + deadlineAfter(shutdownGraceMillis), + ) + if (awaitProcessExit(process, gracefulDeadlineNanos)) return + + forceTerminateOwnedProcessGroup(managedProcess.processGroupFile, deadlineNanos) + process.destroyForcibly() + awaitProcessExit(process, deadlineNanos) + } + + private fun forceTerminateOwnedProcessGroup(processGroupFile: Path, deadlineNanos: Long) { + val processGroupText = runCatching { Files.readString(processGroupFile) }.getOrNull() ?: return + val processGroupId = processGroupText.trim().toLongOrNull() ?: return + val command = if (IS_WINDOWS) { + listOf("taskkill", "/PID", processGroupId.toString(), "/T", "/F") + } else { + listOf("/bin/kill", "-KILL", "--", "-$processGroupId") + } + val killer = runCatching { + ProcessBuilder(command) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start() + }.getOrNull() ?: return + val waitMillis = minOf(remainingMillis(deadlineNanos), PROCESS_GROUP_KILL_WAIT_MILLIS) + if (waitMillis == 0L) return + + try { + if (!killer.waitFor(waitMillis, TimeUnit.MILLISECONDS)) killer.destroyForcibly() + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + killer.destroyForcibly() + } + } + + private fun awaitProcessExit(process: Process, deadlineNanos: Long): Boolean { + while (process.isAlive) { + val waitMillis = minOf(remainingMillis(deadlineNanos), PROCESS_POLL_MILLIS) + if (waitMillis == 0L) return false + + try { + if (process.waitFor(waitMillis, TimeUnit.MILLISECONDS)) return true + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + return false + } + } + + return true + } + + private fun closeStreams(process: Process) { + runCatching { process.outputStream.close() } + runCatching { process.inputStream.close() } + runCatching { process.errorStream.close() } + } + + private fun timeout(description: String, reportedTimeoutMillis: Long): Nothing = fail( + code = PbtDiagnosticCode.BACKEND_PROCESS_TIMEOUT, + message = "$description exceeded the $reportedTimeoutMillis ms timeout", + ) + + private fun fail(code: String, message: String, cause: Throwable? = null): Nothing = + throw FastCheckTransportException(code = code, message = message, cause = cause) + + private companion object { + const val IO_TASK_COUNT = 3 + const val PROCESS_SUPERVISOR_COMMAND = "--command" + const val PROCESS_GROUP_FILE_PREFIX = "usvm-fast-check-process-group-" + const val PROCESS_POLL_MILLIS = 10L + const val IO_POLL_MILLIS = 10L + const val FORCED_TERMINATION_RESERVE_MILLIS = 25L + const val PROCESS_GROUP_KILL_WAIT_MILLIS = 10L + + val IS_WINDOWS = System.getProperty("os.name").lowercase().contains("windows") + } +} + +private data class SupervisedProcessHandle( + val process: Process, + val processGroupFile: Path, +) + +private data class ProcessIoTasks( + val stdout: ProcessIoTask, + val stderr: ProcessIoTask, + val writer: ProcessIoTask, +) { + val all: List> = listOf(stdout, stderr, writer) +} + +private data class ProcessIoTask( + val future: Future, + val operation: String, + val failureCode: String, +) { + val isDone: Boolean + get() = future.isDone + + fun cancel() { + future.cancel(true) + } + + fun throwIfFailed(description: String) { + if (isDone) completedValue(description) + } + + fun completedValue(description: String): T = requireNotNull(await(waitMillis = 0, description)) + + fun await(waitMillis: Long, description: String): T? = try { + future.get(waitMillis, TimeUnit.MILLISECONDS) + } catch (_: TimeoutException) { + null + } catch (error: InterruptedException) { + Thread.currentThread().interrupt() + throw FastCheckTransportException( + code = PbtDiagnosticCode.BACKEND_PROCESS_INTERRUPTED, + message = "Interrupted while $operation", + cause = error, + ) + } catch (error: ExecutionException) { + val cause = error.cause ?: error + if (cause is ProcessOutputLimitExceeded) { + throw FastCheckTransportException( + code = PbtDiagnosticCode.BACKEND_RESPONSE_TOO_LARGE, + message = "$description ${cause.stream} exceeds ${cause.limit} bytes", + cause = cause, + ) + } + + throw FastCheckTransportException( + code = failureCode, + message = "Failed while $operation: ${cause.message}", + cause = cause, + ) + } +} + +private class ProcessOutputLimitExceeded( + val stream: String, + val limit: Int, +) : IOException("$stream exceeds $limit bytes") + +private fun InputStream.readBounded(limit: Int, stream: String): String { + val output = ByteArrayOutputStream(minOf(limit, DEFAULT_BUFFER_SIZE)) + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + + while (true) { + val read = read(buffer) + if (read < 0) break + + val remaining = limit - output.size() + if (remaining > 0) output.write(buffer, 0, minOf(read, remaining)) + if (read > remaining) throw ProcessOutputLimitExceeded(stream, limit) + } + + return output.toString(Charsets.UTF_8) +} + +private fun deadlineAfter(timeoutMillis: Long): Long { + val timeoutNanos = TimeUnit.MILLISECONDS.toNanos(timeoutMillis) + val now = System.nanoTime() + + return if (now > Long.MAX_VALUE - timeoutNanos) Long.MAX_VALUE else now + timeoutNanos +} + +private fun deadlineBefore(deadlineNanos: Long, durationMillis: Long): Long { + if (deadlineNanos == Long.MAX_VALUE) return Long.MAX_VALUE + + val durationNanos = TimeUnit.MILLISECONDS.toNanos(durationMillis) + + return if (deadlineNanos < Long.MIN_VALUE + durationNanos) Long.MIN_VALUE else deadlineNanos - durationNanos +} + +private fun remainingMillis(deadlineNanos: Long): Long { + if (deadlineNanos == Long.MAX_VALUE) return Long.MAX_VALUE + + val remainingNanos = deadlineNanos - System.nanoTime() + if (remainingNanos <= 0) return 0 + + return TimeUnit.NANOSECONDS.toMillis(remainingNanos) +} + +internal fun saturatedAdd(left: Long, right: Long): Long = if (left > Long.MAX_VALUE - right) { + Long.MAX_VALUE +} else { + left + right +} diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClient.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClient.kt index e40beccc2..b74c86c49 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClient.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClient.kt @@ -4,108 +4,103 @@ import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import org.usvm.ts.pbt.PbtDiagnosticCode import org.usvm.ts.pbt.manifest.PropertyManifestJson -import java.io.IOException +import org.usvm.ts.pbt.model.contains import java.nio.file.Path -import java.util.concurrent.Executors - -/** - * Synchronous Kotlin client for the private fast-check Node adapter. - * - * Each request starts a fresh adapter process, writes one JSON request, and validates the single JSON response - * before exposing sampled values to Kotlin callers. - */ -class FastCheckProjectionClient( - private val nodeExecutable: String = "node", - private val adapterEntryPoint: Path = FastCheckRuntime.projectionEntryPoint(), + +/** Limits for one projection request to the private Node adapter. */ +internal data class FastCheckProjectionTransportLimits( + val maxRequestBytes: Int, + val maxStdoutBytes: Int, + val maxStderrBytes: Int, + val wallClockTimeoutMillis: Long, + val shutdownGraceMillis: Long, ) { - /** Projects the requested domains to fast-check and returns the generated samples. */ + init { + require(maxRequestBytes > 0) { "Maximum request size must be positive" } + require(maxStdoutBytes > 0) { "Maximum stdout size must be positive" } + require(maxStderrBytes > 0) { "Maximum stderr size must be positive" } + require(wallClockTimeoutMillis > 0) { "Projection wall-clock timeout must be positive" } + require(shutdownGraceMillis in 1..Int.MAX_VALUE.toLong()) { + "Projection shutdown grace period exceeds the delay range supported by Node timers" + } + } +} + +/** Synchronous Kotlin client for sampling concrete values from the private fast-check Node adapter. */ +class FastCheckProjectionClient private constructor( + private val adapterCommand: List, + private val transportLimits: FastCheckProjectionTransportLimits, + private val transport: FastCheckProcessTransport, +) { + constructor( + nodeExecutable: String = "node", + adapterEntryPoint: Path = FastCheckRuntime.projectionEntryPoint(), + ) : this( + adapterCommand = listOf(nodeExecutable, adapterEntryPoint.toString()), + transportLimits = DEFAULT_TRANSPORT_LIMITS, + transport = createTransport(nodeExecutable, DEFAULT_TRANSPORT_LIMITS), + ) + + internal constructor( + nodeExecutable: String = "node", + adapterEntryPoint: Path = FastCheckRuntime.projectionEntryPoint(), + transportLimits: FastCheckProjectionTransportLimits, + ) : this( + adapterCommand = listOf(nodeExecutable, adapterEntryPoint.toString()), + transportLimits = transportLimits, + transport = createTransport(nodeExecutable, transportLimits), + ) + + /** Projects the requested domains to fast-check and returns validated samples. */ fun sample(request: FastCheckProjectionRequest): FastCheckProjectionResponse { validateRequest(request) - val response = decodeResponse(invokeAdapter(request)) + val encodedRequest = PropertyManifestJson.json.encodeToString(request) + val output = invokeAdapter(encodedRequest) + val response = decodeResponse(output) throwBackendError(response) validateSuccessfulResponse(request, response) - return FastCheckProjectionResponse( - samples = response.samples, - ) - } - - private fun throwBackendError(response: FastCheckProjectionWireResponse) { - if (response.status == "error") { - val diagnostic = response.diagnostics.firstOrNull() - ?: invalidResponse("fast-check error response does not contain a diagnostic") - - throw FastCheckProjectionException( - code = diagnostic.code, - message = diagnostic.message, - path = diagnostic.path, - ) - } + return FastCheckProjectionResponse(samples = response.samples) } - private fun validateSuccessfulResponse( - request: FastCheckProjectionRequest, - response: FastCheckProjectionWireResponse, - ) { - val hasExpectedStatus = response.status == "ok" - val hasExpectedSampleCount = response.samples.size == request.numSamples - val hasExpectedArity = response.samples.all { it.size == request.domains.size } - - if (!hasExpectedStatus || !hasExpectedSampleCount || !hasExpectedArity) { - throw FastCheckProjectionException( - code = PbtDiagnosticCode.BACKEND_RESPONSE_INVALID, - message = "fast-check adapter returned an invalid successful response", + private fun invokeAdapter(encodedRequest: String): String { + val output = try { + transport.invoke( + command = adapterCommand, + request = encodedRequest, + timeoutMillis = transportLimits.wallClockTimeoutMillis, + reportedTimeoutMillis = transportLimits.wallClockTimeoutMillis, + description = "fast-check projection adapter", ) + } catch (error: FastCheckTransportException) { + transportFailure(error) } - } - private fun invokeAdapter(request: FastCheckProjectionRequest): String { - val process = startAdapter() - val errorReaderExecutor = Executors.newSingleThreadExecutor() - val stderr = errorReaderExecutor.submit { - process.errorStream.bufferedReader(Charsets.UTF_8).use { reader -> reader.readText() } - } - - try { - process.outputStream.bufferedWriter(Charsets.UTF_8).use { writer -> - writer.write(PropertyManifestJson.json.encodeToString(request)) - } - - val stdout = process.inputStream.bufferedReader(Charsets.UTF_8).use { reader -> reader.readText() } - val exitCode = process.waitFor() - val stderrText = stderr.get() - - if (exitCode != 0) { - throw FastCheckProjectionException( - code = PbtDiagnosticCode.BACKEND_PROCESS_FAILED, - message = "fast-check adapter exited with code $exitCode: ${stderrText.trim()}", - ) - } - - if (stdout.isBlank()) { - throw FastCheckProjectionException( - code = PbtDiagnosticCode.BACKEND_RESPONSE_EMPTY, - message = "fast-check adapter returned an empty response", - ) - } + if (output.exitCode != 0) processFailure(output) + if (output.stdout.isBlank()) emptyResponse() - return stdout - } finally { - errorReaderExecutor.shutdownNow() - } + return output.stdout } - private fun startAdapter(): Process = try { - ProcessBuilder(nodeExecutable, adapterEntryPoint.toString()).start() - } catch (error: IOException) { + private fun transportFailure(error: FastCheckTransportException): Nothing = throw FastCheckProjectionException( - code = PbtDiagnosticCode.BACKEND_PROCESS_START_FAILED, - message = "Failed to start fast-check adapter: ${error.message}", + code = error.code, + message = error.message.orEmpty(), cause = error, ) - } + + private fun processFailure(output: FastCheckProcessOutput): Nothing = + throw FastCheckProjectionException( + code = PbtDiagnosticCode.BACKEND_PROCESS_FAILED, + message = "fast-check adapter exited with code ${output.exitCode}: ${output.stderr.trim()}", + ) + + private fun emptyResponse(): Nothing = throw FastCheckProjectionException( + code = PbtDiagnosticCode.BACKEND_RESPONSE_EMPTY, + message = "fast-check adapter returned an empty response", + ) private fun decodeResponse(stdout: String): FastCheckProjectionWireResponse = try { PropertyManifestJson.json.decodeFromString(stdout) @@ -117,21 +112,85 @@ class FastCheckProjectionClient( ) } - private fun invalidResponse(message: String): Nothing = throw FastCheckProjectionException( - code = PbtDiagnosticCode.BACKEND_RESPONSE_INVALID, - message = message, - ) + private fun throwBackendError(response: FastCheckProjectionWireResponse) { + if (response.status != "error") return - private fun validateRequest(request: FastCheckProjectionRequest) { - val hasValidSampleCount = request.numSamples > 0 - val hasDomains = request.domains.isNotEmpty() + val diagnostic = response.diagnostics.firstOrNull() + ?: invalidResponse("fast-check error response does not contain a diagnostic") + + throw FastCheckProjectionException( + code = diagnostic.code, + message = diagnostic.message, + path = diagnostic.path, + ) + } + + private fun validateSuccessfulResponse( + request: FastCheckProjectionRequest, + response: FastCheckProjectionWireResponse, + ) { + val hasOkStatus = response.status == "ok" + val hasExpectedSampleCount = response.samples.size == request.numSamples + val allSamplesHaveExpectedInputCount = response.samples.all { sample -> + sample.size == request.domains.size + } + val validShape = hasOkStatus && hasExpectedSampleCount && allSamplesHaveExpectedInputCount + if (!validShape) invalidResponse("fast-check adapter returned an invalid successful response") + + response.samples.forEachIndexed { sampleIndex, sample -> + sample.forEachIndexed { inputIndex, value -> + if (value !in request.domains[inputIndex]) { + invalidResponse( + message = "fast-check adapter returned a value outside its requested domain", + path = "samples[$sampleIndex][$inputIndex]", + ) + } + } + } + } - if (!hasValidSampleCount || !hasDomains) { + private fun validateRequest(request: FastCheckProjectionRequest) { + if (request.numSamples !in 1..MAX_SAMPLES || request.domains.isEmpty()) { throw FastCheckProjectionException( code = PbtDiagnosticCode.PROTOCOL_REQUEST_INVALID, - message = "Request requires domains and a positive numSamples", + message = "Request requires domains and numSamples in 1..$MAX_SAMPLES", path = "request", ) } } + + private fun invalidResponse(message: String, path: String? = null): Nothing = + throw FastCheckProjectionException( + code = PbtDiagnosticCode.BACKEND_RESPONSE_INVALID, + message = message, + path = path, + ) + + private companion object { + const val MAX_SAMPLES = 10_000 + const val DEFAULT_MAX_REQUEST_BYTES = 4 * 1024 * 1024 + const val DEFAULT_MAX_STDOUT_BYTES = 4 * 1024 * 1024 + const val DEFAULT_MAX_STDERR_BYTES = 64 * 1024 + const val DEFAULT_WALL_CLOCK_TIMEOUT_MILLIS = 60_000L + const val DEFAULT_SHUTDOWN_GRACE_MILLIS = 250L + + val DEFAULT_TRANSPORT_LIMITS = FastCheckProjectionTransportLimits( + maxRequestBytes = DEFAULT_MAX_REQUEST_BYTES, + maxStdoutBytes = DEFAULT_MAX_STDOUT_BYTES, + maxStderrBytes = DEFAULT_MAX_STDERR_BYTES, + wallClockTimeoutMillis = DEFAULT_WALL_CLOCK_TIMEOUT_MILLIS, + shutdownGraceMillis = DEFAULT_SHUTDOWN_GRACE_MILLIS, + ) + + fun createTransport( + nodeExecutable: String, + limits: FastCheckProjectionTransportLimits, + ) = FastCheckProcessTransport( + nodeExecutable = nodeExecutable, + maxRequestBytes = limits.maxRequestBytes, + maxStdoutBytes = limits.maxStdoutBytes, + maxStderrBytes = limits.maxStderrBytes, + shutdownGraceMillis = limits.shutdownGraceMillis, + ) + } } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntime.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntime.kt index 32e95af37..860a04f57 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntime.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntime.kt @@ -10,6 +10,8 @@ internal object FastCheckRuntime { fun projectionEntryPoint(): Path = locateEntryPoint(PROJECTION_CLI) + fun processSupervisorEntryPoint(): Path = locateEntryPoint(PROCESS_SUPERVISOR) + private fun locateEntryPoint(fileName: String): Path { val candidates = runtimeDirectories().map { runtimeDirectory -> runtimeDirectory.resolve(ENTRY_POINT_DIRECTORY).resolve(fileName) @@ -46,5 +48,6 @@ internal object FastCheckRuntime { private const val ENTRY_POINT_DIRECTORY = "dist/src" private const val EXECUTION_CLI = "execution-cli.js" private const val PROJECTION_CLI = "projection-cli.js" + private const val PROCESS_SUPERVISOR = "process-supervisor.js" private const val INSTALLED_RUNTIME_DIRECTORY = "fast-check-adapter" } 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 new file mode 100644 index 000000000..6de2704d9 --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsCoverageMapper.kt @@ -0,0 +1,479 @@ +package org.usvm.ts.pbt.mapping + +import org.jacodb.ets.model.EtsFile +import org.jacodb.ets.model.EtsIfStmt +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.model.EtsSourceSpan +import org.jacodb.ets.model.EtsStmt +import org.usvm.ts.pbt.PbtDiagnosticCode +import org.usvm.ts.pbt.backend.BranchArmCoverage +import org.usvm.ts.pbt.backend.BranchCoverage +import org.usvm.ts.pbt.backend.PropertyCoverageArtifact +import org.usvm.ts.pbt.backend.StatementCoverage +import org.usvm.ts.pbt.model.PropertyId +import java.nio.file.Path + +/** Maps Istanbul statement and branch locations to EtsIR statements and CFG edges. */ +internal class EtsCoverageMapper( + scene: EtsScene, + private val sourceLocations: SourceLocationNormalizer, + private val branchSuccessorOrder: EtsBranchSuccessorOrder, +) { + private val sceneFileCandidates = scene.projectFiles.map { file -> + val canonicalPaths = sourceLocations.sourcePathCandidates(file.name).mapTo(hashSetOf(), Path::toString) + + SceneFileCandidate( + file = file, + canonicalPaths = canonicalPaths, + ) + } + + fun map( + propertyId: PropertyId, + coverage: PropertyCoverageArtifact?, + ): EtsCoverageMapping { + if (coverage == null) return coverageUnavailable() + + if (coverage.propertyId != propertyId) { + val diagnostic = EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_COVERAGE_PROPERTY_ID_MISMATCH, + message = "Coverage property ${coverage.propertyId.value} does not match ${propertyId.value}", + ) + + return EtsCoverageMapping( + status = EtsMappingStatus.UNSUPPORTED, + backendProvenance = coverage.provenance, + diagnostics = listOf(diagnostic), + ) + } + + val statements = coverage.files.flatMap { file -> + file.statements.map { statement -> mapStatementCoverage(file.path, statement) } + } + val branches = coverage.files.flatMap { file -> + file.branches.map { branch -> mapBranchCoverage(file.path, branch) } + } + val diagnostics = coverage.diagnostics.map { diagnostic -> + EtsMappingDiagnostic( + code = diagnostic.code, + message = diagnostic.message, + sourcePath = diagnostic.path, + ) + } + val statementStatuses = statements.map { statement -> statement.mapping.status } + val branchStatuses = branches.map { branch -> branch.mapping.status } + val branchArmStatuses = branches.flatMap { branch -> + branch.arms.map { arm -> arm.mapping.status } + } + val mappingStatuses = statementStatuses + branchStatuses + branchArmStatuses + val status = aggregateStatus(mappingStatuses) + + return EtsCoverageMapping( + status = status, + backendProvenance = coverage.provenance, + statements = statements, + branches = branches, + diagnostics = diagnostics, + ) + } + + private fun coverageUnavailable(): EtsCoverageMapping { + val diagnostic = EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_COVERAGE_UNAVAILABLE, + message = "The property backend returned no source coverage artifact", + ) + + return EtsCoverageMapping( + status = EtsMappingStatus.UNSUPPORTED, + backendProvenance = null, + diagnostics = listOf(diagnostic), + ) + } + + private fun mapBranchCoverage( + sourcePath: String, + coverage: BranchCoverage, + ): EtsBranchCoverageMapping { + val normalization = runCatching { sourceLocations.normalizeRange(sourcePath, coverage.location) } + val location = normalization.getOrNull() + if (location == null) { + val normalizationFailure = normalization.exceptionOrNull() + val diagnostic = sourceNormalizationDiagnostic(sourcePath, normalizationFailure) + + return unsupportedBranchCoverage( + sourcePath = sourcePath, + coverage = coverage, + location = null, + diagnostic = diagnostic, + normalizeArmLocations = false, + ) + } + + 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 diagnostic = EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_BRANCH_SHAPE_UNSUPPORTED, + message = "EtsIR branch mapping requires $requiredBranchShape", + sourcePath = location.path, + ) + + return unsupportedBranchCoverage( + sourcePath = sourcePath, + coverage = coverage, + location = location, + diagnostic = diagnostic, + normalizeArmLocations = true, + ) + } + + // Preserve the EtsFile groups: two frontend files for one canonical source path are ambiguous provenance. + val conditionGroupsInSourceFile = sceneFileCandidates + .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 diagnostic = EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_SOURCE_ORIGINS_UNSUPPORTED, + message = "EtsIR conditions for the covered source file have no source origins", + sourcePath = location.path, + ) + + return unsupportedBranchCoverage( + sourcePath = sourcePath, + coverage = coverage, + location = location, + diagnostic = diagnostic, + normalizeArmLocations = true, + ) + } + + val conditionGroups = conditionGroupsInSourceFile + .map { statements -> statements.filter { statement -> statement.hasOriginWithin(location) } } + .filter { statements -> statements.isNotEmpty() } + val conditions = conditionGroups.flatten() + if (conditions.any { statement -> statement.successorCount() != expectedBranchArmCount }) { + val diagnostic = EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_BRANCH_CFG_UNSUPPORTED, + message = "EtsIR branch mapping requires exactly $expectedBranchArmCount ordered CFG successors", + sourcePath = location.path, + ) + + return unsupportedBranchCoverage( + sourcePath = sourcePath, + coverage = coverage, + location = location, + diagnostic = diagnostic, + normalizeArmLocations = true, + ) + } + + val mapping = branchMapping( + location = location, + conditions = conditions, + sourceCandidateCount = conditionGroupsInSourceFile.size, + ) + val arms = coverage.arms.mapIndexed { index, arm -> + mapBranchArm(sourcePath, arm, index, mapping) + } + + return EtsBranchCoverageMapping( + coverage = coverage, + location = location, + mapping = mapping, + arms = arms, + ) + } + + private fun unsupportedBranchCoverage( + sourcePath: String, + coverage: BranchCoverage, + location: NormalizedSourceRange?, + diagnostic: EtsMappingDiagnostic, + normalizeArmLocations: Boolean, + ): EtsBranchCoverageMapping { + val mapping = unsupportedMapping(diagnostic) + val arms = if (normalizeArmLocations) { + coverage.arms.mapIndexed { index, arm -> + mapBranchArm(sourcePath, arm, index, mapping) + } + } else { + coverage.arms.map { arm -> + EtsBranchArmCoverageMapping( + coverage = arm, + location = null, + mapping = unsupportedMapping(diagnostic), + ) + } + } + + return EtsBranchCoverageMapping( + coverage = coverage, + location = location, + mapping = mapping, + arms = arms, + ) + } + + private fun branchMapping( + location: NormalizedSourceRange, + conditions: List, + sourceCandidateCount: Int, + ): EtsMappingResult { + val targets = conditions.map(::EtsBranchTarget) + + return when { + conditions.isEmpty() -> { + val diagnostic = EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_BRANCH_UNMAPPED, + message = "No EtsIR condition belongs to the covered TypeScript branch", + sourcePath = location.path, + ) + + EtsMappingResult( + status = EtsMappingStatus.UNMAPPED, + targets = emptyList(), + diagnostics = listOf(diagnostic), + ) + } + + conditions.size == 1 && sourceCandidateCount == 1 -> EtsMappingResult( + status = EtsMappingStatus.EXACT, + targets = targets, + ) + + conditions.size > 1 || sourceCandidateCount > 1 -> { + val diagnostic = EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_BRANCH_AMBIGUOUS, + message = "The covered TypeScript branch contains several EtsIR conditions", + sourcePath = location.path, + ) + + EtsMappingResult( + status = EtsMappingStatus.AMBIGUOUS, + targets = targets, + diagnostics = listOf(diagnostic), + ) + } + + else -> error("EtsIR branch mapping has targets without source provenance") + } + } + + private fun mapBranchArm( + sourcePath: String, + coverage: BranchArmCoverage, + armIndex: Int, + branchMapping: EtsMappingResult, + ): EtsBranchArmCoverageMapping { + val normalization = runCatching { sourceLocations.normalizeRange(sourcePath, coverage.location) } + val location = normalization.getOrNull() + if (location == null) { + val diagnostic = sourceNormalizationDiagnostic(sourcePath, normalization.exceptionOrNull()) + val mapping = unsupportedMapping(diagnostic) + + return EtsBranchArmCoverageMapping( + coverage = coverage, + location = null, + mapping = mapping, + ) + } + + val targets = branchMapping.targets.map { branch -> + val graph = branch.statement.location.method.cfg + val successors = graph.successors(branch.statement).toList() + val outcome = branchSuccessorOrder.outcomeAt(armIndex) + + // Istanbul if arms and the EtsIR CFG both use true-then-false order by contract. + EtsBranchArmTarget( + condition = branch.statement, + outcome = outcome, + successor = successors[armIndex], + ) + } + val mapping = EtsMappingResult( + status = branchMapping.status, + targets = targets, + diagnostics = branchMapping.diagnostics, + ) + + return EtsBranchArmCoverageMapping( + coverage = coverage, + location = location, + mapping = mapping, + ) + } + + private fun mapStatementCoverage( + sourcePath: String, + coverage: StatementCoverage, + ): EtsStatementCoverageMapping { + val normalization = runCatching { sourceLocations.normalizeRange(sourcePath, coverage.location) } + val location = normalization.getOrNull() + if (location == null) { + val diagnostic = sourceNormalizationDiagnostic(sourcePath, normalization.exceptionOrNull()) + val mapping = unsupportedMapping(diagnostic) + + return EtsStatementCoverageMapping( + coverage = coverage, + location = null, + mapping = mapping, + ) + } + + val statementGroupsInSourceFile = sceneFileCandidates + .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 diagnostic = EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_SOURCE_ORIGINS_UNSUPPORTED, + message = "EtsIR statements for the covered source file have no source origins", + sourcePath = location.path, + ) + val mapping = unsupportedMapping(diagnostic) + + return EtsStatementCoverageMapping( + coverage = coverage, + location = location, + mapping = mapping, + ) + } + + // Exact spans win. A containing coverage range is exact only when every target shares one source origin. + val exactTargets = statementGroupsInSourceFile + .flatMap { statements -> statements.filter { statement -> statement.hasOrigin(location) } } + .map(::EtsStatementTarget) + val containedStatementGroups = statementGroupsInSourceFile + .map { statements -> statements.filter { statement -> statement.hasOriginWithin(location) } } + .filter { statements -> statements.isNotEmpty() } + val containedStatements = containedStatementGroups.flatten() + val distinctContainedOrigins = containedStatements + .mapNotNull { statement -> statement.location.origin } + .distinct() + val containedTargets = containedStatements.map(::EtsStatementTarget) + val mapping = when { + statementGroupsInSourceFile.size > 1 && containedStatements.isNotEmpty() -> { + ambiguousStatementMapping(location, containedStatements) + } + + exactTargets.isNotEmpty() -> EtsMappingResult( + status = EtsMappingStatus.EXACT, + targets = exactTargets, + ) + + distinctContainedOrigins.size == 1 -> EtsMappingResult( + status = EtsMappingStatus.EXACT, + targets = containedTargets, + ) + + distinctContainedOrigins.size > 1 -> ambiguousStatementMapping(location, containedStatements) + + else -> { + val diagnostic = EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_STATEMENT_UNMAPPED, + message = "No EtsIR statement has the covered TypeScript source span", + sourcePath = location.path, + ) + + EtsMappingResult( + status = EtsMappingStatus.UNMAPPED, + targets = emptyList(), + diagnostics = listOf(diagnostic), + ) + } + } + + return EtsStatementCoverageMapping( + coverage = coverage, + location = location, + mapping = mapping, + ) + } + + private fun ambiguousStatementMapping( + location: NormalizedSourceRange, + statements: List, + ): EtsMappingResult { + val targets = statements.map(::EtsStatementTarget) + val diagnostic = EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_STATEMENT_AMBIGUOUS, + message = "The covered TypeScript range matches several EtsIR source candidates or spans", + sourcePath = location.path, + ) + + return EtsMappingResult( + status = EtsMappingStatus.AMBIGUOUS, + targets = targets, + diagnostics = listOf(diagnostic), + ) + } + + private fun sourceNormalizationDiagnostic( + sourcePath: String, + failure: Throwable?, + ): EtsMappingDiagnostic { + val diagnosticCode = if (failure is UnsupportedSourceLocationException) { + PbtDiagnosticCode.MAPPING_SOURCE_LOCATION_UNSUPPORTED + } else { + PbtDiagnosticCode.MAPPING_SOURCE_UNAVAILABLE + } + + return EtsMappingDiagnostic( + code = diagnosticCode, + message = "Cannot normalize covered source $sourcePath: ${failure?.message}", + sourcePath = sourcePath, + ) + } + + private fun unsupportedMapping(diagnostic: EtsMappingDiagnostic): EtsMappingResult = EtsMappingResult( + status = EtsMappingStatus.UNSUPPORTED, + targets = emptyList(), + diagnostics = listOf(diagnostic), + ) + + private fun EtsStmt.hasOrigin(location: NormalizedSourceRange): Boolean { + val origin = this.location.origin ?: return false + if (!origin.hasPath(location.path)) return false + + return origin.startLine == location.start.line && + origin.startColumn == location.start.column && + origin.startOffset == location.start.offset && + origin.endLine == location.end.line && + origin.endColumn == location.end.column && + origin.endOffset == location.end.offset + } + + private fun EtsStmt.hasOriginWithin(location: NormalizedSourceRange): Boolean { + val origin = this.location.origin ?: return false + + return origin.hasPath(location.path) && + origin.startOffset >= location.start.offset && + origin.endOffset <= location.end.offset + } + + private fun EtsSourceSpan.hasPath(path: String): Boolean = + sourceLocations.sourcePathCandidates(fileName).any { candidate -> candidate.toString() == path } +} + +private data class SceneFileCandidate( + val file: EtsFile, + val canonicalPaths: Set, +) { + val statements: List = file.allClasses + .flatMap { etsClass -> etsClass.methods } + .flatMap { method -> method.cfg.stmts } +} + +private fun EtsIfStmt.successorCount(): Int = location.method.cfg.successors(this).size + +private fun aggregateStatus(statuses: List): EtsMappingStatus = when { + statuses.isEmpty() -> EtsMappingStatus.EXACT + EtsMappingStatus.UNSUPPORTED in statuses -> EtsMappingStatus.UNSUPPORTED + EtsMappingStatus.AMBIGUOUS in statuses -> EtsMappingStatus.AMBIGUOUS + EtsMappingStatus.UNMAPPED in statuses -> EtsMappingStatus.UNMAPPED + else -> EtsMappingStatus.EXACT +} + +private const val ISTANBUL_IF_BRANCH_TYPE = "if" diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsEntryPointResolver.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsEntryPointResolver.kt new file mode 100644 index 000000000..12cc28be1 --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsEntryPointResolver.kt @@ -0,0 +1,316 @@ +package org.usvm.ts.pbt.mapping + +import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsClass +import org.jacodb.ets.model.EtsClassType +import org.jacodb.ets.model.EtsExportInfo +import org.jacodb.ets.model.EtsExportType +import org.jacodb.ets.model.EtsFile +import org.jacodb.ets.model.EtsFunctionType +import org.jacodb.ets.model.EtsLocal +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsMethodSignature +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.model.EtsStaticFieldRef +import org.jacodb.ets.utils.ANONYMOUS_METHOD_PREFIX +import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME +import org.jacodb.ets.utils.DEFAULT_ARK_METHOD_NAME +import org.usvm.ts.pbt.PbtDiagnosticCode +import org.usvm.ts.pbt.manifest.PropertyManifest +import org.usvm.ts.pbt.model.TypeScriptEntryPoint +import java.util.IdentityHashMap + +/** Resolves TypeScript runtime exports to callable EtsIR methods and their stack bindings. */ +internal class EtsEntryPointResolver( + private val scene: EtsScene, + private val sourceLocations: SourceLocationNormalizer, +) { + fun resolve( + entryPoint: TypeScriptEntryPoint, + manifest: PropertyManifest, + ): EtsMappingResult { + if (sourceLocations.sourceRootDiagnostics.isNotEmpty()) { + return EtsMappingResult( + status = EtsMappingStatus.UNSUPPORTED, + targets = emptyList(), + diagnostics = sourceLocations.sourceRootDiagnostics, + ) + } + + val sourceCandidates = scene.projectFiles.filter { candidate -> candidate.matches(entryPoint.module) } + val candidateResolutions = sourceCandidates.map { file -> + resolveExportedMethods(file, entryPoint.exportName, visited = emptySet()) + } + val methods = candidateResolutions + .flatMap { resolution -> resolution.methods } + .distinctByIdentity() + val hasAmbiguousCandidateResolution = candidateResolutions.any { resolution -> resolution.isAmbiguous } + val hasAmbiguousSourceResolution = sourceCandidates.size > 1 + val hasAmbiguousResolution = hasAmbiguousCandidateResolution || hasAmbiguousSourceResolution + val entryPointName = "${entryPoint.module}#${entryPoint.exportName}" + + if (methods.any { method -> method.parameters.size != manifest.inputs.size }) { + val diagnostic = EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_ENTRY_POINT_BINDINGS_UNSUPPORTED, + message = "Property inputs do not match EtsIR parameters for ${entryPoint.exportName}", + sourcePath = entryPoint.module, + ) + + return EtsMappingResult( + status = EtsMappingStatus.UNSUPPORTED, + targets = emptyList(), + diagnostics = listOf(diagnostic), + ) + } + + val targets = methods.map { method -> + EtsEntryPointTarget( + method = method, + bindings = method.bindingsFor(manifest), + ) + } + + if (targets.size == 1 && !hasAmbiguousResolution) { + return EtsMappingResult( + status = EtsMappingStatus.EXACT, + targets = targets, + ) + } + if (targets.isNotEmpty()) { + val diagnostic = EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_ENTRY_POINT_AMBIGUOUS, + message = "Several EtsIR methods, source candidates, or export links match $entryPointName", + sourcePath = entryPoint.module, + ) + + return EtsMappingResult( + status = EtsMappingStatus.AMBIGUOUS, + targets = targets, + diagnostics = listOf(diagnostic), + ) + } + + val diagnostic = EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_ENTRY_POINT_UNMAPPED, + message = "No EtsIR method matches $entryPointName", + sourcePath = entryPoint.module, + ) + + return EtsMappingResult( + status = EtsMappingStatus.UNMAPPED, + targets = emptyList(), + diagnostics = listOf(diagnostic), + ) + } + + private fun resolveExportedMethods( + file: EtsFile, + exportName: String, + visited: Set, + ): MethodResolution { + val currentStep = ExportResolutionStep(file, exportName) + if (currentStep in visited) return MethodResolution.EMPTY + + val resolutionPath = visited + currentStep + + val runtimeExports = file.exportInfos.filter { export -> + !export.isTypeOnly && export.type != EtsExportType.TYPE + } + val namedRuntimeExports = runtimeExports.filter { export -> + export.runtimeName == exportName + } + // TypeScript gives an explicit named export precedence over fallback exports from `export *`. + val matchingExports = namedRuntimeExports.ifEmpty { + runtimeExports.filter { export -> + export.isBareStarReExport && exportName != DEFAULT_EXPORT_NAME + } + } + val directMethodNames = matchingExports + .filter { export -> export.type == EtsExportType.METHOD && !export.isReExport } + .map { export -> export.originalName } + val directMethods = file.classes + .filter { etsClass -> etsClass.name == DEFAULT_ARK_CLASS_NAME } + .flatMap { etsClass -> etsClass.methods } + .filter { method -> method.name in directMethodNames } + val localResolutions = matchingExports + .filter { export -> export.type == EtsExportType.LOCAL && !export.isReExport } + .map { export -> resolveCallableLocal(file, export.originalName) } + val reExportedResolutions = matchingExports + .filter { export -> export.isReExport && !export.isNamespaceStarReExport } + .flatMap { export -> + val targetExportName = if (export.isBareStarReExport) exportName else export.originalName + val targetFiles = resolveReExportFiles(file, requireNotNull(export.from)) + + targetFiles.map { targetFile -> + val resolution = resolveExportedMethods(targetFile, targetExportName, resolutionPath) + + resolution.copy(isAmbiguous = resolution.isAmbiguous || targetFiles.size > 1) + } + } + val localMethods = localResolutions.flatMap { resolution -> resolution.methods } + val reExportedMethods = reExportedResolutions.flatMap { resolution -> resolution.methods } + val methodCandidates = directMethods + localMethods + reExportedMethods + val methods = methodCandidates.distinctByIdentity() + val hasAmbiguousLocalResolution = localResolutions.any { resolution -> resolution.isAmbiguous } + val hasAmbiguousReExportedResolution = reExportedResolutions.any { resolution -> resolution.isAmbiguous } + val hasAmbiguousResolution = hasAmbiguousLocalResolution || hasAmbiguousReExportedResolution + + return MethodResolution( + methods = methods, + isAmbiguous = hasAmbiguousResolution, + ) + } + + private fun resolveCallableLocal(file: EtsFile, localName: String): MethodResolution { + // The frontend lowers a callable local to a static-field assignment whose function signature identifies + // the lifted anonymous method. Keep every link so repeated or partial lowering remains visibly ambiguous. + val assignments = file.findLocalAssignments(localName) + val callableAssignments = assignments.mapNotNull { assignment -> assignment.toCallableAssignment() } + val linkedMethods = callableAssignments.flatMap { assignment -> + assignment.findLinkedMethods() + } + val methods = linkedMethods.distinctByIdentity() + val isExactLink = assignments.size == 1 && callableAssignments.size == 1 && linkedMethods.size == 1 + + return MethodResolution( + methods = methods, + isAmbiguous = methods.isNotEmpty() && !isExactLink, + ) + } + + private fun EtsFile.findLocalAssignments(localName: String): List { + val assignments = mutableListOf() + + for (defaultClass in classes) { + if (defaultClass.name != DEFAULT_ARK_CLASS_NAME) continue + + assignments += defaultClass.findLocalAssignments(localName) + } + + return assignments + } + + private fun EtsClass.findLocalAssignments(localName: String): List { + val assignments = mutableListOf() + + for (defaultMethod in methods) { + if (defaultMethod.name != DEFAULT_ARK_METHOD_NAME) continue + + for (statement in defaultMethod.cfg.stmts) { + val assignment = statement as? EtsAssignStmt ?: continue + val field = assignment.lhv as? EtsStaticFieldRef ?: continue + val belongsToDefaultClass = field.field.enclosingClass == signature + val hasRequestedName = field.field.name == localName + if (belongsToDefaultClass && hasRequestedName) { + assignments += LocalAssignment(this, assignment) + } + } + } + + return assignments + } + + private fun resolveReExportFiles(file: EtsFile, module: String): List { + val targetPaths = sourceLocations.sourcePathCandidates(file.name).flatMapTo(linkedSetOf()) { sourcePath -> + val targetPath = requireNotNull(sourcePath.parent).resolve(module).normalize() + + sourceLocations.modulePathCandidates(targetPath) + } + + return scene.projectFiles.filter { candidate -> + sourceLocations.sourcePathCandidates(candidate.name).any(targetPaths::contains) + } + } + + private fun EtsFile.matches(module: String): Boolean { + val modulePaths = sourceLocations.sourcePathCandidates(module).flatMapTo(linkedSetOf()) { path -> + sourceLocations.modulePathCandidates(path) + } + val filePaths = sourceLocations.sourcePathCandidates(name) + + return modulePaths.any(filePaths::contains) + } + + private fun EtsMethod.bindingsFor(manifest: PropertyManifest): EtsEntryPointBindings { + val receiverType = EtsClassType( + signature = signature.enclosingClass, + typeParameters = requireNotNull(enclosingClass).typeParameters, + ) + val inputBindings = manifest.inputs.zip(parameters).mapIndexed { index, (input, parameter) -> + EtsInputBinding( + propertyInputName = input.name, + parameter = parameter, + stackSlot = index + RECEIVER_STACK_SLOTS, + ) + } + + return EtsEntryPointBindings( + receiver = EtsReceiverBinding( + stackSlot = RECEIVER_STACK_SLOT, + type = receiverType, + ), + inputs = inputBindings, + result = EtsResultBinding(type = returnType), + ) + } +} + +private data class ExportResolutionStep( + val file: EtsFile, + val exportName: String, +) + +private data class LocalAssignment( + val defaultClass: EtsClass, + val assignment: EtsAssignStmt, +) { + fun toCallableAssignment(): CallableLocalAssignment? { + val local = assignment.rhv as? EtsLocal ?: return null + val functionType = local.type as? EtsFunctionType ?: return null + + return CallableLocalAssignment( + defaultClass = defaultClass, + functionSignature = functionType.signature, + ) + } +} + +private data class CallableLocalAssignment( + val defaultClass: EtsClass, + val functionSignature: EtsMethodSignature, +) { + fun findLinkedMethods(): List = defaultClass.methods.filter { method -> + val isAnonymousMethod = method.name.startsWith(ANONYMOUS_METHOD_PREFIX) + val hasExpectedSignature = method.signature == functionSignature + + isAnonymousMethod && hasExpectedSignature + } +} + +private data class MethodResolution( + val methods: List, + val isAmbiguous: Boolean = false, +) { + companion object { + val EMPTY = MethodResolution(methods = emptyList()) + } +} + +private fun List.distinctByIdentity(): List { + val seen = IdentityHashMap() + + return filter { method -> seen.put(method, Unit) == null } +} + +private val EtsExportInfo.isBareStarReExport: Boolean + get() = isStarReExport && !isAliased + +private val EtsExportInfo.isNamespaceStarReExport: Boolean + get() = isStarReExport && isAliased + +private val EtsExportInfo.runtimeName: String + get() = if (!isReExport && isDefaultExport) DEFAULT_EXPORT_NAME else name + +private const val DEFAULT_EXPORT_NAME = "default" +private const val RECEIVER_STACK_SLOT = 0 +private const val RECEIVER_STACK_SLOTS = 1 diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsMappingModel.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsMappingModel.kt new file mode 100644 index 000000000..5d0b3f931 --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsMappingModel.kt @@ -0,0 +1,193 @@ +package org.usvm.ts.pbt.mapping + +import org.jacodb.ets.model.EtsIfStmt +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsMethodParameter +import org.jacodb.ets.model.EtsStmt +import org.jacodb.ets.model.EtsType +import org.usvm.ts.pbt.backend.BranchArmCoverage +import org.usvm.ts.pbt.backend.BranchCoverage +import org.usvm.ts.pbt.backend.CoverageProvenance +import org.usvm.ts.pbt.backend.StatementCoverage +import org.usvm.ts.pbt.model.PropertyId + +/** Classification shared by entry-point and source-coverage mapping results. */ +enum class EtsMappingStatus { + EXACT, + AMBIGUOUS, + UNMAPPED, + UNSUPPORTED, +} + +/** Source coordinate convention shared by TypeScript and native EtsIR origins. */ +enum class EtsSourceCoordinateSystem { + TYPESCRIPT_UTF16_ZERO_BASED_HALF_OPEN, +} + +/** Ordered-successor convention used to bind backend branch arms to EtsIR CFG successors. */ +enum class EtsBranchSuccessorOrder( + private val orderedOutcomes: List, +) { + TRUE_FALSE(orderedOutcomes = listOf(true, false)), + ; + + internal val armCount: Int + get() = orderedOutcomes.size + + internal fun outcomeAt(successorIndex: Int): Boolean = orderedOutcomes[successorIndex] +} + +/** Mapping-layer assumptions needed to interpret every target in one property artifact. */ +data class EtsMappingProvenance( + val sourceRoots: List, + val coordinates: EtsSourceCoordinateSystem, + val branchSuccessorOrder: EtsBranchSuccessorOrder, +) + +/** Stable reason explaining why a mapping could not produce one exact target. */ +data class EtsMappingDiagnostic( + val code: String, + val message: String, + val sourcePath: String? = null, +) { + init { + require(code.isNotBlank()) { "Mapping diagnostic code must not be blank" } + require(message.isNotBlank()) { "Mapping diagnostic message must not be blank" } + } +} + +/** + * One mapping decision together with every EtsIR target selected by that decision. + * + * One TypeScript statement may be lowered into several EtsIR statements that share its source origin, so an exact + * result may contain several targets. Ambiguous results preserve every candidate and explain the ambiguity. + * Unmapped and unsupported results carry only their diagnostic reasons. + */ +data class EtsMappingResult( + val status: EtsMappingStatus, + val targets: List, + val diagnostics: List = emptyList(), +) { + init { + when (status) { + EtsMappingStatus.EXACT -> { + require(targets.isNotEmpty()) { "Exact mapping requires at least one target" } + require(diagnostics.isEmpty()) { "Exact mapping must not contain diagnostics" } + } + + EtsMappingStatus.AMBIGUOUS -> { + require(targets.isNotEmpty()) { "Ambiguous mapping requires at least one candidate target" } + require(diagnostics.isNotEmpty()) { "Ambiguous mapping requires a diagnostic" } + } + + EtsMappingStatus.UNMAPPED, + EtsMappingStatus.UNSUPPORTED, + -> { + require(targets.isEmpty()) { "$status mapping must not contain targets" } + require(diagnostics.isNotEmpty()) { "$status mapping requires a diagnostic" } + } + } + } +} + +/** Explicit stack binding for the receiver reserved by the TypeScript interpreter. */ +data class EtsReceiverBinding( + val stackSlot: Int, + val type: EtsType, +) + +/** Connects one ordered property input to the corresponding EtsIR parameter and stack slot. */ +data class EtsInputBinding( + val propertyInputName: String, + val parameter: EtsMethodParameter, + val stackSlot: Int, +) + +/** Identifies the value produced when the mapped EtsIR method returns. */ +data class EtsResultBinding( + val type: EtsType, +) + +/** EtsIR value bindings required to execute one property entry point symbolically. */ +data class EtsEntryPointBindings( + val receiver: EtsReceiverBinding, + val inputs: List, + val result: EtsResultBinding, +) + +/** Resolved EtsIR method and its property-facing symbolic bindings. */ +data class EtsEntryPointTarget( + val method: EtsMethod, + val bindings: EtsEntryPointBindings, +) + +/** Zero-based TypeScript position with its UTF-16 source-file offset. */ +data class NormalizedSourcePosition( + val line: Int, + val column: Int, + val offset: Int, +) + +/** Canonical source path and half-open zero-based UTF-16 range. */ +data class NormalizedSourceRange( + val path: String, + val start: NormalizedSourcePosition, + val end: NormalizedSourcePosition, +) + +/** One EtsIR statement selected for a backend-neutral statement coverage location. */ +data class EtsStatementTarget( + val statement: EtsStmt, +) + +/** Source statement coverage paired with its normalized location and EtsIR mapping decision. */ +data class EtsStatementCoverageMapping( + val coverage: StatementCoverage, + val location: NormalizedSourceRange?, + val mapping: EtsMappingResult, +) + +/** EtsIR conditional selected for one backend-neutral branch location. */ +data class EtsBranchTarget( + val statement: EtsIfStmt, +) + +/** One explicit EtsIR control-flow edge associated with a covered branch arm. */ +data class EtsBranchArmTarget( + val condition: EtsIfStmt, + val outcome: Boolean, + val successor: EtsStmt, +) + +/** One backend branch arm paired with its normalized location and EtsIR edge mapping. */ +data class EtsBranchArmCoverageMapping( + val coverage: BranchArmCoverage, + val location: NormalizedSourceRange?, + val mapping: EtsMappingResult, +) + +/** Backend branch coverage paired with its EtsIR condition and ordered arm mappings. */ +data class EtsBranchCoverageMapping( + val coverage: BranchCoverage, + val location: NormalizedSourceRange?, + val mapping: EtsMappingResult, + val arms: List, +) + +/** Mapping state for optional backend-neutral source coverage. */ +data class EtsCoverageMapping( + val status: EtsMappingStatus, + val backendProvenance: CoverageProvenance?, + val statements: List = emptyList(), + val branches: List = emptyList(), + val diagnostics: List, +) + +/** Kotlin-owned mapping artifact for one analyzed property. */ +data class PropertyEtsMappingArtifact( + val propertyId: PropertyId, + val provenance: EtsMappingProvenance, + val predicate: EtsMappingResult, + val precondition: EtsMappingResult?, + val coverage: EtsCoverageMapping, +) diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsMapper.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsMapper.kt new file mode 100644 index 000000000..fd2668aeb --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsMapper.kt @@ -0,0 +1,45 @@ +package org.usvm.ts.pbt.mapping + +import org.jacodb.ets.model.EtsScene +import org.usvm.ts.pbt.backend.PropertyCoverageArtifact +import org.usvm.ts.pbt.manifest.PropertyManifest +import org.usvm.ts.pbt.model.PropertyId +import java.nio.file.Path + +/** Coordinates entry-point and coverage mapping for one project scene. */ +class PropertyEtsMapper( + scene: EtsScene, + sourceRoots: List, +) { + private val sourceLocations = SourceLocationNormalizer(sourceRoots) + private val branchSuccessorOrder = EtsBranchSuccessorOrder.TRUE_FALSE + private val entryPointResolver = EtsEntryPointResolver(scene, sourceLocations) + private val coverageMapper = EtsCoverageMapper(scene, sourceLocations, branchSuccessorOrder) + + /** Produces a complete mapping artifact even when individual entry points or coverage locations do not map. */ + fun map( + manifest: PropertyManifest, + coverage: PropertyCoverageArtifact? = null, + ): PropertyEtsMappingArtifact { + val propertyId = PropertyId(manifest.propertyId) + val predicate = entryPointResolver.resolve(manifest.predicate, manifest) + val precondition = manifest.precondition?.let { entryPoint -> + entryPointResolver.resolve(entryPoint, manifest) + } + val sourceRootPaths = sourceLocations.normalizedSourceRoots.map(Path::toString) + val provenance = EtsMappingProvenance( + sourceRoots = sourceRootPaths, + coordinates = EtsSourceCoordinateSystem.TYPESCRIPT_UTF16_ZERO_BASED_HALF_OPEN, + branchSuccessorOrder = branchSuccessorOrder, + ) + val mappedCoverage = coverageMapper.map(propertyId, coverage) + + return PropertyEtsMappingArtifact( + propertyId = propertyId, + provenance = provenance, + predicate = predicate, + precondition = precondition, + coverage = mappedCoverage, + ) + } +} diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/SourceLocationNormalizer.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/SourceLocationNormalizer.kt new file mode 100644 index 000000000..f541c7e62 --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/SourceLocationNormalizer.kt @@ -0,0 +1,198 @@ +package org.usvm.ts.pbt.mapping + +import org.usvm.ts.pbt.PbtDiagnosticCode +import org.usvm.ts.pbt.backend.SourcePosition +import org.usvm.ts.pbt.backend.SourceRange +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path + +internal class SourceLocationNormalizer(sourceRoots: List) { + private val paths = SourcePathResolver(sourceRoots) + private val sourceLineMaps = hashMapOf() + + val normalizedSourceRoots: List = paths.normalizedSourceRoots + val sourceRootDiagnostics: List = paths.sourceRootDiagnostics + + fun normalizeRange(sourcePath: String, range: SourceRange): NormalizedSourceRange { + val path = paths.resolveSourceFile(sourcePath) + val sourceLineMap = sourceLineMaps.getOrPut(path) { readSourceLineMap(path) } + val start = sourceLineMap.normalize(range.start) + val end = sourceLineMap.normalize(range.end) + + return NormalizedSourceRange( + path = path.toString(), + start = start, + end = end, + ) + } + + fun sourcePathCandidates(value: String): Set = paths.sourcePathCandidates(value) + + fun modulePathCandidates(path: Path): Set = paths.modulePathCandidates(path) + + private fun readSourceLineMap(path: Path): TypeScriptLineMap { + val source = Files.readString(path) + + return TypeScriptLineMap(source) + } +} + +private class SourcePathResolver(sourceRoots: List) { + private val sourceRootResolutions = sourceRoots.mapIndexed { index, root -> + normalizeSourceRoot(index, root) + } + + val normalizedSourceRoots: List = sourceRootResolutions.map { resolution -> resolution.path } + val sourceRootDiagnostics: List = sourceRootResolutions.mapNotNull { resolution -> + resolution.diagnostic + } + + fun sourcePathCandidates(value: String): Set { + val path = Path.of(value) + val candidates = if (path.isAbsolute) { + listOf(path) + } else { + normalizedSourceRoots.map { root -> root.resolve(path) } + } + + return candidates.mapTo(linkedSetOf()) { candidate -> candidate.canonicalizeIfExisting() } + } + + fun resolveSourceFile(sourcePath: String): Path { + val candidates = sourcePathCandidates(sourcePath) + val existingFiles = candidates.filter(Files::isRegularFile) + + return when (existingFiles.size) { + 0 -> { + val unresolvedCandidate = candidates.singleOrNull() + + unresolvedCandidate ?: throw UnsupportedSourceLocationException( + "Source path $sourcePath does not resolve uniquely below the configured source roots", + ) + } + + 1 -> existingFiles.single() + + else -> throw UnsupportedSourceLocationException( + "Source path $sourcePath resolves to several files: ${existingFiles.sorted().joinToString()}", + ) + } + } + + fun modulePathCandidates(path: Path): Set { + val candidates = mutableListOf(path) + if (!path.hasTypeScriptModuleSuffix()) { + val moduleName = path.fileName?.toString().orEmpty() + + for (suffix in TYPESCRIPT_MODULE_SUFFIXES) { + candidates.add(path.resolveSibling("$moduleName$suffix")) + } + for (suffix in TYPESCRIPT_MODULE_SUFFIXES) { + candidates.add(path.resolve("index$suffix")) + } + } + + return candidates.mapTo(linkedSetOf()) { candidate -> candidate.canonicalizeIfExisting() } + } + + private fun normalizeSourceRoot(index: Int, root: Path): SourceRootResolution { + val normalizedRoot = root.toAbsolutePath().normalize() + + return try { + val realRoot = normalizedRoot.toRealPath() + if (Files.isDirectory(realRoot)) { + SourceRootResolution(path = realRoot) + } else { + unsupportedSourceRoot(index, normalizedRoot, "the path is not a directory") + } + } catch (error: IOException) { + unsupportedSourceRoot(index, normalizedRoot, error.message ?: "the path cannot be resolved") + } + } + + private fun unsupportedSourceRoot(index: Int, path: Path, reason: String): SourceRootResolution { + val diagnostic = EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_SOURCE_ROOT_UNSUPPORTED, + message = "Cannot resolve TypeScript source root $index ($path): $reason", + sourcePath = path.toString(), + ) + + return SourceRootResolution( + path = path, + diagnostic = diagnostic, + ) + } + + private fun Path.canonicalizeIfExisting(): Path { + val absolutePath = if (isAbsolute) this else toAbsolutePath() + + return try { + absolutePath.toRealPath() + } catch (_: IOException) { + absolutePath.normalize() + } + } +} + +private class TypeScriptLineMap(source: String) { + private val lines = source.indexTypeScriptLines() + + fun normalize(position: SourcePosition): NormalizedSourcePosition { + val zeroBasedLine = position.line - ISTANBUL_LINE_BASE + val sourceLine = lines.getOrNull(zeroBasedLine) + ?: throw UnsupportedSourceLocationException("Source line ${position.line} is outside the file") + val offset = sourceLine.startOffset + position.column + if (offset > sourceLine.endOffset) { + throw UnsupportedSourceLocationException( + "Source column ${position.column} is outside line ${position.line}", + ) + } + + return NormalizedSourcePosition( + line = zeroBasedLine, + column = position.column, + offset = offset, + ) + } +} + +private fun String.indexTypeScriptLines(): List { + val lines = mutableListOf() + var lineStart = 0 + for (lineBreak in TYPESCRIPT_LINE_BREAK.findAll(this)) { + lines += SourceLine(startOffset = lineStart, endOffset = lineBreak.range.first) + lineStart = lineBreak.range.last + 1 + } + + lines += SourceLine(startOffset = lineStart, endOffset = length) + + return lines +} + +private fun Path.hasTypeScriptModuleSuffix(): Boolean { + val name = fileName?.toString().orEmpty() + + return TYPESCRIPT_MODULE_SUFFIXES.any(name::endsWith) +} + +private data class SourceLine( + val startOffset: Int, + val endOffset: Int, +) + +private data class SourceRootResolution( + val path: Path, + val diagnostic: EtsMappingDiagnostic? = null, +) + +internal class UnsupportedSourceLocationException(message: String) : IllegalArgumentException(message) + +// TypeScript also treats the Unicode LINE SEPARATOR and PARAGRAPH SEPARATOR characters as line breaks. +private const val UNICODE_LINE_SEPARATOR = '\u2028' +private const val UNICODE_PARAGRAPH_SEPARATOR = '\u2029' +private const val ISTANBUL_LINE_BASE = 1 +private val TYPESCRIPT_LINE_BREAK = Regex( + pattern = "\r\n|[\n\r$UNICODE_LINE_SEPARATOR$UNICODE_PARAGRAPH_SEPARATOR]", +) +private val TYPESCRIPT_MODULE_SUFFIXES = listOf(".ts", ".ets", ".d.ts") 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 74384e18c..9dba12e1f 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 @@ -12,13 +12,14 @@ import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put +private typealias KotlinBoolean = Boolean +private typealias KotlinString = String + /** Tags the finite and non-finite cases of an ECMAScript binary64 value. */ @Serializable enum class JsNumberKind { @@ -96,10 +97,10 @@ sealed interface JsConcreteValue { data object Null : JsConcreteValue /** Concrete JavaScript boolean value. */ - data class Boolean(val value: kotlin.Boolean) : JsConcreteValue + data class Boolean(val value: KotlinBoolean) : JsConcreteValue /** Concrete JavaScript UTF-16 string value. */ - data class String(val value: kotlin.String) : JsConcreteValue + data class String(val value: KotlinString) : JsConcreteValue /** Concrete JavaScript binary64 number with lossless special-value encoding. */ data class Number(val number: JsNumber) : JsConcreteValue { @@ -169,22 +170,50 @@ object JsConcreteValueSerializer : KSerializer { val jsonDecoder = decoder as? JsonDecoder ?: throw SerializationException("JsConcreteValue supports JSON deserialization only") - val value = jsonDecoder.decodeJsonElement().jsonObject + val value = jsonDecoder.decodeJsonElement() as? JsonObject + ?: throw SerializationException("JsConcreteValue must be a JSON object") return when (val kind = value.requiredString("kind")) { - "undefined" -> JsConcreteValue.Undefined - "null" -> JsConcreteValue.Null - "boolean" -> deserializeBoolean(value) - "string" -> JsConcreteValue.String(value.requiredString("value")) - "number" -> deserializeNumber(value) - "array" -> deserializeArray(jsonDecoder, value) - else -> throw SerializationException("Unknown JavaScript value kind: $kind") + "undefined" -> { + value.requireExactKeys("kind") + JsConcreteValue.Undefined + } + + "null" -> { + value.requireExactKeys("kind") + JsConcreteValue.Null + } + + "boolean" -> { + value.requireExactKeys("kind", "value") + deserializeBoolean(value) + } + + "string" -> { + value.requireExactKeys("kind", "value") + JsConcreteValue.String(value.requiredString("value")) + } + + "number" -> { + deserializeNumber(value) + } + + "array" -> { + value.requireExactKeys("kind", "elements") + deserializeArray(jsonDecoder, value) + } + + else -> { + throw SerializationException("Unknown JavaScript value kind: $kind") + } } } } private fun deserializeBoolean(value: JsonObject): JsConcreteValue.Boolean { - val booleanValue = value["value"]?.jsonPrimitive?.booleanOrNull + val primitive = value["value"] as? JsonPrimitive + ?: throw SerializationException("Boolean JsConcreteValue requires a boolean value") + val booleanValue = primitive.takeUnless(JsonPrimitive::isString)?.booleanOrNull ?: throw SerializationException("Boolean JsConcreteValue requires a boolean value") return JsConcreteValue.Boolean(booleanValue) @@ -200,14 +229,27 @@ private fun deserializeNumber(value: JsonObject): JsConcreteValue.Number { else -> throw SerializationException("Unknown JavaScript number kind: $numberKindName") } - val bits = value["bits"]?.jsonPrimitive?.content + val bits = when (numberKind) { + JsNumberKind.FINITE -> { + value.requireExactKeys("kind", "value", "bits") + value.requiredFiniteBits() + } + + JsNumberKind.NAN, + JsNumberKind.POSITIVE_INFINITY, + JsNumberKind.NEGATIVE_INFINITY, + -> { + value.requireExactKeys("kind", "value") + null + } + } val number = JsNumber(value = numberKind, bits = bits) return JsConcreteValue.Number(number) } private fun deserializeArray(jsonDecoder: JsonDecoder, value: JsonObject): JsConcreteValue.Array { - val jsonElements = value["elements"]?.jsonArray + val jsonElements = value["elements"] as? JsonArray ?: throw SerializationException("Array JsConcreteValue requires elements") val elements = jsonElements.map { element -> @@ -225,9 +267,36 @@ private val JsNumberKind.serialName: String JsNumberKind.NEGATIVE_INFINITY -> "negative-infinity" } -private fun JsonObject.requiredString(name: String): String = - get(name)?.jsonPrimitive?.content - ?: throw SerializationException("JsConcreteValue requires a $name field") +private fun JsonObject.requireExactKeys(vararg expectedKeys: String) { + if (keys != expectedKeys.toSet()) { + throw SerializationException("JsConcreteValue has unexpected fields") + } +} + +private fun JsonObject.requiredString(name: String): String { + val value = get(name) as? JsonPrimitive + ?: throw SerializationException("JsConcreteValue requires a string $name field") + if (!value.isString) { + throw SerializationException("JsConcreteValue requires a string $name field") + } + + return value.content +} + +private fun JsonObject.requiredFiniteBits(): String { + val bits = requiredString("bits") + if (!bits.matches(FINITE_NUMBER_BITS_REGEX)) { + throw SerializationException("Finite JsConcreteValue requires sixteen lowercase hexadecimal bits") + } + + val number = Double.fromBits(bits.toULong(JS_NUMBER_HEX_RADIX).toLong()) + if (!number.isFinite()) { + throw SerializationException("Finite JsConcreteValue requires finite IEEE-754 bits") + } + + return bits +} private const val JS_NUMBER_HEX_DIGITS = 16 private const val JS_NUMBER_HEX_RADIX = 16 +private val FINITE_NUMBER_BITS_REGEX = Regex("[0-9a-f]{16}") 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 8ca1bd3c9..08f630936 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 @@ -79,15 +79,17 @@ operator fun PropertyDomain.contains(value: JsConcreteValue): Boolean = when (th is StringDomain -> value is JsConcreteValue.String && value.value.length in minLength..maxLength is ConstantDomain -> value == this.value is OptionalDomain -> value == nil || value in this.value - is TupleDomain -> + is TupleDomain -> { value is JsConcreteValue.Array && value.elements.size == elements.size && value.elements.zip(elements).all { (element, domain) -> element in domain } + } - is ArrayDomain -> + is ArrayDomain -> { value is JsConcreteValue.Array && value.elements.size in minLength..maxLength && value.elements.all { elementValue -> elementValue in element } + } } private fun JsConcreteValue.Number.isIntegerIn(domain: IntegerDomain): Boolean { diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/registry/PropertyRegistry.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/registry/PropertyRegistry.kt index 4e0a11dde..0fbaf8743 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/registry/PropertyRegistry.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/registry/PropertyRegistry.kt @@ -22,17 +22,25 @@ class PropertyRegistry(properties: List) { } /** Returns the property identified by [id], or reports all IDs available in this registry. */ - operator fun get(id: PropertyId): PropertyDefinition = propertiesById[id] - ?: throw UnknownPropertyIdException( + operator fun get(id: PropertyId): PropertyDefinition { + val property = propertiesById[id] + if (property != null) return property + + val availablePropertyIds = properties.map(PropertyDefinition::id) + + throw UnknownPropertyIdException( propertyId = id, - availablePropertyIds = properties.map(PropertyDefinition::id), + availablePropertyIds = availablePropertyIds, ) + } companion object { /** Combines registries in input order and validates IDs across registry boundaries. */ - fun combine(registries: List): PropertyRegistry = PropertyRegistry( - registries.flatMap(PropertyRegistry::properties), - ) + fun combine(registries: List): PropertyRegistry { + val properties = registries.flatMap(PropertyRegistry::properties) + + return PropertyRegistry(properties) + } } } 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 aa85b7c48..09079ca16 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 @@ -262,7 +262,7 @@ private fun validateJsNumber( diagnostics: MutableList, ): Boolean { val valid = when (number.value) { - JsNumberKind.FINITE -> number.bits?.matches(FINITE_NUMBER_BITS_REGEX) == true + JsNumberKind.FINITE -> number.bits.isFiniteNumberBits() else -> number.bits == null } if (!valid) { @@ -275,6 +275,11 @@ private fun validateJsNumber( return valid } +private fun String?.isFiniteNumberBits(): Boolean = this + ?.takeIf { bits -> bits.matches(FINITE_NUMBER_BITS_REGEX) } + ?.let { bits -> Double.fromBits(bits.toULong(JS_NUMBER_HEX_RADIX).toLong()).isFinite() } + ?: false + private fun validateLengths( minLength: Int, maxLength: Int, @@ -364,6 +369,7 @@ private fun diagnostic(code: String, message: String, path: String) = Validation ) private val FINITE_NUMBER_BITS_REGEX = Regex("[0-9a-f]{16}") +private const val JS_NUMBER_HEX_RADIX = 16 // ECMAScript permits these otherwise invisible Unicode characters after the first identifier character. private const val ZERO_WIDTH_NON_JOINER_CODE_POINT = 0x200C diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/backend/PropertyBasedTestingBackendTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/backend/PropertyBasedTestingBackendTest.kt index 7e17af5bf..1ea7658c3 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/backend/PropertyBasedTestingBackendTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/backend/PropertyBasedTestingBackendTest.kt @@ -76,6 +76,17 @@ class PropertyBasedTestingBackendTest { } } + @Test + fun `failure details preserve an empty thrown value message`() { + val details = PropertyFailureDetails( + kind = PropertyFailureKind.PROPERTY, + errorName = "ThrownValue", + message = "", + ) + + assertEquals("", details.message) + } + @Test fun `result rejects negative counters and execution time`() { assertFailsWith { diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/CoveragePathFilterTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/CoveragePathFilterTest.kt new file mode 100644 index 000000000..4a915d36b --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/CoveragePathFilterTest.kt @@ -0,0 +1,36 @@ +package org.usvm.ts.pbt.coverage + +import org.junit.jupiter.api.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CoveragePathFilterTest { + @Test + fun `Unix filesystem root contains absolute descendants and produces relative candidates`() { + val path = "/workspace/src/property.ts" + + assertTrue(isWithin(path = path, root = "/")) + assertTrue( + matchesCoveragePath( + path = path, + patterns = listOf("workspace/src/*.ts"), + sourceRoots = listOf("/"), + ), + ) + } + + @Test + fun `normalized Windows drive root contains descendants and produces relative candidates`() { + val path = "C:/workspace/src/property.ts" + + assertTrue(isWithin(path = path, root = "C:/")) + assertTrue( + matchesCoveragePath( + path = path, + patterns = listOf("workspace/src/*.ts"), + sourceRoots = listOf("C:/"), + ), + ) + assertFalse(isWithin(path = "D:/workspace/src/property.ts", root = "C:/")) + } +} diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspectorTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspectorTest.kt new file mode 100644 index 000000000..098e8ea4d --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspectorTest.kt @@ -0,0 +1,426 @@ +package org.usvm.ts.pbt.coverage + +import org.junit.jupiter.api.Test +import org.usvm.ts.pbt.backend.CoverageDiagnostic +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.createDirectory +import kotlin.io.path.createTempDirectory +import kotlin.io.path.writeText +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class RawV8SourceMapInspectorTest { + @Test + fun `empty raw coverage directory is a typed missing report failure`() { + withRawDirectory { rawDirectory -> + val error = assertFailsWith { + inspectRawV8SourceMapDiagnostics( + rawDirectory = rawDirectory, + sourceRoots = listOf(rawDirectory.toString()), + ) + } + + assertEquals("coverage.report.missing", error.diagnostic.code) + assertEquals(rawDirectory.toString(), error.diagnostic.path) + } + } + + @Test + fun `raw report count is bounded before source-map caches are decoded`() { + withRawDirectory { rawDirectory -> + rawDirectory.resolve("first.json").writeText("{not-json") + rawDirectory.resolve("second.json").writeText("{not-json") + + val error = assertFailsWith { + inspectRawV8SourceMapDiagnostics( + rawDirectory = rawDirectory, + sourceRoots = listOf(rawDirectory.toString()), + maxReportFiles = 1, + maxReportBytes = 1_024, + ) + } + + assertEquals("coverage.report.invalid", error.diagnostic.code) + assertEquals(rawDirectory.toString(), error.diagnostic.path) + } + } + + @Test + fun `raw report bytes are bounded before the file is parsed`() { + withRawDirectory { rawDirectory -> + val rawReport = rawDirectory.resolve("coverage.json") + rawReport.writeText("{not-json-but-over-the-test-limit") + + val error = assertFailsWith { + inspectRawV8SourceMapDiagnostics( + rawDirectory = rawDirectory, + sourceRoots = listOf(rawDirectory.toString()), + maxReportFiles = 1, + maxReportBytes = 8, + ) + } + + assertEquals("coverage.report.invalid", error.diagnostic.code) + assertEquals(rawReport.toString(), error.diagnostic.path) + } + } + + @Test + fun `bounded reader enforces aggregate bytes after a report replacement`() { + withRawDirectory { rawDirectory -> + val firstReport = rawDirectory.resolve("first.json") + val replacedReport = rawDirectory.resolve("replaced.json") + firstReport.writeText("{}") + replacedReport.writeText("{}") + val preflightBytes = Files.size(firstReport) + Files.size(replacedReport) + replacedReport.writeText("""{"source-map-cache": {}, "replacement": "larger"}""") + val reader = RawV8ReportReader(maxReportBytes = preflightBytes) + + reader.readText(firstReport) + + val error = assertFailsWith { + reader.readText(replacedReport) + } + + assertEquals("coverage.report.invalid", error.diagnostic.code) + assertEquals(replacedReport.toString(), error.diagnostic.path) + } + } + + @Test + fun `malformed raw source-map-cache schema is a typed coverage failure`() { + withRawDirectory { rawDirectory -> + val rawReport = rawDirectory.resolve("coverage.json") + rawReport.writeText("""{"source-map-cache": []}""") + + val error = assertFailsWith { + inspectRawV8SourceMapDiagnostics( + rawDirectory = rawDirectory, + sourceRoots = listOf(rawDirectory.toString()), + ) + } + + assertEquals("coverage.report.invalid", error.diagnostic.code) + assertEquals("$rawReport.source-map-cache", error.diagnostic.path) + } + } + + @Test + fun `primitive raw source-map data is a typed coverage failure`() { + assertInvalidSourceMapData(dataJson = "true") + } + + @Test + fun `array raw source-map data is a typed coverage failure`() { + assertInvalidSourceMapData(dataJson = "[]") + } + + @Test + fun `malformed source-map cache key is a typed coverage failure`() { + withRawDirectory { rawDirectory -> + val rawReport = rawDirectory.resolve("coverage.json") + rawReport.writeText( + """ + { + "source-map-cache": { + "not a valid URI": { + "lineLengths": [1], + "data": null, + "url": "generated.js.map" + } + } + } + """.trimIndent(), + ) + + val error = assertFailsWith { + inspectRawV8SourceMapDiagnostics( + rawDirectory = rawDirectory, + sourceRoots = listOf(rawDirectory.toString()), + ) + } + + assertEquals("coverage.report.invalid", error.diagnostic.code) + assertEquals("$rawReport.source-map-cache[not a valid URI]", error.diagnostic.path) + } + } + + @Test + fun `raw diagnostics are deterministic across file order and duplicate cache entries`() { + withRawDirectory { rawDirectory -> + val sourceRoot = rawDirectory.resolve("source").createDirectory() + val firstScript = sourceRoot.resolve("first.js") + val secondScript = sourceRoot.resolve("second.js") + firstScript.writeText("export const first = 1") + secondScript.writeText("export const second = 2") + rawDirectory.resolve("z-last.json").writeText(rawReport(firstScript, secondScript)) + rawDirectory.resolve("a-first.json").writeText(rawReport(secondScript, firstScript)) + + val diagnostics = inspectRawV8SourceMapDiagnostics( + rawDirectory = rawDirectory, + sourceRoots = listOf(sourceRoot.toString()), + ) + + assertEquals( + listOf( + firstScript.toString() to "coverage.source-map.missing", + secondScript.toString() to "coverage.source-map.missing", + ), + diagnostics.map { diagnostic -> diagnostic.path to diagnostic.code }, + ) + } + } + + @Test + fun `invalid raw source-map diagnostic wins for one script regardless of report order`() { + withRawDirectory { rawDirectory -> + val sourceRoot = rawDirectory.resolve("source").createDirectory() + val script = sourceRoot.resolve("generated.js") + val firstReport = rawDirectory.resolve("a-first.json") + val secondReport = rawDirectory.resolve("z-second.json") + script.writeText("export const generated = 1") + val missingSourceMap = rawReport(script, referencedUrl = "generated.js.map") + val invalidSourceMap = rawReport( + script = script, + referencedUrl = "data:application/json;base64,e30=", + ) + val expected = listOf(script.toString() to "coverage.source-map.invalid") + + firstReport.writeText(missingSourceMap) + secondReport.writeText(invalidSourceMap) + val missingFirst = inspectRawV8SourceMapDiagnostics( + rawDirectory = rawDirectory, + sourceRoots = listOf(sourceRoot.toString()), + ) + + firstReport.writeText(invalidSourceMap) + secondReport.writeText(missingSourceMap) + val invalidFirst = inspectRawV8SourceMapDiagnostics( + rawDirectory = rawDirectory, + sourceRoots = listOf(sourceRoot.toString()), + ) + + assertEquals(expected, missingFirst.map { diagnostic -> diagnostic.path to diagnostic.code }) + assertEquals(expected, invalidFirst.map { diagnostic -> diagnostic.path to diagnostic.code }) + } + } + + @Test + fun `present referenced map with a URL query is classified as invalid`() { + withRawDirectory { rawDirectory -> + val sourceRoot = rawDirectory.resolve("source").createDirectory() + val script = sourceRoot.resolve("generated.js") + script.writeText("export const generated = 1") + sourceRoot.resolve("generated.js.map").writeText("{not-json") + rawDirectory.resolve("coverage.json").writeText( + """ + { + "source-map-cache": { + "${script.toUri()}": { + "lineLengths": [1], + "data": null, + "url": "generated.js.map?cache=1" + } + } + } + """.trimIndent(), + ) + + val diagnostic = inspectRawV8SourceMapDiagnostics( + rawDirectory = rawDirectory, + sourceRoots = listOf(sourceRoot.toString()), + ).single() + + assertEquals("coverage.source-map.invalid", diagnostic.code) + assertEquals(script.toString(), diagnostic.path) + } + } + + @Test + fun `source-map references are classified by URI semantics`() { + val cases = listOf( + SourceMapReferenceCase( + name = "remote file URI", + scriptPath = "generated.js", + referencedUrl = "file://coverage.example/maps/generated.js.map", + ), + SourceMapReferenceCase( + name = "network-path reference", + scriptPath = "generated.js", + referencedUrl = "//coverage.example/maps/generated.js.map", + ), + SourceMapReferenceCase( + name = "HTTP URL", + scriptPath = "generated.js", + referencedUrl = "https://coverage.example/maps/generated.js.map", + ), + SourceMapReferenceCase( + name = "local fragment", + scriptPath = "generated.js", + referencedUrl = "generated.js.map#section", + presentMapPath = "generated.js.map", + ), + SourceMapReferenceCase( + name = "local traversal", + scriptPath = "scripts/generated.js", + referencedUrl = "../maps/generated.js.map", + presentMapPath = "maps/generated.js.map", + ), + SourceMapReferenceCase( + name = "local path with invalid URI syntax", + scriptPath = "generated.js", + referencedUrl = "generated script.js.map", + presentMapPath = "generated script.js.map", + ), + ) + + cases.forEach { case -> + withRawDirectory { rawDirectory -> + val sourceRoot = rawDirectory.resolve("source").createDirectory() + val script = sourceRoot.resolve(case.scriptPath) + script.parent.createDirectories() + script.writeText("export const generated = 1") + case.presentMapPath?.let { presentMapPath -> + val sourceMap = sourceRoot.resolve(presentMapPath) + sourceMap.parent.createDirectories() + sourceMap.writeText("{not-json") + } + rawDirectory.resolve("coverage.json").writeText( + rawReport( + script = script, + referencedUrl = case.referencedUrl, + ), + ) + + val diagnostic = inspectRawV8SourceMapDiagnostics( + rawDirectory = rawDirectory, + sourceRoots = listOf(sourceRoot.toString()), + ).single() + + assertEquals("coverage.source-map.invalid", diagnostic.code, case.name) + assertEquals(script.toString(), diagnostic.path, case.name) + } + } + } + + @Test + fun `raw source-map diagnostics override final-report guesses and merge without duplicates`() { + val finalDiagnostics = listOf( + CoverageDiagnostic( + code = "coverage.source-map.missing", + message = "final missing", + path = "/workspace/second.js", + ), + ) + val rawDiagnostics = listOf( + CoverageDiagnostic( + code = "coverage.source-map.invalid", + message = "raw invalid", + path = "/workspace/second.js", + ), + CoverageDiagnostic( + code = "coverage.source-map.missing", + message = "raw missing", + path = "/workspace/first.js", + ), + CoverageDiagnostic( + code = "coverage.source-map.missing", + message = "raw missing", + path = "/workspace/first.js", + ), + ) + + val merged = mergeCoverageDiagnostics( + finalDiagnostics = finalDiagnostics, + rawDiagnostics = rawDiagnostics, + ) + + assertEquals( + listOf( + "/workspace/first.js" to "coverage.source-map.missing", + "/workspace/second.js" to "coverage.source-map.invalid", + ), + merged.map { diagnostic -> diagnostic.path to diagnostic.code }, + ) + } + + private fun rawReport(firstScript: Path, secondScript: Path): String = + """ + { + "source-map-cache": { + "${firstScript.toUri()}": { + "lineLengths": [1], + "data": null, + "url": "${firstScript.fileName}.map" + }, + "${secondScript.toUri()}": { + "lineLengths": [1], + "data": null, + "url": "${secondScript.fileName}.map" + } + } + } + """.trimIndent() + + private fun rawReport(script: Path, referencedUrl: String): String = + """ + { + "source-map-cache": { + "${script.toUri()}": { + "lineLengths": [1], + "data": null, + "url": "$referencedUrl" + } + } + } + """.trimIndent() + + private fun assertInvalidSourceMapData(dataJson: String) { + withRawDirectory { rawDirectory -> + val rawReport = rawDirectory.resolve("coverage.json") + val scriptUrl = "file:///generated.js" + rawReport.writeText( + """ + { + "source-map-cache": { + "$scriptUrl": { + "lineLengths": [1], + "data": $dataJson, + "url": "generated.js.map" + } + } + } + """.trimIndent(), + ) + + val error = assertFailsWith { + inspectRawV8SourceMapDiagnostics( + rawDirectory = rawDirectory, + sourceRoots = listOf(rawDirectory.toString()), + ) + } + + assertEquals("coverage.report.invalid", error.diagnostic.code) + assertEquals("$rawReport.source-map-cache[$scriptUrl].data", error.diagnostic.path) + } + } + + private fun withRawDirectory(block: (Path) -> Unit) { + val rawDirectory = createTempDirectory(prefix = "raw-v8-source-maps-") + + try { + block(rawDirectory) + } finally { + rawDirectory.toFile().deleteRecursively() + } + } + + private data class SourceMapReferenceCase( + val name: String, + val scriptPath: String, + val referencedUrl: String, + val presentMapPath: String? = null, + ) +} diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageTest.kt index 1e846000e..14cef79ef 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageTest.kt @@ -1,8 +1,10 @@ package org.usvm.ts.pbt.fastcheck import org.junit.jupiter.api.Test +import org.usvm.ts.pbt.backend.CoverageScope import org.usvm.ts.pbt.backend.PropertyCoverageRequest import org.usvm.ts.pbt.backend.PropertyRunConfiguration +import org.usvm.ts.pbt.backend.PropertyRunResult import org.usvm.ts.pbt.backend.PropertyRunStatus import org.usvm.ts.pbt.backend.SourceFileCoverage import org.usvm.ts.pbt.model.IntegerDomain @@ -13,8 +15,13 @@ import org.usvm.ts.pbt.model.TypeScriptEntryPoint import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.absolute +import kotlin.io.path.createDirectory +import kotlin.io.path.createSymbolicLinkPointingTo +import kotlin.io.path.createTempDirectory +import kotlin.io.path.writeText import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertTrue class FastCheckCoverageTest { private val backend = FastCheckBackend( @@ -51,6 +58,12 @@ class FastCheckCoverageTest { assertEquals(listOf(2), zeroHitBranchLines(nonPositiveFile)) assertEquals(1L, statementHitsAtLine(positiveFile, line = 2)) assertEquals(1L, statementHitsAtLine(nonPositiveFile, line = 2)) + assertTrue( + (positiveFile.branches + nonPositiveFile.branches).all { branch -> + branch.type == "branch" && branch.arms.size == 1 + }, + "The pinned c8/V8 collector must expose its backend-specific single-arm branch shape", + ) } @Test @@ -71,7 +84,86 @@ class FastCheckCoverageTest { assertEquals(listOf(5), zeroHitBranchLines(file)) } - private fun sourceUnderTest(result: org.usvm.ts.pbt.backend.PropertyRunResult): SourceFileCoverage { + @Test + fun `real c8 reports a missing referenced source map when the final report omits the script`() { + val module = "properties/coverage/missing-map-entry.js" + + val result = backend.run( + property = property( + module = module, + exportName = "missingMapPredicate", + domain = IntegerDomain(min = 1, max = 1), + ), + configuration = configuration, + ) + + val artifact = assertNotNull(result.coverage) + val diagnostic = artifact.diagnostics.single() + assertEquals("coverage.source-map.missing", diagnostic.code) + assertEquals(sourceRoot().resolve(module).toRealPath().toString(), diagnostic.path) + } + + @Test + fun `real c8 reports an invalid referenced source map when the final report omits the script`() { + val module = "properties/coverage/invalid-map-entry.js" + + val result = backend.run( + property = property( + module = module, + exportName = "invalidMapPredicate", + domain = IntegerDomain(min = 1, max = 1), + ), + configuration = configuration, + ) + + val artifact = assertNotNull(result.coverage) + val diagnostic = artifact.diagnostics.single() + assertEquals("coverage.source-map.invalid", diagnostic.code) + assertEquals(sourceRoot().resolve(module).toRealPath().toString(), diagnostic.path) + } + + @Test + fun `symlinked entry point is retained only in the entry-point scope`() { + val sourceRoot = createTempDirectory(prefix = "coverage-symlink-entry-") + try { + val realDirectory = sourceRoot.resolve("real").createDirectory() + val realEntryPoint = realDirectory.resolve("Property.ts") + realEntryPoint.writeText("export function predicate(value: number): boolean { return value > 0; }") + sourceRoot.resolve("Property.ts").createSymbolicLinkPointingTo(realEntryPoint) + val symlinkBackend = FastCheckBackend( + sourceRoots = listOf(sourceRoot), + adapterEntryPoint = adapterEntryPoint(), + ) + val symlinkProperty = property( + module = "Property.ts", + exportName = "predicate", + domain = IntegerDomain(min = 1, max = 1), + ) + + val sourceResult = symlinkBackend.run( + property = symlinkProperty, + configuration = configuration, + ) + val entryPointResult = symlinkBackend.run( + property = symlinkProperty, + configuration = configuration.copy( + coverageRequest = PropertyCoverageRequest( + scopes = setOf(CoverageScope.PROPERTY_ENTRY_POINTS), + ), + ), + ) + + assertTrue(assertNotNull(sourceResult.coverage).files.isEmpty()) + assertEquals( + listOf(realEntryPoint.toRealPath().toString()), + assertNotNull(entryPointResult.coverage).files.map { file -> file.path }, + ) + } finally { + sourceRoot.toFile().deleteRecursively() + } + } + + private fun sourceUnderTest(result: PropertyRunResult): SourceFileCoverage { val artifact = assertNotNull(result.coverage) return artifact.files.single { file -> file.path.endsWith("properties/coverage/source-under-test.ts") } } @@ -86,7 +178,11 @@ class FastCheckCoverageTest { .filter { line -> line > 1 } .sorted() - private fun property(exportName: String, domain: IntegerDomain) = PropertyDefinition( + private fun property( + exportName: String, + domain: IntegerDomain, + module: String = "properties/coverage/CoverageProperties.ts", + ) = PropertyDefinition( id = PropertyId("coverage.$exportName"), inputs = listOf( PropertyInput( @@ -95,7 +191,7 @@ class FastCheckCoverageTest { ), ), predicate = TypeScriptEntryPoint( - module = "properties/coverage/CoverageProperties.ts", + module = module, exportName = exportName, ), ) diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClientTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClientTest.kt index 008bd2d5f..2d7addcfb 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClientTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClientTest.kt @@ -2,6 +2,7 @@ package org.usvm.ts.pbt.fastcheck import org.junit.jupiter.api.Test import org.usvm.ts.pbt.backend.PropertyCoverageRequest +import org.usvm.ts.pbt.backend.PropertyRunStatus import org.usvm.ts.pbt.manifest.toManifest import org.usvm.ts.pbt.model.BooleanDomain import org.usvm.ts.pbt.model.PropertyDefinition @@ -11,6 +12,7 @@ import org.usvm.ts.pbt.model.TypeScriptEntryPoint import org.usvm.ts.pbt.testResourcesRoot import java.nio.file.Files import java.nio.file.Path +import java.util.concurrent.TimeUnit import kotlin.io.path.createDirectories import kotlin.io.path.createFile import kotlin.io.path.createTempDirectory @@ -20,9 +22,20 @@ 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 FastCheckProcessClientTest { + @Test + fun `process client rejects a shutdown grace period outside the Node timer range`() { + assertFailsWith { + FastCheckProcessClient( + adapterEntryPoint = Path.of("unused"), + shutdownGraceMillis = 2_147_483_648L, + ) + } + } + @Test fun `process startup failure is typed`() { val startup = assertFailsWith { @@ -300,6 +313,65 @@ class FastCheckProcessClientTest { } } + @Test + fun `successful adapter exit terminates a descendant retaining an inherited pipe`() { + val childPidFile = createTempFile(prefix = "fast-check-inherited-pipe-pid-", suffix = ".txt") + val naturalExitFile = createTempFile(prefix = "fast-check-inherited-pipe-exit-", suffix = ".txt") + childPidFile.deleteIfExists() + naturalExitFile.deleteIfExists() + + try { + withTemporaryAdapter( + source = """ + import { spawn } from 'node:child_process' + import { writeFileSync } from 'node:fs' + + const childSource = `setTimeout( + () => require('node:fs').writeFileSync( + ${naturalExitFile.toJavaScriptStringLiteral()}, + 'done' + ), + 30000 + )` + const child = spawn( + process.execPath, + ['-e', childSource], + { stdio: ['ignore', 'inherit', 'inherit'] } + ) + writeFileSync(${childPidFile.toJavaScriptStringLiteral()}, String(child.pid)) + child.unref() + + process.stdout.write(JSON.stringify({ + status: 'ok', + result: { + propertyId: 'example.property', + status: 'success', + seed: 42, + replayPath: null, + counterexample: null, + numRuns: 1, + numSkips: 0, + numShrinks: 0, + failure: null, + executionTimeMillis: 1 + } + })) + """.trimIndent(), + transportGraceMillis = 100, + ) { client -> + val result = client.check(validRequest.copy(timeoutMillis = 100)) + + assertEquals(PropertyRunStatus.SUCCESS, result.status) + assertFalse(Files.exists(naturalExitFile), "Inherited-pipe descendant reached its natural exit") + assertFalse(processIsAlive(childPidFile), "Inherited-pipe descendant is still running") + } + } finally { + terminateProcess(childPidFile) + childPidFile.deleteIfExists() + naturalExitFile.deleteIfExists() + } + } + private fun withTemporaryAdapter( source: String, transportGraceMillis: Long = 2_000, @@ -327,6 +399,23 @@ class FastCheckProcessClientTest { } } + private fun Path.toJavaScriptStringLiteral(): String = "'${toString().replace("\\", "\\\\").replace("'", "\\'")}'" + + private fun processIsAlive(pidFile: Path): Boolean { + val pid = pidFile.takeIf(Files::exists)?.readText()?.trim()?.toLongOrNull() ?: return false + val process = ProcessHandle.of(pid).orElse(null) ?: return false + + return process.isAlive + } + + private fun terminateProcess(pidFile: Path) { + val pid = pidFile.takeIf(Files::exists)?.readText()?.trim()?.toLongOrNull() ?: return + val process = ProcessHandle.of(pid).orElse(null) ?: return + + process.destroyForcibly() + process.onExit().get(1, TimeUnit.SECONDS) + } + private companion object { data class InvalidResponseCase( val script: String, diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClientTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClientTest.kt index 6b3469e0f..444dc50ce 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClientTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClientTest.kt @@ -1,20 +1,34 @@ package org.usvm.ts.pbt.fastcheck import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout import org.usvm.ts.pbt.model.ArrayDomain import org.usvm.ts.pbt.model.BooleanDomain +import org.usvm.ts.pbt.model.ConstantDomain import org.usvm.ts.pbt.model.IntegerDomain import org.usvm.ts.pbt.model.JsConcreteValue import org.usvm.ts.pbt.model.PropertyDomain +import java.nio.file.Files import java.nio.file.Path +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException import kotlin.io.path.createTempFile import kotlin.io.path.deleteIfExists +import kotlin.io.path.readText import kotlin.io.path.writeText import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertTrue class FastCheckProjectionClientTest { + @Test + fun `transport limits reject a shutdown grace period outside the Node timer range`() { + assertFailsWith { + transportLimits(shutdownGraceMillis = 2_147_483_648L) + } + } + private val client = FastCheckProjectionClient() @Test @@ -47,6 +61,20 @@ class FastCheckProjectionClientTest { assertEquals("protocol.request.invalid", error.code) } + @Test + fun `requests above the projection sample cap are rejected before starting Node`() { + val missingAdapterClient = FastCheckProjectionClient( + nodeExecutable = "definitely-not-a-node-executable", + adapterEntryPoint = Path.of("missing-adapter.mjs"), + ) + + val error = assertFailsWith { + missingAdapterClient.sample(validRequest.copy(numSamples = 10_001)) + } + + assertEquals("protocol.request.invalid", error.code) + } + @Test fun `process startup and exit failures are typed transport errors`() { val startup = assertFailsWith { @@ -85,12 +113,223 @@ class FastCheckProjectionClientTest { } } + @Test + fun `successful samples outside their domains are rejected`() { + withTemporaryAdapter( + """ + process.stdout.write(JSON.stringify({ + status: 'ok', + samples: [[{ kind: 'boolean', value: true }]] + })) + """.trimIndent(), + ) { temporaryClient -> + val error = assertFailsWith { + temporaryClient.sample( + validRequest.copy(domains = listOf(IntegerDomain(min = 0, max = 1))), + ) + } + + assertEquals("backend.response.invalid", error.code) + assertEquals("samples[0][0]", error.path) + } + } + + @Test + fun `requests beyond the transport byte limit are rejected before starting Node`() { + withTemporaryAdapter( + source = "", + transportLimits = transportLimits(maxRequestBytes = 100), + ) { temporaryClient -> + val error = assertFailsWith { + temporaryClient.sample( + validRequest.copy( + domains = listOf(ConstantDomain(JsConcreteValue.String("x".repeat(101)))), + ), + ) + } + + assertEquals("backend.request.too-large", error.code) + } + } + + @Test + fun `stdout beyond the transport byte limit is rejected`() { + withTemporaryAdapter( + source = "process.stdout.write('x'.repeat(1025))", + transportLimits = transportLimits(maxStdoutBytes = 1_024), + ) { temporaryClient -> + val error = assertFailsWith { + temporaryClient.sample(validRequest) + } + + assertEquals("backend.response.too-large", error.code) + } + } + + @Test + fun `stderr beyond the transport byte limit is rejected`() { + withTemporaryAdapter( + source = """ + process.stderr.write('x'.repeat(1025)) + process.stdout.write(JSON.stringify({ + status: 'ok', + samples: [[{ kind: 'boolean', value: true }]] + })) + """.trimIndent(), + transportLimits = transportLimits(maxStderrBytes = 1_024), + ) { temporaryClient -> + val error = assertFailsWith { + temporaryClient.sample(validRequest) + } + + assertEquals("backend.response.too-large", error.code) + } + } + + @Test + @Timeout(value = 2, unit = TimeUnit.SECONDS) + fun `continuing stdout beyond the transport byte limit fails promptly`() { + val pidFile = createTempFile(prefix = "fast-check-stdout-pid-", suffix = ".txt") + pidFile.deleteIfExists() + + try { + withTemporaryAdapter( + source = """ + import { writeFileSync } from 'node:fs' + writeFileSync(${pidFile.toJavaScriptStringLiteral()}, String(process.pid)) + process.stdout.on('error', () => undefined) + process.on('SIGTERM', () => undefined) + setInterval(() => process.stdout.write('x'.repeat(1025)), 1) + """.trimIndent(), + transportLimits = transportLimits( + maxStdoutBytes = 1_024, + wallClockTimeoutMillis = 250, + shutdownGraceMillis = 500, + ), + ) { temporaryClient -> + val startedAt = System.nanoTime() + val error = assertFailsWith { + temporaryClient.sample(validRequest) + } + val elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000 + + assertEquals("backend.response.too-large", error.code) + assertTrue(elapsedMillis < 2_000, "Stdout limit took $elapsedMillis ms") + assertTrue(adapterIsTerminated(pidFile), "Stdout adapter is still running") + } + } finally { + terminateAdapter(pidFile) + pidFile.deleteIfExists() + } + } + + @Test + @Timeout(value = 2, unit = TimeUnit.SECONDS) + fun `continuing stderr beyond the transport byte limit fails promptly`() { + val pidFile = createTempFile(prefix = "fast-check-stderr-pid-", suffix = ".txt") + pidFile.deleteIfExists() + + try { + withTemporaryAdapter( + source = """ + import { writeFileSync } from 'node:fs' + writeFileSync(${pidFile.toJavaScriptStringLiteral()}, String(process.pid)) + setInterval(() => process.stderr.write('x'.repeat(1025)), 1) + """.trimIndent(), + transportLimits = transportLimits(maxStderrBytes = 1_024), + ) { temporaryClient -> + val startedAt = System.nanoTime() + val error = assertFailsWith { + temporaryClient.sample(validRequest) + } + val elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000 + + assertEquals("backend.response.too-large", error.code) + assertTrue(elapsedMillis < 2_000, "Stderr limit took $elapsedMillis ms") + assertTrue(adapterIsTerminated(pidFile), "Stderr adapter is still running") + } + } finally { + terminateAdapter(pidFile) + pidFile.deleteIfExists() + } + } + + @Test + @Timeout(value = 2, unit = TimeUnit.SECONDS) + fun `immediate parent exit still terminates a descendant retaining a pipe`() { + val childPidFile = createTempFile(prefix = "fast-check-descendant-pid-", suffix = ".txt") + childPidFile.deleteIfExists() + + try { + withTemporaryAdapter( + source = """ + import { spawn } from 'node:child_process' + import { writeFileSync } from 'node:fs' + const child = spawn(process.execPath, [ + '-e', + "process.on('SIGTERM', () => undefined); setInterval(() => undefined, 1000)" + ], { stdio: 'inherit' }) + writeFileSync(${childPidFile.toJavaScriptStringLiteral()}, String(child.pid)) + process.exit(0) + """.trimIndent(), + transportLimits = transportLimits( + wallClockTimeoutMillis = 250, + shutdownGraceMillis = 500, + ), + ) { temporaryClient -> + val startedAt = System.nanoTime() + val error = assertFailsWith { + temporaryClient.sample(validRequest) + } + val elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000 + + assertEquals("backend.response.empty", error.code) + assertTrue(elapsedMillis < 600, "Descendant cleanup took $elapsedMillis ms") + assertTrue(adapterIsTerminated(childPidFile), "Descendant is still running") + } + } finally { + assertTrue(terminateAdapter(childPidFile), "Test cleanup did not terminate descendant") + childPidFile.deleteIfExists() + } + } + + @Test + @Timeout(value = 2, unit = TimeUnit.SECONDS) + fun `wall clock timeout returns promptly and terminates the adapter`() { + val pidFile = createTempFile(prefix = "fast-check-adapter-pid-", suffix = ".txt") + pidFile.deleteIfExists() + + try { + withTemporaryAdapter( + source = """ + import { writeFileSync } from 'node:fs' + writeFileSync(${pidFile.toJavaScriptStringLiteral()}, String(process.pid)) + setInterval(() => undefined, 1_000) + """.trimIndent(), + transportLimits = transportLimits(wallClockTimeoutMillis = 250), + ) { temporaryClient -> + val startedAt = System.nanoTime() + val error = assertFailsWith { + temporaryClient.sample(validRequest) + } + val elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000 + + assertEquals("backend.process.timeout", error.code) + assertTrue(elapsedMillis < 600, "Projection timeout took $elapsedMillis ms") + assertTrue(adapterIsTerminated(pidFile), "Adapter is still running") + } + } finally { + terminateAdapter(pidFile) + pidFile.deleteIfExists() + } + } + @Test fun `large adapter stderr does not block a successful response`() { withTemporaryAdapter( """ const timeout = setTimeout(() => process.exit(2), 1000) - process.stderr.write('x'.repeat(1024 * 1024), () => { + process.stderr.write('x'.repeat(32 * 1024), () => { clearTimeout(timeout) process.stdout.write(JSON.stringify({ status: 'ok', @@ -138,17 +377,87 @@ class FastCheckProjectionClientTest { } } - private fun withTemporaryAdapter(source: String, block: (FastCheckProjectionClient) -> Unit) { + private fun withTemporaryAdapter( + source: String, + transportLimits: FastCheckProjectionTransportLimits? = null, + block: (FastCheckProjectionClient) -> Unit, + ) { val script = createTempFile(prefix = "fast-check-adapter-", suffix = ".mjs") try { script.writeText(source) - block(FastCheckProjectionClient(adapterEntryPoint = script)) + val client = transportLimits?.let { limits -> + FastCheckProjectionClient( + adapterEntryPoint = script, + transportLimits = limits, + ) + } ?: FastCheckProjectionClient(adapterEntryPoint = script) + + block(client) } finally { script.deleteIfExists() } } + private fun transportLimits( + maxRequestBytes: Int = 1_024, + maxStdoutBytes: Int = 1_024, + maxStderrBytes: Int = 1_024, + wallClockTimeoutMillis: Long = 1_000, + shutdownGraceMillis: Long = 25, + ) = FastCheckProjectionTransportLimits( + maxRequestBytes = maxRequestBytes, + maxStdoutBytes = maxStdoutBytes, + maxStderrBytes = maxStderrBytes, + wallClockTimeoutMillis = wallClockTimeoutMillis, + shutdownGraceMillis = shutdownGraceMillis, + ) + + private fun Path.toJavaScriptStringLiteral(): String = "'${toString().replace("\\", "\\\\").replace("'", "\\'")}'" + + private fun terminateAdapter(pidFile: Path): Boolean { + val pid = readAdapterPid(pidFile) ?: return true + val process = ProcessHandle.of(pid).orElse(null) ?: return true + + process.destroyForcibly() + + try { + process.onExit().get(1, TimeUnit.SECONDS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + + return true + } + + return !process.isAlive + } + + private fun adapterIsTerminated(pidFile: Path): Boolean { + val pid = readAdapterPid(pidFile) ?: return true + val process = ProcessHandle.of(pid).orElse(null) + if (process == null || !process.isAlive) return true + + try { + process.onExit().get(1, TimeUnit.SECONDS) + } catch (_: TimeoutException) { + return false + } catch (_: ExecutionException) { + return !process.isAlive + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + + return !process.isAlive + } + + return !process.isAlive + } + + private fun readAdapterPid(pidFile: Path): Long? { + if (!Files.exists(pidFile)) return null + + return pidFile.readText().trim().toLongOrNull() + } + private companion object { val validRequest = FastCheckProjectionRequest( seed = 42, diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/EtsMappingModelTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/EtsMappingModelTest.kt new file mode 100644 index 000000000..cb56aa8cc --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/EtsMappingModelTest.kt @@ -0,0 +1,69 @@ +package org.usvm.ts.pbt.mapping + +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +class EtsMappingModelTest { + @Test + fun `mapping diagnostics require a non-blank code`() { + assertFailsWith { + EtsMappingDiagnostic(code = " ", message = "Mapping failed") + } + } + + @Test + fun `mapping diagnostics require a non-blank message`() { + assertFailsWith { + EtsMappingDiagnostic(code = "mapping.test", message = " ") + } + } + + @Test + fun `exact mapping requires a target and rejects diagnostics`() { + val diagnostic = EtsMappingDiagnostic(code = "mapping.test", message = "Mapping failed") + + assertFailsWith { + EtsMappingResult( + status = EtsMappingStatus.EXACT, + targets = emptyList(), + ) + } + assertFailsWith { + EtsMappingResult( + status = EtsMappingStatus.EXACT, + targets = listOf(1), + diagnostics = listOf(diagnostic), + ) + } + } + + @Test + fun `ambiguous mapping requires targets and a diagnostic`() { + assertFailsWith { + EtsMappingResult( + status = EtsMappingStatus.AMBIGUOUS, + targets = listOf(1), + ) + } + } + + @Test + fun `unmapped and unsupported mappings reject targets or missing diagnostics`() { + val diagnostic = EtsMappingDiagnostic(code = "mapping.test", message = "Mapping failed") + + assertFailsWith { + EtsMappingResult( + status = EtsMappingStatus.UNMAPPED, + targets = listOf(1), + diagnostics = listOf(diagnostic), + ) + } + + assertFailsWith { + EtsMappingResult( + status = EtsMappingStatus.UNSUPPORTED, + targets = emptyList(), + ) + } + } +} diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsExportResolutionTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsExportResolutionTest.kt new file mode 100644 index 000000000..401ce47be --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsExportResolutionTest.kt @@ -0,0 +1,319 @@ +package org.usvm.ts.pbt.mapping + +import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsFile +import org.jacodb.ets.model.EtsFunctionType +import org.jacodb.ets.model.EtsLocal +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.model.EtsStaticFieldRef +import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME +import org.jacodb.ets.utils.DEFAULT_ARK_METHOD_NAME +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.junit.jupiter.api.Test +import org.usvm.ts.pbt.manifest.PropertyManifest +import org.usvm.ts.pbt.model.IntegerDomain +import org.usvm.ts.pbt.model.PropertyInput +import org.usvm.ts.pbt.model.TypeScriptEntryPoint +import org.usvm.ts.pbt.testResourcePath +import java.nio.file.Path +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PropertyEtsExportResolutionTest { + @Test + fun `named default declaration resolves only through the default export name`() { + val source = testResourcePath("/mapping/exports/NamedDefaultDeclaration.ts") + val mapper = mapper(source) + + val defaultArtifact = mapper.map(manifest(module = source.fileName.toString(), exportName = "default")) + val sourceNameArtifact = mapper.map(manifest(module = source.fileName.toString(), exportName = "namedDefault")) + + assertEquals(EtsMappingStatus.EXACT, defaultArtifact.predicate.status) + val defaultMethod = defaultArtifact.predicate.targets.single().method + assertEquals("namedDefault", defaultMethod.name) + assertEquals(EtsMappingStatus.UNMAPPED, sourceNameArtifact.predicate.status) + assertEquals(emptyList(), sourceNameArtifact.predicate.targets) + } + + @Test + fun `direct function export ignores same-named class methods`() { + val source = testResourcePath("/mapping/exports/DirectExportFixture.ts") + val mapper = mapper(source) + + val artifact = mapper.map(manifest(module = source.fileName.toString(), exportName = "predicate")) + + assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) + val method = artifact.predicate.targets.single().method + assertEquals("predicate", method.name) + assertEquals("%dflt", method.signature.enclosingClass.name) + } + + @Test + fun `namespace star export is not a transparent named re-export`() { + val entrySource = testResourcePath("/mapping/exports/NamespaceEntry.ts") + val predicateSource = testResourcePath("/mapping/exports/Predicate.ts") + val mapper = mapper(entrySource, predicateSource) + + val artifact = mapper.map(manifest(module = entrySource.fileName.toString(), exportName = "corePredicate")) + + assertEquals(EtsMappingStatus.UNMAPPED, artifact.predicate.status) + assertEquals(emptyList(), artifact.predicate.targets) + } + + @Test + fun `explicit named re-export takes precedence over bare star export`() { + val sourceDirectory = testResourcePath("/mapping/exports") + val sources = listOf("ExplicitPrecedenceEntry.ts", "Predicate.ts", "StarPredicate.ts") + .map(sourceDirectory::resolve) + val mapper = mapper(*sources.toTypedArray()) + + val artifact = mapper.map(manifest(module = "ExplicitPrecedenceEntry.ts", exportName = "predicate")) + val target = artifact.predicate.targets.single() + + assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) + assertEquals("corePredicate", target.method.name) + } + + @Test + fun `type-only named export does not mask a bare star value export`() { + val sourceDirectory = testResourcePath("/mapping/exports") + val sources = listOf("TypeOnlyPrecedenceEntry.ts", "TypeOnlyPredicate.ts", "StarPredicate.ts") + .map(sourceDirectory::resolve) + val mapper = mapper(*sources.toTypedArray()) + + val artifact = mapper.map(manifest(module = "TypeOnlyPrecedenceEntry.ts", exportName = "predicate")) + + assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) + val target = artifact.predicate.targets.single() + val enclosingClass = target.method.signature.enclosingClass + val targetFileName = enclosingClass.file.fileName + assertEquals("predicate", target.method.name) + assertTrue(targetFileName.endsWith("StarPredicate.ts")) + } + + @Test + fun `type-only star export does not add a runtime candidate`() { + val sourceDirectory = testResourcePath("/mapping/exports") + val sources = listOf("TypeOnlyStarEntry.ts", "TypeOnlyPredicate.ts", "StarPredicate.ts") + .map(sourceDirectory::resolve) + val mapper = mapper(*sources.toTypedArray()) + + val artifact = mapper.map(manifest(module = "TypeOnlyStarEntry.ts", exportName = "predicate")) + + assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) + val target = artifact.predicate.targets.single() + val enclosingClass = target.method.signature.enclosingClass + val targetFileName = enclosingClass.file.fileName + assertTrue(targetFileName.endsWith("StarPredicate.ts")) + } + + @Test + fun `exported arrow local resolves through its lifted method`() { + val source = testResourcePath("/mapping/exports/CallableLocalFixture.ts") + val mapper = mapper(source) + + val artifact = mapper.map(manifest(module = source.fileName.toString(), exportName = "arrowPredicate")) + + assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) + val method = artifact.predicate.targets.single().method + assertTrue(method.name.startsWith("%AM")) + assertEquals(listOf("value"), method.parameters.map { parameter -> parameter.name }) + } + + @Test + fun `local function expression alias routes only through the export name`() { + val source = testResourcePath("/mapping/exports/CallableLocalFixture.ts") + val mapper = mapper(source) + + val aliasArtifact = mapper.map( + manifest(module = source.fileName.toString(), exportName = "aliasedPredicate"), + ) + val localNameArtifact = mapper.map( + manifest(module = source.fileName.toString(), exportName = "functionPredicate"), + ) + + assertEquals(EtsMappingStatus.EXACT, aliasArtifact.predicate.status) + val aliasMethod = aliasArtifact.predicate.targets.single().method + assertTrue(aliasMethod.name.startsWith("%AM")) + assertEquals(EtsMappingStatus.UNMAPPED, localNameArtifact.predicate.status) + assertEquals(emptyList(), localNameArtifact.predicate.targets) + } + + @Test + fun `non-callable exported local remains unmapped`() { + val source = testResourcePath("/mapping/exports/CallableLocalFixture.ts") + val mapper = mapper(source) + + val artifact = mapper.map(manifest(module = source.fileName.toString(), exportName = "nonCallable")) + + assertEquals(EtsMappingStatus.UNMAPPED, artifact.predicate.status) + assertEquals(emptyList(), artifact.predicate.targets) + } + + @Test + fun `multiple lifted assignments for one export remain ambiguous`() { + val source = testResourcePath("/mapping/exports/CallableLocalFixture.ts") + val mapper = mapper(source) + + val artifact = mapper.map(manifest(module = source.fileName.toString(), exportName = "reassignedPredicate")) + + assertEquals(EtsMappingStatus.AMBIGUOUS, artifact.predicate.status) + assertEquals(2, artifact.predicate.targets.size) + assertTrue(artifact.predicate.targets.all { target -> target.method.name.startsWith("%AM") }) + assertEquals("mapping.entry-point.ambiguous", artifact.predicate.diagnostics.single().code) + } + + @Test + fun `callable local followed by a non-callable assignment remains ambiguous`() { + val source = testResourcePath("/mapping/exports/CallableLocalFixture.ts") + val mapper = mapper(source) + + val artifact = mapper.map(manifest(module = source.fileName.toString(), exportName = "callableThenValue")) + val target = artifact.predicate.targets.single() + + assertEquals(EtsMappingStatus.AMBIGUOUS, artifact.predicate.status) + assertEquals(1, artifact.predicate.targets.size) + assertTrue(target.method.name.startsWith("%AM")) + assertEquals("mapping.entry-point.ambiguous", artifact.predicate.diagnostics.single().code) + } + + @Test + fun `aliased callable with repeated links to one lifted method remains ambiguous`() { + val source = testResourcePath("/mapping/exports/CallableLocalFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val defaultClass = file.classes.single { etsClass -> etsClass.name == DEFAULT_ARK_CLASS_NAME } + val defaultMethod = defaultClass.methods.single { method -> method.name == DEFAULT_ARK_METHOD_NAME } + val linkedSignatures = defaultMethod.cfg.stmts + .filterIsInstance() + .mapNotNull { assignment -> + val field = assignment.lhv as? EtsStaticFieldRef ?: return@mapNotNull null + if (field.field.name != "multiplyLinkedPredicate") return@mapNotNull null + + val local = assignment.rhv as? EtsLocal ?: return@mapNotNull null + val functionType = local.type as? EtsFunctionType ?: return@mapNotNull null + + functionType.signature + } + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val aliasArtifact = mapper.map( + manifest(module = source.fileName.toString(), exportName = "aliasedMultiplyLinkedPredicate"), + ) + val localNameArtifact = mapper.map( + manifest(module = source.fileName.toString(), exportName = "multiplyLinkedPredicate"), + ) + + assertEquals(2, linkedSignatures.size) + assertEquals(1, linkedSignatures.distinct().size) + assertEquals(EtsMappingStatus.AMBIGUOUS, aliasArtifact.predicate.status) + val target = aliasArtifact.predicate.targets.single() + assertEquals(linkedSignatures.distinct().single(), target.method.signature) + assertTrue(target.method.name.startsWith("%AM")) + val diagnostic = aliasArtifact.predicate.diagnostics.single() + assertEquals("mapping.entry-point.ambiguous", diagnostic.code) + assertEquals( + "Several EtsIR methods, source candidates, or export links match " + + "CallableLocalFixture.ts#aliasedMultiplyLinkedPredicate", + diagnostic.message, + ) + assertEquals(EtsMappingStatus.UNMAPPED, localNameArtifact.predicate.status) + assertEquals(emptyList(), localNameArtifact.predicate.targets) + } + + @Test + fun `bare star export does not forward the default export`() { + val entrySource = testResourcePath("/mapping/exports/StarDefaultEntry.ts") + val predicateSource = testResourcePath("/mapping/exports/DefaultPredicate.ts") + val mapper = mapper(entrySource, predicateSource) + + val artifact = mapper.map(manifest(module = entrySource.fileName.toString(), exportName = "default")) + + assertEquals(EtsMappingStatus.UNMAPPED, artifact.predicate.status) + assertEquals(emptyList(), artifact.predicate.targets) + } + + @Test + fun `duplicate re-export paths resolve one EtsIR method exactly`() { + val sourceDirectory = testResourcePath("/mapping/exports") + val sources = listOf("DiamondEntry.ts", "Left.ts", "Right.ts", "Predicate.ts") + .map(sourceDirectory::resolve) + val mapper = mapper(*sources.toTypedArray()) + + val artifact = mapper.map(manifest(module = "DiamondEntry.ts", exportName = "predicate")) + + assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) + val targetMethod = artifact.predicate.targets.single().method + assertEquals("corePredicate", targetMethod.name) + } + + @Test + fun `re-export with multiple module files stays ambiguous when only one exports the target`() { + val sourceDirectory = testResourcePath("/mapping/exports/ambiguous-reexport") + val sources = listOf("Entry.ts", "Foo.ts", "Foo/index.ts").map(sourceDirectory::resolve) + val mapper = mapper(*sources.toTypedArray()) + + val artifact = mapper.map(manifest(module = "Entry.ts", exportName = "predicate")) + val target = artifact.predicate.targets.single() + + assertEquals(EtsMappingStatus.AMBIGUOUS, artifact.predicate.status) + assertEquals(1, artifact.predicate.targets.size) + assertEquals("predicate", target.method.name) + assertEquals("mapping.entry-point.ambiguous", artifact.predicate.diagnostics.single().code) + } + + @Test + fun `cyclic star re-exports terminate when the export is absent`() { + val sourceDirectory = testResourcePath("/mapping/exports") + val sources = listOf("CycleEntry.ts", "CyclePeer.ts").map(sourceDirectory::resolve) + val mapper = mapper(*sources.toTypedArray()) + + val artifact = mapper.map(manifest(module = "CycleEntry.ts", exportName = "predicate")) + + assertEquals(EtsMappingStatus.UNMAPPED, artifact.predicate.status) + assertEquals(emptyList(), artifact.predicate.targets) + assertEquals("mapping.entry-point.unmapped", artifact.predicate.diagnostics.single().code) + } + + @Test + fun `re-export path may revisit a file to resolve another export`() { + val sourceDirectory = testResourcePath("/mapping/exports") + val sources = listOf("RenamedCycleEntry.ts", "RenamedCyclePeer.ts").map(sourceDirectory::resolve) + val mapper = mapper(*sources.toTypedArray()) + + val artifact = mapper.map(manifest(module = "RenamedCycleEntry.ts", exportName = "predicate")) + val predicateTarget = artifact.predicate.targets.single() + + assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) + assertEquals("actual", predicateTarget.method.name) + } + + private fun mapper(vararg sources: Path): PropertyEtsMapper { + val sourceRoot = sources.first().parent + val files = sources.map { source -> + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + + EtsFile( + signature = file.signature.copy(fileName = sourceRoot.relativize(source).toString()), + classes = file.classes, + namespaces = file.namespaces, + importInfos = file.importInfos, + exportInfos = file.exportInfos, + ) + } + + return PropertyEtsMapper( + scene = EtsScene(files), + sourceRoots = listOf(sourceRoot), + ) + } + + private fun manifest(module: String, exportName: String): PropertyManifest = PropertyManifest( + propertyId = "mapping.export-resolution", + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint(module = module, exportName = exportName), + ) +} diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsMapperTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsMapperTest.kt new file mode 100644 index 000000000..151b55482 --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsMapperTest.kt @@ -0,0 +1,1281 @@ +package org.usvm.ts.pbt.mapping + +import org.jacodb.ets.model.EtsBlockCfg +import org.jacodb.ets.model.EtsClassType +import org.jacodb.ets.model.EtsIfStmt +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.ts.pbt.backend.BranchArmCoverage +import org.usvm.ts.pbt.backend.BranchCoverage +import org.usvm.ts.pbt.backend.CoverageCollectorIdentity +import org.usvm.ts.pbt.backend.CoverageDiagnostic +import org.usvm.ts.pbt.backend.CoverageProvenance +import org.usvm.ts.pbt.backend.PropertyCoverageArtifact +import org.usvm.ts.pbt.backend.PropertyCoverageRequest +import org.usvm.ts.pbt.backend.SourceFileCoverage +import org.usvm.ts.pbt.backend.SourcePosition +import org.usvm.ts.pbt.backend.SourceRange +import org.usvm.ts.pbt.backend.StatementCoverage +import org.usvm.ts.pbt.manifest.PropertyManifest +import org.usvm.ts.pbt.model.IntegerDomain +import org.usvm.ts.pbt.model.PropertyId +import org.usvm.ts.pbt.model.PropertyInput +import org.usvm.ts.pbt.model.TypeScriptEntryPoint +import org.usvm.ts.pbt.testResourcePath +import java.nio.file.Path +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PropertyEtsMapperTest { + @Test + fun `maps an exported predicate and its symbolic bindings`() { + val source = testResourcePath("/mapping/PropertyMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val manifest = PropertyManifest( + propertyId = "mapping.positive", + inputs = listOf( + PropertyInput( + name = "value", + domain = IntegerDomain(min = -10, max = 10), + ), + ), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "isPositive", + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest) + + assertEquals(PropertyId("mapping.positive"), artifact.propertyId) + assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) + val target = artifact.predicate.targets.single() + val inputBinding = target.bindings.inputs.single() + assertEquals("isPositive", target.method.name) + assertEquals(0, target.bindings.receiver.stackSlot) + assertEquals("value", inputBinding.propertyInputName) + assertEquals(0, inputBinding.parameter.index) + assertEquals(1, inputBinding.stackSlot) + assertEquals(target.method.returnType, target.bindings.result.type) + } + + @Test + fun `maps property inputs to parameters and stack slots in declaration order`() { + val source = testResourcePath("/mapping/PropertyMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val manifest = PropertyManifest( + propertyId = "mapping.ordered-bindings", + inputs = listOf( + PropertyInput(name = "first", domain = IntegerDomain()), + PropertyInput(name = "second", domain = IntegerDomain()), + ), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "needsTwoInputs", + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest) + + val target = artifact.predicate.targets.single() + val bindings = target.bindings + val receiverType = bindings.receiver.type as EtsClassType + assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) + assertEquals(target.method.signature.enclosingClass, receiverType.signature) + assertEquals(listOf("first", "second"), bindings.inputs.map { binding -> binding.propertyInputName }) + assertEquals(listOf("left", "right"), bindings.inputs.map { binding -> binding.parameter.name }) + assertEquals(listOf(0, 1), bindings.inputs.map { binding -> binding.parameter.index }) + assertEquals(listOf(1, 2), bindings.inputs.map { binding -> binding.stackSlot }) + } + + @Test + fun `maps an optional precondition independently from the predicate`() { + val predicateSource = testResourcePath("/mapping/PropertyMappingFixture.ts") + val preconditionSource = testResourcePath("/mapping/PropertyPreconditionFixture.ts") + val files = listOf(predicateSource, preconditionSource).map { source -> + loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + } + val manifest = PropertyManifest( + propertyId = "mapping.precondition", + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "isPositive", + ), + precondition = TypeScriptEntryPoint( + module = "PropertyPreconditionFixture.ts", + exportName = "isNonZero", + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(files), + sourceRoots = listOf(predicateSource.parent), + ) + + val artifact = mapper.map(manifest) + + val precondition = assertNotNull(artifact.precondition) + assertEquals(EtsMappingStatus.EXACT, precondition.status) + assertEquals("isNonZero", precondition.targets.single().method.name) + val predicateTarget = artifact.predicate.targets.single() + assertEquals("isPositive", predicateTarget.method.name) + } + + @Test + fun `reports an unmapped predicate instead of guessing or throwing`() { + val source = testResourcePath("/mapping/PropertyMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val manifest = PropertyManifest( + propertyId = "mapping.missing", + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "missingPredicate", + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest) + + assertEquals(EtsMappingStatus.UNMAPPED, artifact.predicate.status) + assertEquals(emptyList(), artifact.predicate.targets) + assertEquals("mapping.entry-point.unmapped", artifact.predicate.diagnostics.single().code) + } + + @Test + fun `reports ambiguous predicate candidates across source roots`() { + val primarySource = testResourcePath("/mapping/PropertyMappingFixture.ts") + val duplicateSource = testResourcePath("/mapping/duplicate/PropertyMappingFixture.ts") + val files = listOf(primarySource, duplicateSource).map { source -> + loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + } + val manifest = PropertyManifest( + propertyId = "mapping.ambiguous", + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "isPositive", + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(files), + sourceRoots = listOf(primarySource.parent, duplicateSource.parent), + ) + + val artifact = mapper.map(manifest) + + assertEquals(EtsMappingStatus.AMBIGUOUS, artifact.predicate.status) + assertEquals(2, artifact.predicate.targets.size) + assertEquals("mapping.entry-point.ambiguous", artifact.predicate.diagnostics.single().code) + } + + @Test + fun `duplicate frontend signatures stay ambiguous when one source has no matching targets`() { + val sourceRoot = testResourcePath("/mapping/source-roots") + val primarySource = sourceRoot.resolve("a/Foo.ts") + val duplicateSource = sourceRoot.resolve("b/Foo.ts") + val primaryFile = loadEtsFileAutoConvert(primarySource, provider = EtsIrProvider.TS_FRONTEND) + val duplicateFile = loadEtsFileAutoConvert(duplicateSource, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.duplicate-source-provenance") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "Foo.ts", + exportName = "predicate", + ), + ) + val branchLocation = SourceRange( + start = SourcePosition(line = 2, column = 2), + end = SourcePosition(line = 6, column = 3), + ) + val coverage = coverageArtifact( + source = primarySource, + propertyId = propertyId, + statements = listOf( + StatementCoverage( + statementId = 0, + location = SourceRange( + start = SourcePosition(line = 3, column = 4), + end = SourcePosition(line = 3, column = 16), + ), + hits = 1, + ), + ), + branches = listOf( + BranchCoverage( + branchId = 0, + type = "if", + location = branchLocation, + arms = listOf( + BranchArmCoverage(location = branchLocation, hits = 1), + BranchArmCoverage(location = branchLocation, hits = 0), + ), + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(primaryFile, duplicateFile)), + sourceRoots = listOf(primarySource.parent, duplicateSource.parent), + ) + + val artifact = mapper.map(manifest, coverage) + val primaryMethods = primaryFile.classes.flatMap { etsClass -> etsClass.methods } + val duplicateMethods = duplicateFile.classes.flatMap { etsClass -> etsClass.methods } + + assertEquals(EtsMappingStatus.AMBIGUOUS, artifact.predicate.status) + assertEquals(1, artifact.predicate.targets.size) + val mapping = artifact.coverage.statements.single().mapping + assertEquals(EtsMappingStatus.AMBIGUOUS, mapping.status) + assertTrue(mapping.targets.isNotEmpty()) + assertTrue( + mapping.targets.all { target -> + primaryMethods.any { method -> target.statement.location.method === method } + }, + ) + assertTrue( + mapping.targets.none { target -> + duplicateMethods.any { method -> target.statement.location.method === method } + }, + ) + assertEquals("mapping.statement.ambiguous", mapping.diagnostics.single().code) + val branch = artifact.coverage.branches.single() + assertEquals(EtsMappingStatus.AMBIGUOUS, branch.mapping.status) + assertEquals("mapping.branch.ambiguous", branch.mapping.diagnostics.single().code) + assertEquals( + listOf(EtsMappingStatus.AMBIGUOUS, EtsMappingStatus.AMBIGUOUS), + branch.arms.map { arm -> arm.mapping.status }, + ) + branch.arms.forEach { arm -> + val targetMethods = arm.mapping.targets.map { target -> target.condition.location.method } + + assertTrue(targetMethods.isNotEmpty()) + assertTrue(targetMethods.all { target -> primaryMethods.any { method -> target === method } }) + assertTrue(targetMethods.none { target -> duplicateMethods.any { method -> target === method } }) + } + } + + @Test + fun `reports unsupported bindings when an ambiguous candidate has another arity`() { + val primarySource = testResourcePath("/mapping/PropertyMappingFixture.ts") + val mismatchedSource = testResourcePath("/mapping/mismatched/PropertyMappingFixture.ts") + val files = listOf(primarySource, mismatchedSource).map { source -> + loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + } + val manifest = PropertyManifest( + propertyId = "mapping.ambiguous-arity", + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "isPositive", + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(files), + sourceRoots = listOf(primarySource.parent, mismatchedSource.parent), + ) + + val artifact = mapper.map(manifest) + + assertEquals(EtsMappingStatus.UNSUPPORTED, artifact.predicate.status) + assertEquals(emptyList(), artifact.predicate.targets) + assertEquals("mapping.entry-point.bindings.unsupported", artifact.predicate.diagnostics.single().code) + } + + @Test + fun `reports unsupported bindings when property inputs do not match parameters`() { + val source = testResourcePath("/mapping/PropertyMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val manifest = PropertyManifest( + propertyId = "mapping.unsupported-bindings", + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "needsTwoInputs", + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest) + + assertEquals(EtsMappingStatus.UNSUPPORTED, artifact.predicate.status) + assertEquals(emptyList(), artifact.predicate.targets) + assertEquals("mapping.entry-point.bindings.unsupported", artifact.predicate.diagnostics.single().code) + } + + @Test + fun `produces an unsupported coverage mapping when the backend returned no coverage`() { + val source = testResourcePath("/mapping/PropertyMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val manifest = PropertyManifest( + propertyId = "mapping.no-coverage", + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "isPositive", + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest) + + assertEquals(EtsMappingStatus.UNSUPPORTED, artifact.coverage.status) + assertNull(artifact.coverage.backendProvenance) + assertEquals("mapping.coverage.unavailable", artifact.coverage.diagnostics.single().code) + } + + @Test + fun `does not map coverage produced for another property`() { + val source = testResourcePath("/mapping/PropertyMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val manifest = PropertyManifest( + propertyId = "mapping.expected-property", + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "isPositive", + ), + ) + val coverage = coverageArtifact( + source = source, + propertyId = PropertyId("mapping.other-property"), + statements = emptyList(), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + assertEquals(PropertyId("mapping.expected-property"), artifact.propertyId) + assertEquals(EtsMappingStatus.UNSUPPORTED, artifact.coverage.status) + assertEquals(coverage.provenance, artifact.coverage.backendProvenance) + assertEquals(emptyList(), artifact.coverage.statements) + assertEquals(emptyList(), artifact.coverage.branches) + assertEquals("mapping.coverage.property-id.mismatch", artifact.coverage.diagnostics.single().code) + } + + @Test + fun `empty coverage is exact and preserves backend diagnostics`() { + val source = testResourcePath("/mapping/PropertyMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.empty-coverage") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "isPositive", + ), + ) + val diagnostic = CoverageDiagnostic( + code = "coverage.fixture.omitted", + message = "A file outside the requested scopes was omitted", + path = "Dependency.ts", + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = emptyList(), + ).copy( + files = emptyList(), + diagnostics = listOf(diagnostic), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + assertEquals(EtsMappingStatus.EXACT, artifact.coverage.status) + assertEquals(emptyList(), artifact.coverage.statements) + assertEquals(emptyList(), artifact.coverage.branches) + val mappedDiagnostic = artifact.coverage.diagnostics.single() + assertEquals(diagnostic.code, mappedDiagnostic.code) + assertEquals(diagnostic.message, mappedDiagnostic.message) + assertEquals(diagnostic.path, mappedDiagnostic.sourcePath) + } +} + +class PropertyEtsStatementMappingTest { + @Test + fun `normalizes a TypeScript statement location and maps it to its EtsIR origin`() { + val source = testResourcePath("/mapping/PropertyMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.statement") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "isPositive", + ), + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = listOf( + StatementCoverage( + statementId = 0, + location = SourceRange( + start = SourcePosition(line = 3, column = 2), + end = SourcePosition(line = 3, column = 19), + ), + hits = 1, + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + assertEquals(coverage.provenance, artifact.coverage.backendProvenance) + assertEquals( + listOf(source.parent.toAbsolutePath().normalize().toString()), + artifact.provenance.sourceRoots, + ) + assertEquals(EtsSourceCoordinateSystem.TYPESCRIPT_UTF16_ZERO_BASED_HALF_OPEN, artifact.provenance.coordinates) + assertEquals(EtsBranchSuccessorOrder.TRUE_FALSE, artifact.provenance.branchSuccessorOrder) + val statement = artifact.coverage.statements.single() + val location = assertNotNull(statement.location) + assertEquals(source.toAbsolutePath().normalize().toString(), location.path) + assertEquals(NormalizedSourcePosition(line = 2, column = 2, offset = 82), location.start) + assertEquals(NormalizedSourcePosition(line = 2, column = 19, offset = 99), location.end) + assertEquals(EtsMappingStatus.EXACT, statement.mapping.status) + assertTrue(statement.mapping.targets.size > 1, "Normalized EtsIR statements must retain their shared span") + statement.mapping.targets.forEach { target -> + val origin = assertNotNull(target.statement.location.origin) + assertEquals("ReturnStatement", origin.nodeKind) + assertEquals(82, origin.startOffset) + assertEquals(99, origin.endOffset) + } + } + + @Test + fun `reports a source range containing distinct EtsIR spans as ambiguous`() { + val source = testResourcePath("/mapping/BranchMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.ambiguous-statement") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "BranchMappingFixture.ts", + exportName = "classifiesPositive", + ), + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = listOf( + StatementCoverage( + statementId = 0, + location = SourceRange( + start = SourcePosition(line = 2, column = 1), + end = SourcePosition(line = 7, column = 0), + ), + hits = 1, + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val statement = artifact.coverage.statements.single() + assertEquals(EtsMappingStatus.AMBIGUOUS, statement.mapping.status) + assertTrue(statement.mapping.targets.isNotEmpty()) + assertEquals("mapping.statement.ambiguous", statement.mapping.diagnostics.single().code) + } + + @Test + fun `reports a valid source range without an EtsIR statement as unmapped`() { + val source = testResourcePath("/mapping/PropertyMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.unmapped-statement") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "isPositive", + ), + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = listOf( + StatementCoverage( + statementId = 0, + location = SourceRange( + start = SourcePosition(line = 5, column = 0), + end = SourcePosition(line = 5, column = 0), + ), + hits = 0, + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val statement = artifact.coverage.statements.single() + assertEquals(EtsMappingStatus.UNMAPPED, statement.mapping.status) + assertEquals(emptyList(), statement.mapping.targets) + assertEquals("mapping.statement.unmapped", statement.mapping.diagnostics.single().code) + } +} + +class PropertyEtsBranchMappingTest { + @Test + fun `maps an Istanbul if branch to ordered true and false EtsIR edges`() { + val source = testResourcePath("/mapping/BranchMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.branch") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "BranchMappingFixture.ts", + exportName = "classifiesPositive", + ), + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = emptyList(), + branches = listOf( + BranchCoverage( + branchId = 0, + type = "if", + location = SourceRange( + start = SourcePosition(line = 2, column = 2), + end = SourcePosition(line = 6, column = 3), + ), + arms = listOf( + BranchArmCoverage( + location = SourceRange( + start = SourcePosition(line = 2, column = 2), + end = SourcePosition(line = 4, column = 3), + ), + hits = 5, + ), + BranchArmCoverage( + location = SourceRange( + start = SourcePosition(line = 4, column = 4), + end = SourcePosition(line = 6, column = 3), + ), + hits = 2, + ), + ), + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val branch = artifact.coverage.branches.single() + assertEquals(EtsMappingStatus.EXACT, branch.mapping.status) + assertEquals(listOf(5L, 2L), branch.arms.map { arm -> arm.coverage.hits }) + assertEquals(listOf(true, false), branch.arms.map { arm -> arm.mapping.targets.single().outcome }) + val successorLines = branch.arms.map { arm -> + val target = arm.mapping.targets.single() + val origin = assertNotNull(target.successor.location.origin) + + origin.startLine + } + assertEquals(listOf(2, 4), successorLines) + } + + @Test + fun `reports a branch range without an EtsIR condition as unmapped`() { + val source = testResourcePath("/mapping/PropertyMappingFixture.ts") + val duplicateSource = testResourcePath("/mapping/duplicate/PropertyMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val duplicateFile = loadEtsFileAutoConvert(duplicateSource, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.unmapped-branch") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "isPositive", + ), + ) + val branchLocation = SourceRange( + start = SourcePosition(line = 3, column = 2), + end = SourcePosition(line = 3, column = 19), + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = emptyList(), + branches = listOf( + BranchCoverage( + branchId = 0, + type = "if", + location = branchLocation, + arms = listOf( + BranchArmCoverage(location = branchLocation, hits = 1), + BranchArmCoverage(location = branchLocation, hits = 0), + ), + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file, duplicateFile)), + sourceRoots = listOf(source.parent, duplicateSource.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val branch = artifact.coverage.branches.single() + assertEquals(EtsMappingStatus.UNMAPPED, branch.mapping.status) + assertEquals(emptyList(), branch.mapping.targets) + assertEquals("mapping.branch.unmapped", branch.mapping.diagnostics.single().code) + assertTrue(branch.arms.all { arm -> arm.mapping.status == EtsMappingStatus.UNMAPPED }) + } + + @Test + fun `reports a branch range containing distinct EtsIR conditions as ambiguous`() { + val source = testResourcePath("/mapping/AmbiguousBranchMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.ambiguous-branch") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "AmbiguousBranchMappingFixture.ts", + exportName = "classifiesLargePositive", + ), + ) + val branchLocation = SourceRange( + start = SourcePosition(line = 2, column = 2), + end = SourcePosition(line = 6, column = 3), + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = emptyList(), + branches = listOf( + BranchCoverage( + branchId = 0, + type = "if", + location = branchLocation, + arms = listOf( + BranchArmCoverage(location = branchLocation, hits = 1), + BranchArmCoverage(location = branchLocation, hits = 0), + ), + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val branch = artifact.coverage.branches.single() + assertEquals(EtsMappingStatus.AMBIGUOUS, branch.mapping.status) + assertEquals(2, branch.mapping.targets.size) + assertEquals("mapping.branch.ambiguous", branch.mapping.diagnostics.single().code) + assertTrue(branch.arms.all { arm -> arm.mapping.targets.size == 2 }) + } + + @Test + fun `reports short circuit conditions sharing one source origin as ambiguous`() { + val source = testResourcePath("/mapping/ShortCircuitBranchMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.short-circuit-branch") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "ShortCircuitBranchMappingFixture.ts", + exportName = "bothConditions", + ), + ) + val andLocation = SourceRange( + start = SourcePosition(line = 2, column = 2), + end = SourcePosition(line = 6, column = 3), + ) + val orLocation = SourceRange( + start = SourcePosition(line = 10, column = 2), + end = SourcePosition(line = 14, column = 3), + ) + val branches = listOf(andLocation, orLocation).mapIndexed { index, location -> + BranchCoverage( + branchId = index, + type = "if", + location = location, + arms = listOf( + BranchArmCoverage(location = location, hits = 1), + BranchArmCoverage(location = location, hits = 1), + ), + ) + } + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = emptyList(), + branches = branches, + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + assertEquals(2, artifact.coverage.branches.size) + for (branch in artifact.coverage.branches) { + assertEquals(EtsMappingStatus.AMBIGUOUS, branch.mapping.status) + assertEquals(2, branch.mapping.targets.size) + assertEquals("mapping.branch.ambiguous", branch.mapping.diagnostics.single().code) + assertTrue(branch.arms.all { arm -> arm.mapping.status == EtsMappingStatus.AMBIGUOUS }) + } + } +} + +class PropertyEtsUnsupportedMappingTest { + @Test + fun `reports unsupported source mapping when covered source text is unavailable`() { + val source = testResourcePath("/mapping/PropertyMappingFixture.ts") + val missingSource = source.resolveSibling("MissingMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.missing-source") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "isPositive", + ), + ) + val coverage = coverageArtifact( + source = source, + coveragePath = missingSource, + propertyId = propertyId, + statements = listOf( + StatementCoverage( + statementId = 0, + location = SourceRange( + start = SourcePosition(line = 1, column = 0), + end = SourcePosition(line = 1, column = 1), + ), + hits = 0, + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val statement = artifact.coverage.statements.single() + assertNull(statement.location) + assertEquals(EtsMappingStatus.UNSUPPORTED, statement.mapping.status) + assertEquals("mapping.source.unavailable", statement.mapping.diagnostics.single().code) + } + + @Test + fun `reports unsupported mapping when the EtsIR frontend supplied no source origins`() { + val source = testResourcePath("/mapping/PropertyMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + file.allClasses + .flatMap { etsClass -> etsClass.methods } + .flatMap { method -> method.cfg.stmts } + .forEach { statement -> statement.location.origin = null } + val propertyId = PropertyId("mapping.no-origins") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "isPositive", + ), + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = listOf( + StatementCoverage( + statementId = 0, + location = SourceRange( + start = SourcePosition(line = 3, column = 2), + end = SourcePosition(line = 3, column = 19), + ), + hits = 1, + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val statement = artifact.coverage.statements.single() + assertEquals(EtsMappingStatus.UNSUPPORTED, statement.mapping.status) + assertEquals("mapping.source-origins.unsupported", statement.mapping.diagnostics.single().code) + } + + @Test + fun `reports unsupported branch mapping when the EtsIR frontend supplied no source origins`() { + val source = testResourcePath("/mapping/BranchMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + file.allClasses + .flatMap { etsClass -> etsClass.methods } + .flatMap { method -> method.cfg.stmts } + .forEach { statement -> statement.location.origin = null } + val propertyId = PropertyId("mapping.branch-no-origins") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "BranchMappingFixture.ts", + exportName = "classifiesPositive", + ), + ) + val branchLocation = SourceRange( + start = SourcePosition(line = 2, column = 2), + end = SourcePosition(line = 6, column = 3), + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = emptyList(), + branches = listOf( + BranchCoverage( + branchId = 0, + type = "if", + location = branchLocation, + arms = listOf( + BranchArmCoverage(location = branchLocation, hits = 1), + BranchArmCoverage(location = branchLocation, hits = 0), + ), + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val branch = artifact.coverage.branches.single() + assertEquals(EtsMappingStatus.UNSUPPORTED, branch.mapping.status) + assertEquals("mapping.source-origins.unsupported", branch.mapping.diagnostics.single().code) + assertTrue(branch.arms.all { arm -> arm.mapping.status == EtsMappingStatus.UNSUPPORTED }) + } + + @Test + fun `reports unsupported non-if branch types even when they have two arms`() { + val source = testResourcePath("/mapping/BranchMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.unsupported-branch") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "BranchMappingFixture.ts", + exportName = "classifiesPositive", + ), + ) + val branchLocation = SourceRange( + start = SourcePosition(line = 2, column = 2), + end = SourcePosition(line = 6, column = 3), + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = emptyList(), + branches = listOf( + BranchCoverage( + branchId = 0, + type = "switch", + location = branchLocation, + arms = listOf( + BranchArmCoverage(location = branchLocation, hits = 1), + BranchArmCoverage(location = branchLocation, hits = 1), + ), + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val branch = artifact.coverage.branches.single() + assertEquals(EtsMappingStatus.UNSUPPORTED, branch.mapping.status) + assertEquals(emptyList(), branch.mapping.targets) + assertEquals("mapping.branch.shape.unsupported", branch.mapping.diagnostics.single().code) + assertTrue(branch.arms.all { arm -> arm.mapping.status == EtsMappingStatus.UNSUPPORTED }) + } + + @Test + fun `one-arm c8 branch is unsupported while statement coverage remains mapped`() { + val source = testResourcePath("/mapping/BranchMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.one-arm-c8-branch") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "BranchMappingFixture.ts", + exportName = "classifiesPositive", + ), + ) + val branchLocation = SourceRange( + start = SourcePosition(line = 2, column = 2), + end = SourcePosition(line = 6, column = 3), + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = listOf( + StatementCoverage( + statementId = 0, + location = SourceRange( + start = SourcePosition(line = 3, column = 4), + end = SourcePosition(line = 3, column = 16), + ), + hits = 1, + ), + ), + branches = listOf( + BranchCoverage( + branchId = 0, + type = "if", + location = branchLocation, + arms = listOf(BranchArmCoverage(location = branchLocation, hits = 1)), + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + val statementMapping = artifact.coverage.statements.single().mapping + val branch = artifact.coverage.branches.single() + + assertEquals(EtsMappingStatus.EXACT, statementMapping.status) + assertEquals(EtsMappingStatus.UNSUPPORTED, branch.mapping.status) + assertEquals("mapping.branch.shape.unsupported", branch.mapping.diagnostics.single().code) + assertTrue(branch.arms.all { arm -> arm.mapping.status == EtsMappingStatus.UNSUPPORTED }) + assertEquals(EtsMappingStatus.UNSUPPORTED, artifact.coverage.status) + } + + @Test + fun `reports unsupported mapping when an EtsIR condition has fewer than two successors`() { + val source = testResourcePath("/mapping/BranchMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val condition = file.allClasses + .flatMap { etsClass -> etsClass.methods } + .flatMap { method -> method.cfg.stmts } + .filterIsInstance() + .single() + val method = condition.location.method + val originalCfg = method.cfg + val conditionBlock = originalCfg.blocks.single { block -> condition in block.statements } + val conditionSuccessors = originalCfg.successors.getValue(conditionBlock.id) + method.body.cfg = EtsBlockCfg( + blocks = originalCfg.blocks, + successors = originalCfg.successors + (conditionBlock.id to conditionSuccessors.take(1)), + ) + val propertyId = PropertyId("mapping.unsupported-cfg-branch") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "BranchMappingFixture.ts", + exportName = "classifiesPositive", + ), + ) + val branchLocation = SourceRange( + start = SourcePosition(line = 2, column = 2), + end = SourcePosition(line = 6, column = 3), + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = emptyList(), + branches = listOf( + BranchCoverage( + branchId = 0, + type = "if", + location = branchLocation, + arms = listOf( + BranchArmCoverage(location = branchLocation, hits = 1), + BranchArmCoverage(location = branchLocation, hits = 0), + ), + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val branch = artifact.coverage.branches.single() + assertEquals(EtsMappingStatus.UNSUPPORTED, branch.mapping.status) + assertEquals("mapping.branch.cfg.unsupported", branch.mapping.diagnostics.single().code) + assertTrue(branch.arms.all { arm -> arm.mapping.status == EtsMappingStatus.UNSUPPORTED }) + } +} + +class PropertyEtsMappingEdgeCasesTest { + @Test + fun `resolves extensionless modules through a named TypeScript re-export`() { + val entrySource = testResourcePath("/mapping/reexports/Entry.ts") + val predicateSource = testResourcePath("/mapping/reexports/Predicate.ts") + val files = listOf(entrySource, predicateSource).map { source -> + loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + } + val manifest = PropertyManifest( + propertyId = "mapping.reexport", + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "Entry", + exportName = "predicate", + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(files), + sourceRoots = listOf(entrySource.parent), + ) + + val artifact = mapper.map(manifest) + + assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) + val target = artifact.predicate.targets.single() + val targetClass = target.method.signature.enclosingClass + val targetFileName = targetClass.file.fileName + assertEquals("corePredicate", target.method.name) + assertTrue(targetFileName.endsWith("Predicate.ts")) + } + + @Test + fun `reports unsupported coverage coordinates outside the UTF-16 source line`() { + val source = testResourcePath("/mapping/PropertyMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.invalid-location") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "isPositive", + ), + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = listOf( + StatementCoverage( + statementId = 0, + location = SourceRange( + start = SourcePosition(line = 3, column = 200), + end = SourcePosition(line = 3, column = 200), + ), + hits = 0, + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val statement = artifact.coverage.statements.single() + assertNull(statement.location) + assertEquals(EtsMappingStatus.UNSUPPORTED, statement.mapping.status) + assertEquals("mapping.source.location.unsupported", statement.mapping.diagnostics.single().code) + } + + @Test + fun `reports an invalid branch arm without discarding the mapped condition`() { + val source = testResourcePath("/mapping/BranchMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.invalid-branch-arm") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "BranchMappingFixture.ts", + exportName = "classifiesPositive", + ), + ) + val branchLocation = SourceRange( + start = SourcePosition(line = 2, column = 2), + end = SourcePosition(line = 6, column = 3), + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = emptyList(), + branches = listOf( + BranchCoverage( + branchId = 0, + type = "if", + location = branchLocation, + arms = listOf( + BranchArmCoverage(location = branchLocation, hits = 1), + BranchArmCoverage( + location = SourceRange( + start = SourcePosition(line = 4, column = 200), + end = SourcePosition(line = 4, column = 200), + ), + hits = 0, + ), + ), + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val branch = artifact.coverage.branches.single() + assertEquals(EtsMappingStatus.EXACT, branch.mapping.status) + assertEquals(EtsMappingStatus.EXACT, branch.arms.first().mapping.status) + val invalidArm = branch.arms.last() + assertNull(invalidArm.location) + assertEquals(EtsMappingStatus.UNSUPPORTED, invalidArm.mapping.status) + assertEquals("mapping.source.location.unsupported", invalidArm.mapping.diagnostics.single().code) + assertEquals(EtsMappingStatus.UNSUPPORTED, artifact.coverage.status) + } + + @Test + fun `reports unsupported branch mapping when covered source text is unavailable`() { + val source = testResourcePath("/mapping/BranchMappingFixture.ts") + val missingSource = source.resolveSibling("MissingBranchMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.missing-branch-source") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "BranchMappingFixture.ts", + exportName = "classifiesPositive", + ), + ) + val location = SourceRange( + start = SourcePosition(line = 1, column = 0), + end = SourcePosition(line = 1, column = 1), + ) + val coverage = coverageArtifact( + source = source, + coveragePath = missingSource, + propertyId = propertyId, + statements = emptyList(), + branches = listOf( + BranchCoverage( + branchId = 0, + type = "if", + location = location, + arms = listOf( + BranchArmCoverage(location = location, hits = 0), + BranchArmCoverage(location = location, hits = 0), + ), + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val branch = artifact.coverage.branches.single() + assertNull(branch.location) + assertEquals(EtsMappingStatus.UNSUPPORTED, branch.mapping.status) + assertEquals("mapping.source.unavailable", branch.mapping.diagnostics.single().code) + assertTrue( + branch.arms.all { arm -> + arm.location == null && arm.mapping.status == EtsMappingStatus.UNSUPPORTED + }, + ) + } +} + +private fun coverageArtifact( + source: Path, + coveragePath: Path = source, + propertyId: PropertyId, + statements: List, + branches: List = emptyList(), +): PropertyCoverageArtifact = PropertyCoverageArtifact( + backendId = "fixture-backend", + backendVersion = "1.0", + propertyId = propertyId, + provenance = CoverageProvenance( + collector = CoverageCollectorIdentity(id = "fixture", version = "1.0"), + runtimeId = "node", + runtimeVersion = "22.0.0", + sourceRoots = listOf(source.parent.toString()), + request = PropertyCoverageRequest(), + ), + files = listOf( + SourceFileCoverage( + path = coveragePath.toString(), + statements = statements, + functions = emptyList(), + branches = branches, + ), + ), +) diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsSourceNormalizationTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsSourceNormalizationTest.kt new file mode 100644 index 000000000..673e9ba09 --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsSourceNormalizationTest.kt @@ -0,0 +1,241 @@ +package org.usvm.ts.pbt.mapping + +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.junit.jupiter.api.io.TempDir +import org.usvm.ts.pbt.backend.CoverageCollectorIdentity +import org.usvm.ts.pbt.backend.CoverageProvenance +import org.usvm.ts.pbt.backend.PropertyCoverageArtifact +import org.usvm.ts.pbt.backend.PropertyCoverageRequest +import org.usvm.ts.pbt.backend.SourceFileCoverage +import org.usvm.ts.pbt.backend.SourcePosition +import org.usvm.ts.pbt.backend.SourceRange +import org.usvm.ts.pbt.backend.StatementCoverage +import org.usvm.ts.pbt.manifest.PropertyManifest +import org.usvm.ts.pbt.model.IntegerDomain +import org.usvm.ts.pbt.model.PropertyId +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 +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class PropertyEtsSourceNormalizationTest { + @TempDir + lateinit var tempDirectory: Path + + @Test + fun `missing source roots produce an unsupported entry-point diagnostic`() { + val missingRoot = tempDirectory.resolve("missing") + val propertyId = PropertyId("mapping.missing-root") + val mapper = PropertyEtsMapper( + scene = EtsScene(emptyList()), + sourceRoots = listOf(missingRoot), + ) + + val artifact = mapper.map(manifest(propertyId, module = "Predicate.ts")) + + assertEquals(EtsMappingStatus.UNSUPPORTED, artifact.predicate.status) + assertEquals("mapping.source-root.unsupported", artifact.predicate.diagnostics.single().code) + } + + @Test + fun `canonical source roots align symlinked EtsIR origins with backend coverage`() { + val realRoot = Files.createDirectory(tempDirectory.resolve("real")) + val symlinkRoot = Files.createSymbolicLink(tempDirectory.resolve("alias"), realRoot) + val realSource = realRoot.resolve("Predicate.ts") + Files.writeString( + realSource, + """ + export function predicate(value: number): boolean { + return value > 0; + } + """.trimIndent(), + ) + val symlinkSource = symlinkRoot.resolve(realSource.fileName) + val file = loadEtsFileAutoConvert(symlinkSource, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.symlink-root") + val coverage = coverageArtifact( + sourceRoots = listOf(realRoot), + sourcePath = realSource.toRealPath(), + propertyId = propertyId, + statements = listOf( + StatementCoverage( + statementId = 0, + location = SourceRange( + start = SourcePosition(line = 2, column = 2), + end = SourcePosition(line = 2, column = 19), + ), + hits = 1, + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(symlinkRoot), + ) + + val artifact = mapper.map(manifest(propertyId, module = "Predicate.ts"), coverage) + + assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) + val statement = artifact.coverage.statements.single() + assertEquals(EtsMappingStatus.EXACT, statement.mapping.status) + assertEquals(realSource.toRealPath().toString(), statement.location?.path) + } + + @Test + fun `TypeScript line terminators produce UTF-16 source offsets`() { + val source = tempDirectory.resolve("LineTerminators.ts") + Files.writeString(source, "a\r\nb\rc\u2028d\u2029e") + val propertyId = PropertyId("mapping.line-terminators") + val statements = listOf( + statement(statementId = 0, line = 2), + statement(statementId = 1, line = 3), + statement(statementId = 2, line = 4), + statement(statementId = 3, line = 5), + ) + val coverage = coverageArtifact( + sourceRoots = listOf(tempDirectory), + sourcePath = source, + propertyId = propertyId, + statements = statements, + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(emptyList()), + sourceRoots = listOf(tempDirectory), + ) + + val artifact = mapper.map(manifest(propertyId, module = source.fileName.toString()), coverage) + + val locations = artifact.coverage.statements.map { mapping -> assertNotNull(mapping.location) } + assertEquals(listOf(3, 5, 7, 9), locations.map { location -> location.start.offset }) + assertEquals(listOf(4, 6, 8, 10), locations.map { location -> location.end.offset }) + } + + @Test + fun `relative coverage path resolves through its only existing source root`() { + val emptyRoot = Files.createDirectory(tempDirectory.resolve("empty")) + val sourceRoot = Files.createDirectory(tempDirectory.resolve("source")) + val source = sourceRoot.resolve("Predicate.ts") + Files.writeString(source, "x") + + val propertyId = PropertyId("mapping.relative-source") + val coverage = coverageArtifact( + sourceRoots = listOf(emptyRoot, sourceRoot), + sourcePath = Path.of("Predicate.ts"), + propertyId = propertyId, + statements = listOf(statement(statementId = 0, line = 1)), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(emptyList()), + sourceRoots = listOf(emptyRoot, sourceRoot), + ) + + val artifact = mapper.map(manifest(propertyId, module = source.fileName.toString()), coverage) + + val statement = artifact.coverage.statements.single() + assertEquals(source.toRealPath().toString(), statement.location?.path) + assertEquals(EtsMappingStatus.UNMAPPED, statement.mapping.status) + } + + @Test + fun `relative coverage path below several source roots remains unsupported`() { + val firstRoot = Files.createDirectory(tempDirectory.resolve("first")) + val secondRoot = Files.createDirectory(tempDirectory.resolve("second")) + Files.writeString(firstRoot.resolve("Predicate.ts"), "x") + Files.writeString(secondRoot.resolve("Predicate.ts"), "x") + + val propertyId = PropertyId("mapping.ambiguous-relative-source") + val coverage = coverageArtifact( + sourceRoots = listOf(firstRoot, secondRoot), + sourcePath = Path.of("Predicate.ts"), + propertyId = propertyId, + statements = listOf(statement(statementId = 0, line = 1)), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(emptyList()), + sourceRoots = listOf(firstRoot, secondRoot), + ) + + val artifact = mapper.map(manifest(propertyId, module = "Predicate.ts"), coverage) + + val statement = artifact.coverage.statements.single() + assertNull(statement.location) + assertEquals(EtsMappingStatus.UNSUPPORTED, statement.mapping.status) + assertEquals("mapping.source.location.unsupported", statement.mapping.diagnostics.single().code) + } + + @Test + fun `module path candidates include every supported file and directory suffix`() { + val normalizer = SourceLocationNormalizer(sourceRoots = listOf(tempDirectory)) + val modulePath = tempDirectory.resolve("Predicate") + val expectedCandidates = setOf( + modulePath, + tempDirectory.resolve("Predicate.ts"), + tempDirectory.resolve("Predicate.ets"), + tempDirectory.resolve("Predicate.d.ts"), + modulePath.resolve("index.ts"), + modulePath.resolve("index.ets"), + modulePath.resolve("index.d.ts"), + ) + + val candidates = normalizer.modulePathCandidates(modulePath) + + assertEquals(expectedCandidates, candidates) + } + + @Test + fun `module path with a supported suffix is already resolved`() { + val normalizer = SourceLocationNormalizer(sourceRoots = listOf(tempDirectory)) + val declarationPath = tempDirectory.resolve("Predicate.d.ts") + + val candidates = normalizer.modulePathCandidates(declarationPath) + + assertEquals(setOf(declarationPath), candidates) + } + + private fun statement(statementId: Int, line: Int): StatementCoverage = StatementCoverage( + statementId = statementId, + location = SourceRange( + start = SourcePosition(line = line, column = 0), + end = SourcePosition(line = line, column = 1), + ), + hits = 0, + ) + + private fun manifest(propertyId: PropertyId, module: String): PropertyManifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint(module = module, exportName = "predicate"), + ) + + private fun coverageArtifact( + sourceRoots: List, + sourcePath: Path, + propertyId: PropertyId, + statements: List, + ): PropertyCoverageArtifact = PropertyCoverageArtifact( + backendId = "fixture-backend", + backendVersion = "1.0", + propertyId = propertyId, + provenance = CoverageProvenance( + collector = CoverageCollectorIdentity(id = "fixture", version = "1.0"), + runtimeId = "node", + runtimeVersion = "22.0.0", + sourceRoots = sourceRoots.map(Path::toString), + request = PropertyCoverageRequest(), + ), + files = listOf( + SourceFileCoverage( + path = sourcePath.toString(), + statements = statements, + functions = emptyList(), + branches = emptyList(), + ), + ), + ) +} diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/JsConcreteValueTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/JsConcreteValueTest.kt index e80f23a99..d858c0a71 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/JsConcreteValueTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/JsConcreteValueTest.kt @@ -1,9 +1,11 @@ package org.usvm.ts.pbt.model +import kotlinx.serialization.SerializationException import kotlinx.serialization.encodeToString import org.junit.jupiter.api.Test import org.usvm.ts.pbt.manifest.PropertyManifestJson import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class JsConcreteValueTest { @Test @@ -53,4 +55,75 @@ class JsConcreteValueTest { assertEquals(value, PropertyManifestJson.json.decodeFromString(encoded)) } + + @Test + fun `string tags require string values`() { + assertFailsWith { + PropertyManifestJson.json.decodeFromString("""{"kind":"string","value":123}""") + } + } + + @Test + fun `boolean tags require Boolean values`() { + assertFailsWith { + PropertyManifestJson.json.decodeFromString("""{"kind":"boolean","value":"true"}""") + } + } + + @Test + fun `array tags require array elements`() { + assertFailsWith { + PropertyManifestJson.json.decodeFromString("""{"kind":"array","elements":"[]"}""") + } + } + + @Test + fun `number tags require string bits`() { + assertFailsWith { + PropertyManifestJson.json.decodeFromString( + """{"kind":"number","value":"finite","bits":4607182418800017408}""", + ) + } + } + + @Test + fun `undefined tags reject unexpected fields`() { + assertFailsWith { + PropertyManifestJson.json.decodeFromString("""{"kind":"undefined","value":null}""") + } + } + + @Test + fun `every tagged value kind rejects unexpected fields`() { + val cases = listOf( + """{"kind":"null","extra":null}""", + """{"kind":"boolean","value":true,"extra":null}""", + """{"kind":"string","value":"value","extra":null}""", + """{"kind":"array","elements":[],"extra":null}""", + """{"kind":"number","value":"finite","bits":"3ff0000000000000","extra":null}""", + """{"kind":"number","value":"nan","extra":null}""", + """{"kind":"number","value":"positive-infinity","extra":null}""", + """{"kind":"number","value":"negative-infinity","extra":null}""", + ) + + cases.forEach { encoded -> + assertFailsWith { + PropertyManifestJson.json.decodeFromString(encoded) + } + } + } + + @Test + fun `finite number tags reject non-finite bit patterns`() { + listOf( + "7ff0000000000000", + "7ff8000000000000", + ).forEach { bits -> + assertFailsWith { + PropertyManifestJson.json.decodeFromString( + """{"kind":"number","value":"finite","bits":"$bits"}""", + ) + } + } + } } 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 dde7a5bcc..a847e1cdb 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 @@ -91,6 +91,25 @@ class PropertyValidationTest { ) } + @Test + fun `finite tags for infinity and NaN are invalid`() { + listOf( + "7ff0000000000000", + "7ff8000000000000", + ).forEach { bits -> + val definition = validDefinition( + ConstantDomain( + JsConcreteValue.Number(JsNumber(JsNumberKind.FINITE, bits = bits)), + ), + ) + + assertEquals( + listOf("js-number.encoding.invalid"), + validatePropertyDefinition(definition).diagnostics.map { it.code }, + ) + } + } + @Test fun `valid definition has no diagnostics`() { assertTrue(validatePropertyDefinition(validDefinition(IntegerDomain(-5, 5))).isValid) diff --git a/usvm-ts-pbt/src/test/resources/mapping/AmbiguousBranchMappingFixture.ts b/usvm-ts-pbt/src/test/resources/mapping/AmbiguousBranchMappingFixture.ts new file mode 100644 index 000000000..60f55eb03 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/AmbiguousBranchMappingFixture.ts @@ -0,0 +1,8 @@ +export function classifiesLargePositive(value: number): boolean { + if (value > 0) { + if (value > 10) { + return true; + } + } + return false; +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/BranchMappingFixture.ts b/usvm-ts-pbt/src/test/resources/mapping/BranchMappingFixture.ts new file mode 100644 index 000000000..f9f412ba0 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/BranchMappingFixture.ts @@ -0,0 +1,7 @@ +export function classifiesPositive(value: number): boolean { + if (value > 0) { + return true; + } else { + return false; + } +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/PropertyMappingFixture.ts b/usvm-ts-pbt/src/test/resources/mapping/PropertyMappingFixture.ts new file mode 100644 index 000000000..fc09a7ef2 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/PropertyMappingFixture.ts @@ -0,0 +1,8 @@ +const astralMarker = "😀"; +export function isPositive(value: number): boolean { + return value > 0; +} + +export function needsTwoInputs(left: number, right: number): boolean { + return left !== right; +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/PropertyPreconditionFixture.ts b/usvm-ts-pbt/src/test/resources/mapping/PropertyPreconditionFixture.ts new file mode 100644 index 000000000..843d4ce0d --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/PropertyPreconditionFixture.ts @@ -0,0 +1,3 @@ +export function isNonZero(value: number): boolean { + return value !== 0; +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/ShortCircuitBranchMappingFixture.ts b/usvm-ts-pbt/src/test/resources/mapping/ShortCircuitBranchMappingFixture.ts new file mode 100644 index 000000000..9c555a8ab --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/ShortCircuitBranchMappingFixture.ts @@ -0,0 +1,15 @@ +export function bothConditions(value: number): boolean { + if (value > 0 && value < 10) { + return true; + } else { + return false; + } +} + +export function eitherCondition(value: number): boolean { + if (value < 0 || value > 10) { + return true; + } else { + return false; + } +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/duplicate/PropertyMappingFixture.ts b/usvm-ts-pbt/src/test/resources/mapping/duplicate/PropertyMappingFixture.ts new file mode 100644 index 000000000..f337bbde6 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/duplicate/PropertyMappingFixture.ts @@ -0,0 +1,3 @@ +export function isPositive(value: number): boolean { + return value >= 0; +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/CallableLocalFixture.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/CallableLocalFixture.ts new file mode 100644 index 000000000..77976b594 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/CallableLocalFixture.ts @@ -0,0 +1,18 @@ +export const arrowPredicate = (value: number): boolean => value > 0; + +const functionPredicate = function (value: number): boolean { + return value !== 0; +}; +export { functionPredicate as aliasedPredicate }; + +export const nonCallable = 42; + +export let reassignedPredicate = (value: number): boolean => value > 0; +reassignedPredicate = (value: number): boolean => value < 0; + +export let callableThenValue: any = (value: number): boolean => value > 0; +callableThenValue = 42; + +let multiplyLinkedPredicate: (value: number) => boolean; +multiplyLinkedPredicate = multiplyLinkedPredicate = (value: number): boolean => value === 0; +export { multiplyLinkedPredicate as aliasedMultiplyLinkedPredicate }; diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/CycleEntry.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/CycleEntry.ts new file mode 100644 index 000000000..50b7a7fc1 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/CycleEntry.ts @@ -0,0 +1 @@ +export * from './CyclePeer'; diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/CyclePeer.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/CyclePeer.ts new file mode 100644 index 000000000..00e99f4e8 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/CyclePeer.ts @@ -0,0 +1 @@ +export * from './CycleEntry'; diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/DefaultPredicate.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/DefaultPredicate.ts new file mode 100644 index 000000000..870f7517d --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/DefaultPredicate.ts @@ -0,0 +1,5 @@ +function defaultPredicate(value: number): boolean { + return value > 0; +} + +export { defaultPredicate as default }; diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/DiamondEntry.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/DiamondEntry.ts new file mode 100644 index 000000000..412c1af4d --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/DiamondEntry.ts @@ -0,0 +1,2 @@ +export * from './Left'; +export * from './Right'; diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/DirectExportFixture.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/DirectExportFixture.ts new file mode 100644 index 000000000..d08d5e373 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/DirectExportFixture.ts @@ -0,0 +1,9 @@ +export function predicate(value: number): boolean { + return value > 0; +} + +export class PredicateContainer { + predicate(left: number, right: number): boolean { + return left > right; + } +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/ExplicitPrecedenceEntry.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/ExplicitPrecedenceEntry.ts new file mode 100644 index 000000000..3ea4396ff --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/ExplicitPrecedenceEntry.ts @@ -0,0 +1,2 @@ +export { corePredicate as predicate } from './Predicate'; +export * from './StarPredicate'; diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/Left.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/Left.ts new file mode 100644 index 000000000..4562f7d5d --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/Left.ts @@ -0,0 +1 @@ +export { corePredicate as predicate } from './Predicate'; diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/NamedDefaultDeclaration.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/NamedDefaultDeclaration.ts new file mode 100644 index 000000000..fc48aaa72 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/NamedDefaultDeclaration.ts @@ -0,0 +1,3 @@ +export default function namedDefault(value: number): boolean { + return value > 0; +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/NamespaceEntry.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/NamespaceEntry.ts new file mode 100644 index 000000000..30cc86670 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/NamespaceEntry.ts @@ -0,0 +1 @@ +export * as api from './Predicate'; diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/Predicate.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/Predicate.ts new file mode 100644 index 000000000..b94fc03b6 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/Predicate.ts @@ -0,0 +1,3 @@ +export function corePredicate(value: number): boolean { + return value > 0; +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/RenamedCycleEntry.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/RenamedCycleEntry.ts new file mode 100644 index 000000000..7a6350176 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/RenamedCycleEntry.ts @@ -0,0 +1,5 @@ +export { predicate } from './RenamedCyclePeer'; + +export function actual(value: number): boolean { + return value > 0; +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/RenamedCyclePeer.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/RenamedCyclePeer.ts new file mode 100644 index 000000000..00f232657 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/RenamedCyclePeer.ts @@ -0,0 +1 @@ +export { actual as predicate } from './RenamedCycleEntry'; diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/Right.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/Right.ts new file mode 100644 index 000000000..4562f7d5d --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/Right.ts @@ -0,0 +1 @@ +export { corePredicate as predicate } from './Predicate'; diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/StarDefaultEntry.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/StarDefaultEntry.ts new file mode 100644 index 000000000..ecc97cec4 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/StarDefaultEntry.ts @@ -0,0 +1 @@ +export * from './DefaultPredicate'; diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/StarPredicate.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/StarPredicate.ts new file mode 100644 index 000000000..0d2a5f370 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/StarPredicate.ts @@ -0,0 +1,3 @@ +export function predicate(value: number): boolean { + return value >= 0; +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/TypeOnlyPrecedenceEntry.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/TypeOnlyPrecedenceEntry.ts new file mode 100644 index 000000000..390abba96 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/TypeOnlyPrecedenceEntry.ts @@ -0,0 +1,2 @@ +export type { predicate } from './TypeOnlyPredicate'; +export * from './StarPredicate'; diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/TypeOnlyPredicate.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/TypeOnlyPredicate.ts new file mode 100644 index 000000000..70861f657 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/TypeOnlyPredicate.ts @@ -0,0 +1,3 @@ +export function predicate(value: number): boolean { + return value < 0; +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/TypeOnlyStarEntry.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/TypeOnlyStarEntry.ts new file mode 100644 index 000000000..312d32a9d --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/TypeOnlyStarEntry.ts @@ -0,0 +1,2 @@ +export type * from './TypeOnlyPredicate'; +export * from './StarPredicate'; diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/ambiguous-reexport/Entry.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/ambiguous-reexport/Entry.ts new file mode 100644 index 000000000..98869708b --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/ambiguous-reexport/Entry.ts @@ -0,0 +1 @@ +export { predicate } from './Foo'; diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/ambiguous-reexport/Foo.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/ambiguous-reexport/Foo.ts new file mode 100644 index 000000000..81398a0c1 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/ambiguous-reexport/Foo.ts @@ -0,0 +1,3 @@ +export function predicate(value: number): boolean { + return value > 0; +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/ambiguous-reexport/Foo/index.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/ambiguous-reexport/Foo/index.ts new file mode 100644 index 000000000..242912fe6 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/ambiguous-reexport/Foo/index.ts @@ -0,0 +1 @@ +export const unrelated = 0; diff --git a/usvm-ts-pbt/src/test/resources/mapping/mismatched/PropertyMappingFixture.ts b/usvm-ts-pbt/src/test/resources/mapping/mismatched/PropertyMappingFixture.ts new file mode 100644 index 000000000..f6f20d269 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/mismatched/PropertyMappingFixture.ts @@ -0,0 +1,3 @@ +export function isPositive(left: number, right: number): boolean { + return left > 0 && right > 0; +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/reexports/Entry.ts b/usvm-ts-pbt/src/test/resources/mapping/reexports/Entry.ts new file mode 100644 index 000000000..4562f7d5d --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/reexports/Entry.ts @@ -0,0 +1 @@ +export { corePredicate as predicate } from './Predicate'; diff --git a/usvm-ts-pbt/src/test/resources/mapping/reexports/Predicate.ts b/usvm-ts-pbt/src/test/resources/mapping/reexports/Predicate.ts new file mode 100644 index 000000000..b94fc03b6 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/reexports/Predicate.ts @@ -0,0 +1,3 @@ +export function corePredicate(value: number): boolean { + return value > 0; +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/source-roots/a/Foo.ts b/usvm-ts-pbt/src/test/resources/mapping/source-roots/a/Foo.ts new file mode 100644 index 000000000..606a033c5 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/source-roots/a/Foo.ts @@ -0,0 +1,7 @@ +export function predicate(value: number): boolean { + if (value > 0) { + return true; + } else { + return false; + } +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/source-roots/b/Foo.ts b/usvm-ts-pbt/src/test/resources/mapping/source-roots/b/Foo.ts new file mode 100644 index 000000000..242912fe6 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/source-roots/b/Foo.ts @@ -0,0 +1 @@ +export const unrelated = 0; diff --git a/usvm-ts-pbt/src/test/resources/properties/coverage/invalid-map-entry.js b/usvm-ts-pbt/src/test/resources/properties/coverage/invalid-map-entry.js new file mode 100644 index 000000000..03d16f8e0 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/properties/coverage/invalid-map-entry.js @@ -0,0 +1,4 @@ +export function invalidMapPredicate(value) { + return value > 0; +} +//# sourceMappingURL=invalid-map-entry.js.map diff --git a/usvm-ts-pbt/src/test/resources/properties/coverage/invalid-map-entry.js.map b/usvm-ts-pbt/src/test/resources/properties/coverage/invalid-map-entry.js.map new file mode 100644 index 000000000..89d4d2557 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/properties/coverage/invalid-map-entry.js.map @@ -0,0 +1 @@ +{not-json diff --git a/usvm-ts-pbt/src/test/resources/properties/coverage/missing-map-entry.js b/usvm-ts-pbt/src/test/resources/properties/coverage/missing-map-entry.js new file mode 100644 index 000000000..9aca88783 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/properties/coverage/missing-map-entry.js @@ -0,0 +1,4 @@ +export function missingMapPredicate(value) { + return value > 0; +} +//# sourceMappingURL=missing-map-entry.js.map 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 b887fd471..cefcc3588 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 @@ -277,13 +277,14 @@ class TsTypeSystem( is EtsPrimitiveType -> emptySequence() is EtsAnyType, is EtsUnknownType, - -> + -> { scene.projectAndSdkClasses .asSequence() .map { it.type } .plus(sequenceOf(EtsNumberType, EtsBooleanType, EtsStringType)) + } - is EtsAuxiliaryType -> + is EtsAuxiliaryType -> { scene.projectAndSdkClasses .asSequence() .filter { cls -> @@ -292,8 +293,9 @@ class TsTypeSystem( .containsAll(t.properties) } .map { it.type } + } - is EtsArrayType -> + is EtsArrayType -> { findSubtypes(t.elementType).map { child -> if (child is EtsArrayType) { EtsArrayType(child.elementType, child.dimensions + 1) @@ -301,10 +303,11 @@ class TsTypeSystem( EtsArrayType(child, dimensions = 1) } } + } is EtsUnclearRefType, is EtsClassType, - -> + -> { if ((t as? EtsClassType)?.signature == EtsHierarchy.OBJECT_CLASS.signature) { // TODO change it scene.projectAndSdkClasses.asSequence().map { it.type } + EtsStringType + EtsAnyType } else { @@ -313,6 +316,7 @@ class TsTypeSystem( .flatMap { hierarchy.getInheritors(it).asSequence() } .map { it.type } } + } else -> emptySequence() }