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()
}