From 2425bf9e4f20e1b4970998931ed197254963a978 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 29 Aug 2026 19:22:37 +0300 Subject: [PATCH 01/16] [TS PBT] Map property coverage to EtsIR --- usvm-ts-pbt/DESIGN.md | 54 +- usvm-ts-pbt/README.md | 55 + .../usvm/ts/pbt/mapping/EtsMappingModel.kt | 153 +++ .../usvm/ts/pbt/mapping/PropertyEtsMapper.kt | 619 ++++++++++ .../pbt/mapping/SourceLocationNormalizer.kt | 154 +++ .../PropertyEtsExportResolutionTest.kt | 111 ++ .../ts/pbt/mapping/PropertyEtsMapperTest.kt | 1007 +++++++++++++++++ .../PropertyEtsSourceNormalizationTest.kt | 158 +++ .../mapping/AmbiguousBranchMappingFixture.ts | 8 + .../resources/mapping/BranchMappingFixture.ts | 7 + .../mapping/PropertyMappingFixture.ts | 8 + .../mapping/PropertyPreconditionFixture.ts | 3 + .../duplicate/PropertyMappingFixture.ts | 3 + .../mapping/exports/DefaultPredicate.ts | 5 + .../resources/mapping/exports/DiamondEntry.ts | 2 + .../mapping/exports/DirectExportFixture.ts | 9 + .../exports/ExplicitPrecedenceEntry.ts | 2 + .../test/resources/mapping/exports/Left.ts | 1 + .../mapping/exports/NamespaceEntry.ts | 1 + .../resources/mapping/exports/Predicate.ts | 3 + .../test/resources/mapping/exports/Right.ts | 1 + .../mapping/exports/StarDefaultEntry.ts | 1 + .../mapping/exports/StarPredicate.ts | 3 + .../exports/TypeOnlyPrecedenceEntry.ts | 2 + .../mismatched/PropertyMappingFixture.ts | 3 + .../test/resources/mapping/reexports/Entry.ts | 1 + .../resources/mapping/reexports/Predicate.ts | 3 + 27 files changed, 2375 insertions(+), 2 deletions(-) create mode 100644 usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsMappingModel.kt create mode 100644 usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsMapper.kt create mode 100644 usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/SourceLocationNormalizer.kt create mode 100644 usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsExportResolutionTest.kt create mode 100644 usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsMapperTest.kt create mode 100644 usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsSourceNormalizationTest.kt create mode 100644 usvm-ts-pbt/src/test/resources/mapping/AmbiguousBranchMappingFixture.ts create mode 100644 usvm-ts-pbt/src/test/resources/mapping/BranchMappingFixture.ts create mode 100644 usvm-ts-pbt/src/test/resources/mapping/PropertyMappingFixture.ts create mode 100644 usvm-ts-pbt/src/test/resources/mapping/PropertyPreconditionFixture.ts create mode 100644 usvm-ts-pbt/src/test/resources/mapping/duplicate/PropertyMappingFixture.ts create mode 100644 usvm-ts-pbt/src/test/resources/mapping/exports/DefaultPredicate.ts create mode 100644 usvm-ts-pbt/src/test/resources/mapping/exports/DiamondEntry.ts create mode 100644 usvm-ts-pbt/src/test/resources/mapping/exports/DirectExportFixture.ts create mode 100644 usvm-ts-pbt/src/test/resources/mapping/exports/ExplicitPrecedenceEntry.ts create mode 100644 usvm-ts-pbt/src/test/resources/mapping/exports/Left.ts create mode 100644 usvm-ts-pbt/src/test/resources/mapping/exports/NamespaceEntry.ts create mode 100644 usvm-ts-pbt/src/test/resources/mapping/exports/Predicate.ts create mode 100644 usvm-ts-pbt/src/test/resources/mapping/exports/Right.ts create mode 100644 usvm-ts-pbt/src/test/resources/mapping/exports/StarDefaultEntry.ts create mode 100644 usvm-ts-pbt/src/test/resources/mapping/exports/StarPredicate.ts create mode 100644 usvm-ts-pbt/src/test/resources/mapping/exports/TypeOnlyPrecedenceEntry.ts create mode 100644 usvm-ts-pbt/src/test/resources/mapping/mismatched/PropertyMappingFixture.ts create mode 100644 usvm-ts-pbt/src/test/resources/mapping/reexports/Entry.ts create mode 100644 usvm-ts-pbt/src/test/resources/mapping/reexports/Predicate.ts diff --git a/usvm-ts-pbt/DESIGN.md b/usvm-ts-pbt/DESIGN.md index 82dfe6214..858bfce04 100644 --- a/usvm-ts-pbt/DESIGN.md +++ b/usvm-ts-pbt/DESIGN.md @@ -8,6 +8,8 @@ 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. @@ -25,6 +27,7 @@ flowchart LR Backend[FastCheckBackend] Process[FastCheckProcessClient] Projection[FastCheckProjectionClient] + Mapping[PropertyEtsMapper] end subgraph Node_adapter[Private Node adapter] @@ -41,6 +44,7 @@ flowchart LR Tsx[tsx] C8[c8 and Istanbul JSON] UserTS[User TypeScript source] + EtsIR[EtsScene and EtsSourceSpan] CLI --> Registry CLI --> Backend @@ -48,6 +52,9 @@ flowchart LR Registry --> Model Backend --> Model Backend --> Process + Model --> Mapping + Process --> Mapping + Mapping --> EtsIR Process --> ExecutionCLI Process --> C8 C8 --> ExecutionCLI @@ -72,6 +79,7 @@ flowchart LR | 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. | +| `PropertyEtsMapper` | Resolve property entry points and backend-neutral coverage to explicit EtsIR targets. | | `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. | @@ -213,6 +221,45 @@ 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. +## 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. + +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. EtsIR currently loses TypeScript `isTypeOnly` on named re-exports whose declaration has a runtime kind; +the mapper treats those exports conservatively as runtime-bearing instead of guessing that a star export wins. +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. + +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 using TypeScript's LF, CRLF, CR, U+2028, and U+2029 line terminators. 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 exact; several distinct condition origins are ambiguous. 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 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. + 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 @@ -238,6 +285,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 +295,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..8d2cd6a85 100644 --- a/usvm-ts-pbt/README.md +++ b/usvm-ts-pbt/README.md @@ -137,6 +137,61 @@ 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 does not preserve TypeScript `isTypeOnly` for named re-exports whose declaration also has a +runtime kind; those cases are conservatively treated as runtime exports and may remain unmapped instead of +following a bare-star export. +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. CRLF, lone CR, LF, +U+2028, and U+2029 are recognized as TypeScript line terminators. +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. + +| Status | Meaning | +| --- | --- | +| `EXACT` | One source identity was established; normalized statements may produce several EtsIR targets with that shared identity. | +| `AMBIGUOUS` | Several distinct entry points or source origins 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`. 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/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..27f0fb0c5 --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsMappingModel.kt @@ -0,0 +1,153 @@ +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 binary backend branch arms. */ +enum class EtsBranchSuccessorOrder { + TRUE_FALSE, +} + +/** 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, +) + +/** One mapping decision together with every EtsIR target selected by that decision. */ +data class EtsMappingResult( + val status: EtsMappingStatus, + val targets: List, + val diagnostics: List = emptyList(), +) + +/** 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..08d0f0895 --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsMapper.kt @@ -0,0 +1,619 @@ +package org.usvm.ts.pbt.mapping + +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.EtsIfStmt +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.model.EtsStmt +import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME +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.manifest.PropertyManifest +import org.usvm.ts.pbt.model.PropertyId +import org.usvm.ts.pbt.model.TypeScriptEntryPoint +import java.nio.file.Path +import java.util.IdentityHashMap + +/** Maps backend-independent property manifests to EtsIR objects in one project scene. */ +class PropertyEtsMapper( + private val scene: EtsScene, + sourceRoots: List, +) { + private val sourceLocations = SourceLocationNormalizer(sourceRoots) + private val sceneStatements = scene.projectClasses + .flatMap { etsClass -> etsClass.methods } + .flatMap { method -> method.cfg.stmts } + + /** 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 = resolveEntryPoint(manifest.predicate, manifest) + val precondition = manifest.precondition?.let { entryPoint -> + resolveEntryPoint(entryPoint, manifest) + } + val coverageMapping = coverage?.let { artifact -> mapCoverage(propertyId, artifact) } ?: unsupportedCoverage() + + return PropertyEtsMappingArtifact( + propertyId = propertyId, + provenance = EtsMappingProvenance( + sourceRoots = sourceLocations.normalizedSourceRoots.map(Path::toString), + coordinates = EtsSourceCoordinateSystem.TYPESCRIPT_UTF16_ZERO_BASED_HALF_OPEN, + branchSuccessorOrder = EtsBranchSuccessorOrder.TRUE_FALSE, + ), + predicate = predicate, + precondition = precondition, + coverage = coverageMapping, + ) + } + + private fun unsupportedCoverage(): EtsCoverageMapping = EtsCoverageMapping( + status = EtsMappingStatus.UNSUPPORTED, + backendProvenance = null, + diagnostics = listOf( + EtsMappingDiagnostic( + code = "mapping.coverage.unavailable", + message = "The property backend returned no source coverage artifact", + ), + ), + ) + + private fun mapCoverage( + propertyId: PropertyId, + coverage: PropertyCoverageArtifact, + ): EtsCoverageMapping { + if (coverage.propertyId != propertyId) { + return EtsCoverageMapping( + status = EtsMappingStatus.UNSUPPORTED, + backendProvenance = coverage.provenance, + diagnostics = listOf( + EtsMappingDiagnostic( + code = "mapping.coverage.property-id.mismatch", + message = "Coverage property ${coverage.propertyId.value} does not match ${propertyId.value}", + ), + ), + ) + } + + 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, + ) + } + + return EtsCoverageMapping( + status = aggregateStatus( + statements.map { statement -> statement.mapping.status } + + branches.flatMap { branch -> + listOf(branch.mapping.status) + branch.arms.map { arm -> arm.mapping.status } + }, + ), + backendProvenance = coverage.provenance, + statements = statements, + branches = branches, + diagnostics = diagnostics, + ) + } + + private fun mapBranchCoverage( + sourcePath: String, + coverage: BranchCoverage, + ): EtsBranchCoverageMapping { + val normalization = runCatching { sourceLocations.normalizeRange(sourcePath, coverage.location) } + val location = normalization.getOrNull() + if (location == null) { + return unsupportedBranchCoverage( + sourcePath = sourcePath, + coverage = coverage, + location = null, + diagnostic = sourceNormalizationDiagnostic(sourcePath, normalization.exceptionOrNull()), + normalizeArmLocations = false, + ) + } + + if (coverage.type != ISTANBUL_IF_BRANCH_TYPE || coverage.arms.size != BINARY_BRANCH_ARM_COUNT) { + val diagnostic = EtsMappingDiagnostic( + code = "mapping.branch.shape.unsupported", + message = "EtsIR branch mapping requires an if branch with exactly two ordered coverage arms", + sourcePath = location.path, + ) + + return unsupportedBranchCoverage( + sourcePath = sourcePath, + coverage = coverage, + location = location, + diagnostic = diagnostic, + normalizeArmLocations = true, + ) + } + + val conditionsInSourceFile = sceneStatements + .filterIsInstance() + .filter { statement -> statement.belongsTo(location.path) } + if (conditionsInSourceFile.isNotEmpty() && conditionsInSourceFile.none { it.location.origin != null }) { + val diagnostic = EtsMappingDiagnostic( + code = "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 conditions = conditionsInSourceFile + .filter { statement -> statement.hasOriginWithin(location) } + if (conditions.any { statement -> statement.successorCount() != BINARY_BRANCH_ARM_COUNT }) { + val diagnostic = EtsMappingDiagnostic( + code = "mapping.branch.cfg.unsupported", + message = "EtsIR branch mapping requires exactly two ordered CFG successors", + sourcePath = location.path, + ) + + return unsupportedBranchCoverage( + sourcePath = sourcePath, + coverage = coverage, + location = location, + diagnostic = diagnostic, + normalizeArmLocations = true, + ) + } + + val distinctOrigins = conditions + .mapNotNull { statement -> statement.location.origin } + .distinct() + val mapping = branchMapping(location, conditions, distinctOrigins.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, + distinctOriginCount: Int, + ): EtsMappingResult = when { + distinctOriginCount == 1 -> EtsMappingResult( + status = EtsMappingStatus.EXACT, + targets = conditions.map(::EtsBranchTarget), + ) + + distinctOriginCount > 1 -> EtsMappingResult( + status = EtsMappingStatus.AMBIGUOUS, + targets = conditions.map(::EtsBranchTarget), + diagnostics = listOf( + EtsMappingDiagnostic( + code = "mapping.branch.ambiguous", + message = "The covered TypeScript branch contains several EtsIR conditions", + sourcePath = location.path, + ), + ), + ) + + else -> EtsMappingResult( + status = EtsMappingStatus.UNMAPPED, + targets = emptyList(), + diagnostics = listOf( + EtsMappingDiagnostic( + code = "mapping.branch.unmapped", + message = "No EtsIR condition belongs to the covered TypeScript branch", + sourcePath = location.path, + ), + ), + ) + } + + 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) { + return EtsBranchArmCoverageMapping( + coverage = coverage, + location = null, + mapping = unsupportedMapping( + sourceNormalizationDiagnostic(sourcePath, normalization.exceptionOrNull()), + ), + ) + } + + val targets = branchMapping.targets.map { branch -> + val graph = branch.statement.location.method.cfg + val successors = graph.successors(branch.statement).toList() + + EtsBranchArmTarget( + condition = branch.statement, + outcome = armIndex == TRUE_BRANCH_ARM_INDEX, + successor = successors[armIndex], + ) + } + + return EtsBranchArmCoverageMapping( + coverage = coverage, + location = location, + mapping = EtsMappingResult( + status = branchMapping.status, + targets = targets, + diagnostics = branchMapping.diagnostics, + ), + ) + } + + private fun mapStatementCoverage( + sourcePath: String, + coverage: StatementCoverage, + ): EtsStatementCoverageMapping { + val normalization = runCatching { sourceLocations.normalizeRange(sourcePath, coverage.location) } + val location = normalization.getOrNull() + if (location == null) { + return EtsStatementCoverageMapping( + coverage = coverage, + location = null, + mapping = unsupportedMapping( + sourceNormalizationDiagnostic(sourcePath, normalization.exceptionOrNull()), + ), + ) + } + + val statementsInSourceFile = sceneStatements + .filter { statement -> statement.belongsTo(location.path) } + if (statementsInSourceFile.isNotEmpty() && statementsInSourceFile.none { it.location.origin != null }) { + return EtsStatementCoverageMapping( + coverage = coverage, + location = location, + mapping = unsupportedMapping( + EtsMappingDiagnostic( + code = "mapping.source-origins.unsupported", + message = "EtsIR statements for the covered source file have no source origins", + sourcePath = location.path, + ), + ), + ) + } + + val exactTargets = sceneStatements + .filter { statement -> statement.hasOrigin(location) } + .map(::EtsStatementTarget) + val containedStatements = sceneStatements + .filter { statement -> statement.hasOriginWithin(location) } + val distinctContainedOrigins = containedStatements + .mapNotNull { statement -> statement.location.origin } + .distinct() + val mapping = when { + exactTargets.isNotEmpty() -> EtsMappingResult( + status = EtsMappingStatus.EXACT, + targets = exactTargets, + ) + + distinctContainedOrigins.size == 1 -> EtsMappingResult( + status = EtsMappingStatus.EXACT, + targets = containedStatements.map(::EtsStatementTarget), + ) + + distinctContainedOrigins.size > 1 -> EtsMappingResult( + status = EtsMappingStatus.AMBIGUOUS, + targets = containedStatements.map(::EtsStatementTarget), + diagnostics = listOf( + EtsMappingDiagnostic( + code = "mapping.statement.ambiguous", + message = "The covered TypeScript range contains several distinct EtsIR source spans", + sourcePath = location.path, + ), + ), + ) + + else -> EtsMappingResult( + status = EtsMappingStatus.UNMAPPED, + targets = emptyList(), + diagnostics = listOf( + EtsMappingDiagnostic( + code = "mapping.statement.unmapped", + message = "No EtsIR statement has the covered TypeScript source span", + sourcePath = location.path, + ), + ), + ) + } + + return EtsStatementCoverageMapping( + coverage = coverage, + location = location, + mapping = mapping, + ) + } + + private fun sourceNormalizationDiagnostic( + sourcePath: String, + failure: Throwable?, + ): EtsMappingDiagnostic { + val diagnosticCode = if (failure is UnsupportedSourceLocationException) { + "mapping.source.location.unsupported" + } else { + "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 EtsStmt.belongsTo(path: String): Boolean { + val enclosingClass = location.method.signature.enclosingClass + val fileName = enclosingClass.file.fileName + + return sourceLocations.normalizePath(fileName).any { candidate -> candidate.toString() == path } + } + + private fun EtsIfStmt.successorCount(): Int = location.method.cfg.successors(this).size + + private fun org.jacodb.ets.model.EtsSourceSpan.hasPath(path: String): Boolean = + sourceLocations.normalizePath(fileName).any { candidate -> candidate.toString() == path } + + 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 fun resolveEntryPoint( + entryPoint: TypeScriptEntryPoint, + manifest: PropertyManifest, + ): EtsMappingResult { + if (sourceLocations.sourceRootDiagnostics.isNotEmpty()) { + return EtsMappingResult( + status = EtsMappingStatus.UNSUPPORTED, + targets = emptyList(), + diagnostics = sourceLocations.sourceRootDiagnostics, + ) + } + + val methods = scene.projectFiles + .filter { candidate -> candidate.matches(entryPoint.module) } + .flatMap { file -> resolveExportedMethods(file, entryPoint.exportName, visited = emptySet()) } + .distinctByIdentity() + + if (methods.any { method -> method.parameters.size != manifest.inputs.size }) { + return EtsMappingResult( + status = EtsMappingStatus.UNSUPPORTED, + targets = emptyList(), + diagnostics = listOf( + EtsMappingDiagnostic( + code = "mapping.entry-point.bindings.unsupported", + message = "Property inputs do not match EtsIR parameters for ${entryPoint.exportName}", + sourcePath = entryPoint.module, + ), + ), + ) + } + + val targets = methods.map { method -> + EtsEntryPointTarget( + method = method, + bindings = method.bindingsFor(manifest), + ) + } + + if (targets.size == 1) { + return EtsMappingResult( + status = EtsMappingStatus.EXACT, + targets = targets, + ) + } + if (targets.size > 1) { + return EtsMappingResult( + status = EtsMappingStatus.AMBIGUOUS, + targets = targets, + diagnostics = listOf( + EtsMappingDiagnostic( + code = "mapping.entry-point.ambiguous", + message = "Several EtsIR methods match ${entryPoint.module}#${entryPoint.exportName}", + sourcePath = entryPoint.module, + ), + ), + ) + } + + return EtsMappingResult( + status = EtsMappingStatus.UNMAPPED, + targets = emptyList(), + diagnostics = listOf( + EtsMappingDiagnostic( + code = "mapping.entry-point.unmapped", + message = "No EtsIR method matches ${entryPoint.module}#${entryPoint.exportName}", + sourcePath = entryPoint.module, + ), + ), + ) + } + + private fun resolveExportedMethods( + file: EtsFile, + exportName: String, + visited: Set, + ): List { + if (file in visited) return emptyList() + + val namedRuntimeExports = file.exportInfos.filter { export -> + export.name == exportName && export.type != EtsExportType.TYPE + } + val matchingExports = namedRuntimeExports.ifEmpty { + file.exportInfos.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 reExportedMethods = matchingExports + .filter { export -> export.isReExport && !export.isNamespaceStarReExport } + .flatMap { export -> + val targetExportName = if (export.isBareStarReExport) exportName else export.originalName + + resolveReExportFiles(file, requireNotNull(export.from)).flatMap { targetFile -> + resolveExportedMethods(targetFile, targetExportName, visited + file) + } + } + + return (directMethods + reExportedMethods).distinctByIdentity() + } + + 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 fun resolveReExportFiles(file: EtsFile, module: String): List { + val targetPaths = sourceLocations.normalizePath(file.name).flatMapTo(linkedSetOf()) { sourcePath -> + val targetPath = requireNotNull(sourcePath.parent).resolve(module).normalize() + + sourceLocations.modulePathCandidates(targetPath) + } + + return scene.projectFiles.filter { candidate -> + sourceLocations.normalizePath(candidate.name).any(targetPaths::contains) + } + } + + private fun EtsFile.matches(module: String): Boolean { + val modulePaths = sourceLocations.normalizePath(module).flatMapTo(linkedSetOf()) { path -> + sourceLocations.modulePathCandidates(path) + } + val filePaths = sourceLocations.normalizePath(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 companion object { + const val BINARY_BRANCH_ARM_COUNT = 2 + const val DEFAULT_EXPORT_NAME = "default" + const val ISTANBUL_IF_BRANCH_TYPE = "if" + const val RECEIVER_STACK_SLOT = 0 + const val RECEIVER_STACK_SLOTS = 1 + const val TRUE_BRANCH_ARM_INDEX = 0 + } +} 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..dbf9db615 --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/SourceLocationNormalizer.kt @@ -0,0 +1,154 @@ +package org.usvm.ts.pbt.mapping + +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 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 normalizeRange(sourcePath: String, range: SourceRange): NormalizedSourceRange { + val path = normalizePath(sourcePath).single() + val source = Files.readString(path) + val lines = source.sourceLines() + val start = range.start.normalize(lines) + val end = range.end.normalize(lines) + if (end.offset < start.offset) { + throw UnsupportedSourceLocationException("Source range end precedes its start") + } + + return NormalizedSourceRange( + path = path.toString(), + start = start, + end = end, + ) + } + + fun normalizePath(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 modulePathCandidates(path: Path): Set { + val candidates = buildList { + add(path) + val name = path.fileName?.toString().orEmpty() + if (name.endsWith(".ts") || name.endsWith(".ets")) return@buildList + + add(path.resolveSibling("$name.ts")) + add(path.resolveSibling("$name.ets")) + add(path.resolveSibling("$name.d.ts")) + add(path.resolve("index.ts")) + add(path.resolve("index.ets")) + add(path.resolve("index.d.ts")) + } + + return candidates.mapTo(linkedSetOf()) { candidate -> candidate.canonicalizeIfExisting() } + } + + private fun SourcePosition.normalize(lines: List): NormalizedSourcePosition { + val zeroBasedLine = line - ISTANBUL_LINE_BASE + val sourceLine = lines.getOrNull(zeroBasedLine) + ?: throw UnsupportedSourceLocationException("Source line $line is outside the file") + val offset = sourceLine.startOffset + column + if (offset > sourceLine.endOffset) { + throw UnsupportedSourceLocationException( + "Source column $column is outside line $line", + ) + } + + return NormalizedSourcePosition( + line = zeroBasedLine, + column = column, + offset = offset, + ) + } + + private fun String.sourceLines(): List = buildList { + var lineStart = 0 + var index = 0 + while (index < length) { + val terminatorLength = when (this@sourceLines[index]) { + '\r' -> if (this@sourceLines.getOrNull(index + 1) == '\n') 2 else 1 + '\n', '\u2028', '\u2029' -> 1 + else -> 0 + } + if (terminatorLength == 0) { + index++ + continue + } + + add(SourceLine(startOffset = lineStart, endOffset = index)) + index += terminatorLength + lineStart = index + } + + add(SourceLine(startOffset = lineStart, endOffset = length)) + } + + 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 = + SourceRootResolution( + path = path, + diagnostic = EtsMappingDiagnostic( + code = "mapping.source-root.unsupported", + message = "Cannot resolve TypeScript source root $index ($path): $reason", + sourcePath = path.toString(), + ), + ) + + private fun Path.canonicalizeIfExisting(): Path { + val absolutePath = if (isAbsolute) this else toAbsolutePath() + + return try { + absolutePath.toRealPath() + } catch (_: IOException) { + absolutePath.normalize() + } + } + + private companion object { + const val ISTANBUL_LINE_BASE = 1 + } +} + +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) 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..befa277be --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsExportResolutionTest.kt @@ -0,0 +1,111 @@ +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.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 + +class PropertyEtsExportResolutionTest { + @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 declaration does not mask a bare star value export`() { + val sourceDirectory = testResourcePath("/mapping/exports") + val sources = listOf("TypeOnlyPrecedenceEntry.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() + assertEquals("predicate", target.method.name) + } + + @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) + } + + private fun mapper(vararg sources: Path): PropertyEtsMapper { + val files = sources.map { source -> + loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + } + + return PropertyEtsMapper( + scene = EtsScene(files), + sourceRoots = listOf(sources.first().parent), + ) + } + + 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..3e8c88589 --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsMapperTest.kt @@ -0,0 +1,1007 @@ +package org.usvm.ts.pbt.mapping + +import org.jacodb.ets.model.EtsBlockCfg +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.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 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 `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) + } +} + +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 file = loadEtsFileAutoConvert(source, 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)), + sourceRoots = listOf(source.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 }) + } +} + +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 `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..0bc6d52e9 --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsSourceNormalizationTest.kt @@ -0,0 +1,158 @@ +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 + +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( + sourceRoot = 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( + sourceRoot = 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 }) + } + + 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( + sourceRoot: Path, + 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 = listOf(sourceRoot.toString()), + request = PropertyCoverageRequest(), + ), + files = listOf( + SourceFileCoverage( + path = sourcePath.toString(), + statements = statements, + functions = emptyList(), + branches = emptyList(), + ), + ), + ) +} 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/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/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/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/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..40ce04caf --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/TypeOnlyPrecedenceEntry.ts @@ -0,0 +1,2 @@ +export type predicate = (value: number) => boolean; +export * from './StarPredicate'; 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; +} From 68d720a405527122ec27eb033ca03eff0ab5becb Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 29 Aug 2026 21:37:59 +0300 Subject: [PATCH 02/16] [TS PBT] Harden projection protocol boundaries --- .../fastcheck/FastCheckProjectionClient.kt | 260 ++++++++++++++++-- .../org/usvm/ts/pbt/model/JsConcreteValue.kt | 92 ++++++- .../ts/pbt/validation/PropertyValidation.kt | 8 +- .../FastCheckProjectionClientTest.kt | 170 +++++++++++- .../usvm/ts/pbt/model/JsConcreteValueTest.kt | 53 ++++ .../pbt/validation/PropertyValidationTest.kt | 19 ++ 6 files changed, 559 insertions(+), 43 deletions(-) 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..2dcae2ee8 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,9 +4,32 @@ import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import org.usvm.ts.pbt.PbtDiagnosticCode import org.usvm.ts.pbt.manifest.PropertyManifestJson +import org.usvm.ts.pbt.model.contains +import java.io.ByteArrayOutputStream import java.io.IOException +import java.io.InputStream import java.nio.file.Path +import java.util.concurrent.ExecutionException import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit + +/** Internal transport limits for bounded projection-process communication. */ +internal data class FastCheckProjectionTransportLimits( + val maxRequestBytes: Int, + val maxStdoutBytes: Int, + val maxStderrBytes: Int, + val wallClockTimeoutMillis: Long, + 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(wallClockTimeoutMillis > 0) { "Projection wall-clock timeout must be positive" } + require(shutdownGraceMillis > 0) { "Projection shutdown grace period must be positive" } + } +} /** * Synchronous Kotlin client for the private fast-check Node adapter. @@ -14,15 +37,39 @@ import java.util.concurrent.Executors * 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(), +class FastCheckProjectionClient private constructor( + private val nodeExecutable: String, + private val adapterEntryPoint: Path, + private val transportLimits: FastCheckProjectionTransportLimits, + @Suppress("UNUSED_PARAMETER") internalConstructorMarker: Unit, ) { + constructor( + nodeExecutable: String = "node", + adapterEntryPoint: Path = FastCheckRuntime.projectionEntryPoint(), + ) : this( + nodeExecutable = nodeExecutable, + adapterEntryPoint = adapterEntryPoint, + transportLimits = DEFAULT_TRANSPORT_LIMITS, + internalConstructorMarker = Unit, + ) + + internal constructor( + nodeExecutable: String = "node", + adapterEntryPoint: Path = FastCheckRuntime.projectionEntryPoint(), + transportLimits: FastCheckProjectionTransportLimits, + ) : this( + nodeExecutable = nodeExecutable, + adapterEntryPoint = adapterEntryPoint, + transportLimits = transportLimits, + internalConstructorMarker = Unit, + ) + /** Projects the requested domains to fast-check and returns the generated samples. */ fun sample(request: FastCheckProjectionRequest): FastCheckProjectionResponse { validateRequest(request) - val response = decodeResponse(invokeAdapter(request)) + val encodedRequest = encodeRequest(request) + val response = decodeResponse(invokeAdapter(encodedRequest)) throwBackendError(response) validateSuccessfulResponse(request, response) @@ -54,47 +101,150 @@ class FastCheckProjectionClient( val hasExpectedArity = response.samples.all { it.size == request.domains.size } if (!hasExpectedStatus || !hasExpectedSampleCount || !hasExpectedArity) { + 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]", + ) + } + } + } + } + + private fun encodeRequest(request: FastCheckProjectionRequest): String { + val encodedRequest = PropertyManifestJson.json.encodeToString(request) + if (encodedRequest.toByteArray(Charsets.UTF_8).size > transportLimits.maxRequestBytes) { throw FastCheckProjectionException( - code = PbtDiagnosticCode.BACKEND_RESPONSE_INVALID, - message = "fast-check adapter returned an invalid successful response", + code = PbtDiagnosticCode.BACKEND_REQUEST_TOO_LARGE, + message = "fast-check projection request exceeds ${transportLimits.maxRequestBytes} bytes", ) } + + return encodedRequest } - private fun invokeAdapter(request: FastCheckProjectionRequest): String { + private fun invokeAdapter(encodedRequest: String): String { val process = startAdapter() - val errorReaderExecutor = Executors.newSingleThreadExecutor() - val stderr = errorReaderExecutor.submit { - process.errorStream.bufferedReader(Charsets.UTF_8).use { reader -> reader.readText() } - } + val ioExecutor = Executors.newFixedThreadPool(IO_TASKS) try { - process.outputStream.bufferedWriter(Charsets.UTF_8).use { writer -> - writer.write(PropertyManifestJson.json.encodeToString(request)) + val stdout = ioExecutor.submit { + process.inputStream.readProjectionBounded(transportLimits.maxStdoutBytes) + } + val stderr = ioExecutor.submit { + process.errorStream.readProjectionBounded(transportLimits.maxStderrBytes) + } + val writer = ioExecutor.submit { + process.outputStream.bufferedWriter(Charsets.UTF_8).use { output -> + output.write(encodedRequest) + } } - val stdout = process.inputStream.bufferedReader(Charsets.UTF_8).use { reader -> reader.readText() } - val exitCode = process.waitFor() - val stderrText = stderr.get() + awaitProcess(process) - if (exitCode != 0) { + awaitIo( + task = writer, + operation = "writing the fast-check projection request", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_WRITE_FAILED, + ) + + val stdoutText = awaitIo( + task = stdout, + operation = "reading fast-check projection stdout", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + ) + val stderrText = awaitIo( + task = stderr, + operation = "reading fast-check projection stderr", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + ) + + if (process.exitValue() != 0) { throw FastCheckProjectionException( code = PbtDiagnosticCode.BACKEND_PROCESS_FAILED, - message = "fast-check adapter exited with code $exitCode: ${stderrText.trim()}", + message = "fast-check adapter exited with code ${process.exitValue()}: ${stderrText.text.trim()}", + ) + } + + if (stdoutText.exceeded) { + throw FastCheckProjectionException( + code = PbtDiagnosticCode.BACKEND_RESPONSE_TOO_LARGE, + message = "fast-check projection stdout exceeds ${transportLimits.maxStdoutBytes} bytes", ) } - if (stdout.isBlank()) { + if (stderrText.exceeded) { + throw FastCheckProjectionException( + code = PbtDiagnosticCode.BACKEND_RESPONSE_TOO_LARGE, + message = "fast-check projection stderr exceeds ${transportLimits.maxStderrBytes} bytes", + ) + } + + if (stdoutText.text.isBlank()) { throw FastCheckProjectionException( code = PbtDiagnosticCode.BACKEND_RESPONSE_EMPTY, message = "fast-check adapter returned an empty response", ) } - return stdout + return stdoutText.text } finally { - errorReaderExecutor.shutdownNow() + closeStreams(process) + if (process.isAlive) { + terminate(process) + } + ioExecutor.shutdownNow() + } + } + + private fun awaitProcess(process: Process) { + val completed = try { + process.waitFor(transportLimits.wallClockTimeoutMillis, TimeUnit.MILLISECONDS) + } catch (error: InterruptedException) { + Thread.currentThread().interrupt() + + throw FastCheckProjectionException( + code = PbtDiagnosticCode.BACKEND_PROCESS_INTERRUPTED, + message = "Interrupted while waiting for the fast-check projection adapter", + cause = error, + ) } + + if (!completed) { + terminate(process) + + throw FastCheckProjectionException( + code = PbtDiagnosticCode.BACKEND_PROCESS_TIMEOUT, + message = "fast-check projection adapter exceeded the ${transportLimits.wallClockTimeoutMillis} ms timeout", + ) + } + } + + private fun awaitIo( + task: Future, + operation: String, + failureCode: String, + ): T = try { + task.get() + } catch (error: InterruptedException) { + Thread.currentThread().interrupt() + + throw FastCheckProjectionException( + code = PbtDiagnosticCode.BACKEND_PROCESS_INTERRUPTED, + message = "Interrupted while $operation", + cause = error, + ) + } catch (error: ExecutionException) { + throw FastCheckProjectionException( + code = failureCode, + message = "Failed while $operation: ${error.cause?.message}", + cause = error.cause, + ) } private fun startAdapter(): Process = try { @@ -117,21 +267,83 @@ class FastCheckProjectionClient( ) } - private fun invalidResponse(message: String): Nothing = throw FastCheckProjectionException( + private fun invalidResponse(message: String, path: String? = null): Nothing = throw FastCheckProjectionException( code = PbtDiagnosticCode.BACKEND_RESPONSE_INVALID, message = message, + path = path, ) private fun validateRequest(request: FastCheckProjectionRequest) { - val hasValidSampleCount = request.numSamples > 0 + val hasValidSampleCount = request.numSamples in 1..MAX_SAMPLES val hasDomains = request.domains.isNotEmpty() if (!hasValidSampleCount || !hasDomains) { 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 closeStreams(process: Process) { + runCatching { process.outputStream.close() } + runCatching { process.inputStream.close() } + runCatching { process.errorStream.close() } + } + + private fun terminate(process: Process) { + process.destroy() + + try { + if (!process.waitFor(transportLimits.shutdownGraceMillis, TimeUnit.MILLISECONDS)) { + process.destroyForcibly() + process.waitFor(transportLimits.shutdownGraceMillis, TimeUnit.MILLISECONDS) + } + } catch (error: InterruptedException) { + process.destroyForcibly() + Thread.currentThread().interrupt() + } + } + + 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 + const val IO_TASKS = 3 + + 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, + ) + } +} + +private data class ProjectionBoundedText(val text: String, val exceeded: Boolean) + +private fun InputStream.readProjectionBounded(limit: Int): ProjectionBoundedText { + 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 ProjectionBoundedText( + text = output.toString(Charsets.UTF_8), + exceeded = exceeded, + ) } 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..6c7a6fef1 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,11 +12,9 @@ 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 /** Tags the finite and non-finite cases of an ECMAScript binary64 value. */ @@ -169,22 +167,46 @@ 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")) + "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" -> deserializeArray(jsonDecoder, 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 +222,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 +260,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/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/fastcheck/FastCheckProjectionClientTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClientTest.kt index 6b3469e0f..94f832bcf 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,14 +1,19 @@ 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.TimeUnit 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 @@ -47,6 +52,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 +104,123 @@ 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 `wall clock timeout returns promptly and terminates the adapter`() { + val pidFile = createTempFile(prefix = "fast-check-adapter-pid-", suffix = ".txt") + val terminationMarker = createTempFile(prefix = "fast-check-adapter-termination-", suffix = ".txt") + pidFile.deleteIfExists() + terminationMarker.deleteIfExists() + + try { + withTemporaryAdapter( + source = """ + import { writeFileSync } from 'node:fs' + writeFileSync(${pidFile.toJavaScriptStringLiteral()}, String(process.pid)) + process.on('SIGTERM', () => { + writeFileSync(${terminationMarker.toJavaScriptStringLiteral()}, 'terminated') + process.exit(0) + }) + 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 < 2_000, "Projection timeout took $elapsedMillis ms") + assertEquals("terminated", terminationMarker.readText()) + } + } finally { + terminateAdapter(pidFile) + pidFile.deleteIfExists() + terminationMarker.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 +268,51 @@ 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, + ) = FastCheckProjectionTransportLimits( + maxRequestBytes = maxRequestBytes, + maxStdoutBytes = maxStdoutBytes, + maxStderrBytes = maxStderrBytes, + wallClockTimeoutMillis = wallClockTimeoutMillis, + shutdownGraceMillis = 25, + ) + + private fun Path.toJavaScriptStringLiteral(): String = "'${toString().replace("\\", "\\\\").replace("'", "\\'")}'" + + private fun terminateAdapter(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 { val validRequest = FastCheckProjectionRequest( seed = 42, 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..7f71ad536 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.encodeToString +import kotlinx.serialization.SerializationException 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,55 @@ 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 `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) From ba775f00d539d71f83b06249254861b9ea9756c9 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 29 Aug 2026 22:01:13 +0300 Subject: [PATCH 03/16] [TS PBT] Bound projection adapter I/O waits --- .../fastcheck/FastCheckProjectionClient.kt | 283 ++++++++++++++---- .../FastCheckProjectionClientTest.kt | 121 +++++++- .../usvm/ts/pbt/model/JsConcreteValueTest.kt | 20 ++ 3 files changed, 354 insertions(+), 70 deletions(-) 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 2dcae2ee8..b24434165 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 @@ -13,6 +13,7 @@ import java.util.concurrent.ExecutionException import java.util.concurrent.Executors import java.util.concurrent.Future import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException /** Internal transport limits for bounded projection-process communication. */ internal data class FastCheckProjectionTransportLimits( @@ -130,70 +131,60 @@ class FastCheckProjectionClient private constructor( private fun invokeAdapter(encodedRequest: String): String { val process = startAdapter() + val deadlineNanos = deadlineAfter(transportLimits.wallClockTimeoutMillis) val ioExecutor = Executors.newFixedThreadPool(IO_TASKS) + var stdout: Future? = null + var stderr: Future? = null + var writer: Future<*>? = null try { - val stdout = ioExecutor.submit { - process.inputStream.readProjectionBounded(transportLimits.maxStdoutBytes) + stdout = ioExecutor.submit { + process.inputStream.readProjectionBounded( + limit = transportLimits.maxStdoutBytes, + stream = "stdout", + ) } - val stderr = ioExecutor.submit { - process.errorStream.readProjectionBounded(transportLimits.maxStderrBytes) + stderr = ioExecutor.submit { + process.errorStream.readProjectionBounded( + limit = transportLimits.maxStderrBytes, + stream = "stderr", + ) } - val writer = ioExecutor.submit { + val writerTask = ioExecutor.submit { process.outputStream.bufferedWriter(Charsets.UTF_8).use { output -> output.write(encodedRequest) } } - - awaitProcess(process) - - awaitIo( - task = writer, - operation = "writing the fast-check projection request", - failureCode = PbtDiagnosticCode.BACKEND_PROCESS_WRITE_FAILED, - ) - - val stdoutText = awaitIo( - task = stdout, - operation = "reading fast-check projection stdout", - failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, - ) - val stderrText = awaitIo( - task = stderr, - operation = "reading fast-check projection stderr", - failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + writer = writerTask + + val output = awaitAdapter( + process = process, + writer = writerTask, + stdout = requireNotNull(stdout), + stderr = requireNotNull(stderr), + deadlineNanos = deadlineNanos, ) if (process.exitValue() != 0) { throw FastCheckProjectionException( code = PbtDiagnosticCode.BACKEND_PROCESS_FAILED, - message = "fast-check adapter exited with code ${process.exitValue()}: ${stderrText.text.trim()}", - ) - } - - if (stdoutText.exceeded) { - throw FastCheckProjectionException( - code = PbtDiagnosticCode.BACKEND_RESPONSE_TOO_LARGE, - message = "fast-check projection stdout exceeds ${transportLimits.maxStdoutBytes} bytes", - ) - } - - if (stderrText.exceeded) { - throw FastCheckProjectionException( - code = PbtDiagnosticCode.BACKEND_RESPONSE_TOO_LARGE, - message = "fast-check projection stderr exceeds ${transportLimits.maxStderrBytes} bytes", + message = "fast-check adapter exited with code ${process.exitValue()}: ${output.stderr.text.trim()}", ) } - if (stdoutText.text.isBlank()) { + if (output.stdout.text.isBlank()) { throw FastCheckProjectionException( code = PbtDiagnosticCode.BACKEND_RESPONSE_EMPTY, message = "fast-check adapter returned an empty response", ) } - return stdoutText.text + return output.stdout.text } finally { + stdout?.cancel(true) + stderr?.cancel(true) + writer?.cancel(true) + closeStreams(process) if (process.isAlive) { terminate(process) @@ -202,25 +193,144 @@ class FastCheckProjectionClient private constructor( } } - private fun awaitProcess(process: Process) { - val completed = try { - process.waitFor(transportLimits.wallClockTimeoutMillis, TimeUnit.MILLISECONDS) - } catch (error: InterruptedException) { - Thread.currentThread().interrupt() + private fun awaitAdapter( + process: Process, + writer: Future<*>, + stdout: Future, + stderr: Future, + deadlineNanos: Long, + ): ProjectionAdapterOutput { + while (true) { + checkCompletedIo( + task = stdout, + operation = "reading fast-check projection stdout", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + ) + checkCompletedIo( + task = stderr, + operation = "reading fast-check projection stderr", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + ) + checkCompletedIo( + task = writer, + operation = "writing the fast-check projection request", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_WRITE_FAILED, + ) - throw FastCheckProjectionException( - code = PbtDiagnosticCode.BACKEND_PROCESS_INTERRUPTED, - message = "Interrupted while waiting for the fast-check projection adapter", - cause = error, + val waitMillis = minOf(remainingMillis(deadlineNanos), PROCESS_POLL_MILLIS) + if (waitMillis == 0L) projectionTimeout() + + val completed = try { + process.waitFor(waitMillis, TimeUnit.MILLISECONDS) + } catch (error: InterruptedException) { + Thread.currentThread().interrupt() + + throw FastCheckProjectionException( + code = PbtDiagnosticCode.BACKEND_PROCESS_INTERRUPTED, + message = "Interrupted while waiting for the fast-check projection adapter", + cause = error, + ) + } + + if (completed) { + return awaitIoAfterProcessExit( + writer = writer, + stdout = stdout, + stderr = stderr, + deadlineNanos = deadlineNanos, + ) + } + } + } + + private fun awaitIoAfterProcessExit( + writer: Future<*>, + stdout: Future, + stderr: Future, + deadlineNanos: Long, + ): ProjectionAdapterOutput { + while (!writer.isDone || !stdout.isDone || !stderr.isDone) { + checkCompletedIo( + task = stdout, + operation = "reading fast-check projection stdout", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + ) + checkCompletedIo( + task = stderr, + operation = "reading fast-check projection stderr", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + ) + checkCompletedIo( + task = writer, + operation = "writing the fast-check projection request", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_WRITE_FAILED, ) + + val waitMillis = minOf(remainingMillis(deadlineNanos), IO_POLL_MILLIS) + if (waitMillis == 0L) projectionTimeout() + + when { + !stdout.isDone -> awaitIo( + task = stdout, + operation = "reading fast-check projection stdout", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + waitMillis = waitMillis, + ) + + !stderr.isDone -> awaitIo( + task = stderr, + operation = "reading fast-check projection stderr", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + waitMillis = waitMillis, + ) + + else -> awaitIo( + task = writer, + operation = "writing the fast-check projection request", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_WRITE_FAILED, + waitMillis = waitMillis, + ) + } } - if (!completed) { - terminate(process) + awaitIo( + task = writer, + operation = "writing the fast-check projection request", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_WRITE_FAILED, + waitMillis = 0, + ) - throw FastCheckProjectionException( - code = PbtDiagnosticCode.BACKEND_PROCESS_TIMEOUT, - message = "fast-check projection adapter exceeded the ${transportLimits.wallClockTimeoutMillis} ms timeout", + return ProjectionAdapterOutput( + stdout = requireNotNull( + awaitIo( + task = stdout, + operation = "reading fast-check projection stdout", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + waitMillis = 0, + ), + ), + stderr = requireNotNull( + awaitIo( + task = stderr, + operation = "reading fast-check projection stderr", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + waitMillis = 0, + ), + ), + ) + } + + private fun checkCompletedIo( + task: Future, + operation: String, + failureCode: String, + ) { + if (task.isDone) { + awaitIo( + task = task, + operation = operation, + failureCode = failureCode, + waitMillis = 0, ) } } @@ -229,8 +339,11 @@ class FastCheckProjectionClient private constructor( task: Future, operation: String, failureCode: String, - ): T = try { - task.get() + waitMillis: Long, + ): T? = try { + task.get(waitMillis, TimeUnit.MILLISECONDS) + } catch (_: TimeoutException) { + null } catch (error: InterruptedException) { Thread.currentThread().interrupt() @@ -240,13 +353,43 @@ class FastCheckProjectionClient private constructor( cause = error, ) } catch (error: ExecutionException) { + val cause = error.cause + if (cause is ProjectionOutputLimitExceeded) { + throw FastCheckProjectionException( + code = PbtDiagnosticCode.BACKEND_RESPONSE_TOO_LARGE, + message = "fast-check projection ${cause.stream} exceeds ${cause.limit} bytes", + cause = cause, + ) + } + throw FastCheckProjectionException( code = failureCode, - message = "Failed while $operation: ${error.cause?.message}", - cause = error.cause, + message = "Failed while $operation: ${cause?.message}", + cause = cause, ) } + 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 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).coerceAtLeast(1) + } + + private fun projectionTimeout(): Nothing = throw FastCheckProjectionException( + code = PbtDiagnosticCode.BACKEND_PROCESS_TIMEOUT, + message = "fast-check projection adapter exceeded the ${transportLimits.wallClockTimeoutMillis} ms timeout", + ) + private fun startAdapter(): Process = try { ProcessBuilder(nodeExecutable, adapterEntryPoint.toString()).start() } catch (error: IOException) { @@ -314,6 +457,8 @@ class FastCheckProjectionClient private constructor( const val DEFAULT_WALL_CLOCK_TIMEOUT_MILLIS = 60_000L const val DEFAULT_SHUTDOWN_GRACE_MILLIS = 250L const val IO_TASKS = 3 + const val PROCESS_POLL_MILLIS = 10L + const val IO_POLL_MILLIS = 10L val DEFAULT_TRANSPORT_LIMITS = FastCheckProjectionTransportLimits( maxRequestBytes = DEFAULT_MAX_REQUEST_BYTES, @@ -325,12 +470,21 @@ class FastCheckProjectionClient private constructor( } } -private data class ProjectionBoundedText(val text: String, val exceeded: Boolean) +private data class ProjectionAdapterOutput( + val stdout: ProjectionBoundedText, + val stderr: ProjectionBoundedText, +) -private fun InputStream.readProjectionBounded(limit: Int): ProjectionBoundedText { +private data class ProjectionBoundedText(val text: String) + +private class ProjectionOutputLimitExceeded( + val stream: String, + val limit: Int, +) : IOException("fast-check projection $stream exceeds $limit bytes") + +private fun InputStream.readProjectionBounded(limit: Int, stream: String): ProjectionBoundedText { val output = ByteArrayOutputStream(minOf(limit, DEFAULT_BUFFER_SIZE)) val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var exceeded = false while (true) { val read = read(buffer) @@ -339,11 +493,8 @@ private fun InputStream.readProjectionBounded(limit: Int): ProjectionBoundedText val remaining = limit - output.size() if (remaining > 0) output.write(buffer, 0, minOf(read, remaining)) - if (read > remaining) exceeded = true + if (read > remaining) throw ProjectionOutputLimitExceeded(stream, limit) } - return ProjectionBoundedText( - text = output.toString(Charsets.UTF_8), - exceeded = exceeded, - ) + return ProjectionBoundedText(text = output.toString(Charsets.UTF_8)) } 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 94f832bcf..2b1a3f3d9 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 @@ -177,6 +177,103 @@ class FastCheckProjectionClientTest { } } + @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)) + setInterval(() => process.stdout.write('x'.repeat(1025)), 1) + """.trimIndent(), + transportLimits = transportLimits(maxStdoutBytes = 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, "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 `parent exit with a descendant retaining a pipe reaches the adapter deadline`() { + 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', + 'setInterval(() => undefined, 1000)' + ], { stdio: 'inherit' }) + writeFileSync(${childPidFile.toJavaScriptStringLiteral()}, String(child.pid)) + setTimeout(() => process.exit(0), 25) + """.trimIndent(), + transportLimits = transportLimits(wallClockTimeoutMillis = 500), + ) { 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 < 2_000, "Descendant pipe timeout took $elapsedMillis ms") + } + } 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`() { @@ -305,12 +402,28 @@ class FastCheckProjectionClientTest { private fun Path.toJavaScriptStringLiteral(): String = "'${toString().replace("\\", "\\\\").replace("'", "\\'")}'" - private fun terminateAdapter(pidFile: Path) { - val pid = pidFile.takeIf(Files::exists)?.readText()?.trim()?.toLongOrNull() ?: return - val process = ProcessHandle.of(pid).orElse(null) ?: return + private fun terminateAdapter(pidFile: Path): Boolean { + val pid = pidFile.takeIf(Files::exists)?.readText()?.trim()?.toLongOrNull() ?: return true + val process = ProcessHandle.of(pid).orElse(null) ?: return true process.destroyForcibly() - process.onExit().get(1, TimeUnit.SECONDS) + + 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 = pidFile.readText().trim().toLong() + val process = ProcessHandle.of(pid).orElse(null) + + return process == null || !process.isAlive } private companion object { 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 7f71ad536..32b92e89f 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 @@ -93,6 +93,26 @@ class JsConcreteValueTest { } } + @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( From ea41799b0ffbbebdf79ed7eb22d30f6bdd28def6 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 29 Aug 2026 22:15:39 +0300 Subject: [PATCH 04/16] [TS PBT] Terminate projection process trees --- .../fastcheck/FastCheckProjectionClient.kt | 117 +++++++++++++++--- .../FastCheckProjectionClientTest.kt | 34 ++--- 2 files changed, 119 insertions(+), 32 deletions(-) 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 b24434165..0e71b6765 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 @@ -131,6 +131,7 @@ class FastCheckProjectionClient private constructor( private fun invokeAdapter(encodedRequest: String): String { val process = startAdapter() + val processTree = ProjectionProcessTree(process.toHandle()) val deadlineNanos = deadlineAfter(transportLimits.wallClockTimeoutMillis) val ioExecutor = Executors.newFixedThreadPool(IO_TASKS) var stdout: Future? = null @@ -159,6 +160,7 @@ class FastCheckProjectionClient private constructor( val output = awaitAdapter( process = process, + processTree = processTree, writer = writerTask, stdout = requireNotNull(stdout), stderr = requireNotNull(stderr), @@ -181,26 +183,27 @@ class FastCheckProjectionClient private constructor( return output.stdout.text } finally { + processTree.observe() stdout?.cancel(true) stderr?.cancel(true) writer?.cancel(true) closeStreams(process) - if (process.isAlive) { - terminate(process) - } + terminate(processTree = processTree, deadlineNanos = deadlineNanos) ioExecutor.shutdownNow() } } private fun awaitAdapter( process: Process, + processTree: ProjectionProcessTree, writer: Future<*>, stdout: Future, stderr: Future, deadlineNanos: Long, ): ProjectionAdapterOutput { while (true) { + processTree.observe() checkCompletedIo( task = stdout, operation = "reading fast-check projection stdout", @@ -217,8 +220,10 @@ class FastCheckProjectionClient private constructor( failureCode = PbtDiagnosticCode.BACKEND_PROCESS_WRITE_FAILED, ) - val waitMillis = minOf(remainingMillis(deadlineNanos), PROCESS_POLL_MILLIS) - if (waitMillis == 0L) projectionTimeout() + val remainingMillis = remainingMillis(deadlineNanos) + if (remainingMillis <= FORCED_TERMINATION_RESERVE_MILLIS) projectionTimeout() + + val waitMillis = minOf(remainingMillis, PROCESS_POLL_MILLIS) val completed = try { process.waitFor(waitMillis, TimeUnit.MILLISECONDS) @@ -233,6 +238,8 @@ class FastCheckProjectionClient private constructor( } if (completed) { + processTree.observe() + return awaitIoAfterProcessExit( writer = writer, stdout = stdout, @@ -266,8 +273,10 @@ class FastCheckProjectionClient private constructor( failureCode = PbtDiagnosticCode.BACKEND_PROCESS_WRITE_FAILED, ) - val waitMillis = minOf(remainingMillis(deadlineNanos), IO_POLL_MILLIS) - if (waitMillis == 0L) projectionTimeout() + val remainingMillis = remainingMillis(deadlineNanos) + if (remainingMillis <= FORCED_TERMINATION_RESERVE_MILLIS) projectionTimeout() + + val waitMillis = minOf(remainingMillis, IO_POLL_MILLIS) when { !stdout.isDone -> awaitIo( @@ -376,13 +385,21 @@ class FastCheckProjectionClient private constructor( 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).coerceAtLeast(1) + return TimeUnit.NANOSECONDS.toMillis(remainingNanos) } private fun projectionTimeout(): Nothing = throw FastCheckProjectionException( @@ -435,17 +452,50 @@ class FastCheckProjectionClient private constructor( runCatching { process.errorStream.close() } } - private fun terminate(process: Process) { - process.destroy() + private fun terminate(processTree: ProjectionProcessTree, deadlineNanos: Long) { + processTree.observe() + val processes = processTree.processesInTerminationOrder() + if (processes.none(ProcessHandle::isAlive)) return - try { - if (!process.waitFor(transportLimits.shutdownGraceMillis, TimeUnit.MILLISECONDS)) { - process.destroyForcibly() - process.waitFor(transportLimits.shutdownGraceMillis, TimeUnit.MILLISECONDS) + if (remainingMillis(deadlineNanos) <= FORCED_TERMINATION_RESERVE_MILLIS) { + processes.destroyForcibly() + awaitProcessTreeExit(processes, deadlineNanos) + + return + } + + processes.destroy() + + val gracefulDeadlineNanos = minOf( + deadlineBefore( + deadlineNanos = deadlineNanos, + durationMillis = FORCED_TERMINATION_RESERVE_MILLIS, + ), + deadlineAfter(transportLimits.shutdownGraceMillis), + ) + if (awaitProcessTreeExit(processes, gracefulDeadlineNanos)) return + + processes.destroyForcibly() + awaitProcessTreeExit(processes, deadlineNanos) + } + + private fun awaitProcessTreeExit(processes: List, deadlineNanos: Long): Boolean { + while (true) { + val liveProcess = processes.firstOrNull(ProcessHandle::isAlive) ?: return true + val waitMillis = minOf(remainingMillis(deadlineNanos), PROCESS_POLL_MILLIS) + if (waitMillis == 0L) return false + + try { + liveProcess.onExit().get(waitMillis, TimeUnit.MILLISECONDS) + } catch (_: TimeoutException) { + continue + } catch (_: ExecutionException) { + continue + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + + return false } - } catch (error: InterruptedException) { - process.destroyForcibly() - Thread.currentThread().interrupt() } } @@ -459,6 +509,7 @@ class FastCheckProjectionClient private constructor( const val IO_TASKS = 3 const val PROCESS_POLL_MILLIS = 10L const val IO_POLL_MILLIS = 10L + const val FORCED_TERMINATION_RESERVE_MILLIS = 25L val DEFAULT_TRANSPORT_LIMITS = FastCheckProjectionTransportLimits( maxRequestBytes = DEFAULT_MAX_REQUEST_BYTES, @@ -482,6 +533,38 @@ private class ProjectionOutputLimitExceeded( val limit: Int, ) : IOException("fast-check projection $stream exceeds $limit bytes") +private class ProjectionProcessTree(private val root: ProcessHandle) { + private val processes = linkedMapOf(root.pid() to root) + + fun observe() { + processes.values.toList().forEach { process -> + runCatching { + process.descendants().use { descendants -> + descendants.forEach { descendant -> + processes.putIfAbsent(descendant.pid(), descendant) + } + } + } + } + } + + fun processesInTerminationOrder(): List = processes.values.sortedBy { process -> + if (process.pid() == root.pid()) 1 else 0 + } +} + +private fun List.destroy() { + forEach { process -> + runCatching { process.destroy() } + } +} + +private fun List.destroyForcibly() { + forEach { process -> + runCatching { process.destroyForcibly() } + } +} + private fun InputStream.readProjectionBounded(limit: Int, stream: String): ProjectionBoundedText { val output = ByteArrayOutputStream(minOf(limit, DEFAULT_BUFFER_SIZE)) val buffer = ByteArray(DEFAULT_BUFFER_SIZE) 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 2b1a3f3d9..570caa051 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 @@ -188,9 +188,15 @@ class FastCheckProjectionClientTest { 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), + transportLimits = transportLimits( + maxStdoutBytes = 1_024, + wallClockTimeoutMillis = 250, + shutdownGraceMillis = 500, + ), ) { temporaryClient -> val startedAt = System.nanoTime() val error = assertFailsWith { @@ -252,12 +258,15 @@ class FastCheckProjectionClientTest { import { writeFileSync } from 'node:fs' const child = spawn(process.execPath, [ '-e', - 'setInterval(() => undefined, 1000)' + "process.on('SIGTERM', () => undefined); setInterval(() => undefined, 1000)" ], { stdio: 'inherit' }) writeFileSync(${childPidFile.toJavaScriptStringLiteral()}, String(child.pid)) - setTimeout(() => process.exit(0), 25) + setTimeout(() => process.exit(0), 100) """.trimIndent(), - transportLimits = transportLimits(wallClockTimeoutMillis = 500), + transportLimits = transportLimits( + wallClockTimeoutMillis = 250, + shutdownGraceMillis = 500, + ), ) { temporaryClient -> val startedAt = System.nanoTime() val error = assertFailsWith { @@ -266,7 +275,8 @@ class FastCheckProjectionClientTest { val elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000 assertEquals("backend.process.timeout", error.code) - assertTrue(elapsedMillis < 2_000, "Descendant pipe timeout took $elapsedMillis ms") + 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") @@ -278,19 +288,13 @@ class FastCheckProjectionClientTest { @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") - val terminationMarker = createTempFile(prefix = "fast-check-adapter-termination-", suffix = ".txt") pidFile.deleteIfExists() - terminationMarker.deleteIfExists() try { withTemporaryAdapter( source = """ import { writeFileSync } from 'node:fs' writeFileSync(${pidFile.toJavaScriptStringLiteral()}, String(process.pid)) - process.on('SIGTERM', () => { - writeFileSync(${terminationMarker.toJavaScriptStringLiteral()}, 'terminated') - process.exit(0) - }) setInterval(() => undefined, 1_000) """.trimIndent(), transportLimits = transportLimits(wallClockTimeoutMillis = 250), @@ -302,13 +306,12 @@ class FastCheckProjectionClientTest { val elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000 assertEquals("backend.process.timeout", error.code) - assertTrue(elapsedMillis < 2_000, "Projection timeout took $elapsedMillis ms") - assertEquals("terminated", terminationMarker.readText()) + assertTrue(elapsedMillis < 600, "Projection timeout took $elapsedMillis ms") + assertTrue(adapterIsTerminated(pidFile), "Adapter is still running") } } finally { terminateAdapter(pidFile) pidFile.deleteIfExists() - terminationMarker.deleteIfExists() } } @@ -392,12 +395,13 @@ class FastCheckProjectionClientTest { 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 = 25, + shutdownGraceMillis = shutdownGraceMillis, ) private fun Path.toJavaScriptStringLiteral(): String = "'${toString().replace("\\", "\\\\").replace("'", "\\'")}'" From 7ff4d39ca7101eca43d900b1867872ae6bbb6564 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 29 Aug 2026 22:35:05 +0300 Subject: [PATCH 05/16] [TS PBT] Preserve failures across backend execution --- .../src/execute-property.ts | 64 +++++++--- .../test/execute-property.test.ts | 114 ++++++++++++++++++ .../test/execution-cli.test.ts | 24 ++++ .../backend/PropertyBasedTestingBackend.kt | 1 - .../pbt/fastcheck/FastCheckProcessClient.kt | 114 ++++++++++++++---- .../PropertyBasedTestingBackendTest.kt | 11 ++ .../fastcheck/FastCheckProcessClientTest.kt | 43 +++++++ 7 files changed, 330 insertions(+), 41 deletions(-) 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..dc122c661 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 => { - const argumentsList = values as JsConcreteValue[]; + 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); + if (precondition !== undefined && !precondition.invoke(cloneArguments(values))) fc.pre(false); - return predicate.invoke(argumentsList) as boolean; + return predicate.invoke(cloneArguments(values)) as boolean; }); } +async function checkProperty( + property: fc.IProperty<[unknown[]]> | fc.IAsyncProperty<[unknown[]]>, + parameters: Parameters<[unknown[]]>, + replayPath: string | undefined, +): Promise> { + try { + return await Promise.resolve(fc.check(property, parameters)); + } catch (error: unknown) { + const replayFailed = replayPath !== undefined + && error instanceof Error + && error.message.startsWith('Unable to replay,'); + if (replayFailed) { + throw protocolError( + adapterDiagnostic.protocolReplayPathInvalid, + 'Replay path cannot be applied to this property run', + 'replayPath', + ); + } + + throw error; + } +} + +function cloneArguments(values: unknown[]): JsConcreteValue[] { + return cloneRecursiveArrays(values, new Map()) as JsConcreteValue[]; +} + +function cloneRecursiveArrays(value: unknown, clones: Map): unknown { + if (!Array.isArray(value)) return value; + + const existing = clones.get(value); + if (existing !== undefined) return existing; + + const clone: unknown[] = []; + clones.set(value, clone); + value.forEach((element) => clone.push(cloneRecursiveArrays(element, clones))); + + return clone; +} + function buildParameters(request: FastCheckExecutionRequest): Parameters<[unknown[]]> { const decodedExamples = request.examples.map((example, exampleIndex) => { if (example.length !== request.manifest.inputs.length) { @@ -200,10 +236,8 @@ function failureDetails(details: RunDetails<[unknown[]]>): FastCheckFailureDetai 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), }; } @@ -248,7 +282,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 +424,4 @@ 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+)*$/; 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..b52471456 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 @@ -114,6 +114,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']; @@ -172,6 +261,31 @@ async function withPropertyModule(block: (sourceRoot: string) => 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;', + '}', ].join('\n'), ); 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/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/fastcheck/FastCheckProcessClient.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClient.kt index 85085343b..c3e8e9541 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 @@ -54,38 +54,52 @@ internal class FastCheckProcessClient( val coverageRuntimeVersion = request.coverageRequest?.let { nodeVersion(request) } val coverageWorkspace = request.coverageRequest?.let { createCoverageWorkspace(request) } + val deadlineNanos = deadlineAfter(safeAdd(request.timeoutMillis, transportGraceMillis)) var process: Process? = null + var stdout: Deferred? = null + var stderr: Deferred? = null + var writer: Deferred? = 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) { + val stdoutTask = async(ioDispatcher) { startedProcess.inputStream.readBounded(MAX_STDOUT_BYTES) } + stdout = stdoutTask + val stderrTask = async(ioDispatcher) { startedProcess.errorStream.readBounded(MAX_STDERR_BYTES) } + stderr = stderrTask + val writerTask = async(ioDispatcher) { startedProcess.outputStream.bufferedWriter(Charsets.UTF_8).use { output -> output.write(encodedRequest) } } + writer = writerTask - awaitProcess(startedProcess, request) + awaitProcess( + process = startedProcess, + deadlineNanos = deadlineNanos, + request = request, + ) awaitIo( - task = writer, + task = writerTask, operation = "writing the fast-check request", failureCode = PbtDiagnosticCode.BACKEND_PROCESS_WRITE_FAILED, + deadlineNanos = deadlineNanos, request = request, ) val stdoutText = awaitIo( - task = stdout, + task = stdoutTask, operation = "reading fast-check stdout", failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + deadlineNanos = deadlineNanos, request = request, ) val stderrText = awaitIo( - task = stderr, + task = stderrTask, operation = "reading fast-check stderr", failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + deadlineNanos = deadlineNanos, request = request, ) @@ -105,7 +119,13 @@ internal class FastCheckProcessClient( ) } ?: result } finally { - process?.takeIf(Process::isAlive)?.let(::terminate) + writer?.cancel() + stdout?.cancel() + stderr?.cancel() + process?.let { startedProcess -> + closeStreams(startedProcess) + terminate(startedProcess, deadlineNanos) + } coverageWorkspace?.root?.toFile()?.deleteRecursively() } } @@ -125,22 +145,17 @@ internal class FastCheckProcessClient( return encodedRequest } - private suspend fun awaitProcess(process: Process, request: FastCheckExecutionRequest) { - val hardTimeoutMillis = safeAdd(request.timeoutMillis, transportGraceMillis) - val exitCode = withTimeoutOrNull(hardTimeoutMillis) { + private suspend fun awaitProcess( + process: Process, + deadlineNanos: Long, + request: FastCheckExecutionRequest, + ) { + val completed = withTimeoutOrNull(remainingMillis(deadlineNanos)) { runInterruptible(ioDispatcher) { process.waitFor() } + true } - 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, - ) - } + if (completed == null) executionTimeout(request) } private fun validateProcessExit( @@ -391,9 +406,12 @@ internal class FastCheckProcessClient( task: Deferred, operation: String, failureCode: String, + deadlineNanos: Long, request: FastCheckExecutionRequest, ): T = try { - task.await() + withTimeoutOrNull(remainingMillis(deadlineNanos)) { + task.await() + } ?: executionTimeout(request) } catch (error: CancellationException) { throw error } catch (error: IOException) { @@ -406,6 +424,13 @@ internal class FastCheckProcessClient( ) } + private fun executionTimeout(request: FastCheckExecutionRequest): Nothing = throw backendError( + kind = BackendErrorKind.TIMEOUT, + code = PbtDiagnosticCode.BACKEND_PROCESS_TIMEOUT, + message = "fast-check adapter exceeded the ${request.timeoutMillis} ms timeout", + request = request, + ) + private fun decodeResponse( stdout: String, request: FastCheckExecutionRequest, @@ -513,15 +538,52 @@ internal class FastCheckProcessClient( cause = cause, ) - private fun terminate(process: Process) { + private fun closeStreams(process: Process) { + runCatching { process.outputStream.close() } + runCatching { process.inputStream.close() } + runCatching { process.errorStream.close() } + } + + private fun terminate(process: Process, deadlineNanos: Long) { + if (!process.isAlive) return + process.destroy() - if (!process.waitFor(shutdownGraceMillis, TimeUnit.MILLISECONDS)) { - process.destroyForcibly() - process.waitFor() + val gracefulWaitMillis = minOf(shutdownGraceMillis, remainingMillis(deadlineNanos)) + if (awaitProcessExit(process, gracefulWaitMillis)) return + + process.destroyForcibly() + awaitProcessExit(process, remainingMillis(deadlineNanos)) + } + + private fun awaitProcessExit(process: Process, waitMillis: Long): Boolean { + if (waitMillis <= 0) return !process.isAlive + + return try { + process.waitFor(waitMillis, TimeUnit.MILLISECONDS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + + false } } + 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 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) + } + private companion object { const val MAX_REQUEST_BYTES = 4 * 1024 * 1024 const val MAX_STDOUT_BYTES = 4 * 1024 * 1024 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/fastcheck/FastCheckProcessClientTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClientTest.kt index 008bd2d5f..2e51a4813 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 @@ -300,6 +300,49 @@ class FastCheckProcessClientTest { } } + @Test + fun `hard deadline includes inherited descendant pipe drain`() { + withTemporaryAdapter( + source = """ + import { spawn } from 'node:child_process' + + const child = spawn( + process.execPath, + ['-e', 'setTimeout(() => undefined, 3000)'], + { stdio: ['ignore', 'inherit', 'inherit'] } + ) + 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 startedAt = System.nanoTime() + val error = assertFailsWith { + client.check(validRequest.copy(timeoutMillis = 100)) + } + val elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000 + + assertEquals(BackendErrorKind.TIMEOUT, error.kind) + assertEquals("backend.process.timeout", error.code) + assertTrue(elapsedMillis < 1_000, "Inherited pipe timeout took $elapsedMillis ms") + } + } + private fun withTemporaryAdapter( source: String, transportGraceMillis: Long = 2_000, From 7dd564bdd17d3a19d522d48f053602ea5c623de7 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 29 Aug 2026 22:46:23 +0300 Subject: [PATCH 06/16] [TS PBT] Distinguish exhausted preconditions --- .../src/execute-property.ts | 8 ++++++ .../test/execute-property.test.ts | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+) 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 dc122c661..136b6f8c9 100644 --- a/usvm-ts-pbt/fast-check-adapter/src/execute-property.ts +++ b/usvm-ts-pbt/fast-check-adapter/src/execute-property.ts @@ -234,6 +234,14 @@ 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: 'ThrownValue', 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 b52471456..d0cb8452a 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'); @@ -251,6 +275,7 @@ async function withPropertyModule(block: (sourceRoot: string) => Promise): '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 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;', From d5ed01ff58284f7d1697999587180755d32d79e2 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 29 Aug 2026 23:12:03 +0300 Subject: [PATCH 07/16] [TS PBT] Preserve coverage diagnostics and scopes --- .../ts/pbt/coverage/CoveragePathFilter.kt | 6 +- .../pbt/coverage/IstanbulCoverageDecoder.kt | 25 +- .../coverage/IstanbulCoverageReportReader.kt | 4 +- .../pbt/coverage/RawV8SourceMapInspector.kt | 368 ++++++++++++++++++ .../pbt/fastcheck/FastCheckProcessClient.kt | 26 +- .../ts/pbt/coverage/CoveragePathFilterTest.kt | 36 ++ .../coverage/RawV8SourceMapInspectorTest.kt | 211 ++++++++++ .../ts/pbt/fastcheck/FastCheckCoverageTest.kt | 93 ++++- .../properties/coverage/invalid-map-entry.js | 4 + .../coverage/invalid-map-entry.js.map | 1 + .../properties/coverage/missing-map-entry.js | 4 + 11 files changed, 746 insertions(+), 32 deletions(-) create mode 100644 usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspector.kt create mode 100644 usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/CoveragePathFilterTest.kt create mode 100644 usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspectorTest.kt create mode 100644 usvm-ts-pbt/src/test/resources/properties/coverage/invalid-map-entry.js create mode 100644 usvm-ts-pbt/src/test/resources/properties/coverage/invalid-map-entry.js.map create mode 100644 usvm-ts-pbt/src/test/resources/properties/coverage/missing-map-entry.js 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..831c25813 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 @@ -10,7 +10,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 +74,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..099d8c60c 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 @@ -77,23 +76,9 @@ internal class IstanbulCoverageDecoder( 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 = Files.exists(sourceMapPath), ) } @@ -155,11 +140,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..87e51b2ef --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspector.kt @@ -0,0 +1,368 @@ +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.net.URI +import java.net.URISyntaxException +import java.nio.file.Files +import java.nio.file.Path + +/** 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 > 0) { "Raw V8 report byte limit must be positive" } + + if (!Files.isDirectory(rawDirectory)) { + throw CoverageArtifactException.create( + code = PbtDiagnosticCode.COVERAGE_REPORT_MISSING, + message = "c8 did not produce the expected raw V8 coverage directory: $rawDirectory", + path = rawDirectory.toString(), + ) + } + + val reportPaths = listRawReportPaths(rawDirectory, maxReportFiles) + if (reportPaths.isEmpty()) { + throw CoverageArtifactException.create( + code = PbtDiagnosticCode.COVERAGE_REPORT_MISSING, + message = "c8 did not produce any raw V8 coverage reports in $rawDirectory", + path = rawDirectory.toString(), + ) + } + requireAllowedRawReportSizes(reportPaths, maxReportBytes) + val normalizedSourceRoots = sourceRoots.map(::normalizeCoveragePath) + val diagnostics = reportPaths.flatMap { reportPath -> + inspectRawReport( + reportPath = reportPath, + sourceRoots = normalizedSourceRoots, + ) + } + + return diagnostics + .distinct() + .sortedWith(COVERAGE_DIAGNOSTIC_ORDER) +} + +/** Raw source-map evidence takes precedence over final-report guesses for the same generated script. */ +internal fun mergeCoverageDiagnostics( + finalDiagnostics: List, + rawDiagnostics: List, +): List { + val rawSourceMapPaths = rawDiagnostics + .filter(::isSourceMapDiagnostic) + .mapNotNullTo(hashSetOf(), CoverageDiagnostic::path) + val retainedFinalDiagnostics = finalDiagnostics.filterNot { diagnostic -> + isSourceMapDiagnostic(diagnostic) && diagnostic.path in rawSourceMapPaths + } + + return (retainedFinalDiagnostics + rawDiagnostics) + .distinct() + .sortedWith(COVERAGE_DIAGNOSTIC_ORDER) +} + +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 normalizeCoveragePath(path: String): String = Path.of(path) + .toAbsolutePath() + .normalize() + .toString() + .replace('\\', '/') + +private fun listRawReportPaths(rawDirectory: Path, maxReportFiles: Int): List = try { + val reportPaths = mutableListOf() + Files.newDirectoryStream(rawDirectory, "*.json").use { entries -> + for (entry in entries) { + addRawReportPath( + reportPaths = reportPaths, + reportPath = entry, + rawDirectory = rawDirectory, + maxReportFiles = maxReportFiles, + ) + } + } + + reportPaths.sortedBy { path -> path.fileName.toString() } +} catch (error: CoverageArtifactException) { + throw error +} catch (error: IOException) { + throw invalidRawReport( + message = "Cannot list raw V8 coverage reports: ${error.message}", + path = rawDirectory, + cause = error, + ) +} + +private fun addRawReportPath( + reportPaths: MutableList, + reportPath: Path, + rawDirectory: Path, + maxReportFiles: Int, +) { + if (reportPaths.size == maxReportFiles) { + throw CoverageArtifactException.create( + code = PbtDiagnosticCode.COVERAGE_REPORT_INVALID, + message = "Raw V8 coverage contains more than $maxReportFiles report files", + path = rawDirectory.toString(), + ) + } + + reportPaths.add(reportPath) +} + +private fun requireAllowedRawReportSizes(reportPaths: List, maxReportBytes: Long) { + var totalBytes = 0L + for (reportPath in reportPaths) { + val reportBytes = try { + Files.size(reportPath) + } catch (error: IOException) { + throw invalidRawReport( + message = "Cannot read raw V8 coverage report size: ${error.message}", + path = reportPath, + cause = error, + ) + } + + if (reportBytes > maxReportBytes - totalBytes) { + throw invalidRawReport( + message = "Raw V8 coverage reports exceed $maxReportBytes bytes", + path = reportPath, + ) + } + + totalBytes += reportBytes + } +} + +private fun inspectRawReport(reportPath: Path, sourceRoots: List): List { + val report = readRawReport(reportPath) + val sourceMapCache = readSourceMapCache(report, reportPath) ?: return emptyList() + + return sourceMapCache.mapNotNull { (scriptUrl, cacheEntryElement) -> + inspectSourceMapCacheEntry( + reportPath = reportPath, + scriptUrl = scriptUrl, + cacheEntry = requireSourceMapCacheEntry( + reportPath = reportPath, + scriptUrl = scriptUrl, + element = cacheEntryElement, + ), + sourceRoots = sourceRoots, + ) + } +} + +private fun readRawReport(reportPath: Path): JsonObject { + val reportText = readRawReportText(reportPath) + + return parseRawReport(reportText, reportPath) +} + +private fun readRawReportText(reportPath: Path): String = try { + Files.readString(reportPath) +} catch (error: IOException) { + throw invalidRawReport( + message = "Cannot read raw V8 coverage report: ${error.message}", + path = reportPath, + cause = error, + ) +} + +private fun parseRawReport(reportText: String, reportPath: Path): JsonObject = try { + PropertyManifestJson.json.parseToJsonElement(reportText) as? JsonObject + ?: throw invalidRawReport( + message = "Raw V8 coverage report must be a JSON object", + path = reportPath, + ) +} catch (error: CoverageArtifactException) { + throw error +} catch (error: IllegalArgumentException) { + throw invalidRawReport( + message = "Raw V8 coverage report is not valid JSON: ${error.message}", + path = reportPath, + cause = error, + ) +} + +private fun readSourceMapCache(report: JsonObject, reportPath: Path): JsonObject? { + val sourceMapCacheElement = report["source-map-cache"] ?: return null + return sourceMapCacheElement as? JsonObject + ?: throw invalidRawReport( + message = "Raw V8 source-map-cache must be a JSON object", + path = "$reportPath.source-map-cache", + ) +} + +private fun requireSourceMapCacheEntry( + reportPath: Path, + scriptUrl: String, + element: JsonElement, +): JsonObject = element as? JsonObject + ?: throw invalidRawReport( + message = "Raw V8 source-map cache entry must be a JSON object", + path = "$reportPath.source-map-cache[$scriptUrl]", + ) + +private fun inspectSourceMapCacheEntry( + reportPath: Path, + scriptUrl: String, + cacheEntry: JsonObject, + sourceRoots: List, +): CoverageDiagnostic? { + val data = cacheEntry["data"] + ?: throw invalidRawReport( + message = "Raw V8 source-map cache entry is missing data", + path = "$reportPath.source-map-cache[$scriptUrl].data", + ) + if (data != JsonNull) return null + + val scriptUri = parseScriptUri(reportPath, scriptUrl) + if (scriptUri.scheme != "file") return null + + val scriptPath = scriptUri.toCoveragePath(reportPath, scriptUrl) + if (!isGeneratedJavaScriptBelowSourceRoot(scriptPath, sourceRoots)) return null + + val referencedUrl = cacheEntry["url"] as? JsonPrimitive + if (referencedUrl == null || !referencedUrl.isString || referencedUrl.content.isBlank()) { + throw invalidRawReport( + message = "Raw V8 source-map cache entry must contain a source-map URL", + path = "$reportPath.source-map-cache[$scriptUrl].url", + ) + } + + val sourceMapPath = resolveSourceMapPath( + scriptUri = scriptUri, + scriptPath = Path.of(scriptPath), + referencedUrl = referencedUrl.content, + ) + val sourceMapExists = sourceMapPath == null || Files.exists(sourceMapPath) + + return buildSourceMapDiagnostic( + path = scriptPath, + sourceMapExists = sourceMapExists, + ) +} + +private fun parseScriptUri(reportPath: Path, scriptUrl: String): URI = try { + URI(scriptUrl) +} catch (error: IllegalArgumentException) { + throw invalidRawReport( + message = "Raw V8 source-map cache key is not a valid script URL: ${error.message}", + path = "$reportPath.source-map-cache[$scriptUrl]", + cause = error, + ) +} + +private fun URI.toCoveragePath(reportPath: Path, scriptUrl: String): String { + return try { + normalizeCoveragePath(toLocalFilePath().toString()) + } catch (error: IllegalArgumentException) { + throw invalidScriptUrlPath(reportPath, scriptUrl, error) + } catch (error: URISyntaxException) { + throw invalidScriptUrlPath(reportPath, scriptUrl, error) + } +} + +private fun invalidScriptUrlPath( + reportPath: Path, + scriptUrl: String, + error: Exception, +): CoverageArtifactException = invalidRawReport( + message = "Raw V8 script URL cannot be converted to a path: ${error.message}", + path = "$reportPath.source-map-cache[$scriptUrl]", + cause = error, +) + +private fun resolveSourceMapPath(scriptUri: URI, scriptPath: Path, referencedUrl: String): Path? { + if (referencedUrl.startsWith("data:")) return null + + return try { + val referenceUri = URI(referencedUrl) + val resolvedUri = scriptUri.resolve(referenceUri) + + if (resolvedUri.scheme == "file") resolvedUri.toLocalFilePath().normalize() else null + } catch (_: IllegalArgumentException) { + resolveSourceMapPathFallback(scriptPath, referencedUrl) + } catch (_: URISyntaxException) { + resolveSourceMapPathFallback(scriptPath, referencedUrl) + } +} + +@Throws(URISyntaxException::class) +private fun URI.toLocalFilePath(): Path { + val localFileUri = URI(scheme, authority, path, null, null) + + return Path.of(localFileUri) +} + +private fun resolveSourceMapPathFallback(scriptPath: Path, referencedUrl: String): Path? = + runCatching { + val referencedPath = Path.of(referencedUrl) + + if (referencedPath.isAbsolute) { + referencedPath.normalize() + } else { + scriptPath.parent.resolve(referencedPath).normalize() + } + }.getOrNull() + +private fun isGeneratedJavaScriptBelowSourceRoot(path: String, sourceRoots: List): Boolean { + val extension = path.substringAfterLast('.', missingDelimiterValue = "").lowercase() + + return extension in GENERATED_JAVASCRIPT_EXTENSIONS && sourceRoots.any { root -> isWithin(path, root) } +} + +private fun isSourceMapDiagnostic(diagnostic: CoverageDiagnostic): Boolean = + diagnostic.code == PbtDiagnosticCode.COVERAGE_SOURCE_MAP_MISSING || + diagnostic.code == PbtDiagnosticCode.COVERAGE_SOURCE_MAP_INVALID + +private fun invalidRawReport( + message: String, + path: Path, + cause: Throwable? = null, +): CoverageArtifactException = invalidRawReport( + message = message, + path = path.toString(), + cause = cause, +) + +private fun invalidRawReport( + message: String, + path: String, + cause: Throwable? = null, +): CoverageArtifactException = CoverageArtifactException.create( + code = PbtDiagnosticCode.COVERAGE_REPORT_INVALID, + message = message, + path = path, + cause = cause, +) + +private const val MAX_RAW_V8_REPORT_FILES = 1_024 +private val GENERATED_JAVASCRIPT_EXTENSIONS = hashSetOf("js", "mjs", "cjs") +private val COVERAGE_DIAGNOSTIC_ORDER = compareBy(CoverageDiagnostic::path, CoverageDiagnostic::code) 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 c3e8e9541..2a87e7891 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 @@ -17,6 +17,8 @@ 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 org.usvm.ts.pbt.manifest.PropertyManifestJson import org.usvm.ts.pbt.model.PropertyId import java.io.ByteArrayOutputStream @@ -272,13 +274,17 @@ internal class FastCheckProcessClient( val entryPointPaths = hashSetOf() request.sourceRoots.forEach { sourceRoot -> val root = Path.of(sourceRoot) - entryPointPaths += root.resolve(request.manifest.predicate.module).normalize().toString() + entryPointPaths += canonicalizeExistingEntryPoint( + root.resolve(request.manifest.predicate.module).normalize(), + ) request.manifest.precondition?.let { precondition -> - entryPointPaths += root.resolve(precondition.module).normalize().toString() + entryPointPaths += canonicalizeExistingEntryPoint( + root.resolve(precondition.module).normalize(), + ) } } val artifact = try { - decodeIstanbulCoverageReport( + val finalArtifact = decodeIstanbulCoverageReport( reportPath = workspace.reportDirectory.resolve("coverage-final.json"), context = IstanbulCoverageContext( backendId = FastCheckBackend.FAST_CHECK_BACKEND_ID, @@ -292,6 +298,17 @@ internal class FastCheckProcessClient( request = coverageRequest, ), ) + val rawDiagnostics = inspectRawV8SourceMapDiagnostics( + rawDirectory = workspace.rawDirectory, + sourceRoots = request.sourceRoots, + ) + + finalArtifact.copy( + diagnostics = mergeCoverageDiagnostics( + finalDiagnostics = finalArtifact.diagnostics, + rawDiagnostics = rawDiagnostics, + ), + ) } catch (error: CoverageArtifactException) { throw backendError( kind = BackendErrorKind.COVERAGE, @@ -306,6 +323,9 @@ internal class FastCheckProcessClient( return result.copy(coverage = artifact) } + private fun canonicalizeExistingEntryPoint(candidate: Path): String = + if (Files.exists(candidate)) candidate.toRealPath().toString() else candidate.toString() + private suspend fun nodeVersion(request: FastCheckExecutionRequest): String { val process = startNodeVersionProcess(request) 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..e78366843 --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspectorTest.kt @@ -0,0 +1,211 @@ +package org.usvm.ts.pbt.coverage + +import org.junit.jupiter.api.Test +import org.usvm.ts.pbt.backend.CoverageDiagnostic +import java.nio.file.Path +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 `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 `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 `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 `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 withRawDirectory(block: (Path) -> Unit) { + val rawDirectory = createTempDirectory(prefix = "raw-v8-source-maps-") + + try { + block(rawDirectory) + } finally { + rawDirectory.toFile().deleteRecursively() + } + } +} 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..805b2eb69 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,6 +1,7 @@ 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.PropertyRunStatus @@ -13,8 +14,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( @@ -71,6 +77,85 @@ class FastCheckCoverageTest { assertEquals(listOf(5), zeroHitBranchLines(file)) } + @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: org.usvm.ts.pbt.backend.PropertyRunResult): SourceFileCoverage { val artifact = assertNotNull(result.coverage) return artifact.files.single { file -> file.path.endsWith("properties/coverage/source-under-test.ts") } @@ -86,7 +171,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 +184,7 @@ class FastCheckCoverageTest { ), ), predicate = TypeScriptEntryPoint( - module = "properties/coverage/CoverageProperties.ts", + module = module, exportName = exportName, ), ) 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 From 6a9f05ee5bb41b333355aedea6166f3f1e15881a Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 30 Aug 2026 18:45:39 +0300 Subject: [PATCH 08/16] [TS PBT] Harden raw coverage inspection --- .../pbt/coverage/RawV8SourceMapInspector.kt | 114 +++++++++++--- .../coverage/RawV8SourceMapInspectorTest.kt | 141 ++++++++++++++++++ 2 files changed, 235 insertions(+), 20 deletions(-) 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 index 87e51b2ef..dcefcd0f4 100644 --- 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 @@ -7,9 +7,12 @@ 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.ByteArrayOutputStream import java.io.IOException import java.net.URI import java.net.URISyntaxException +import java.nio.ByteBuffer +import java.nio.charset.CharacterCodingException import java.nio.file.Files import java.nio.file.Path @@ -41,10 +44,12 @@ internal fun inspectRawV8SourceMapDiagnostics( } requireAllowedRawReportSizes(reportPaths, maxReportBytes) val normalizedSourceRoots = sourceRoots.map(::normalizeCoveragePath) + val reportReader = RawV8ReportReader(maxReportBytes = maxReportBytes) val diagnostics = reportPaths.flatMap { reportPath -> inspectRawReport( reportPath = reportPath, sourceRoots = normalizedSourceRoots, + reportReader = reportReader, ) } @@ -95,6 +100,67 @@ internal fun normalizeCoveragePath(path: String): String = Path.of(path) .toString() .replace('\\', '/') +/** Reads raw reports under one aggregate byte budget, including bytes consumed after preflight. */ +internal class RawV8ReportReader(private val maxReportBytes: Long) { + private var consumedBytes = 0L + + init { + require(maxReportBytes > 0) { "Raw V8 report byte limit must be positive" } + } + + fun readText(reportPath: Path): String { + val reportBytes = try { + readBytes(reportPath) + } catch (error: IOException) { + throw invalidRawReport( + message = "Cannot read raw V8 coverage report: ${error.message}", + path = reportPath, + cause = error, + ) + } + + return try { + Charsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(reportBytes)).toString() + } catch (error: CharacterCodingException) { + throw invalidRawReport( + message = "Cannot decode raw V8 coverage report as UTF-8: ${error.message}", + path = reportPath, + cause = error, + ) + } + } + + private fun readBytes(reportPath: Path): ByteArray { + val output = ByteArrayOutputStream() + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + + Files.newInputStream(reportPath).use { input -> + while (true) { + val remainingBytes = maxReportBytes - consumedBytes + val readLength = if (remainingBytes >= buffer.size) { + buffer.size + } else { + remainingBytes.toInt() + 1 + } + val readBytes = input.read(buffer, 0, readLength) + if (readBytes < 0) break + + if (readBytes > remainingBytes) { + throw invalidRawReport( + message = "Raw V8 coverage reports exceed $maxReportBytes bytes", + path = reportPath, + ) + } + + output.write(buffer, 0, readBytes) + consumedBytes += readBytes + } + } + + return output.toByteArray() + } +} + private fun listRawReportPaths(rawDirectory: Path, maxReportFiles: Int): List = try { val reportPaths = mutableListOf() Files.newDirectoryStream(rawDirectory, "*.json").use { entries -> @@ -160,8 +226,12 @@ private fun requireAllowedRawReportSizes(reportPaths: List, maxReportBytes } } -private fun inspectRawReport(reportPath: Path, sourceRoots: List): List { - val report = readRawReport(reportPath) +private fun inspectRawReport( + reportPath: Path, + sourceRoots: List, + reportReader: RawV8ReportReader, +): List { + val report = readRawReport(reportPath, reportReader) val sourceMapCache = readSourceMapCache(report, reportPath) ?: return emptyList() return sourceMapCache.mapNotNull { (scriptUrl, cacheEntryElement) -> @@ -178,22 +248,12 @@ private fun inspectRawReport(reportPath: Path, sourceRoots: List): List< } } -private fun readRawReport(reportPath: Path): JsonObject { - val reportText = readRawReportText(reportPath) +private fun readRawReport(reportPath: Path, reportReader: RawV8ReportReader): JsonObject { + val reportText = reportReader.readText(reportPath) return parseRawReport(reportText, reportPath) } -private fun readRawReportText(reportPath: Path): String = try { - Files.readString(reportPath) -} catch (error: IOException) { - throw invalidRawReport( - message = "Cannot read raw V8 coverage report: ${error.message}", - path = reportPath, - cause = error, - ) -} - private fun parseRawReport(reportText: String, reportPath: Path): JsonObject = try { PropertyManifestJson.json.parseToJsonElement(reportText) as? JsonObject ?: throw invalidRawReport( @@ -277,6 +337,12 @@ private fun parseScriptUri(reportPath: Path, scriptUrl: String): URI = try { path = "$reportPath.source-map-cache[$scriptUrl]", cause = error, ) +} catch (error: URISyntaxException) { + throw invalidRawReport( + message = "Raw V8 source-map cache key is not a valid script URL: ${error.message}", + path = "$reportPath.source-map-cache[$scriptUrl]", + cause = error, + ) } private fun URI.toCoveragePath(reportPath: Path, scriptUrl: String): String { @@ -302,15 +368,23 @@ private fun invalidScriptUrlPath( private fun resolveSourceMapPath(scriptUri: URI, scriptPath: Path, referencedUrl: String): Path? { if (referencedUrl.startsWith("data:")) return null - return try { - val referenceUri = URI(referencedUrl) - val resolvedUri = scriptUri.resolve(referenceUri) + val referenceUri = try { + URI(referencedUrl) + } catch (_: IllegalArgumentException) { + return resolveSourceMapPathFallback(scriptPath, referencedUrl) + } catch (_: URISyntaxException) { + return resolveSourceMapPathFallback(scriptPath, referencedUrl) + } + val resolvedUri = scriptUri.resolve(referenceUri) + val hasRemoteAuthority = !resolvedUri.authority.isNullOrEmpty() + if (resolvedUri.scheme != "file" || hasRemoteAuthority) return null - if (resolvedUri.scheme == "file") resolvedUri.toLocalFilePath().normalize() else null + return try { + resolvedUri.toLocalFilePath().normalize() } catch (_: IllegalArgumentException) { - resolveSourceMapPathFallback(scriptPath, referencedUrl) + null } catch (_: URISyntaxException) { - resolveSourceMapPathFallback(scriptPath, referencedUrl) + null } } 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 index e78366843..a06601424 100644 --- 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 @@ -2,7 +2,9 @@ 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 @@ -65,6 +67,28 @@ class RawV8SourceMapInspectorTest { } } + @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 -> @@ -83,6 +107,36 @@ class RawV8SourceMapInspectorTest { } } + @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 -> @@ -140,6 +194,73 @@ class RawV8SourceMapInspectorTest { } } + @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( @@ -199,6 +320,19 @@ class RawV8SourceMapInspectorTest { } """.trimIndent() + private fun rawReport(script: Path, referencedUrl: String): String = + """ + { + "source-map-cache": { + "${script.toUri()}": { + "lineLengths": [1], + "data": null, + "url": "$referencedUrl" + } + } + } + """.trimIndent() + private fun withRawDirectory(block: (Path) -> Unit) { val rawDirectory = createTempDirectory(prefix = "raw-v8-source-maps-") @@ -208,4 +342,11 @@ class RawV8SourceMapInspectorTest { rawDirectory.toFile().deleteRecursively() } } + + private data class SourceMapReferenceCase( + val name: String, + val scriptPath: String, + val referencedUrl: String, + val presentMapPath: String? = null, + ) } From 5e1544508c3d182bf99d6c01eec28f8fadfc61ca Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 30 Aug 2026 19:16:48 +0300 Subject: [PATCH 09/16] [TS PBT] Resolve runtime exports soundly --- buildSrc/src/main/kotlin/Dependencies.kt | 2 +- .../usvm/ts/pbt/mapping/PropertyEtsMapper.kt | 201 ++++++++++++++---- .../PropertyEtsExportResolutionTest.kt | 94 +++++++- .../ts/pbt/mapping/PropertyEtsMapperTest.kt | 67 ++++++ .../mapping/exports/CallableLocalFixture.ts | 11 + .../exports/NamedDefaultDeclaration.ts | 3 + .../exports/TypeOnlyPrecedenceEntry.ts | 2 +- .../mapping/exports/TypeOnlyPredicate.ts | 3 + .../mapping/exports/TypeOnlyStarEntry.ts | 2 + .../resources/mapping/source-roots/a/Foo.ts | 7 + .../resources/mapping/source-roots/b/Foo.ts | 7 + 11 files changed, 347 insertions(+), 52 deletions(-) create mode 100644 usvm-ts-pbt/src/test/resources/mapping/exports/CallableLocalFixture.ts create mode 100644 usvm-ts-pbt/src/test/resources/mapping/exports/NamedDefaultDeclaration.ts create mode 100644 usvm-ts-pbt/src/test/resources/mapping/exports/TypeOnlyPredicate.ts create mode 100644 usvm-ts-pbt/src/test/resources/mapping/exports/TypeOnlyStarEntry.ts create mode 100644 usvm-ts-pbt/src/test/resources/mapping/source-roots/a/Foo.ts create mode 100644 usvm-ts-pbt/src/test/resources/mapping/source-roots/b/Foo.ts diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 9e465a8ec..eff370310 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 = "aa319129f8" const val juliet = "1.3.2" const val junit = "5.9.3" const val kotlin = "2.1.0" 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 index 08d0f0895..704f65b46 100644 --- 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 @@ -1,14 +1,22 @@ 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.EtsIfStmt +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.model.EtsStmt +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.backend.BranchArmCoverage import org.usvm.ts.pbt.backend.BranchCoverage import org.usvm.ts.pbt.backend.PropertyCoverageArtifact @@ -25,9 +33,12 @@ class PropertyEtsMapper( sourceRoots: List, ) { private val sourceLocations = SourceLocationNormalizer(sourceRoots) - private val sceneStatements = scene.projectClasses - .flatMap { etsClass -> etsClass.methods } - .flatMap { method -> method.cfg.stmts } + private val sceneFileCandidates = scene.projectFiles.map { file -> + SceneFileCandidate( + file = file, + canonicalPaths = sourceLocations.normalizePath(file.name).mapTo(hashSetOf(), Path::toString), + ) + } /** Produces a complete mapping artifact even when individual entry points or coverage locations do not map. */ fun map( @@ -142,9 +153,10 @@ class PropertyEtsMapper( ) } - val conditionsInSourceFile = sceneStatements - .filterIsInstance() - .filter { statement -> statement.belongsTo(location.path) } + 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 = "mapping.source-origins.unsupported", @@ -161,8 +173,10 @@ class PropertyEtsMapper( ) } - val conditions = conditionsInSourceFile - .filter { statement -> statement.hasOriginWithin(location) } + 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() != BINARY_BRANCH_ARM_COUNT }) { val diagnostic = EtsMappingDiagnostic( code = "mapping.branch.cfg.unsupported", @@ -182,7 +196,12 @@ class PropertyEtsMapper( val distinctOrigins = conditions .mapNotNull { statement -> statement.location.origin } .distinct() - val mapping = branchMapping(location, conditions, distinctOrigins.size) + val mapping = branchMapping( + location = location, + conditions = conditions, + distinctOriginCount = distinctOrigins.size, + sourceCandidateCount = conditionGroups.size, + ) val arms = coverage.arms.mapIndexed { index, arm -> mapBranchArm(sourcePath, arm, index, mapping) } @@ -229,13 +248,14 @@ class PropertyEtsMapper( location: NormalizedSourceRange, conditions: List, distinctOriginCount: Int, + sourceCandidateCount: Int, ): EtsMappingResult = when { - distinctOriginCount == 1 -> EtsMappingResult( + distinctOriginCount == 1 && sourceCandidateCount == 1 -> EtsMappingResult( status = EtsMappingStatus.EXACT, targets = conditions.map(::EtsBranchTarget), ) - distinctOriginCount > 1 -> EtsMappingResult( + distinctOriginCount > 1 || sourceCandidateCount > 1 -> EtsMappingResult( status = EtsMappingStatus.AMBIGUOUS, targets = conditions.map(::EtsBranchTarget), diagnostics = listOf( @@ -316,8 +336,10 @@ class PropertyEtsMapper( ) } - val statementsInSourceFile = sceneStatements - .filter { statement -> statement.belongsTo(location.path) } + 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 }) { return EtsStatementCoverageMapping( coverage = coverage, @@ -332,15 +354,19 @@ class PropertyEtsMapper( ) } - val exactTargets = sceneStatements - .filter { statement -> statement.hasOrigin(location) } + val exactTargets = statementGroupsInSourceFile + .flatMap { statements -> statements.filter { statement -> statement.hasOrigin(location) } } .map(::EtsStatementTarget) - val containedStatements = sceneStatements - .filter { statement -> statement.hasOriginWithin(location) } + 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 mapping = when { + containedStatementGroups.size > 1 -> ambiguousStatementMapping(location, containedStatements) + exactTargets.isNotEmpty() -> EtsMappingResult( status = EtsMappingStatus.EXACT, targets = exactTargets, @@ -351,17 +377,7 @@ class PropertyEtsMapper( targets = containedStatements.map(::EtsStatementTarget), ) - distinctContainedOrigins.size > 1 -> EtsMappingResult( - status = EtsMappingStatus.AMBIGUOUS, - targets = containedStatements.map(::EtsStatementTarget), - diagnostics = listOf( - EtsMappingDiagnostic( - code = "mapping.statement.ambiguous", - message = "The covered TypeScript range contains several distinct EtsIR source spans", - sourcePath = location.path, - ), - ), - ) + distinctContainedOrigins.size > 1 -> ambiguousStatementMapping(location, containedStatements) else -> EtsMappingResult( status = EtsMappingStatus.UNMAPPED, @@ -383,6 +399,21 @@ class PropertyEtsMapper( ) } + private fun ambiguousStatementMapping( + location: NormalizedSourceRange, + statements: List, + ): EtsMappingResult = EtsMappingResult( + status = EtsMappingStatus.AMBIGUOUS, + targets = statements.map(::EtsStatementTarget), + diagnostics = listOf( + EtsMappingDiagnostic( + code = "mapping.statement.ambiguous", + message = "The covered TypeScript range matches several EtsIR source candidates or spans", + sourcePath = location.path, + ), + ), + ) + private fun sourceNormalizationDiagnostic( sourcePath: String, failure: Throwable?, @@ -426,13 +457,6 @@ class PropertyEtsMapper( origin.endOffset <= location.end.offset } - private fun EtsStmt.belongsTo(path: String): Boolean { - val enclosingClass = location.method.signature.enclosingClass - val fileName = enclosingClass.file.fileName - - return sourceLocations.normalizePath(fileName).any { candidate -> candidate.toString() == path } - } - private fun EtsIfStmt.successorCount(): Int = location.method.cfg.successors(this).size private fun org.jacodb.ets.model.EtsSourceSpan.hasPath(path: String): Boolean = @@ -458,10 +482,14 @@ class PropertyEtsMapper( ) } - val methods = scene.projectFiles + val candidateResolutions = scene.projectFiles .filter { candidate -> candidate.matches(entryPoint.module) } - .flatMap { file -> resolveExportedMethods(file, entryPoint.exportName, visited = emptySet()) } + .map { file -> resolveExportedMethods(file, entryPoint.exportName, visited = emptySet()) } + val methods = candidateResolutions + .flatMap { resolution -> resolution.methods } .distinctByIdentity() + val hasAmbiguousResolution = candidateResolutions.any { resolution -> resolution.isAmbiguous } || + candidateResolutions.count { resolution -> resolution.methods.isNotEmpty() } > 1 if (methods.any { method -> method.parameters.size != manifest.inputs.size }) { return EtsMappingResult( @@ -484,13 +512,13 @@ class PropertyEtsMapper( ) } - if (targets.size == 1) { + if (targets.size == 1 && !hasAmbiguousResolution) { return EtsMappingResult( status = EtsMappingStatus.EXACT, targets = targets, ) } - if (targets.size > 1) { + if (targets.isNotEmpty()) { return EtsMappingResult( status = EtsMappingStatus.AMBIGUOUS, targets = targets, @@ -521,14 +549,17 @@ class PropertyEtsMapper( file: EtsFile, exportName: String, visited: Set, - ): List { - if (file in visited) return emptyList() + ): MethodResolution { + if (file in visited) return MethodResolution.EMPTY - val namedRuntimeExports = file.exportInfos.filter { export -> - export.name == exportName && export.type != EtsExportType.TYPE + val runtimeExports = file.exportInfos.filter { export -> + !export.isTypeOnly && export.type != EtsExportType.TYPE + } + val namedRuntimeExports = runtimeExports.filter { export -> + export.runtimeName == exportName } val matchingExports = namedRuntimeExports.ifEmpty { - file.exportInfos.filter { export -> + runtimeExports.filter { export -> export.isBareStarReExport && exportName != DEFAULT_EXPORT_NAME } } @@ -539,17 +570,67 @@ class PropertyEtsMapper( .filter { etsClass -> etsClass.name == DEFAULT_ARK_CLASS_NAME } .flatMap { etsClass -> etsClass.methods } .filter { method -> method.name in directMethodNames } - val reExportedMethods = matchingExports + 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 - resolveReExportFiles(file, requireNotNull(export.from)).flatMap { targetFile -> + resolveReExportFiles(file, requireNotNull(export.from)).map { targetFile -> resolveExportedMethods(targetFile, targetExportName, visited + file) } } + val methods = ( + directMethods + + localResolutions.flatMap { resolution -> resolution.methods } + + reExportedResolutions.flatMap { resolution -> resolution.methods } + ).distinctByIdentity() + + return MethodResolution( + methods = methods, + isAmbiguous = localResolutions.any { resolution -> resolution.isAmbiguous } || + reExportedResolutions.any { resolution -> resolution.isAmbiguous }, + ) + } + + private fun resolveCallableLocal(file: EtsFile, localName: String): MethodResolution { + val callableAssignments = file.classes + .filter { etsClass -> etsClass.name == DEFAULT_ARK_CLASS_NAME } + .flatMap { defaultClass -> + defaultClass.methods + .filter { method -> method.name == DEFAULT_ARK_METHOD_NAME } + .flatMap { method -> method.cfg.stmts } + .filterIsInstance() + .mapNotNull { assignment -> + val field = assignment.lhv as? EtsStaticFieldRef ?: return@mapNotNull null + if (field.field.enclosingClass != defaultClass.signature || field.field.name != localName) { + return@mapNotNull null + } + + val local = assignment.rhv as? EtsLocal ?: return@mapNotNull null + val functionType = local.type as? EtsFunctionType ?: return@mapNotNull null + + CallableLocalAssignment( + defaultClass = defaultClass, + functionSignature = functionType.signature, + ) + } + } + val linkedMethods = callableAssignments.flatMap { assignment -> + assignment.defaultClass.methods.filter { method -> + method.name.startsWith(ANONYMOUS_METHOD_PREFIX) && + method.signature == assignment.functionSignature + } + } + val methods = linkedMethods.distinctByIdentity() + val isExactLink = callableAssignments.size == 1 && linkedMethods.size == 1 - return (directMethods + reExportedMethods).distinctByIdentity() + return MethodResolution( + methods = methods, + isAmbiguous = methods.isNotEmpty() && !isExactLink, + ) } private fun List.distinctByIdentity(): List { @@ -564,6 +645,9 @@ class PropertyEtsMapper( private val EtsExportInfo.isNamespaceStarReExport: Boolean get() = isStarReExport && isAliased + private val EtsExportInfo.runtimeName: String + get() = if (!isReExport && isDefaultExport) DEFAULT_EXPORT_NAME else name + private fun resolveReExportFiles(file: EtsFile, module: String): List { val targetPaths = sourceLocations.normalizePath(file.name).flatMapTo(linkedSetOf()) { sourcePath -> val targetPath = requireNotNull(sourcePath.parent).resolve(module).normalize() @@ -608,6 +692,29 @@ class PropertyEtsMapper( ) } + 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 data class CallableLocalAssignment( + val defaultClass: EtsClass, + val functionSignature: EtsMethodSignature, + ) + + private data class MethodResolution( + val methods: List, + val isAmbiguous: Boolean = false, + ) { + companion object { + val EMPTY = MethodResolution(methods = emptyList()) + } + } + private companion object { const val BINARY_BRANCH_ARM_COUNT = 2 const val DEFAULT_EXPORT_NAME = "default" 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 index befa277be..110ac367e 100644 --- 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 @@ -11,8 +11,24 @@ 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") @@ -53,17 +69,89 @@ class PropertyEtsExportResolutionTest { } @Test - fun `type-only declaration does not mask a bare star value export`() { + fun `type-only named export does not mask a bare star value export`() { val sourceDirectory = testResourcePath("/mapping/exports") - val sources = listOf("TypeOnlyPrecedenceEntry.ts", "StarPredicate.ts") + 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 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 index 3e8c88589..fedb404cc 100644 --- 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 @@ -149,6 +149,73 @@ class PropertyEtsMapperTest { assertEquals("mapping.entry-point.ambiguous", artifact.predicate.diagnostics.single().code) } + @Test + fun `duplicate frontend signatures keep coverage source provenance ambiguous`() { + 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 mapping = artifact.coverage.statements.single().mapping + assertEquals(EtsMappingStatus.AMBIGUOUS, mapping.status) + assertTrue( + mapping.targets.any { target -> + duplicateFile.classes + .flatMap { etsClass -> etsClass.methods } + .any { method -> target.statement.location.method === method } + }, + ) + assertEquals("mapping.statement.ambiguous", mapping.diagnostics.single().code) + val branchMapping = artifact.coverage.branches.single().mapping + assertEquals(EtsMappingStatus.AMBIGUOUS, branchMapping.status) + assertEquals("mapping.branch.ambiguous", branchMapping.diagnostics.single().code) + } + @Test fun `reports unsupported bindings when an ambiguous candidate has another arity`() { val primarySource = testResourcePath("/mapping/PropertyMappingFixture.ts") 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..81db3494e --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/CallableLocalFixture.ts @@ -0,0 +1,11 @@ +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; 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/TypeOnlyPrecedenceEntry.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/TypeOnlyPrecedenceEntry.ts index 40ce04caf..390abba96 100644 --- a/usvm-ts-pbt/src/test/resources/mapping/exports/TypeOnlyPrecedenceEntry.ts +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/TypeOnlyPrecedenceEntry.ts @@ -1,2 +1,2 @@ -export type predicate = (value: number) => boolean; +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/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..606a033c5 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/source-roots/b/Foo.ts @@ -0,0 +1,7 @@ +export function predicate(value: number): boolean { + if (value > 0) { + return true; + } else { + return false; + } +} From c6d9874276bd790f3719639314626e9d3b1c1e35 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 30 Aug 2026 19:37:58 +0300 Subject: [PATCH 10/16] [TS PBT] Characterize ambiguous export and branch mappings --- .../PropertyEtsExportResolutionTest.kt | 46 +++++++++++++++++++ .../ts/pbt/mapping/PropertyEtsMapperTest.kt | 18 ++++++-- .../mapping/exports/CallableLocalFixture.ts | 4 ++ 3 files changed, 65 insertions(+), 3 deletions(-) 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 index 110ac367e..5cd6e54ed 100644 --- 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 @@ -1,6 +1,12 @@ package org.usvm.ts.pbt.mapping +import org.jacodb.ets.model.EtsAssignStmt +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 @@ -154,6 +160,46 @@ class PropertyEtsExportResolutionTest { 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")) + assertEquals("mapping.entry-point.ambiguous", aliasArtifact.predicate.diagnostics.single().code) + 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") 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 index fedb404cc..3bb808d3f 100644 --- 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 @@ -200,6 +200,8 @@ class PropertyEtsMapperTest { ) val artifact = mapper.map(manifest, coverage) + val primaryMethods = primaryFile.classes.flatMap { etsClass -> etsClass.methods } + val duplicateMethods = duplicateFile.classes.flatMap { etsClass -> etsClass.methods } val mapping = artifact.coverage.statements.single().mapping assertEquals(EtsMappingStatus.AMBIGUOUS, mapping.status) @@ -211,9 +213,19 @@ class PropertyEtsMapperTest { }, ) assertEquals("mapping.statement.ambiguous", mapping.diagnostics.single().code) - val branchMapping = artifact.coverage.branches.single().mapping - assertEquals(EtsMappingStatus.AMBIGUOUS, branchMapping.status) - assertEquals("mapping.branch.ambiguous", branchMapping.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.any { target -> primaryMethods.any { method -> target === method } }) + assertTrue(targetMethods.any { target -> duplicateMethods.any { method -> target === method } }) + } } @Test diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/CallableLocalFixture.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/CallableLocalFixture.ts index 81db3494e..ff23207d8 100644 --- a/usvm-ts-pbt/src/test/resources/mapping/exports/CallableLocalFixture.ts +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/CallableLocalFixture.ts @@ -9,3 +9,7 @@ export const nonCallable = 42; export let reassignedPredicate = (value: number): boolean => value > 0; reassignedPredicate = (value: number): boolean => value < 0; + +let multiplyLinkedPredicate: (value: number) => boolean; +multiplyLinkedPredicate = multiplyLinkedPredicate = (value: number): boolean => value === 0; +export { multiplyLinkedPredicate as aliasedMultiplyLinkedPredicate }; From 143a772c9e2a40d780aef756582ecc95da2c9490 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 30 Aug 2026 20:03:07 +0300 Subject: [PATCH 11/16] [TS PBT] Resolve final mapping review findings --- .../pbt/coverage/RawV8SourceMapInspector.kt | 44 +++++++++-- .../fastcheck/FastCheckProjectionClient.kt | 7 +- .../usvm/ts/pbt/mapping/PropertyEtsMapper.kt | 3 +- .../org/usvm/ts/pbt/model/JsConcreteValue.kt | 8 +- .../coverage/RawV8SourceMapInspectorTest.kt | 74 +++++++++++++++++++ .../PropertyEtsExportResolutionTest.kt | 11 ++- .../usvm/ts/pbt/model/JsConcreteValueTest.kt | 2 +- 7 files changed, 135 insertions(+), 14 deletions(-) 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 index dcefcd0f4..db6edf74e 100644 --- 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 @@ -53,9 +53,7 @@ internal fun inspectRawV8SourceMapDiagnostics( ) } - return diagnostics - .distinct() - .sortedWith(COVERAGE_DIAGNOSTIC_ORDER) + return coalesceSourceMapDiagnostics(diagnostics) } /** Raw source-map evidence takes precedence over final-report guesses for the same generated script. */ @@ -70,9 +68,7 @@ internal fun mergeCoverageDiagnostics( isSourceMapDiagnostic(diagnostic) && diagnostic.path in rawSourceMapPaths } - return (retainedFinalDiagnostics + rawDiagnostics) - .distinct() - .sortedWith(COVERAGE_DIAGNOSTIC_ORDER) + return coalesceSourceMapDiagnostics(retainedFinalDiagnostics + rawDiagnostics) } internal fun buildSourceMapDiagnostic(path: String, sourceMapExists: Boolean): CoverageDiagnostic { @@ -300,6 +296,7 @@ private fun inspectSourceMapCacheEntry( message = "Raw V8 source-map cache entry is missing data", path = "$reportPath.source-map-cache[$scriptUrl].data", ) + requireValidSourceMapDataShape(reportPath, scriptUrl, data) if (data != JsonNull) return null val scriptUri = parseScriptUri(reportPath, scriptUrl) @@ -329,6 +326,15 @@ private fun inspectSourceMapCacheEntry( ) } +private fun requireValidSourceMapDataShape(reportPath: Path, scriptUrl: String, data: JsonElement) { + if (data == JsonNull || data is JsonObject) return + + throw invalidRawReport( + message = "Raw V8 source-map cache entry data must be a JSON object when non-null", + path = "$reportPath.source-map-cache[$scriptUrl].data", + ) +} + private fun parseScriptUri(reportPath: Path, scriptUrl: String): URI = try { URI(scriptUrl) } catch (error: IllegalArgumentException) { @@ -416,6 +422,32 @@ private fun isSourceMapDiagnostic(diagnostic: CoverageDiagnostic): Boolean = diagnostic.code == PbtDiagnosticCode.COVERAGE_SOURCE_MAP_MISSING || diagnostic.code == PbtDiagnosticCode.COVERAGE_SOURCE_MAP_INVALID +private fun coalesceSourceMapDiagnostics(diagnostics: List): List { + val diagnosticsByScriptPath = hashMapOf() + val unkeyedDiagnostics = mutableListOf() + + diagnostics.forEach { diagnostic -> + val scriptPath = diagnostic.path + if (!isSourceMapDiagnostic(diagnostic) || scriptPath == null) { + unkeyedDiagnostics += diagnostic + return@forEach + } + + val previous = diagnosticsByScriptPath[scriptPath] + if (previous == null || diagnostic.isInvalidInsteadOfMissing(previous)) { + diagnosticsByScriptPath[scriptPath] = diagnostic + } + } + + return (unkeyedDiagnostics + diagnosticsByScriptPath.values) + .distinct() + .sortedWith(COVERAGE_DIAGNOSTIC_ORDER) +} + +private fun CoverageDiagnostic.isInvalidInsteadOfMissing(other: CoverageDiagnostic): Boolean = + code == PbtDiagnosticCode.COVERAGE_SOURCE_MAP_INVALID && + other.code == PbtDiagnosticCode.COVERAGE_SOURCE_MAP_MISSING + private fun invalidRawReport( message: String, path: Path, 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 0e71b6765..1b826307f 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 @@ -170,7 +170,8 @@ class FastCheckProjectionClient private constructor( if (process.exitValue() != 0) { throw FastCheckProjectionException( code = PbtDiagnosticCode.BACKEND_PROCESS_FAILED, - message = "fast-check adapter exited with code ${process.exitValue()}: ${output.stderr.text.trim()}", + message = "fast-check adapter exited with code ${process.exitValue()}: " + + output.stderr.text.trim(), ) } @@ -362,7 +363,7 @@ class FastCheckProjectionClient private constructor( cause = error, ) } catch (error: ExecutionException) { - val cause = error.cause + val cause = error.cause ?: error if (cause is ProjectionOutputLimitExceeded) { throw FastCheckProjectionException( code = PbtDiagnosticCode.BACKEND_RESPONSE_TOO_LARGE, @@ -373,7 +374,7 @@ class FastCheckProjectionClient private constructor( throw FastCheckProjectionException( code = failureCode, - message = "Failed while $operation: ${cause?.message}", + message = "Failed while $operation: ${cause.message}", cause = cause, ) } 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 index 704f65b46..f998c18ea 100644 --- 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 @@ -525,7 +525,8 @@ class PropertyEtsMapper( diagnostics = listOf( EtsMappingDiagnostic( code = "mapping.entry-point.ambiguous", - message = "Several EtsIR methods match ${entryPoint.module}#${entryPoint.exportName}", + message = "Several EtsIR methods, source candidates, or export links match " + + "${entryPoint.module}#${entryPoint.exportName}", sourcePath = entryPoint.module, ), ), 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 6c7a6fef1..352cbfc65 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 @@ -191,14 +191,18 @@ object JsConcreteValueSerializer : KSerializer { JsConcreteValue.String(value.requiredString("value")) } - "number" -> deserializeNumber(value) + "number" -> { + deserializeNumber(value) + } "array" -> { value.requireExactKeys("kind", "elements") deserializeArray(jsonDecoder, value) } - else -> throw SerializationException("Unknown JavaScript value kind: $kind") + else -> { + throw SerializationException("Unknown JavaScript value kind: $kind") + } } } } 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 index a06601424..098e8ea4d 100644 --- 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 @@ -107,6 +107,16 @@ class RawV8SourceMapInspectorTest { } } + @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 -> @@ -163,6 +173,40 @@ class RawV8SourceMapInspectorTest { } } + @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 -> @@ -333,6 +377,36 @@ class RawV8SourceMapInspectorTest { } """.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-") 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 index 5cd6e54ed..663984f3a 100644 --- 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 @@ -82,6 +82,7 @@ class PropertyEtsExportResolutionTest { 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 @@ -98,6 +99,7 @@ class PropertyEtsExportResolutionTest { 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 @@ -129,6 +131,7 @@ class PropertyEtsExportResolutionTest { 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")) @@ -195,7 +198,13 @@ class PropertyEtsExportResolutionTest { val target = aliasArtifact.predicate.targets.single() assertEquals(linkedSignatures.distinct().single(), target.method.signature) assertTrue(target.method.name.startsWith("%AM")) - assertEquals("mapping.entry-point.ambiguous", aliasArtifact.predicate.diagnostics.single().code) + 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) } 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 32b92e89f..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,7 +1,7 @@ package org.usvm.ts.pbt.model -import kotlinx.serialization.encodeToString 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 From 59047f93e4f2228478b6908113fdea7fcbd8ecd9 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 30 Aug 2026 21:49:59 +0300 Subject: [PATCH 12/16] [TS PBT] Resolve final block review findings --- usvm-ts-pbt/DESIGN.md | 12 +- usvm-ts-pbt/README.md | 8 +- usvm-ts-pbt/fast-check-adapter/package.json | 2 +- .../src/projection-supervisor.ts | 233 ++++++++++++++++++ .../test/projection-supervisor.test.ts | 65 +++++ .../org/usvm/ts/pbt/PbtDiagnosticCode.kt | 16 ++ .../fastcheck/FastCheckProjectionClient.kt | 162 +++++++----- .../usvm/ts/pbt/fastcheck/FastCheckRuntime.kt | 3 + .../usvm/ts/pbt/mapping/EtsMappingModel.kt | 7 +- .../usvm/ts/pbt/mapping/PropertyEtsMapper.kt | 197 ++++++++------- .../pbt/mapping/SourceLocationNormalizer.kt | 3 +- .../FastCheckProjectionClientTest.kt | 23 +- .../ts/pbt/mapping/EtsMappingModelTest.kt | 20 ++ .../PropertyEtsExportResolutionTest.kt | 43 +++- .../ts/pbt/mapping/PropertyEtsMapperTest.kt | 27 +- .../mapping/exports/CallableLocalFixture.ts | 3 + .../exports/ambiguous-reexport/Entry.ts | 1 + .../mapping/exports/ambiguous-reexport/Foo.ts | 3 + .../exports/ambiguous-reexport/Foo/index.ts | 1 + .../resources/mapping/source-roots/b/Foo.ts | 8 +- 20 files changed, 641 insertions(+), 196 deletions(-) create mode 100644 usvm-ts-pbt/fast-check-adapter/src/projection-supervisor.ts create mode 100644 usvm-ts-pbt/fast-check-adapter/test/projection-supervisor.test.ts create mode 100644 usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/EtsMappingModelTest.kt create mode 100644 usvm-ts-pbt/src/test/resources/mapping/exports/ambiguous-reexport/Entry.ts create mode 100644 usvm-ts-pbt/src/test/resources/mapping/exports/ambiguous-reexport/Foo.ts create mode 100644 usvm-ts-pbt/src/test/resources/mapping/exports/ambiguous-reexport/Foo/index.ts diff --git a/usvm-ts-pbt/DESIGN.md b/usvm-ts-pbt/DESIGN.md index 858bfce04..493e4af2e 100644 --- a/usvm-ts-pbt/DESIGN.md +++ b/usvm-ts-pbt/DESIGN.md @@ -233,8 +233,8 @@ Entry-point resolution starts from the manifest module/export pair and follows n 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. EtsIR currently loses TypeScript `isTypeOnly` on named re-exports whose declaration has a runtime kind; -the mapper treats those exports conservatively as runtime-bearing instead of guessing that a star export wins. +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 @@ -262,9 +262,11 @@ with EtsIR-specific binding and mapping records and has no dependency on `usvm-j 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. +limits. The hard deadline is the property timeout plus two seconds for transport, followed by up to 250 ms of +graceful shutdown before force-kill, bounded by the absolute deadline. A private supervisor keeps the adapter in an +owned process group and retains a stable worker until cleanup, so an adapter that exits before its descendants cannot +orphan them. 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 diff --git a/usvm-ts-pbt/README.md b/usvm-ts-pbt/README.md index 8d2cd6a85..e75987fd3 100644 --- a/usvm-ts-pbt/README.md +++ b/usvm-ts-pbt/README.md @@ -157,9 +157,8 @@ re-exports and extensionless `.ts`, `.ets`, `.d.ts`, and directory-index module 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 does not preserve TypeScript `isTypeOnly` for named re-exports whose declaration also has a -runtime kind; those cases are conservatively treated as runtime exports and may remain unmapped instead of -following a bare-star export. +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. @@ -189,7 +188,8 @@ Stable mapping diagnostics include `mapping.entry-point.unmapped`, `mapping.entr `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`. Backend provenance is preserved separately from mapping provenance 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 diff --git a/usvm-ts-pbt/fast-check-adapter/package.json b/usvm-ts-pbt/fast-check-adapter/package.json index de025f782..ac2769379 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/project-domain.test.js dist/test/projection-cli.test.js dist/test/projection-supervisor.test.js" }, "dependencies": { "c8": "10.1.3", diff --git a/usvm-ts-pbt/fast-check-adapter/src/projection-supervisor.ts b/usvm-ts-pbt/fast-check-adapter/src/projection-supervisor.ts new file mode 100644 index 000000000..fa8562507 --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/src/projection-supervisor.ts @@ -0,0 +1,233 @@ +import { spawn, spawnSync } from 'node:child_process'; +import { unlinkSync, writeFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import { isMainThread, Worker, workerData } from 'node:worker_threads'; + +interface AdapterExitMessage { + type: 'adapter-exit'; + code: number; +} + +interface AdapterWorkerData { + adapterEntryPoint: string; +} + +const workerFlag = '--worker'; + +if (!isMainThread) { + const data = requireAdapterWorkerData(workerData); + + await runAdapterWorker(data.adapterEntryPoint); +} else { + const mode = process.argv[2]; + if (mode === workerFlag) { + runWorker(requireArgument(process.argv[3], 'adapter entry point')); + } else { + const adapterEntryPoint = requireArgument(mode, 'adapter entry point'); + const forceKillDelayMillis = requirePositiveInteger(process.argv[3], 'force-kill delay'); + const processGroupFile = requireArgument(process.argv[4], 'process-group file'); + + runSupervisor(adapterEntryPoint, forceKillDelayMillis, processGroupFile); + } +} + +function runSupervisor( + adapterEntryPoint: string, + forceKillDelayMillis: number, + processGroupFile: string, +): void { + const supervisorEntryPoint = requireArgument(process.argv[1], 'supervisor entry point'); + const worker = spawn( + process.execPath, + [supervisorEntryPoint, workerFlag, adapterEntryPoint], + { + detached: true, + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + }, + ); + const workerPid = requirePid(worker.pid, 'projection worker'); + const workerStdin = requireStream(worker.stdin, 'projection worker stdin'); + const workerStdout = requireStream(worker.stdout, 'projection worker stdout'); + const workerStderr = requireStream(worker.stderr, 'projection worker stderr'); + let adapterExitCode: number | undefined; + let shutdownStarted = false; + let forceKillTimer: NodeJS.Timeout | undefined; + + writeFileSync(processGroupFile, String(workerPid)); + + process.stdin.pipe(workerStdin); + workerStdout.pipe(process.stdout); + workerStderr.pipe(process.stderr); + + worker.on('message', (message: unknown) => { + if (!isAdapterExitMessage(message)) return; + + adapterExitCode = message.code; + terminateOwnedProcessGroup(workerPid, true); + }); + worker.on('error', (error: Error) => { + process.stderr.write(`Failed to start projection worker: ${error.message}\n`); + adapterExitCode = 1; + }); + worker.on('close', (code: number | null) => { + if (forceKillTimer !== undefined) clearTimeout(forceKillTimer); + removeProcessGroupFile(processGroupFile); + + process.exitCode = adapterExitCode ?? code ?? 1; + }); + + const shutdown = (): void => { + if (shutdownStarted) return; + + shutdownStarted = true; + terminateOwnedProcessGroup(workerPid, false); + forceKillTimer = setTimeout(() => { + terminateOwnedProcessGroup(workerPid, true); + }, forceKillDelayMillis); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +function runWorker(adapterEntryPoint: string): void { + const supervisorEntryPoint = requireArgument(process.argv[1], 'supervisor entry point'); + let reported = false; + const adapter = new Worker(supervisorEntryPoint, { + workerData: { adapterEntryPoint } satisfies AdapterWorkerData, + stdin: true, + stdout: true, + stderr: true, + }); + const adapterStdin = requireStream(adapter.stdin, 'projection adapter stdin'); + const adapterStdout = requireStream(adapter.stdout, 'projection adapter stdout'); + const adapterStderr = requireStream(adapter.stderr, 'projection adapter stderr'); + + process.stdin.pipe(adapterStdin); + adapterStdout.pipe(process.stdout); + adapterStderr.pipe(process.stderr); + + const reportExit = (code: number): void => { + if (reported) return; + + reported = true; + const message: AdapterExitMessage = { type: 'adapter-exit', code }; + process.send?.(message); + }; + + adapter.on('error', (error: Error) => { + process.stderr.write(`Failed to start projection adapter: ${error.message}\n`); + reportExit(1); + }); + adapter.on('exit', (code: number) => { + reportExit(code); + }); + + process.on('SIGINT', () => undefined); + process.on('SIGTERM', () => undefined); + process.on('disconnect', terminateOwnProcessGroup); +} + +async function runAdapterWorker(adapterEntryPoint: string): Promise { + try { + await import(pathToFileURL(adapterEntryPoint).href); + } finally { + process.stdin.destroy(); + } +} + +function terminateOwnedProcessGroup(pid: number, force: boolean): void { + 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; + } +} + +function terminateOwnProcessGroup(): void { + terminateOwnedProcessGroup(process.pid, true); +} + +function isAdapterExitMessage(value: unknown): value is AdapterExitMessage { + if (value === null || typeof value !== 'object') return false; + + const record = value as Record; + + return record.type === 'adapter-exit' + && typeof record.code === 'number' + && Number.isInteger(record.code); +} + +function isMissingProcess(error: unknown): boolean { + return error instanceof Error + && 'code' in error + && error.code === 'ESRCH'; +} + +function removeProcessGroupFile(processGroupFile: string): void { + try { + unlinkSync(processGroupFile); + } catch (error: unknown) { + if (!isMissingFile(error)) throw error; + } +} + +function isMissingFile(error: unknown): boolean { + return error instanceof Error + && 'code' in error + && error.code === 'ENOENT'; +} + +function requireArgument(value: string | undefined, name: string): string { + if (value === undefined || value.length === 0) fail(`Missing ${name}`); + + return value; +} + +function requirePositiveInteger(value: string | undefined, name: string): number { + const parsed = value === undefined ? Number.NaN : Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) 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 requireAdapterWorkerData(value: unknown): AdapterWorkerData { + if (value === null || typeof value !== 'object') fail('Missing projection adapter worker data'); + + const record = value as Record; + const adapterEntryPoint = requireArgument( + typeof record.adapterEntryPoint === 'string' ? record.adapterEntryPoint : undefined, + 'adapter entry point', + ); + + return { adapterEntryPoint }; +} + +function fail(message: string): never { + process.stderr.write(`${message}\n`); + process.exit(1); +} diff --git a/usvm-ts-pbt/fast-check-adapter/test/projection-supervisor.test.ts b/usvm-ts-pbt/fast-check-adapter/test/projection-supervisor.test.ts new file mode 100644 index 000000000..cbccab597 --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/test/projection-supervisor.test.ts @@ -0,0 +1,65 @@ +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 { setTimeout as delay } from 'node:timers/promises'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const supervisorPath = fileURLToPath(new URL('../src/projection-supervisor.js', import.meta.url)); + +test('adapter runs inside 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, adapterPath, '25', processGroupFile], + { stdio: 'ignore' }, + ); + const supervisorExit = new Promise((resolve) => supervisor.once('close', () => resolve())); + + try { + const [adapterPid, processGroupPid] = await Promise.all([ + readTextEventually(adapterPidFile), + readTextEventually(processGroupFile), + ]); + + assert.equal(adapterPid, processGroupPid); + } finally { + supervisor.kill('SIGTERM'); + await Promise.race([ + supervisorExit, + delay(2_000).then(() => 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'; +} 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/fastcheck/FastCheckProjectionClient.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClient.kt index 1b826307f..5db957e18 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 @@ -8,6 +8,7 @@ import org.usvm.ts.pbt.model.contains 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.Executors @@ -130,8 +131,8 @@ class FastCheckProjectionClient private constructor( } private fun invokeAdapter(encodedRequest: String): String { - val process = startAdapter() - val processTree = ProjectionProcessTree(process.toHandle()) + val managedProcess = startAdapter() + val process = managedProcess.process val deadlineNanos = deadlineAfter(transportLimits.wallClockTimeoutMillis) val ioExecutor = Executors.newFixedThreadPool(IO_TASKS) var stdout: Future? = null @@ -160,7 +161,6 @@ class FastCheckProjectionClient private constructor( val output = awaitAdapter( process = process, - processTree = processTree, writer = writerTask, stdout = requireNotNull(stdout), stderr = requireNotNull(stderr), @@ -184,27 +184,25 @@ class FastCheckProjectionClient private constructor( return output.stdout.text } finally { - processTree.observe() stdout?.cancel(true) stderr?.cancel(true) writer?.cancel(true) + terminate(managedProcess = managedProcess, deadlineNanos = deadlineNanos) closeStreams(process) - terminate(processTree = processTree, deadlineNanos = deadlineNanos) + runCatching { Files.deleteIfExists(managedProcess.processGroupFile) } ioExecutor.shutdownNow() } } private fun awaitAdapter( process: Process, - processTree: ProjectionProcessTree, writer: Future<*>, stdout: Future, stderr: Future, deadlineNanos: Long, ): ProjectionAdapterOutput { while (true) { - processTree.observe() checkCompletedIo( task = stdout, operation = "reading fast-check projection stdout", @@ -239,8 +237,6 @@ class FastCheckProjectionClient private constructor( } if (completed) { - processTree.observe() - return awaitIoAfterProcessExit( writer = writer, stdout = stdout, @@ -408,16 +404,42 @@ class FastCheckProjectionClient private constructor( message = "fast-check projection adapter exceeded the ${transportLimits.wallClockTimeoutMillis} ms timeout", ) - private fun startAdapter(): Process = try { - ProcessBuilder(nodeExecutable, adapterEntryPoint.toString()).start() - } catch (error: IOException) { - throw FastCheckProjectionException( - code = PbtDiagnosticCode.BACKEND_PROCESS_START_FAILED, - message = "Failed to start fast-check adapter: ${error.message}", - cause = error, - ) + private fun startAdapter(): ManagedProjectionProcess { + val processGroupFile = try { + Files.createTempFile("usvm-projection-process-group-", ".pid") + } catch (error: IOException) { + processStartFailure(error) + } + var processStarted = false + + try { + val supervisorEntryPoint = FastCheckRuntime.projectionSupervisorEntryPoint() + val process = ProcessBuilder( + nodeExecutable, + supervisorEntryPoint.toString(), + adapterEntryPoint.toString(), + transportLimits.shutdownGraceMillis.toString(), + processGroupFile.toString(), + ).start() + processStarted = true + + return ManagedProjectionProcess( + process = process, + processGroupFile = processGroupFile, + ) + } catch (error: IOException) { + processStartFailure(error) + } finally { + if (!processStarted) runCatching { Files.deleteIfExists(processGroupFile) } + } } + private fun processStartFailure(error: IOException): Nothing = throw FastCheckProjectionException( + code = PbtDiagnosticCode.BACKEND_PROCESS_START_FAILED, + message = "Failed to start fast-check adapter: ${error.message}", + cause = error, + ) + private fun decodeResponse(stdout: String): FastCheckProjectionWireResponse = try { PropertyManifestJson.json.decodeFromString(stdout) } catch (error: IllegalArgumentException) { @@ -453,19 +475,24 @@ class FastCheckProjectionClient private constructor( runCatching { process.errorStream.close() } } - private fun terminate(processTree: ProjectionProcessTree, deadlineNanos: Long) { - processTree.observe() - val processes = processTree.processesInTerminationOrder() - if (processes.none(ProcessHandle::isAlive)) return - - if (remainingMillis(deadlineNanos) <= FORCED_TERMINATION_RESERVE_MILLIS) { - processes.destroyForcibly() - awaitProcessTreeExit(processes, deadlineNanos) + private fun terminate(managedProcess: ManagedProjectionProcess, deadlineNanos: Long) { + val process = managedProcess.process + if (!process.isAlive) { + forceTerminateOwnedProcessGroup(managedProcess.processGroupFile, deadlineNanos) return } - processes.destroy() + process.destroy() + + val remainingMillis = remainingMillis(deadlineNanos) + if (remainingMillis <= FORCED_TERMINATION_RESERVE_MILLIS) { + forceTerminateOwnedProcessGroup(managedProcess.processGroupFile, deadlineNanos) + process.destroyForcibly() + awaitProcessExit(process, deadlineNanos) + + return + } val gracefulDeadlineNanos = minOf( deadlineBefore( @@ -474,24 +501,47 @@ class FastCheckProjectionClient private constructor( ), deadlineAfter(transportLimits.shutdownGraceMillis), ) - if (awaitProcessTreeExit(processes, gracefulDeadlineNanos)) return + if (awaitProcessExit(process, gracefulDeadlineNanos)) return - processes.destroyForcibly() - awaitProcessTreeExit(processes, deadlineNanos) + forceTerminateOwnedProcessGroup(managedProcess.processGroupFile, deadlineNanos) + process.destroyForcibly() + awaitProcessExit(process, deadlineNanos) } - private fun awaitProcessTreeExit(processes: List, deadlineNanos: Long): Boolean { + private fun forceTerminateOwnedProcessGroup(processGroupFile: Path, deadlineNanos: Long) { + val processGroupId = runCatching { + Files.readString(processGroupFile).trim().toLong() + }.getOrNull() ?: 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) + + 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 (true) { - val liveProcess = processes.firstOrNull(ProcessHandle::isAlive) ?: return true + if (!process.isAlive) return true + val waitMillis = minOf(remainingMillis(deadlineNanos), PROCESS_POLL_MILLIS) if (waitMillis == 0L) return false try { - liveProcess.onExit().get(waitMillis, TimeUnit.MILLISECONDS) - } catch (_: TimeoutException) { - continue - } catch (_: ExecutionException) { - continue + if (process.waitFor(waitMillis, TimeUnit.MILLISECONDS)) return true } catch (_: InterruptedException) { Thread.currentThread().interrupt() @@ -511,6 +561,9 @@ class FastCheckProjectionClient private constructor( 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") val DEFAULT_TRANSPORT_LIMITS = FastCheckProjectionTransportLimits( maxRequestBytes = DEFAULT_MAX_REQUEST_BYTES, @@ -527,6 +580,11 @@ private data class ProjectionAdapterOutput( val stderr: ProjectionBoundedText, ) +private data class ManagedProjectionProcess( + val process: Process, + val processGroupFile: Path, +) + private data class ProjectionBoundedText(val text: String) private class ProjectionOutputLimitExceeded( @@ -534,38 +592,6 @@ private class ProjectionOutputLimitExceeded( val limit: Int, ) : IOException("fast-check projection $stream exceeds $limit bytes") -private class ProjectionProcessTree(private val root: ProcessHandle) { - private val processes = linkedMapOf(root.pid() to root) - - fun observe() { - processes.values.toList().forEach { process -> - runCatching { - process.descendants().use { descendants -> - descendants.forEach { descendant -> - processes.putIfAbsent(descendant.pid(), descendant) - } - } - } - } - } - - fun processesInTerminationOrder(): List = processes.values.sortedBy { process -> - if (process.pid() == root.pid()) 1 else 0 - } -} - -private fun List.destroy() { - forEach { process -> - runCatching { process.destroy() } - } -} - -private fun List.destroyForcibly() { - forEach { process -> - runCatching { process.destroyForcibly() } - } -} - private fun InputStream.readProjectionBounded(limit: Int, stream: String): ProjectionBoundedText { val output = ByteArrayOutputStream(minOf(limit, DEFAULT_BUFFER_SIZE)) val buffer = ByteArray(DEFAULT_BUFFER_SIZE) 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..f654813a4 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 projectionSupervisorEntryPoint(): Path = locateEntryPoint(PROJECTION_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 PROJECTION_SUPERVISOR = "projection-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/EtsMappingModel.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsMappingModel.kt index 27f0fb0c5..131772954 100644 --- 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 @@ -41,7 +41,12 @@ 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. */ data class EtsMappingResult( 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 index f998c18ea..c33cda310 100644 --- 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 @@ -17,6 +17,7 @@ import org.jacodb.ets.model.EtsStmt 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.backend.BranchArmCoverage import org.usvm.ts.pbt.backend.BranchCoverage import org.usvm.ts.pbt.backend.PropertyCoverageArtifact @@ -70,7 +71,7 @@ class PropertyEtsMapper( backendProvenance = null, diagnostics = listOf( EtsMappingDiagnostic( - code = "mapping.coverage.unavailable", + code = PbtDiagnosticCode.MAPPING_COVERAGE_UNAVAILABLE, message = "The property backend returned no source coverage artifact", ), ), @@ -86,7 +87,7 @@ class PropertyEtsMapper( backendProvenance = coverage.provenance, diagnostics = listOf( EtsMappingDiagnostic( - code = "mapping.coverage.property-id.mismatch", + code = PbtDiagnosticCode.MAPPING_COVERAGE_PROPERTY_ID_MISMATCH, message = "Coverage property ${coverage.propertyId.value} does not match ${propertyId.value}", ), ), @@ -139,7 +140,7 @@ class PropertyEtsMapper( if (coverage.type != ISTANBUL_IF_BRANCH_TYPE || coverage.arms.size != BINARY_BRANCH_ARM_COUNT) { val diagnostic = EtsMappingDiagnostic( - code = "mapping.branch.shape.unsupported", + code = PbtDiagnosticCode.MAPPING_BRANCH_SHAPE_UNSUPPORTED, message = "EtsIR branch mapping requires an if branch with exactly two ordered coverage arms", sourcePath = location.path, ) @@ -159,7 +160,7 @@ class PropertyEtsMapper( val conditionsInSourceFile = conditionGroupsInSourceFile.flatten() if (conditionsInSourceFile.isNotEmpty() && conditionsInSourceFile.none { it.location.origin != null }) { val diagnostic = EtsMappingDiagnostic( - code = "mapping.source-origins.unsupported", + code = PbtDiagnosticCode.MAPPING_SOURCE_ORIGINS_UNSUPPORTED, message = "EtsIR conditions for the covered source file have no source origins", sourcePath = location.path, ) @@ -179,7 +180,7 @@ class PropertyEtsMapper( val conditions = conditionGroups.flatten() if (conditions.any { statement -> statement.successorCount() != BINARY_BRANCH_ARM_COUNT }) { val diagnostic = EtsMappingDiagnostic( - code = "mapping.branch.cfg.unsupported", + code = PbtDiagnosticCode.MAPPING_BRANCH_CFG_UNSUPPORTED, message = "EtsIR branch mapping requires exactly two ordered CFG successors", sourcePath = location.path, ) @@ -200,7 +201,7 @@ class PropertyEtsMapper( location = location, conditions = conditions, distinctOriginCount = distinctOrigins.size, - sourceCandidateCount = conditionGroups.size, + sourceCandidateCount = conditionGroupsInSourceFile.size, ) val arms = coverage.arms.mapIndexed { index, arm -> mapBranchArm(sourcePath, arm, index, mapping) @@ -250,6 +251,18 @@ class PropertyEtsMapper( distinctOriginCount: Int, sourceCandidateCount: Int, ): EtsMappingResult = when { + conditions.isEmpty() -> EtsMappingResult( + status = EtsMappingStatus.UNMAPPED, + targets = emptyList(), + diagnostics = listOf( + EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_BRANCH_UNMAPPED, + message = "No EtsIR condition belongs to the covered TypeScript branch", + sourcePath = location.path, + ), + ), + ) + distinctOriginCount == 1 && sourceCandidateCount == 1 -> EtsMappingResult( status = EtsMappingStatus.EXACT, targets = conditions.map(::EtsBranchTarget), @@ -260,24 +273,14 @@ class PropertyEtsMapper( targets = conditions.map(::EtsBranchTarget), diagnostics = listOf( EtsMappingDiagnostic( - code = "mapping.branch.ambiguous", + code = PbtDiagnosticCode.MAPPING_BRANCH_AMBIGUOUS, message = "The covered TypeScript branch contains several EtsIR conditions", sourcePath = location.path, ), ), ) - else -> EtsMappingResult( - status = EtsMappingStatus.UNMAPPED, - targets = emptyList(), - diagnostics = listOf( - EtsMappingDiagnostic( - code = "mapping.branch.unmapped", - message = "No EtsIR condition belongs to the covered TypeScript branch", - sourcePath = location.path, - ), - ), - ) + else -> error("EtsIR branch mapping has targets without source provenance") } private fun mapBranchArm( @@ -346,7 +349,7 @@ class PropertyEtsMapper( location = location, mapping = unsupportedMapping( EtsMappingDiagnostic( - code = "mapping.source-origins.unsupported", + code = PbtDiagnosticCode.MAPPING_SOURCE_ORIGINS_UNSUPPORTED, message = "EtsIR statements for the covered source file have no source origins", sourcePath = location.path, ), @@ -365,7 +368,8 @@ class PropertyEtsMapper( .mapNotNull { statement -> statement.location.origin } .distinct() val mapping = when { - containedStatementGroups.size > 1 -> ambiguousStatementMapping(location, containedStatements) + statementGroupsInSourceFile.size > 1 && containedStatements.isNotEmpty() -> + ambiguousStatementMapping(location, containedStatements) exactTargets.isNotEmpty() -> EtsMappingResult( status = EtsMappingStatus.EXACT, @@ -384,7 +388,7 @@ class PropertyEtsMapper( targets = emptyList(), diagnostics = listOf( EtsMappingDiagnostic( - code = "mapping.statement.unmapped", + code = PbtDiagnosticCode.MAPPING_STATEMENT_UNMAPPED, message = "No EtsIR statement has the covered TypeScript source span", sourcePath = location.path, ), @@ -407,7 +411,7 @@ class PropertyEtsMapper( targets = statements.map(::EtsStatementTarget), diagnostics = listOf( EtsMappingDiagnostic( - code = "mapping.statement.ambiguous", + code = PbtDiagnosticCode.MAPPING_STATEMENT_AMBIGUOUS, message = "The covered TypeScript range matches several EtsIR source candidates or spans", sourcePath = location.path, ), @@ -419,9 +423,9 @@ class PropertyEtsMapper( failure: Throwable?, ): EtsMappingDiagnostic { val diagnosticCode = if (failure is UnsupportedSourceLocationException) { - "mapping.source.location.unsupported" + PbtDiagnosticCode.MAPPING_SOURCE_LOCATION_UNSUPPORTED } else { - "mapping.source.unavailable" + PbtDiagnosticCode.MAPPING_SOURCE_UNAVAILABLE } return EtsMappingDiagnostic( @@ -457,19 +461,9 @@ class PropertyEtsMapper( origin.endOffset <= location.end.offset } - private fun EtsIfStmt.successorCount(): Int = location.method.cfg.successors(this).size - private fun org.jacodb.ets.model.EtsSourceSpan.hasPath(path: String): Boolean = sourceLocations.normalizePath(fileName).any { candidate -> candidate.toString() == path } - 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 fun resolveEntryPoint( entryPoint: TypeScriptEntryPoint, manifest: PropertyManifest, @@ -482,14 +476,15 @@ class PropertyEtsMapper( ) } - val candidateResolutions = scene.projectFiles - .filter { candidate -> candidate.matches(entryPoint.module) } - .map { file -> resolveExportedMethods(file, entryPoint.exportName, visited = emptySet()) } + 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 hasAmbiguousResolution = candidateResolutions.any { resolution -> resolution.isAmbiguous } || - candidateResolutions.count { resolution -> resolution.methods.isNotEmpty() } > 1 + sourceCandidates.size > 1 if (methods.any { method -> method.parameters.size != manifest.inputs.size }) { return EtsMappingResult( @@ -497,7 +492,7 @@ class PropertyEtsMapper( targets = emptyList(), diagnostics = listOf( EtsMappingDiagnostic( - code = "mapping.entry-point.bindings.unsupported", + code = PbtDiagnosticCode.MAPPING_ENTRY_POINT_BINDINGS_UNSUPPORTED, message = "Property inputs do not match EtsIR parameters for ${entryPoint.exportName}", sourcePath = entryPoint.module, ), @@ -524,7 +519,7 @@ class PropertyEtsMapper( targets = targets, diagnostics = listOf( EtsMappingDiagnostic( - code = "mapping.entry-point.ambiguous", + code = PbtDiagnosticCode.MAPPING_ENTRY_POINT_AMBIGUOUS, message = "Several EtsIR methods, source candidates, or export links match " + "${entryPoint.module}#${entryPoint.exportName}", sourcePath = entryPoint.module, @@ -538,7 +533,7 @@ class PropertyEtsMapper( targets = emptyList(), diagnostics = listOf( EtsMappingDiagnostic( - code = "mapping.entry-point.unmapped", + code = PbtDiagnosticCode.MAPPING_ENTRY_POINT_UNMAPPED, message = "No EtsIR method matches ${entryPoint.module}#${entryPoint.exportName}", sourcePath = entryPoint.module, ), @@ -578,9 +573,12 @@ class PropertyEtsMapper( .filter { export -> export.isReExport && !export.isNamespaceStarReExport } .flatMap { export -> val targetExportName = if (export.isBareStarReExport) exportName else export.originalName + val targetFiles = resolveReExportFiles(file, requireNotNull(export.from)) - resolveReExportFiles(file, requireNotNull(export.from)).map { targetFile -> - resolveExportedMethods(targetFile, targetExportName, visited + file) + targetFiles.map { targetFile -> + val resolution = resolveExportedMethods(targetFile, targetExportName, visited + file) + + resolution.copy(isAmbiguous = resolution.isAmbiguous || targetFiles.size > 1) } } val methods = ( @@ -597,7 +595,7 @@ class PropertyEtsMapper( } private fun resolveCallableLocal(file: EtsFile, localName: String): MethodResolution { - val callableAssignments = file.classes + val assignments = file.classes .filter { etsClass -> etsClass.name == DEFAULT_ARK_CLASS_NAME } .flatMap { defaultClass -> defaultClass.methods @@ -610,15 +608,18 @@ class PropertyEtsMapper( return@mapNotNull null } - val local = assignment.rhv as? EtsLocal ?: return@mapNotNull null - val functionType = local.type as? EtsFunctionType ?: return@mapNotNull null - - CallableLocalAssignment( - defaultClass = defaultClass, - functionSignature = functionType.signature, - ) + defaultClass to assignment } } + val callableAssignments = assignments.mapNotNull { (defaultClass, assignment) -> + val local = assignment.rhv as? EtsLocal ?: return@mapNotNull null + val functionType = local.type as? EtsFunctionType ?: return@mapNotNull null + + CallableLocalAssignment( + defaultClass = defaultClass, + functionSignature = functionType.signature, + ) + } val linkedMethods = callableAssignments.flatMap { assignment -> assignment.defaultClass.methods.filter { method -> method.name.startsWith(ANONYMOUS_METHOD_PREFIX) && @@ -626,7 +627,7 @@ class PropertyEtsMapper( } } val methods = linkedMethods.distinctByIdentity() - val isExactLink = callableAssignments.size == 1 && linkedMethods.size == 1 + val isExactLink = assignments.size == 1 && callableAssignments.size == 1 && linkedMethods.size == 1 return MethodResolution( methods = methods, @@ -634,21 +635,6 @@ class PropertyEtsMapper( ) } - 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 fun resolveReExportFiles(file: EtsFile, module: String): List { val targetPaths = sourceLocations.normalizePath(file.name).flatMapTo(linkedSetOf()) { sourcePath -> val targetPath = requireNotNull(sourcePath.parent).resolve(module).normalize() @@ -692,36 +678,59 @@ class PropertyEtsMapper( result = EtsResultBinding(type = returnType), ) } +} - 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 data class SceneFileCandidate( + val file: EtsFile, + val canonicalPaths: Set, +) { + val statements: List = file.allClasses + .flatMap { etsClass -> etsClass.methods } + .flatMap { method -> method.cfg.stmts } +} - private data class CallableLocalAssignment( - val defaultClass: EtsClass, - val functionSignature: EtsMethodSignature, - ) +private data class CallableLocalAssignment( + val defaultClass: EtsClass, + val functionSignature: EtsMethodSignature, +) - private data class MethodResolution( - val methods: List, - val isAmbiguous: Boolean = false, - ) { - companion object { - val EMPTY = MethodResolution(methods = emptyList()) - } +private data class MethodResolution( + val methods: List, + val isAmbiguous: Boolean = false, +) { + companion object { + val EMPTY = MethodResolution(methods = emptyList()) } +} - private companion object { - const val BINARY_BRANCH_ARM_COUNT = 2 - const val DEFAULT_EXPORT_NAME = "default" - const val ISTANBUL_IF_BRANCH_TYPE = "if" - const val RECEIVER_STACK_SLOT = 0 - const val RECEIVER_STACK_SLOTS = 1 - const val TRUE_BRANCH_ARM_INDEX = 0 - } +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 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 BINARY_BRANCH_ARM_COUNT = 2 +private const val DEFAULT_EXPORT_NAME = "default" +private const val ISTANBUL_IF_BRANCH_TYPE = "if" +private const val RECEIVER_STACK_SLOT = 0 +private const val RECEIVER_STACK_SLOTS = 1 +private const val TRUE_BRANCH_ARM_INDEX = 0 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 index dbf9db615..fcf71918a 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -120,7 +121,7 @@ internal class SourceLocationNormalizer(sourceRoots: List) { SourceRootResolution( path = path, diagnostic = EtsMappingDiagnostic( - code = "mapping.source-root.unsupported", + code = PbtDiagnosticCode.MAPPING_SOURCE_ROOT_UNSUPPORTED, message = "Cannot resolve TypeScript source root $index ($path): $reason", sourcePath = path.toString(), ), 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 570caa051..ccfb4562c 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 @@ -10,7 +10,9 @@ 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 @@ -247,7 +249,7 @@ class FastCheckProjectionClientTest { @Test @Timeout(value = 2, unit = TimeUnit.SECONDS) - fun `parent exit with a descendant retaining a pipe reaches the adapter deadline`() { + fun `immediate parent exit still terminates a descendant retaining a pipe`() { val childPidFile = createTempFile(prefix = "fast-check-descendant-pid-", suffix = ".txt") childPidFile.deleteIfExists() @@ -261,7 +263,7 @@ class FastCheckProjectionClientTest { "process.on('SIGTERM', () => undefined); setInterval(() => undefined, 1000)" ], { stdio: 'inherit' }) writeFileSync(${childPidFile.toJavaScriptStringLiteral()}, String(child.pid)) - setTimeout(() => process.exit(0), 100) + process.exit(0) """.trimIndent(), transportLimits = transportLimits( wallClockTimeoutMillis = 250, @@ -274,7 +276,7 @@ class FastCheckProjectionClientTest { } val elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000 - assertEquals("backend.process.timeout", error.code) + assertEquals("backend.response.empty", error.code) assertTrue(elapsedMillis < 600, "Descendant cleanup took $elapsedMillis ms") assertTrue(adapterIsTerminated(childPidFile), "Descendant is still running") } @@ -426,8 +428,21 @@ class FastCheckProjectionClientTest { private fun adapterIsTerminated(pidFile: Path): Boolean { val pid = pidFile.readText().trim().toLong() val process = ProcessHandle.of(pid).orElse(null) + if (process == null || !process.isAlive) return true - return process == null || !process.isAlive + 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 companion object { 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..5c56f7a87 --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/EtsMappingModelTest.kt @@ -0,0 +1,20 @@ +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 = " ") + } + } +} 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 index 663984f3a..e276688e7 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -163,6 +164,20 @@ class PropertyEtsExportResolutionTest { 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") @@ -235,14 +250,38 @@ class PropertyEtsExportResolutionTest { 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) + } + private fun mapper(vararg sources: Path): PropertyEtsMapper { + val sourceRoot = sources.first().parent val files = sources.map { source -> - loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + 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(sources.first().parent), + sourceRoots = listOf(sourceRoot), ) } 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 index 3bb808d3f..57e9dc04c 100644 --- 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 @@ -150,7 +150,7 @@ class PropertyEtsMapperTest { } @Test - fun `duplicate frontend signatures keep coverage source provenance ambiguous`() { + 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") @@ -203,13 +203,19 @@ class PropertyEtsMapperTest { 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.any { target -> - duplicateFile.classes - .flatMap { etsClass -> etsClass.methods } - .any { method -> target.statement.location.method === method } + mapping.targets.none { target -> + duplicateMethods.any { method -> target.statement.location.method === method } }, ) assertEquals("mapping.statement.ambiguous", mapping.diagnostics.single().code) @@ -223,8 +229,9 @@ class PropertyEtsMapperTest { branch.arms.forEach { arm -> val targetMethods = arm.mapping.targets.map { target -> target.condition.location.method } - assertTrue(targetMethods.any { target -> primaryMethods.any { method -> target === method } }) - assertTrue(targetMethods.any { target -> duplicateMethods.any { method -> target === method } }) + assertTrue(targetMethods.isNotEmpty()) + assertTrue(targetMethods.all { target -> primaryMethods.any { method -> target === method } }) + assertTrue(targetMethods.none { target -> duplicateMethods.any { method -> target === method } }) } } @@ -542,7 +549,9 @@ class PropertyEtsBranchMappingTest { @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, @@ -573,8 +582,8 @@ class PropertyEtsBranchMappingTest { ), ) val mapper = PropertyEtsMapper( - scene = EtsScene(listOf(file)), - sourceRoots = listOf(source.parent), + scene = EtsScene(listOf(file, duplicateFile)), + sourceRoots = listOf(source.parent, duplicateSource.parent), ) val artifact = mapper.map(manifest, coverage) diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/CallableLocalFixture.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/CallableLocalFixture.ts index ff23207d8..77976b594 100644 --- a/usvm-ts-pbt/src/test/resources/mapping/exports/CallableLocalFixture.ts +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/CallableLocalFixture.ts @@ -10,6 +10,9 @@ 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/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/source-roots/b/Foo.ts b/usvm-ts-pbt/src/test/resources/mapping/source-roots/b/Foo.ts index 606a033c5..242912fe6 100644 --- 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 @@ -1,7 +1 @@ -export function predicate(value: number): boolean { - if (value > 0) { - return true; - } else { - return false; - } -} +export const unrelated = 0; From 67f462e120be0efc5c5315488eecf2c2786f320e Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 30 Aug 2026 22:02:45 +0300 Subject: [PATCH 13/16] [TS PBT] Stabilize inherited pipe deadline test --- .../fastcheck/FastCheckProcessClientTest.kt | 100 +++++++++++------- 1 file changed, 64 insertions(+), 36 deletions(-) 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 2e51a4813..564efe3d5 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 @@ -1,6 +1,7 @@ package org.usvm.ts.pbt.fastcheck import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout import org.usvm.ts.pbt.backend.PropertyCoverageRequest import org.usvm.ts.pbt.manifest.toManifest import org.usvm.ts.pbt.model.BooleanDomain @@ -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 @@ -301,45 +303,54 @@ class FastCheckProcessClientTest { } @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) fun `hard deadline includes inherited descendant pipe drain`() { - withTemporaryAdapter( - source = """ - import { spawn } from 'node:child_process' + val childPidFile = createTempFile(prefix = "fast-check-inherited-pipe-pid-", suffix = ".txt") + childPidFile.deleteIfExists() - const child = spawn( - process.execPath, - ['-e', 'setTimeout(() => undefined, 3000)'], - { stdio: ['ignore', 'inherit', 'inherit'] } - ) - 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 startedAt = System.nanoTime() - val error = assertFailsWith { - client.check(validRequest.copy(timeoutMillis = 100)) - } - val elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000 + try { + withTemporaryAdapter( + source = """ + import { spawn } from 'node:child_process' + import { writeFileSync } from 'node:fs' + + const child = spawn( + process.execPath, + ['-e', 'setTimeout(() => undefined, 30000)'], + { stdio: ['ignore', 'inherit', 'inherit'] } + ) + writeFileSync(${childPidFile.toJavaScriptStringLiteral()}, String(child.pid)) + child.unref() - assertEquals(BackendErrorKind.TIMEOUT, error.kind) - assertEquals("backend.process.timeout", error.code) - assertTrue(elapsedMillis < 1_000, "Inherited pipe timeout took $elapsedMillis ms") + 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 error = assertFailsWith { + client.check(validRequest.copy(timeoutMillis = 100)) + } + + assertEquals(BackendErrorKind.TIMEOUT, error.kind) + assertEquals("backend.process.timeout", error.code) + assertTrue(processIsAlive(childPidFile), "Inherited-pipe descendant exited before the hard deadline") + } + } finally { + terminateProcess(childPidFile) + childPidFile.deleteIfExists() } } @@ -370,6 +381,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, From 62d7c269883ce7b8c1b0241c0ca384ba145c1000 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 30 Aug 2026 22:14:01 +0300 Subject: [PATCH 14/16] [TS PBT] Remove scheduler-sensitive test timeout --- .../org/usvm/ts/pbt/fastcheck/FastCheckProcessClientTest.kt | 2 -- 1 file changed, 2 deletions(-) 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 564efe3d5..7c24a7822 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 @@ -1,7 +1,6 @@ package org.usvm.ts.pbt.fastcheck import org.junit.jupiter.api.Test -import org.junit.jupiter.api.Timeout import org.usvm.ts.pbt.backend.PropertyCoverageRequest import org.usvm.ts.pbt.manifest.toManifest import org.usvm.ts.pbt.model.BooleanDomain @@ -303,7 +302,6 @@ class FastCheckProcessClientTest { } @Test - @Timeout(value = 5, unit = TimeUnit.SECONDS) fun `hard deadline includes inherited descendant pipe drain`() { val childPidFile = createTempFile(prefix = "fast-check-inherited-pipe-pid-", suffix = ".txt") childPidFile.deleteIfExists() From ea3e43cf27f58469c7f8d5ebebccd5d78065f74d Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 30 Aug 2026 22:36:21 +0300 Subject: [PATCH 15/16] [TS PBT] Own execution adapter process groups --- .../src/projection-supervisor.ts | 97 ++++++++++ .../test/projection-supervisor.test.ts | 69 +++++++ .../pbt/fastcheck/FastCheckProcessClient.kt | 174 ++++++++++++++---- .../fastcheck/FastCheckProjectionClient.kt | 2 +- .../usvm/ts/pbt/fastcheck/FastCheckRuntime.kt | 4 +- .../fastcheck/FastCheckProcessClientTest.kt | 18 +- 6 files changed, 325 insertions(+), 39 deletions(-) diff --git a/usvm-ts-pbt/fast-check-adapter/src/projection-supervisor.ts b/usvm-ts-pbt/fast-check-adapter/src/projection-supervisor.ts index fa8562507..9fda14862 100644 --- a/usvm-ts-pbt/fast-check-adapter/src/projection-supervisor.ts +++ b/usvm-ts-pbt/fast-check-adapter/src/projection-supervisor.ts @@ -12,7 +12,11 @@ interface AdapterWorkerData { adapterEntryPoint: string; } +type Command = [string, ...string[]]; + const workerFlag = '--worker'; +const commandFlag = '--command'; +const commandWorkerFlag = '--command-worker'; if (!isMainThread) { const data = requireAdapterWorkerData(workerData); @@ -22,6 +26,17 @@ if (!isMainThread) { const mode = process.argv[2]; if (mode === workerFlag) { runWorker(requireArgument(process.argv[3], 'adapter entry point')); + } else if (mode === commandWorkerFlag) { + runCommandWorker(requireCommand(process.argv.slice(3))); + } else if (mode === commandFlag) { + const forceKillDelayMillis = requirePositiveInteger(process.argv[3], 'force-kill delay'); + const processGroupFile = requireArgument(process.argv[4], 'process-group file'); + + runCommandSupervisor( + requireCommand(process.argv.slice(5)), + forceKillDelayMillis, + processGroupFile, + ); } else { const adapterEntryPoint = requireArgument(mode, 'adapter entry point'); const forceKillDelayMillis = requirePositiveInteger(process.argv[3], 'force-kill delay'); @@ -31,6 +46,82 @@ if (!isMainThread) { } } +function runCommandSupervisor( + command: Command, + forceKillDelayMillis: number, + processGroupFile: string, +): void { + const supervisorEntryPoint = requireArgument(process.argv[1], 'supervisor entry point'); + const worker = spawn( + process.execPath, + [supervisorEntryPoint, commandWorkerFlag, ...command], + { + detached: true, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ); + const workerPid = requirePid(worker.pid, 'command worker'); + const workerStdin = requireStream(worker.stdin, 'command worker stdin'); + const workerStdout = requireStream(worker.stdout, 'command worker stdout'); + const workerStderr = requireStream(worker.stderr, 'command worker stderr'); + let shutdownStarted = false; + let forceKillTimer: NodeJS.Timeout | undefined; + + writeFileSync(processGroupFile, String(workerPid)); + + process.stdin.pipe(workerStdin); + workerStdout.pipe(process.stdout); + workerStderr.pipe(process.stderr); + + worker.on('error', (error: Error) => { + process.stderr.write(`Failed to start command worker: ${error.message}\n`); + process.exitCode = 1; + }); + worker.on('close', (code: number | null) => { + if (forceKillTimer !== undefined) clearTimeout(forceKillTimer); + removeProcessGroupFile(processGroupFile); + + process.exitCode = code ?? 1; + }); + + const shutdown = (): void => { + if (shutdownStarted) return; + + shutdownStarted = true; + terminateOwnedProcessGroup(workerPid, false); + forceKillTimer = setTimeout(() => { + terminateOwnedProcessGroup(workerPid, true); + }, forceKillDelayMillis); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +function runCommandWorker(command: Command): void { + const child = spawn(command[0], command.slice(1), { + stdio: ['pipe', 'pipe', 'pipe'], + }); + const childStdin = requireStream(child.stdin, 'supervised command stdin'); + const childStdout = requireStream(child.stdout, 'supervised command stdout'); + const childStderr = requireStream(child.stderr, 'supervised command stderr'); + + process.stdin.pipe(childStdin); + childStdout.pipe(process.stdout); + childStderr.pipe(process.stderr); + + child.on('error', (error: Error) => { + process.stderr.write(`Failed to start supervised command: ${error.message}\n`); + process.exitCode = 1; + }); + child.on('close', (code: number | null) => { + process.exitCode = code ?? 1; + }); + + process.on('SIGINT', () => undefined); + process.on('SIGTERM', () => undefined); +} + function runSupervisor( adapterEntryPoint: string, forceKillDelayMillis: number, @@ -196,6 +287,12 @@ function requireArgument(value: string | undefined, name: string): string { return value; } +function requireCommand(command: string[]): Command { + const executable = requireArgument(command[0], 'command executable'); + + return [executable, ...command.slice(1)]; +} + function requirePositiveInteger(value: string | undefined, name: string): number { const parsed = value === undefined ? Number.NaN : Number(value); if (!Number.isInteger(parsed) || parsed <= 0) fail(`Invalid ${name}: ${value ?? ''}`); diff --git a/usvm-ts-pbt/fast-check-adapter/test/projection-supervisor.test.ts b/usvm-ts-pbt/fast-check-adapter/test/projection-supervisor.test.ts index cbccab597..f033a21bf 100644 --- a/usvm-ts-pbt/fast-check-adapter/test/projection-supervisor.test.ts +++ b/usvm-ts-pbt/fast-check-adapter/test/projection-supervisor.test.ts @@ -44,6 +44,49 @@ test('adapter runs inside the stable process-group owner', { timeout: 3_000 }, a } }); +test('command mode owns 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`, + ); + const supervisor = spawn( + process.execPath, + [supervisorPath, '--command', '25', processGroupFile, process.execPath, adapterPath], + { stdio: 'ignore' }, + ); + const supervisorExit = new Promise((resolve) => 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(supervisor.exitCode, null); + + supervisor.kill('SIGTERM'); + await supervisorExit; + + assert.equal(isProcessAlive(childPid), false); + } finally { + supervisor.kill('SIGKILL'); + if (childPid !== undefined) terminateProcess(childPid); + await rm(workspace, { recursive: true, force: true }); + } +}); + async function readTextEventually(file: string): Promise { const deadline = Date.now() + 2_000; @@ -63,3 +106,29 @@ function isMissingFile(error: unknown): boolean { && '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'; +} 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 2a87e7891..cb04b8241 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 @@ -57,28 +57,33 @@ internal class FastCheckProcessClient( val coverageRuntimeVersion = request.coverageRequest?.let { nodeVersion(request) } val coverageWorkspace = request.coverageRequest?.let { createCoverageWorkspace(request) } val deadlineNanos = deadlineAfter(safeAdd(request.timeoutMillis, transportGraceMillis)) - var process: Process? = null + val operationDeadlineNanos = deadlineBefore( + deadlineNanos = deadlineNanos, + durationMillis = minOf(FORCED_TERMINATION_RESERVE_MILLIS, transportGraceMillis), + ) + var managedProcess: ManagedFastCheckProcess? = null var stdout: Deferred? = null var stderr: Deferred? = null var writer: Deferred? = null try { val startedProcess = startAdapter(request, coverageWorkspace) - process = startedProcess - val stdoutTask = async(ioDispatcher) { startedProcess.inputStream.readBounded(MAX_STDOUT_BYTES) } + managedProcess = startedProcess + val process = startedProcess.process + val stdoutTask = async(ioDispatcher) { process.inputStream.readBounded(MAX_STDOUT_BYTES) } stdout = stdoutTask - val stderrTask = async(ioDispatcher) { startedProcess.errorStream.readBounded(MAX_STDERR_BYTES) } + val stderrTask = async(ioDispatcher) { process.errorStream.readBounded(MAX_STDERR_BYTES) } stderr = stderrTask val writerTask = async(ioDispatcher) { - startedProcess.outputStream.bufferedWriter(Charsets.UTF_8).use { output -> + process.outputStream.bufferedWriter(Charsets.UTF_8).use { output -> output.write(encodedRequest) } } writer = writerTask awaitProcess( - process = startedProcess, - deadlineNanos = deadlineNanos, + process = process, + deadlineNanos = operationDeadlineNanos, request = request, ) @@ -86,7 +91,7 @@ internal class FastCheckProcessClient( task = writerTask, operation = "writing the fast-check request", failureCode = PbtDiagnosticCode.BACKEND_PROCESS_WRITE_FAILED, - deadlineNanos = deadlineNanos, + deadlineNanos = operationDeadlineNanos, request = request, ) @@ -94,18 +99,18 @@ internal class FastCheckProcessClient( task = stdoutTask, operation = "reading fast-check stdout", failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, - deadlineNanos = deadlineNanos, + deadlineNanos = operationDeadlineNanos, request = request, ) val stderrText = awaitIo( task = stderrTask, operation = "reading fast-check stderr", failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, - deadlineNanos = deadlineNanos, + deadlineNanos = operationDeadlineNanos, request = request, ) - validateProcessExit(startedProcess, stderrText, request) + validateProcessExit(process, stderrText, request) validateStdout(stdoutText, request) val response = decodeResponse(stdoutText.text, request) @@ -124,9 +129,10 @@ internal class FastCheckProcessClient( writer?.cancel() stdout?.cancel() stderr?.cancel() - process?.let { startedProcess -> - closeStreams(startedProcess) + managedProcess?.let { startedProcess -> terminate(startedProcess, deadlineNanos) + closeStreams(startedProcess.process) + runCatching { Files.deleteIfExists(startedProcess.processGroupFile) } } coverageWorkspace?.root?.toFile()?.deleteRecursively() } @@ -200,16 +206,57 @@ 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, - ) + ): ManagedFastCheckProcess { + val processGroupFile = try { + Files.createTempFile("usvm-execution-process-group-", ".pid") + } catch (error: IOException) { + throw processStartFailure(request, error) + } + var processStarted = false + + try { + val process = ProcessBuilder( + supervisedAdapterCommand( + request = request, + coverageWorkspace = coverageWorkspace, + processGroupFile = processGroupFile, + ), + ).start() + processStarted = true + + return ManagedFastCheckProcess( + process = process, + processGroupFile = processGroupFile, + ) + } catch (error: IOException) { + throw processStartFailure(request, error) + } finally { + if (!processStarted) runCatching { Files.deleteIfExists(processGroupFile) } + } + } + + private fun processStartFailure( + request: FastCheckExecutionRequest, + error: IOException, + ) = 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 supervisedAdapterCommand( + request: FastCheckExecutionRequest, + coverageWorkspace: CoverageWorkspace?, + processGroupFile: Path, + ): List = buildList { + add(nodeExecutable) + add(FastCheckRuntime.processSupervisorEntryPoint().toString()) + add(PROCESS_SUPERVISOR_COMMAND) + add(shutdownGraceMillis.toString()) + add(processGroupFile.toString()) + addAll(adapterCommand(request, coverageWorkspace)) } private fun adapterCommand( @@ -564,27 +611,70 @@ internal class FastCheckProcessClient( runCatching { process.errorStream.close() } } - private fun terminate(process: Process, deadlineNanos: Long) { - if (!process.isAlive) return + private fun terminate(managedProcess: ManagedFastCheckProcess, deadlineNanos: Long) { + val process = managedProcess.process + if (!process.isAlive) { + forceTerminateOwnedProcessGroup(managedProcess.processGroupFile, deadlineNanos) + + return + } process.destroy() - val gracefulWaitMillis = minOf(shutdownGraceMillis, remainingMillis(deadlineNanos)) - if (awaitProcessExit(process, gracefulWaitMillis)) return + 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, remainingMillis(deadlineNanos)) + awaitProcessExit(process, deadlineNanos) } - private fun awaitProcessExit(process: Process, waitMillis: Long): Boolean { - if (waitMillis <= 0) return !process.isAlive + private fun forceTerminateOwnedProcessGroup(processGroupFile: Path, deadlineNanos: Long) { + val processGroupId = runCatching { + Files.readString(processGroupFile).trim().toLong() + }.getOrNull() ?: 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 - return try { - process.waitFor(waitMillis, TimeUnit.MILLISECONDS) + 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 (true) { + if (!process.isAlive) return true + + val waitMillis = minOf(remainingMillis(deadlineNanos), PROCESS_POLL_MILLIS) + if (waitMillis == 0L) return false - false + try { + if (process.waitFor(waitMillis, TimeUnit.MILLISECONDS)) return true + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + + return false + } } } @@ -595,6 +685,14 @@ internal class FastCheckProcessClient( 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 @@ -611,12 +709,22 @@ internal class FastCheckProcessClient( 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 PROCESS_POLL_MILLIS = 10L + const val FORCED_TERMINATION_RESERVE_MILLIS = 25L + const val PROCESS_GROUP_KILL_WAIT_MILLIS = 10L const val MINIMUM_NODE_MAJOR_VERSION = 18 const val MINIMUM_NODE_MINOR_VERSION = 18 + const val PROCESS_SUPERVISOR_COMMAND = "--command" val NODE_VERSION_PATTERN = Regex("""^v(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$""") + val IS_WINDOWS = System.getProperty("os.name").lowercase().contains("windows") } } +private data class ManagedFastCheckProcess( + val process: Process, + val processGroupFile: Path, +) + private data class CoverageWorkspace( val root: Path, val configPath: Path, 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 5db957e18..fe555886c 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 @@ -413,7 +413,7 @@ class FastCheckProjectionClient private constructor( var processStarted = false try { - val supervisorEntryPoint = FastCheckRuntime.projectionSupervisorEntryPoint() + val supervisorEntryPoint = FastCheckRuntime.processSupervisorEntryPoint() val process = ProcessBuilder( nodeExecutable, supervisorEntryPoint.toString(), 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 f654813a4..a171ec10a 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,7 +10,7 @@ internal object FastCheckRuntime { fun projectionEntryPoint(): Path = locateEntryPoint(PROJECTION_CLI) - fun projectionSupervisorEntryPoint(): Path = locateEntryPoint(PROJECTION_SUPERVISOR) + fun processSupervisorEntryPoint(): Path = locateEntryPoint(PROCESS_SUPERVISOR) private fun locateEntryPoint(fileName: String): Path { val candidates = runtimeDirectories().map { runtimeDirectory -> @@ -48,6 +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 PROJECTION_SUPERVISOR = "projection-supervisor.js" + private const val PROCESS_SUPERVISOR = "projection-supervisor.js" private const val INSTALLED_RUNTIME_DIRECTORY = "fast-check-adapter" } 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 7c24a7822..a6b2292bb 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 @@ -21,6 +21,7 @@ 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 { @@ -302,9 +303,11 @@ class FastCheckProcessClientTest { } @Test - fun `hard deadline includes inherited descendant pipe drain`() { + fun `hard deadline 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( @@ -312,9 +315,16 @@ class FastCheckProcessClientTest { 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', 'setTimeout(() => undefined, 30000)'], + ['-e', childSource], { stdio: ['ignore', 'inherit', 'inherit'] } ) writeFileSync(${childPidFile.toJavaScriptStringLiteral()}, String(child.pid)) @@ -344,11 +354,13 @@ class FastCheckProcessClientTest { assertEquals(BackendErrorKind.TIMEOUT, error.kind) assertEquals("backend.process.timeout", error.code) - assertTrue(processIsAlive(childPidFile), "Inherited-pipe descendant exited before the hard deadline") + 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() } } From 094f33979cc2100d3683aaee90d28815ca2f201c Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Mon, 31 Aug 2026 10:53:13 +0300 Subject: [PATCH 16/16] [TS PBT] Pin merged JacoDB export metadata --- buildSrc/src/main/kotlin/Dependencies.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index eff370310..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 = "aa319129f8" + const val jacodb = "ddb127d9ef" const val juliet = "1.3.2" const val junit = "5.9.3" const val kotlin = "2.1.0"