From 742d9953601c652c8be0fdcbc8e42c129ebdc5a5 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 22 Aug 2026 20:57:22 +0300 Subject: [PATCH 01/11] docs: design Kotlin-first property abstraction --- ...otlin-first-property-abstraction-design.md | 409 ++++++++++++++++++ 1 file changed, 409 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-22-kotlin-first-property-abstraction-design.md diff --git a/docs/superpowers/specs/2026-08-22-kotlin-first-property-abstraction-design.md b/docs/superpowers/specs/2026-08-22-kotlin-first-property-abstraction-design.md new file mode 100644 index 0000000000..1f12a2cf75 --- /dev/null +++ b/docs/superpowers/specs/2026-08-22-kotlin-first-property-abstraction-design.md @@ -0,0 +1,409 @@ +# Kotlin-First Property Abstraction Design + +**Issue:** [#347](https://github.com/UnitTestBot/usvm/issues/347) + +**Status:** Kotlin-first architecture approved in chat; detailed specification pending review + +## Context + +`usvm-ts-pbt` will combine USVM with concrete property-based testing engines. fast-check is the first concrete engine, but it is not the only possible PBT backend. USVM and the pipeline orchestration must remain usable with another backend without redefining properties or changing the common artifacts. + +The existing baseline module from #346 contains Kotlin/JacoDB integration and a native `ts-frontend` smoke test. It deliberately does not port the historical custom generator, concrete interpreter, or shrinking implementation. + +The original #347 design put the shared property API in TypeScript and let it construct both fast-check objects and a symbolic manifest. That makes fast-check and Node the architectural owner of the property definition. The revised architecture makes Kotlin the owner and treats fast-check as an adapter. + +## Goals + +1. Define a single Kotlin representation of a TypeScript property, its inputs, and its runtime entry points. +2. Keep the representation independent of fast-check, Node, and USVM implementation types. +3. Produce a versioned, serializable property manifest from the Kotlin definition. +4. Define a backend projection contract and capability reporting model. +5. Implement the initial projection from common domains to real fast-check `Arbitrary` objects through an internal Node adapter. +6. Preserve JavaScript primitive semantics across Kotlin/JSON/Node boundaries. +7. Validate definitions and protocol messages before a backend executes them. + +## Non-Goals + +Issue #347 will not implement: + +- property registry discovery or campaign execution; +- `fc.check`, replay, or shrinking; +- coverage collection; +- TypeScript source-to-EtsIR entry-point resolution; +- USVM symbolic value construction or predicate execution; +- the end-to-end pipeline or public CLI; +- arbitrary fast-check combinators in the common property model. + +Those responsibilities remain in #348–#354. + +## Architectural Ownership + +The dependency direction is fixed: + +```text +Kotlin PropertyDefinition + | + +--> PropertyManifest + | + +--> PBT backend projection --> fast-check Node adapter + | + +--> symbolic projection -----> USVM (implemented in #351) +``` + +Kotlin owns: + +- property identity and input ordering; +- domain semantics and constraints; +- TypeScript predicate and precondition references; +- manifest and protocol schemas; +- validation and capability aggregation; +- backend selection and orchestration in later issues. + +The internal Node adapter owns only the fast-check projection. It does not discover properties, select execution modes, invoke USVM, or define common artifacts. + +## Property Model + +### Property definition + +The public Kotlin model is immutable and serializable through a separate manifest projection: + +```kotlin +data class PropertyDefinition( + val id: PropertyId, + val inputs: List, + val predicate: TypeScriptEntryPoint, + val precondition: TypeScriptEntryPoint? = null, +) + +data class PropertyInput( + val name: String, + val domain: PropertyDomain, +) +``` + +Input order is semantically significant because TypeScript predicate parameters are positional. Input names are unique within a property and appear in diagnostics and artifacts. + +`PropertyId` is a validated value object. Its canonical text matches `[A-Za-z0-9][A-Za-z0-9._/-]*`. IDs are stable across backends and runs. + +### TypeScript entry points + +Predicate and precondition bodies remain in TypeScript so concrete execution uses the original JavaScript semantics and USVM analyzes the same source: + +```kotlin +data class TypeScriptEntryPoint( + val module: String, + val exportName: String, + val executionKind: ExecutionKind = ExecutionKind.SYNC, +) + +enum class ExecutionKind { + SYNC, + ASYNC, +} +``` + +`module` is a normalized, project-relative POSIX path. Absolute paths, empty path segments, and parent traversal are rejected. `exportName` must be a JavaScript identifier. The common model does not store a JavaScript closure. + +An asynchronous entry point may be supported by a concrete backend and unsupported by USVM. This is represented by per-backend capability, not by changing the property definition. + +## Domain Algebra + +`PropertyDomain` is a sealed Kotlin hierarchy. It describes the valid value set and all constraints explicitly; backend defaults must not silently change property semantics. + +The initial variants are: + +```kotlin +sealed interface PropertyDomain + +data object BooleanDomain : PropertyDomain + +data class IntegerDomain( + val min: Int = Int.MIN_VALUE, + val max: Int = Int.MAX_VALUE, +) : PropertyDomain + +data class NumberDomain( + val min: JsNumber = JsNumber.NegativeInfinity, + val max: JsNumber = JsNumber.PositiveInfinity, + val allowNaN: Boolean = true, +) : PropertyDomain + +data class StringDomain( + val minLength: Int = 0, + val maxLength: Int = DEFAULT_MAX_STRING_LENGTH, +) : PropertyDomain + +data class ConstantDomain( + val value: JsValue, +) : PropertyDomain + +data class OptionalDomain( + val value: PropertyDomain, + val nil: JsValue = JsValue.Undefined, +) : PropertyDomain + +data class TupleDomain( + val elements: List, +) : PropertyDomain + +data class ArrayDomain( + val element: PropertyDomain, + val minLength: Int = 0, + val maxLength: Int = DEFAULT_MAX_ARRAY_LENGTH, +) : PropertyDomain +``` + +`DEFAULT_MAX_STRING_LENGTH` and `DEFAULT_MAX_ARRAY_LENGTH` are both `10`. They are stable common-model constants, not values inherited from fast-check. A manifest always contains resolved length bounds, so another backend sees identical semantics. + +`IntegerDomain` uses the signed 32-bit integer set, matching TypeScript numbers that are exact for all values in the range. `NumberDomain` describes ECMAScript binary64 values. An unbounded number domain includes finite values, both infinities, negative zero, and optionally NaN. Setting either bound to a value other than its default makes the domain bounded; bounded domains must set `allowNaN = false` and accept only values satisfying their declared inclusive bounds. + +`StringDomain` contains arbitrary UTF-16 code-unit sequences, including valid surrogate pairs and unpaired surrogates. Length bounds count UTF-16 code units, matching JavaScript `String.length`. The fast-check adapter constructs this domain from arrays of integers in `0..0xffff` instead of inheriting changing `fc.string()` defaults. + +The initial `ConstantDomain` supports JavaScript primitives only. Objects, functions, symbols, and bigints are rejected rather than coerced. `OptionalDomain.nil` must be either `JsValue.Undefined` or `JsValue.Null`; other sentinel values are rejected. + +Tuple and array nesting is recursive. Cycles cannot occur because the model is immutable and value-based. + +## JavaScript Value Encoding + +Ordinary JSON cannot distinguish or preserve `undefined`, NaN, infinities, and negative zero. All values crossing a manifest or backend protocol use a tagged `JsValue` representation: + +```json +{ "kind": "undefined" } +{ "kind": "null" } +{ "kind": "boolean", "value": true } +{ "kind": "string", "value": "text" } +{ "kind": "number", "value": "finite", "bits": "8000000000000000" } +{ "kind": "number", "value": "nan" } +{ "kind": "number", "value": "positive-infinity" } +{ "kind": "number", "value": "negative-infinity" } +``` + +Finite doubles use their exact unsigned 64-bit hexadecimal IEEE-754 representation. This preserves negative zero and avoids decimal round-trip ambiguity. NaN uses one semantic tag because the pipeline does not expose NaN payloads. + +## Property Manifest + +`PropertyManifest` is the canonical engine-neutral serialization of a validated property definition: + +```json +{ + "schemaVersion": 1, + "propertyId": "array.reverse-twice", + "inputs": [ + { + "name": "values", + "domain": { + "kind": "array", + "element": { "kind": "integer", "min": -100, "max": 100 }, + "minLength": 0, + "maxLength": 20 + } + } + ], + "predicate": { + "module": "properties/arrays.ts", + "exportName": "reverseTwicePreservesValues", + "executionKind": "sync" + } +} +``` + +The manifest contains no fast-check type name, arbitrary configuration object, seed, replay path, shrink data, USVM expression, or backend capability result. + +`PropertyDefinition.toManifest()` produces a manifest only after validation. `PropertyManifestValidator` also validates deserialized input so stored artifacts cannot bypass invariants. + +## Backend Projection and Capability + +Backend support depends on the backend implementation and version. Capability is therefore a separate artifact rather than a field frozen into `PropertyManifest`: + +```kotlin +enum class ProjectionLevel { + EXACT, + APPROXIMATE, + UNSUPPORTED, +} + +data class ProjectionCapability( + val backendId: String, + val backendVersion: String, + val level: ProjectionLevel, + val diagnostics: List, +) + +data class CapabilityDiagnostic( + val code: String, + val message: String, + val path: String, +) +``` + +Domain composition takes the least capable child projection: + +```text +EXACT < APPROXIMATE < UNSUPPORTED +``` + +A property is `concrete-only` for a selected combination when the concrete PBT projection is not `UNSUPPORTED` and the USVM projection is `UNSUPPORTED`. `concrete-only` is an aggregate pipeline classification, not a fourth backend projection level. + +Diagnostics use stable codes and structural paths such as `inputs[0].domain.element`. A non-exact result must contain at least one diagnostic reason. + +Issue #347 defines the projection contract and implements the fast-check capability provider. The USVM provider is implemented in #351. Tests for aggregation use controlled capability providers and do not pretend that symbolic lowering already exists. + +## fast-check Adapter + +The adapter is an internal implementation detail under `usvm-ts-pbt/fast-check-adapter`. It is not a public TypeScript property API or a publishable npm package. + +It contains: + +- a private `package.json` that pins fast-check; +- an ECMAScript module that maps each manifest domain recursively to a real `fc.Arbitrary`; +- tagged JavaScript value encode/decode functions; +- a small one-shot protocol executable used by Kotlin integration tests; +- Node built-in tests for the projection. + +The initial protocol operation samples projected domains to prove that Kotlin definitions reach real fast-check arbitraries. It does not run predicates or campaigns. + +### Protocol envelope + +Requests and responses use one JSON document on standard input/output: + +```json +{ + "protocolVersion": 1, + "requestId": "projection-test-1", + "operation": "sample", + "seed": 42, + "numSamples": 10, + "domains": [ + { "kind": "integer", "min": -10, "max": 10 } + ] +} +``` + +Successful responses echo `protocolVersion` and `requestId`, contain `status: "ok"`, and encode sample values as `JsValue`. Validation failures return `status: "error"` with stable diagnostic codes. Process startup failures and invalid non-JSON output are reported by the Kotlin caller as transport errors. + +The adapter writes protocol output only to stdout. Human-readable logging goes to stderr so it cannot corrupt the protocol. + +## Validation and Error Handling + +Validation rejects definitions before backend execution when any of the following holds: + +- invalid or empty property ID; +- empty input list; +- duplicate or invalid input names; +- integer or number minimum greater than maximum; +- NaN used as a numeric bound; +- negative length or minimum length greater than maximum; +- empty tuple; +- unsupported constant value; +- absolute, escaping, or malformed TypeScript module path; +- invalid export name; +- unknown manifest schema version; +- unknown backend protocol version or operation. + +Validation returns all independent structural diagnostics in deterministic path/code order. Programmer-facing factory methods may throw a single `InvalidPropertyDefinitionException` containing the report; deserialization and backend boundaries return typed validation results instead of unchecked casts. + +## Source Layout + +The planned responsibilities are: + +```text +usvm-ts-pbt/ + src/main/kotlin/org/usvm/ts/pbt/ + model/ PropertyDefinition, entry points, domain algebra + manifest/ versioned DTOs, JsValue, serialization, validation + backend/ projection capability contracts and aggregation + fastcheck/ Kotlin protocol DTOs and one-shot process client + src/test/kotlin/org/usvm/ts/pbt/ + model/ definition and validation tests + manifest/ serialization and round-trip tests + backend/ capability aggregation tests + fastcheck/ Kotlin-to-Node projection integration tests + src/test/resources/properties/ + examples/ TypeScript predicate/precondition fixtures + fast-check-adapter/ + package.json + package-lock.json + src/ + test/ +``` + +Kotlin serialization uses `kotlinx.serialization`. The Node adapter uses ECMAScript modules and Node's built-in test runner; TypeScript compilation and predicate loading are deferred to #348. + +Gradle owns installation and verification tasks for the private adapter. `:usvm-ts-pbt:check` runs Kotlin tests, Node adapter tests, and cross-language protocol tests. The existing native frontend baseline remains part of the same check. + +## Testing Strategy + +Tests follow red-green TDD during implementation. + +### Kotlin unit tests + +- validate every domain variant and invalid constraint; +- verify deterministic diagnostic ordering and paths; +- round-trip every manifest and tagged JavaScript value; +- preserve finite double bits, negative zero, NaN, and infinities; +- aggregate nested domain and property capabilities; +- classify a supported concrete plus unsupported symbolic projection as `concrete-only`. + +### Node unit tests + +- project every supported domain to a real fast-check arbitrary; +- assert sampled values satisfy the declared constraints; +- decode constants and optional nil values exactly; +- reject unknown domain kinds and malformed tagged values; +- keep protocol stdout free of logs. + +### Cross-language integration tests + +Kotlin creates and serializes four example definitions: + +1. a two-input relational property; +2. a bounded-input property; +3. a property with a TypeScript precondition reference; +4. an array property. + +The test sends their domains through the Node adapter with a fixed seed and validates returned tagged samples against the original Kotlin domains. It also covers protocol version mismatch and malformed backend output. + +The example TypeScript exports are fixtures for manifest validation in #347; actual predicate execution starts in #348. + +## Extension Rules + +A new common domain requires: + +1. a semantic Kotlin model and validation rules; +2. a manifest schema change or backward-compatible variant; +3. an explicit capability decision from every backend; +4. conformance tests for each exact or approximate projection; +5. documentation of unsupported semantics. + +A backend-specific custom domain may be represented only through an explicitly namespaced extension descriptor. Other backends must report `UNSUPPORTED`; they must never guess or silently approximate it. + +A new PBT backend consumes `PropertyManifest` and implements the projection capability contract. It must not require changes to `PropertyDefinition`, the USVM backend, or common orchestration for already supported domains. + +## Rejected Alternatives + +### TypeScript-first shared API + +A TypeScript `defineProperty` API that owns fast-check arbitraries makes Node and fast-check the center of the model. Supporting another PBT backend would require translating or replacing fast-check objects. This contradicts the Kotlin-first pipeline boundary. + +### Kotlin definitions translated into fast-check CLI commands + +fast-check is a JavaScript library, not a declarative CLI. A stable one-shot Node adapter with a versioned JSON protocol is smaller and testable. It reconstructs library objects internally while Kotlin remains the caller. + +### Kotlin implementations of predicates + +Reimplementing predicates in Kotlin would create a second property body and would not execute the original JavaScript semantics. Kotlin stores only TypeScript module/export references. + +### Capability embedded in the manifest + +Backend support changes with backend versions. Freezing capability into the engine-neutral manifest would make identical property semantics serialize differently depending on installed engines. Capability is therefore a separate versioned report. + +## Issue Boundaries After #347 + +- #348 implements `FastCheckBackend`, TypeScript entry-point loading, `fc.check`, replay configuration, and structured concrete results. +- #349 adds backend-neutral coverage artifacts and c8/Istanbul support to the fast-check backend. +- #350 maps common entry points and coverage locations to EtsIR. +- #351 implements the USVM domain and precondition projection. +- #352 searches for predicate violations with USVM. +- #353 replays USVM witnesses and delegates shrinking to a capable PBT backend. +- #354 implements the Kotlin-orchestrated end-to-end pipeline. +- #355–#357 consume backend-identified artifacts for hints, benchmarks, and evaluation. From 3a469019437009d38ca9113a9c2f5095ca00d71e Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 22 Aug 2026 21:08:05 +0300 Subject: [PATCH 02/11] docs: plan Kotlin-first property abstraction --- ...08-22-kotlin-first-property-abstraction.md | 754 ++++++++++++++++++ 1 file changed, 754 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-22-kotlin-first-property-abstraction.md diff --git a/docs/superpowers/plans/2026-08-22-kotlin-first-property-abstraction.md b/docs/superpowers/plans/2026-08-22-kotlin-first-property-abstraction.md new file mode 100644 index 0000000000..cbf7cf2709 --- /dev/null +++ b/docs/superpowers/plans/2026-08-22-kotlin-first-property-abstraction.md @@ -0,0 +1,754 @@ +# Kotlin-First Property Abstraction Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the engine-neutral Kotlin property model, versioned manifest and capability contracts, plus a real fast-check domain projection behind a Kotlin-controlled Node protocol. + +**Architecture:** Kotlin owns property semantics, validation, serialization, and capability aggregation. A private Node adapter consumes only versioned domain descriptors and projects them to fast-check 4.9.0; it cannot orchestrate properties or USVM. Cross-language tests prove that Kotlin manifests reach real arbitraries without adding fast-check types to the common model. + +**Tech Stack:** Kotlin 2.1, kotlinx.serialization 1.7.3, JUnit 5/Kotlin test, Gradle 8.11, Node.js 18.18+, fast-check 4.9.0, Node built-in test runner. + +**Spec:** `docs/superpowers/specs/2026-08-22-kotlin-first-property-abstraction-design.md` + +## Global Constraints + +- Kotlin is the sole owner of property definitions and common artifacts. +- Common Kotlin model and manifest code must not import fast-check or backend-native generator types. +- Predicate and precondition bodies remain TypeScript module/export references; #347 does not execute them. +- Manifest schema version and Kotlin-to-Node protocol version are both exactly `1`. +- Integer domains are inclusive signed 32-bit ranges. +- String length counts arbitrary UTF-16 code units; default maximum length is `10`. +- Array default maximum length is `10`. +- Bounded number domains reject NaN; all JavaScript special numbers use tagged encoding. +- Capability is separate from `PropertyManifest` and is keyed by backend ID and version. +- The Node adapter is private and pins fast-check `4.9.0`. +- Existing `FrontendBaselineTest` must remain green. + +--- + +### Task 1: Kotlin Property Model, Tagged Values, Manifest, and Validation + +**Files:** + +- Modify: `usvm-ts-pbt/build.gradle.kts` +- Create: `usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsValue.kt` +- Create: `usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDomain.kt` +- Create: `usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDefinition.kt` +- Create: `usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/manifest/PropertyManifest.kt` +- Create: `usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/validation/PropertyValidation.kt` +- Test: `usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/JsValueTest.kt` +- Test: `usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/manifest/PropertyManifestTest.kt` +- Test: `usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/validation/PropertyValidationTest.kt` + +**Interfaces:** + +- Produces: `PropertyDefinition`, `PropertyDomain`, `JsValue`, `JsNumber`, `PropertyManifest`, `PropertyManifestJson`, `validatePropertyDefinition`, and `validatePropertyManifest`. +- Consumes: only Kotlin stdlib and kotlinx.serialization; it has no Node, fast-check, JacoDB, or USVM dependency. + +- [ ] **Step 1: Enable Kotlin serialization and add the JSON runtime** + +```kotlin +plugins { + id("usvm.kotlin-conventions") + kotlin("plugin.serialization") version Versions.kotlin +} + +dependencies { + implementation(project(":usvm-ts")) + implementation(Libs.jacodb_ets) + implementation(Libs.kotlinx_serialization_json) + testImplementation(Libs.logback) +} +``` + +- [ ] **Step 2: Write failing tagged-value and manifest round-trip tests** + +```kotlin +@Test +fun `negative zero keeps its raw IEEE bits through JSON`() { + val value = JsValue.Number(JsNumber.finite(-0.0)) + val encoded = PropertyManifestJson.json.encodeToString(JsValue.serializer(), value) + val decoded = PropertyManifestJson.json.decodeFromString(JsValue.serializer(), encoded) + assertEquals(value, decoded) + assertEquals((-0.0).toRawBits(), (decoded as JsValue.Number).number.toDouble().toRawBits()) +} + +@Test +fun `manifest round trip contains only common property data`() { + val definition = PropertyDefinition( + id = PropertyId("math.commutative"), + inputs = listOf( + PropertyInput("left", IntegerDomain(-10, 10)), + PropertyInput("right", IntegerDomain(-10, 10)), + ), + predicate = TypeScriptEntryPoint("properties/math.ts", "isCommutative"), + ) + val encoded = PropertyManifestJson.encode(definition.toManifest()) + assertEquals(definition.toManifest(), PropertyManifestJson.decode(encoded)) + assertFalse("fast-check" in encoded) +} +``` + +- [ ] **Step 3: Run the focused tests and verify RED** + +Run: + +```shell +env -u ARKANALYZER_DIR ETS_IR_PROVIDER=ts-frontend \ + ./gradlew --no-daemon :usvm-ts-pbt:test \ + --tests 'org.usvm.ts.pbt.model.JsValueTest' \ + --tests 'org.usvm.ts.pbt.manifest.PropertyManifestTest' +``` + +Expected: compilation fails because `JsValue`, `PropertyDefinition`, and manifest APIs do not exist. + +- [ ] **Step 4: Implement tagged JavaScript primitives and domain algebra** + +```kotlin +@Serializable +enum class JsNumberKind { + @SerialName("finite") FINITE, + @SerialName("nan") NAN, + @SerialName("positive-infinity") POSITIVE_INFINITY, + @SerialName("negative-infinity") NEGATIVE_INFINITY, +} + +@Serializable +data class JsNumber(val value: JsNumberKind, val bits: String? = null) { + fun toDouble(): Double = when (value) { + JsNumberKind.FINITE -> Double.fromBits(requireNotNull(bits).toULong(16).toLong()) + JsNumberKind.NAN -> Double.NaN + JsNumberKind.POSITIVE_INFINITY -> Double.POSITIVE_INFINITY + JsNumberKind.NEGATIVE_INFINITY -> Double.NEGATIVE_INFINITY + } + + companion object { + fun finite(value: Double) = JsNumber( + JsNumberKind.FINITE, + value.toRawBits().toULong().toString(16).padStart(16, '0'), + ) + } +} + +@Serializable +sealed interface PropertyDomain + +@Serializable +@SerialName("boolean") +data object BooleanDomain : PropertyDomain + +@Serializable +@SerialName("integer") +data class IntegerDomain(val min: Int = Int.MIN_VALUE, val max: Int = Int.MAX_VALUE) : PropertyDomain + +@Serializable +@SerialName("number") +data class NumberDomain( + val min: JsNumber = JsNumber.negativeInfinity(), + val max: JsNumber = JsNumber.positiveInfinity(), + val allowNaN: Boolean = true, +) : PropertyDomain + +@Serializable +@SerialName("string") +data class StringDomain( + val minLength: Int = 0, + val maxLength: Int = DEFAULT_MAX_STRING_LENGTH, +) : PropertyDomain + +@Serializable +@SerialName("constant") +data class ConstantDomain(val value: JsValue) : PropertyDomain + +@Serializable +@SerialName("optional") +data class OptionalDomain( + val value: PropertyDomain, + val nil: JsValue = JsValue.Undefined, +) : PropertyDomain + +@Serializable +@SerialName("tuple") +data class TupleDomain(val elements: List) : PropertyDomain + +@Serializable +@SerialName("array") +data class ArrayDomain( + val element: PropertyDomain, + val minLength: Int = 0, + val maxLength: Int = DEFAULT_MAX_ARRAY_LENGTH, +) : PropertyDomain +``` + +- [ ] **Step 5: Implement manifest serialization with strict schema versioning** + +```kotlin +@Serializable +data class PropertyManifest( + val schemaVersion: Int = PROPERTY_MANIFEST_SCHEMA_VERSION, + val propertyId: String, + val inputs: List, + val predicate: TypeScriptEntryPoint, + val precondition: TypeScriptEntryPoint? = null, +) + +object PropertyManifestJson { + val json = Json { + classDiscriminator = "kind" + encodeDefaults = true + explicitNulls = false + ignoreUnknownKeys = false + } + + fun encode(manifest: PropertyManifest): String = json.encodeToString(manifest) + fun decode(value: String): PropertyManifest = json.decodeFromString(value) + .also { requireValid(validatePropertyManifest(it)) } +} +``` + +- [ ] **Step 6: Write failing validation tests** + +```kotlin +@Test +fun `validation reports all structural errors in deterministic order`() { + val invalid = PropertyDefinition( + id = PropertyId.unchecked(" bad id "), + inputs = listOf( + PropertyInput("value", IntegerDomain(10, -10)), + PropertyInput("value", StringDomain(-1, 0)), + ), + predicate = TypeScriptEntryPoint("../escape.ts", "not-valid-name"), + ) + assertEquals( + listOf( + "property.id.invalid", + "input.name.duplicate", + "domain.integer.bounds", + "domain.string.length", + "entrypoint.module.invalid", + "entrypoint.export.invalid", + ), + validatePropertyDefinition(invalid).diagnostics.map { it.code }, + ) +} +``` + +- [ ] **Step 7: Run validation tests and verify RED** + +Run: + +```shell +./gradlew --no-daemon :usvm-ts-pbt:test \ + --tests 'org.usvm.ts.pbt.validation.PropertyValidationTest' +``` + +Expected: compilation fails because validation APIs do not exist. + +- [ ] **Step 8: Implement deterministic structural validation** + +```kotlin +data class ValidationDiagnostic(val code: String, val message: String, val path: String) + +data class PropertyValidationResult(val diagnostics: List) { + val isValid: Boolean get() = diagnostics.isEmpty() +} + +fun validatePropertyDefinition(definition: PropertyDefinition): PropertyValidationResult = + PropertyValidator.validate(definition).sortedWith(compareBy({ it.path }, { it.code })) + .let(::PropertyValidationResult) +``` + +Recursively validate every domain, exact finite-number bit encoding, optional nil values, entry-point paths/exports, duplicate inputs, and schema version. + +- [ ] **Step 9: Run all Task 1 tests and verify GREEN** + +```shell +./gradlew --no-daemon :usvm-ts-pbt:test \ + --tests 'org.usvm.ts.pbt.model.*' \ + --tests 'org.usvm.ts.pbt.manifest.*' \ + --tests 'org.usvm.ts.pbt.validation.*' +``` + +Expected: all selected tests pass with no warnings from project code. + +- [ ] **Step 10: Commit the model increment** + +```shell +git add usvm-ts-pbt/build.gradle.kts usvm-ts-pbt/src/main usvm-ts-pbt/src/test +git commit -m "feat(ts-pbt): add Kotlin property model" +``` + +--- + +### Task 2: Backend Projection Capability and Aggregate Classification + +**Files:** + +- Create: `usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/backend/ProjectionCapability.kt` +- Test: `usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/backend/ProjectionCapabilityTest.kt` + +**Interfaces:** + +- Consumes: structural paths and validated `PropertyDefinition` from Task 1. +- Produces: `ProjectionLevel`, `ProjectionCapability`, `CapabilityDiagnostic`, `PropertyCapabilityLevel`, `aggregateProjectionCapabilities`, and `classifyPropertyCapability`. + +- [ ] **Step 1: Write failing capability composition tests** + +```kotlin +@Test +fun `least capable nested projection wins`() { + val capability = aggregateProjectionCapabilities( + backendId = "fast-check", + backendVersion = "4.9.0", + capabilities = listOf(exact(), approximate("domain.string.approximate")), + ) + assertEquals(ProjectionLevel.APPROXIMATE, capability.level) +} + +@Test +fun `supported concrete and unsupported symbolic is concrete only`() { + assertEquals( + PropertyCapabilityLevel.CONCRETE_ONLY, + classifyPropertyCapability(exact("fast-check"), unsupported("usvm", "entrypoint.async")), + ) +} +``` + +- [ ] **Step 2: Run the test and verify RED** + +```shell +./gradlew --no-daemon :usvm-ts-pbt:test \ + --tests 'org.usvm.ts.pbt.backend.ProjectionCapabilityTest' +``` + +Expected: compilation fails because capability APIs do not exist. + +- [ ] **Step 3: Implement capability models and deterministic aggregation** + +```kotlin +enum class ProjectionLevel { EXACT, APPROXIMATE, UNSUPPORTED } +enum class PropertyCapabilityLevel { EXACT, APPROXIMATE, CONCRETE_ONLY, UNSUPPORTED } + +data class ProjectionCapability( + val backendId: String, + val backendVersion: String, + val level: ProjectionLevel, + val diagnostics: List = emptyList(), +) + +fun classifyPropertyCapability( + concrete: ProjectionCapability, + symbolic: ProjectionCapability, +): PropertyCapabilityLevel = when { + concrete.level == ProjectionLevel.UNSUPPORTED -> PropertyCapabilityLevel.UNSUPPORTED + symbolic.level == ProjectionLevel.UNSUPPORTED -> PropertyCapabilityLevel.CONCRETE_ONLY + concrete.level == ProjectionLevel.APPROXIMATE || symbolic.level == ProjectionLevel.APPROXIMATE -> + PropertyCapabilityLevel.APPROXIMATE + else -> PropertyCapabilityLevel.EXACT +} +``` + +Reject non-exact capabilities without diagnostics, sort diagnostics by path/code, and preserve backend identity/version. + +- [ ] **Step 4: Run Task 2 and Task 1 tests and verify GREEN** + +```shell +./gradlew --no-daemon :usvm-ts-pbt:test \ + --tests 'org.usvm.ts.pbt.backend.*' \ + --tests 'org.usvm.ts.pbt.model.*' \ + --tests 'org.usvm.ts.pbt.manifest.*' \ + --tests 'org.usvm.ts.pbt.validation.*' +``` + +- [ ] **Step 5: Commit the capability increment** + +```shell +git add usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/backend \ + usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/backend +git commit -m "feat(ts-pbt): add backend capability model" +``` + +--- + +### Task 3: Private fast-check Domain Projection Adapter + +**Files:** + +- Create: `usvm-ts-pbt/fast-check-adapter/package.json` +- Create: `usvm-ts-pbt/fast-check-adapter/package-lock.json` +- Create: `usvm-ts-pbt/fast-check-adapter/src/js-value.mjs` +- Create: `usvm-ts-pbt/fast-check-adapter/src/project-domain.mjs` +- Create: `usvm-ts-pbt/fast-check-adapter/src/projection-cli.mjs` +- Test: `usvm-ts-pbt/fast-check-adapter/test/js-value.test.mjs` +- Test: `usvm-ts-pbt/fast-check-adapter/test/project-domain.test.mjs` +- Test: `usvm-ts-pbt/fast-check-adapter/test/projection-cli.test.mjs` + +**Interfaces:** + +- Consumes: schema-version-1 domain and `JsValue` JSON produced by Task 1. +- Produces: `decodeJsValue`, `encodeJsValue`, `projectDomain`, `projectionCapability`, and a one-shot `sample` protocol executable. + +- [ ] **Step 1: Add the private adapter package and lock fast-check 4.9.0** + +```json +{ + "name": "@usvm/fast-check-adapter", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test" + }, + "dependencies": { + "fast-check": "4.9.0" + } +} +``` + +Run `npm install --package-lock-only --ignore-scripts` in `usvm-ts-pbt/fast-check-adapter` to generate the exact lock file, then `npm ci --ignore-scripts`. + +- [ ] **Step 2: Write failing Node tests for tagged values and every domain** + +```javascript +import assert from 'node:assert/strict'; +import test from 'node:test'; +import fc from 'fast-check'; +import { projectDomain } from '../src/project-domain.mjs'; + +test('bounded integers use a real fast-check arbitrary', () => { + const arbitrary = projectDomain({ kind: 'integer', min: -3, max: 7 }); + const samples = fc.sample(arbitrary, { seed: 42, numRuns: 100 }); + assert.ok(samples.every((value) => Number.isInteger(value) && value >= -3 && value <= 7)); +}); + +test('strings are arbitrary UTF-16 code-unit sequences', () => { + const arbitrary = projectDomain({ kind: 'string', minLength: 2, maxLength: 4 }); + const samples = fc.sample(arbitrary, { seed: 42, numRuns: 100 }); + assert.ok(samples.every((value) => value.length >= 2 && value.length <= 4)); +}); + +for (const [name, domain, predicate] of [ + ['boolean', { kind: 'boolean' }, (value) => typeof value === 'boolean'], + ['bounded number', boundedNumber(-1.5, 2.5), (value) => !Number.isNaN(value) && value >= -1.5 && value <= 2.5], + ['constant -0', constant(numberValue(-0)), (value) => Object.is(value, -0)], + ['optional undefined', optional(integer(-2, 2), undefinedValue()), + (value) => value === undefined || (Number.isInteger(value) && value >= -2 && value <= 2)], + ['tuple', tuple(booleanDomain(), integer(0, 3)), + (value) => Array.isArray(value) && value.length === 2 && typeof value[0] === 'boolean'], + ['array', array(integer(0, 3), 1, 4), + (value) => Array.isArray(value) && value.length >= 1 && value.length <= 4], +]) { + test(`${name} projects to values satisfying the common domain`, () => { + const samples = fc.sample(projectDomain(domain), { seed: 42, numRuns: 100 }); + assert.ok(samples.every(predicate)); + }); +} + +test('unknown domain kinds are rejected explicitly', () => { + assert.throws(() => projectDomain({ kind: 'object' }), /domain\.kind\.unknown/); +}); +``` + +Add a second optional case with null, a nested-array case, and a tagged-number table containing NaN, both infinities, positive zero, and negative zero using the same fixed-seed sampling pattern. + +- [ ] **Step 3: Run Node tests and verify RED** + +```shell +npm test --prefix usvm-ts-pbt/fast-check-adapter +``` + +Expected: tests fail with `ERR_MODULE_NOT_FOUND` for adapter source modules. + +- [ ] **Step 4: Implement exact tagged-value conversion** + +```javascript +export function decodeJsNumber(number) { + switch (number.value) { + case 'finite': return bitsToDouble(number.bits); + case 'nan': return Number.NaN; + case 'positive-infinity': return Number.POSITIVE_INFINITY; + case 'negative-infinity': return Number.NEGATIVE_INFINITY; + default: throw protocolError('js-number.kind.unknown'); + } +} + +export function encodeJsNumber(value) { + if (Number.isNaN(value)) return { value: 'nan' }; + if (value === Number.POSITIVE_INFINITY) return { value: 'positive-infinity' }; + if (value === Number.NEGATIVE_INFINITY) return { value: 'negative-infinity' }; + return { value: 'finite', bits: doubleToBits(value) }; +} +``` + +Use `DataView` with explicit big-endian order for stable 16-hex-digit double encoding. + +- [ ] **Step 5: Implement recursive domain projection** + +```javascript +export function projectDomain(domain) { + switch (domain.kind) { + case 'boolean': return fc.boolean(); + case 'integer': return fc.integer({ min: domain.min, max: domain.max }); + case 'string': + return fc.array(fc.integer({ min: 0, max: 0xffff }), { + minLength: domain.minLength, + maxLength: domain.maxLength, + }).map((units) => String.fromCharCode(...units)); + case 'constant': return fc.constant(decodeJsValue(domain.value)); + case 'optional': + return fc.option(projectDomain(domain.value), { nil: decodeJsValue(domain.nil) }); + case 'tuple': return fc.tuple(...domain.elements.map(projectDomain)); + case 'array': + return fc.array(projectDomain(domain.element), { + minLength: domain.minLength, + maxLength: domain.maxLength, + }); + default: throw protocolError('domain.kind.unknown'); + } +} + +function projectNumber(domain) { + const min = decodeJsNumber(domain.min); + const max = decodeJsNumber(domain.max); + const finite = fc.double({ min, max, noNaN: true, noDefaultInfinity: true }); + const specials = []; + if (domain.allowNaN) specials.push(fc.constant(Number.NaN)); + if (min === Number.NEGATIVE_INFINITY) specials.push(fc.constant(Number.NEGATIVE_INFINITY)); + if (max === Number.POSITIVE_INFINITY) specials.push(fc.constant(Number.POSITIVE_INFINITY)); + return specials.length === 0 ? finite : fc.oneof(finite, ...specials); +} +``` + +- [ ] **Step 6: Run domain tests and verify GREEN** + +```shell +npm test --prefix usvm-ts-pbt/fast-check-adapter +``` + +- [ ] **Step 7: Write failing one-shot protocol tests** + +```javascript +test('sample response echoes request identity and returns tagged values', async () => { + const response = await invokeCli({ + protocolVersion: 1, + requestId: 'sample-1', + operation: 'sample', + seed: 42, + numSamples: 4, + domains: [{ kind: 'integer', min: -1, max: 1 }], + }); + assert.equal(response.requestId, 'sample-1'); + assert.equal(response.status, 'ok'); + assert.equal(response.samples.length, 4); +}); +``` + +Also test protocol-version mismatch, unknown operations, malformed JSON, and that stderr logging never appears in stdout. + +- [ ] **Step 8: Implement `projection-cli.mjs` and verify GREEN** + +```javascript +const input = await readStdin(); +let response; +try { + const request = validateRequest(JSON.parse(input)); + const arbitrary = fc.tuple(...request.domains.map(projectDomain)); + const tuples = fc.sample(arbitrary, { seed: request.seed, numRuns: request.numSamples }); + response = { + protocolVersion: 1, + requestId: request.requestId, + status: 'ok', + samples: tuples.map((tuple) => tuple.map(encodeJsValue)), + }; +} catch (error) { + response = protocolErrorResponse(error); +} +process.stdout.write(`${JSON.stringify(response)}\n`); +``` + +`validateRequest` accepts only protocol version `1`, operation `sample`, a non-empty request ID and domains, an integer seed, and `numSamples` in `1..10000`. `protocolErrorResponse` preserves a parsed request ID when available and emits stable `protocol.version.unsupported`, `protocol.operation.unsupported`, `protocol.json.invalid`, and `protocol.request.invalid` codes. + +```shell +npm test --prefix usvm-ts-pbt/fast-check-adapter +``` + +- [ ] **Step 9: Commit the adapter increment** + +```shell +git add usvm-ts-pbt/fast-check-adapter +git commit -m "feat(ts-pbt): project domains to fast-check" +``` + +--- + +### Task 4: Kotlin-to-Node Protocol, Examples, Gradle Wiring, and Documentation + +**Files:** + +- Modify: `usvm-ts-pbt/build.gradle.kts` +- Modify: `usvm-ts-pbt/README.md` +- Create: `usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionProtocol.kt` +- Create: `usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClient.kt` +- Test: `usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClientTest.kt` +- Test: `usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/examples/ExamplePropertiesTest.kt` +- Create: `usvm-ts-pbt/src/test/resources/properties/examples/PropertyExamples.ts` + +**Interfaces:** + +- Consumes: validated manifests from Task 1, capabilities from Task 2, and the one-shot CLI from Task 3. +- Produces: versioned Kotlin protocol DTOs, `FastCheckProjectionClient.sample`, four executable test definitions, Gradle verification tasks, and user documentation. + +- [ ] **Step 1: Write failing Kotlin-to-Node integration tests** + +```kotlin +@Test +fun `Kotlin domains produce deterministic tagged fast-check samples`() { + val request = FastCheckProjectionRequest( + requestId = "integration-1", + seed = 42, + numSamples = 20, + domains = listOf(IntegerDomain(-10, 10), ArrayDomain(BooleanDomain, 0, 3)), + ) + val response = client.sample(request) + assertEquals("integration-1", response.requestId) + assertEquals(20, response.samples.size) + response.samples.forEach { sample -> assertConforms(sample, request.domains) } +} + +@Test +fun `protocol version mismatch is a typed backend error`() { + val error = assertFailsWith { + client.sample(validRequest.copy(protocolVersion = 999)) + } + assertEquals("protocol.version.unsupported", error.code) +} +``` + +- [ ] **Step 2: Run the focused integration tests and verify RED** + +```shell +./gradlew --no-daemon :usvm-ts-pbt:test \ + --tests 'org.usvm.ts.pbt.fastcheck.FastCheckProjectionClientTest' +``` + +Expected: compilation fails because protocol DTOs and client do not exist. + +- [ ] **Step 3: Implement protocol DTOs and the one-shot process client** + +```kotlin +@Serializable +data class FastCheckProjectionRequest( + val protocolVersion: Int = FAST_CHECK_PROTOCOL_VERSION, + val requestId: String, + val operation: String = "sample", + val seed: Int, + val numSamples: Int, + val domains: List, +) + +class FastCheckProjectionClient( + private val nodeExecutable: String = "node", + private val adapterEntryPoint: Path, +) { + fun sample(request: FastCheckProjectionRequest): FastCheckProjectionResponse { + val process = ProcessBuilder(nodeExecutable, adapterEntryPoint.toString()).start() + process.outputWriter(Charsets.UTF_8).use { it.write(protocolJson.encodeToString(request)) } + val stdout = process.inputReader(Charsets.UTF_8).readText() + val stderr = process.errorReader(Charsets.UTF_8).readText() + val exit = process.waitFor() + if (exit != 0) throw FastCheckProjectionException("backend.process.failed", stderr) + return decodeProjectionResponse(stdout) + } +} +``` + +Reject invalid request sizes before process launch and return typed errors for process startup, nonzero exit, empty output, malformed JSON, mismatched IDs, and protocol error responses. + +- [ ] **Step 4: Run the Kotlin-to-Node tests and verify GREEN** + +```shell +./gradlew --no-daemon :usvm-ts-pbt:test \ + --tests 'org.usvm.ts.pbt.fastcheck.FastCheckProjectionClientTest' +``` + +- [ ] **Step 5: Add Gradle npm installation and Node test tasks** + +```kotlin +val installFastCheckAdapter = tasks.register("installFastCheckAdapter") { + workingDir(fastCheckAdapterDir) + commandLine(npmExecutable, "ci", "--ignore-scripts") + inputs.files( + fastCheckAdapterDir.resolve("package.json"), + fastCheckAdapterDir.resolve("package-lock.json"), + ) + outputs.dir(fastCheckAdapterDir.resolve("node_modules")) +} + +val testFastCheckAdapter = tasks.register("testFastCheckAdapter") { + dependsOn(installFastCheckAdapter) + workingDir(fastCheckAdapterDir) + commandLine(npmExecutable, "test") + inputs.dir(fastCheckAdapterDir.resolve("src")) + inputs.dir(fastCheckAdapterDir.resolve("test")) +} + +tasks.test { dependsOn(installFastCheckAdapter) } +tasks.check { dependsOn(testFastCheckAdapter) } +``` + +Use `npm.cmd` on Windows. Track package files and adapter source/tests as task inputs; never silently skip Node verification when npm is absent. + +- [ ] **Step 6: Add four example Kotlin definitions and TypeScript export fixtures** + +```kotlin +val relational = PropertyDefinition( + id = PropertyId("example.relational"), + inputs = listOf( + PropertyInput("left", IntegerDomain()), + PropertyInput("right", IntegerDomain()), + ), + predicate = TypeScriptEntryPoint("properties/examples/PropertyExamples.ts", "isCommutative"), +) +``` + +Add bounded, precondition, and array definitions. Assert each validates, serializes, and projects through fast-check. The TypeScript resource exports `isCommutative`, the bounded predicate, the precondition, and the array predicate without executing them in this issue. + +- [ ] **Step 7: Update module documentation** + +Document: + +- Kotlin ownership and backend dependency direction; +- the domain table and exact defaults; +- `PropertyManifest` versus `ProjectionCapability`; +- TypeScript module/export references; +- the private fast-check adapter and protocol boundary; +- the supported and rejected extension mechanisms; +- focused Kotlin, Node, and full module verification commands; +- the explicit #348–#354 boundaries. + +- [ ] **Step 8: Run focused verification** + +```shell +npm test --prefix usvm-ts-pbt/fast-check-adapter +env -u ARKANALYZER_DIR ETS_IR_PROVIDER=ts-frontend \ + ./gradlew --no-daemon :usvm-ts-pbt:test +``` + +Expected: all Node and Kotlin tests pass, including `FrontendBaselineTest`. + +- [ ] **Step 9: Run full module and static verification** + +```shell +env -u ARKANALYZER_DIR ETS_IR_PROVIDER=ts-frontend \ + ./gradlew --no-daemon \ + :usvm-ts-pbt:clean :usvm-ts-pbt:check \ + :usvm-ts-pbt:detektMain :usvm-ts-pbt:detektTest +git diff --check origin/main...HEAD +``` + +- [ ] **Step 10: Commit the integration increment** + +```shell +git add usvm-ts-pbt docs/superpowers/plans/2026-08-22-kotlin-first-property-abstraction.md +git commit -m "feat(ts-pbt): integrate Kotlin property projection" +``` From 7354beea866ec8b6cf39ab02f5e4789395fe7476 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 22 Aug 2026 21:16:43 +0300 Subject: [PATCH 03/11] feat(ts-pbt): add Kotlin property model --- usvm-ts-pbt/build.gradle.kts | 2 + .../usvm/ts/pbt/manifest/PropertyManifest.kt | 51 ++++ .../kotlin/org/usvm/ts/pbt/model/JsValue.kt | 164 +++++++++++ .../usvm/ts/pbt/model/PropertyDefinition.kt | 53 ++++ .../org/usvm/ts/pbt/model/PropertyDomain.kt | 59 ++++ .../ts/pbt/validation/PropertyValidation.kt | 276 ++++++++++++++++++ .../ts/pbt/manifest/PropertyManifestTest.kt | 48 +++ .../org/usvm/ts/pbt/model/JsValueTest.kt | 33 +++ .../pbt/validation/PropertyValidationTest.kt | 89 ++++++ 9 files changed, 775 insertions(+) create mode 100644 usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/manifest/PropertyManifest.kt create mode 100644 usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsValue.kt create mode 100644 usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDefinition.kt create mode 100644 usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDomain.kt create mode 100644 usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/validation/PropertyValidation.kt create mode 100644 usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/manifest/PropertyManifestTest.kt create mode 100644 usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/JsValueTest.kt create mode 100644 usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/validation/PropertyValidationTest.kt diff --git a/usvm-ts-pbt/build.gradle.kts b/usvm-ts-pbt/build.gradle.kts index 4393ff574c..1197ec28fa 100644 --- a/usvm-ts-pbt/build.gradle.kts +++ b/usvm-ts-pbt/build.gradle.kts @@ -1,10 +1,12 @@ plugins { id("usvm.kotlin-conventions") + kotlin("plugin.serialization") version Versions.kotlin } dependencies { implementation(project(":usvm-ts")) implementation(Libs.jacodb_ets) + implementation(Libs.kotlinx_serialization_json) testImplementation(Libs.logback) } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/manifest/PropertyManifest.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/manifest/PropertyManifest.kt new file mode 100644 index 0000000000..cfc42a33b3 --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/manifest/PropertyManifest.kt @@ -0,0 +1,51 @@ +package org.usvm.ts.pbt.manifest + +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.usvm.ts.pbt.model.PropertyDefinition +import org.usvm.ts.pbt.model.PropertyInput +import org.usvm.ts.pbt.model.TypeScriptEntryPoint +import org.usvm.ts.pbt.validation.requireValid +import org.usvm.ts.pbt.validation.validatePropertyDefinition +import org.usvm.ts.pbt.validation.validatePropertyManifest + +const val PROPERTY_MANIFEST_SCHEMA_VERSION = 1 + +@Serializable +data class PropertyManifest( + val schemaVersion: Int = PROPERTY_MANIFEST_SCHEMA_VERSION, + val propertyId: String, + val inputs: List, + val predicate: TypeScriptEntryPoint, + val precondition: TypeScriptEntryPoint? = null, +) + +fun PropertyDefinition.toManifest(): PropertyManifest { + requireValid(validatePropertyDefinition(this)) + return PropertyManifest( + propertyId = id.value, + inputs = inputs, + predicate = predicate, + precondition = precondition, + ) +} + +object PropertyManifestJson { + val json = Json { + classDiscriminator = "kind" + encodeDefaults = true + explicitNulls = false + ignoreUnknownKeys = false + useAlternativeNames = false + } + + fun encode(manifest: PropertyManifest): String { + requireValid(validatePropertyManifest(manifest)) + return json.encodeToString(manifest) + } + + fun decode(value: String): PropertyManifest = json.decodeFromString(value) + .also { requireValid(validatePropertyManifest(it)) } +} diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsValue.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsValue.kt new file mode 100644 index 0000000000..2230986e99 --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsValue.kt @@ -0,0 +1,164 @@ +package org.usvm.ts.pbt.model + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +@Serializable +enum class JsNumberKind { + @SerialName("finite") + FINITE, + + @SerialName("nan") + NAN, + + @SerialName("positive-infinity") + POSITIVE_INFINITY, + + @SerialName("negative-infinity") + NEGATIVE_INFINITY, +} + +@Serializable +data class JsNumber( + val value: JsNumberKind, + val bits: String? = null, +) { + fun toDouble(): Double = when (value) { + JsNumberKind.FINITE -> Double.fromBits( + requireNotNull(bits) { "A finite JavaScript number requires IEEE-754 bits" } + .toULong(16) + .toLong(), + ) + + JsNumberKind.NAN -> Double.NaN + JsNumberKind.POSITIVE_INFINITY -> Double.POSITIVE_INFINITY + JsNumberKind.NEGATIVE_INFINITY -> Double.NEGATIVE_INFINITY + } + + companion object { + fun finite(value: Double): JsNumber { + require(value.isFinite()) { "Use a tagged representation for non-finite JavaScript numbers" } + return JsNumber( + value = JsNumberKind.FINITE, + bits = value.toRawBits().toULong().toString(16).padStart(JS_NUMBER_HEX_DIGITS, '0'), + ) + } + + fun fromDouble(value: Double): JsNumber = when { + value.isNaN() -> nan() + value == Double.POSITIVE_INFINITY -> positiveInfinity() + value == Double.NEGATIVE_INFINITY -> negativeInfinity() + else -> finite(value) + } + + fun nan(): JsNumber = JsNumber(JsNumberKind.NAN) + + fun positiveInfinity(): JsNumber = JsNumber(JsNumberKind.POSITIVE_INFINITY) + + fun negativeInfinity(): JsNumber = JsNumber(JsNumberKind.NEGATIVE_INFINITY) + } +} + +@Serializable(with = JsValueSerializer::class) +sealed interface JsValue { + data object Undefined : JsValue + + data object Null : JsValue + + data class Boolean(val value: kotlin.Boolean) : JsValue + + data class String(val value: kotlin.String) : JsValue + + data class Number(val number: JsNumber) : JsValue { + fun toDouble(): Double = number.toDouble() + } +} + +object JsValueSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("JsValue") + + override fun serialize(encoder: Encoder, value: JsValue) { + val jsonEncoder = encoder as? JsonEncoder + ?: throw SerializationException("JsValue supports JSON serialization only") + jsonEncoder.encodeJsonElement( + buildJsonObject { + when (value) { + JsValue.Undefined -> put("kind", "undefined") + JsValue.Null -> put("kind", "null") + is JsValue.Boolean -> { + put("kind", "boolean") + put("value", value.value) + } + + is JsValue.String -> { + put("kind", "string") + put("value", value.value) + } + + is JsValue.Number -> { + put("kind", "number") + put("value", value.number.value.serialName) + value.number.bits?.let { put("bits", it) } + } + } + }, + ) + } + + override fun deserialize(decoder: Decoder): JsValue { + val jsonDecoder = decoder as? JsonDecoder + ?: throw SerializationException("JsValue supports JSON deserialization only") + val value = jsonDecoder.decodeJsonElement().jsonObject + return when (val kind = value.requiredString("kind")) { + "undefined" -> JsValue.Undefined + "null" -> JsValue.Null + "boolean" -> JsValue.Boolean( + value["value"]?.jsonPrimitive?.booleanOrNull + ?: throw SerializationException("Boolean JsValue requires a boolean value"), + ) + + "string" -> JsValue.String(value.requiredString("value")) + "number" -> JsValue.Number( + JsNumber( + value = when (val numberKind = value.requiredString("value")) { + "finite" -> JsNumberKind.FINITE + "nan" -> JsNumberKind.NAN + "positive-infinity" -> JsNumberKind.POSITIVE_INFINITY + "negative-infinity" -> JsNumberKind.NEGATIVE_INFINITY + else -> throw SerializationException("Unknown JavaScript number kind: $numberKind") + }, + bits = value["bits"]?.jsonPrimitive?.content, + ), + ) + + else -> throw SerializationException("Unknown JavaScript value kind: $kind") + } + } +} + +private val JsNumberKind.serialName: kotlin.String + get() = when (this) { + JsNumberKind.FINITE -> "finite" + JsNumberKind.NAN -> "nan" + JsNumberKind.POSITIVE_INFINITY -> "positive-infinity" + JsNumberKind.NEGATIVE_INFINITY -> "negative-infinity" + } + +private fun kotlinx.serialization.json.JsonObject.requiredString(name: kotlin.String): kotlin.String = + get(name)?.jsonPrimitive?.content + ?: throw SerializationException("JsValue requires a $name field") + +private const val JS_NUMBER_HEX_DIGITS = 16 diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDefinition.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDefinition.kt new file mode 100644 index 0000000000..a5c73fbfce --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDefinition.kt @@ -0,0 +1,53 @@ +package org.usvm.ts.pbt.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@JvmInline +@Serializable +value class PropertyId private constructor(val value: String) { + override fun toString(): String = value + + companion object { + operator fun invoke(value: String): PropertyId { + require(isCanonicalPropertyId(value)) { "Invalid property ID: $value" } + return PropertyId(value) + } + + internal fun unchecked(value: String): PropertyId = PropertyId(value) + } +} + +@Serializable +data class PropertyDefinition( + val id: PropertyId, + val inputs: List, + val predicate: TypeScriptEntryPoint, + val precondition: TypeScriptEntryPoint? = null, +) + +@Serializable +data class PropertyInput( + val name: String, + val domain: PropertyDomain, +) + +@Serializable +data class TypeScriptEntryPoint( + val module: String, + val exportName: String, + val executionKind: ExecutionKind = ExecutionKind.SYNC, +) + +@Serializable +enum class ExecutionKind { + @SerialName("sync") + SYNC, + + @SerialName("async") + ASYNC, +} + +internal fun isCanonicalPropertyId(value: String): Boolean = PROPERTY_ID_REGEX.matches(value) + +private val PROPERTY_ID_REGEX = Regex("[A-Za-z0-9][A-Za-z0-9._/-]*") diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDomain.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDomain.kt new file mode 100644 index 0000000000..957fb64462 --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDomain.kt @@ -0,0 +1,59 @@ +package org.usvm.ts.pbt.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +const val DEFAULT_MAX_STRING_LENGTH = 10 +const val DEFAULT_MAX_ARRAY_LENGTH = 10 + +@Serializable +sealed interface PropertyDomain + +@Serializable +@SerialName("boolean") +data object BooleanDomain : PropertyDomain + +@Serializable +@SerialName("integer") +data class IntegerDomain( + val min: Int = Int.MIN_VALUE, + val max: Int = Int.MAX_VALUE, +) : PropertyDomain + +@Serializable +@SerialName("number") +data class NumberDomain( + val min: JsNumber = JsNumber.negativeInfinity(), + val max: JsNumber = JsNumber.positiveInfinity(), + val allowNaN: Boolean = true, +) : PropertyDomain + +@Serializable +@SerialName("string") +data class StringDomain( + val minLength: Int = 0, + val maxLength: Int = DEFAULT_MAX_STRING_LENGTH, +) : PropertyDomain + +@Serializable +@SerialName("constant") +data class ConstantDomain(val value: JsValue) : PropertyDomain + +@Serializable +@SerialName("optional") +data class OptionalDomain( + val value: PropertyDomain, + val nil: JsValue = JsValue.Undefined, +) : PropertyDomain + +@Serializable +@SerialName("tuple") +data class TupleDomain(val elements: List) : PropertyDomain + +@Serializable +@SerialName("array") +data class ArrayDomain( + val element: PropertyDomain, + val minLength: Int = 0, + val maxLength: Int = DEFAULT_MAX_ARRAY_LENGTH, +) : PropertyDomain 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 new file mode 100644 index 0000000000..d898777ccc --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/validation/PropertyValidation.kt @@ -0,0 +1,276 @@ +package org.usvm.ts.pbt.validation + +import org.usvm.ts.pbt.manifest.PROPERTY_MANIFEST_SCHEMA_VERSION +import org.usvm.ts.pbt.manifest.PropertyManifest +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.JsNumber +import org.usvm.ts.pbt.model.JsNumberKind +import org.usvm.ts.pbt.model.JsValue +import org.usvm.ts.pbt.model.NumberDomain +import org.usvm.ts.pbt.model.OptionalDomain +import org.usvm.ts.pbt.model.PropertyDefinition +import org.usvm.ts.pbt.model.PropertyDomain +import org.usvm.ts.pbt.model.PropertyInput +import org.usvm.ts.pbt.model.StringDomain +import org.usvm.ts.pbt.model.TupleDomain +import org.usvm.ts.pbt.model.TypeScriptEntryPoint +import org.usvm.ts.pbt.model.isCanonicalPropertyId + +data class ValidationDiagnostic( + val code: String, + val message: String, + val path: String, +) + +data class PropertyValidationResult(val diagnostics: List) { + val isValid: Boolean + get() = diagnostics.isEmpty() +} + +class InvalidPropertyDefinitionException( + val result: PropertyValidationResult, +) : IllegalArgumentException(result.diagnostics.joinToString(separator = "; ") { "${it.path}: ${it.message}" }) + +fun validatePropertyDefinition(definition: PropertyDefinition): PropertyValidationResult = validateProperty( + propertyId = definition.id.value, + inputs = definition.inputs, + predicate = definition.predicate, + precondition = definition.precondition, +) + +fun validatePropertyManifest(manifest: PropertyManifest): PropertyValidationResult { + val diagnostics = mutableListOf() + if (manifest.schemaVersion != PROPERTY_MANIFEST_SCHEMA_VERSION) { + diagnostics += diagnostic( + code = "manifest.schema.unsupported", + message = "Unsupported property manifest schema version: ${manifest.schemaVersion}", + path = "schemaVersion", + ) + } + diagnostics += validateProperty( + propertyId = manifest.propertyId, + inputs = manifest.inputs, + predicate = manifest.predicate, + precondition = manifest.precondition, + ).diagnostics + return diagnostics.toResult() +} + +fun requireValid(result: PropertyValidationResult) { + if (!result.isValid) { + throw InvalidPropertyDefinitionException(result) + } +} + +private fun validateProperty( + propertyId: String, + inputs: List, + predicate: TypeScriptEntryPoint, + precondition: TypeScriptEntryPoint?, +): PropertyValidationResult { + val diagnostics = mutableListOf() + if (!isCanonicalPropertyId(propertyId)) { + diagnostics += diagnostic("property.id.invalid", "Invalid property ID", "propertyId") + } + if (inputs.isEmpty()) { + diagnostics += diagnostic("property.inputs.empty", "A property requires at least one input", "inputs") + } + + val firstInputByName = mutableMapOf() + inputs.forEachIndexed { index, input -> + val path = "inputs[$index]" + if (!isJavaScriptIdentifier(input.name)) { + diagnostics += diagnostic("input.name.invalid", "Invalid input name", "$path.name") + } + if (firstInputByName.putIfAbsent(input.name, index) != null) { + diagnostics += diagnostic("input.name.duplicate", "Duplicate input name: ${input.name}", path) + } + validateDomain(input.domain, "$path.domain", diagnostics) + } + + validateEntryPoint(predicate, "predicate", diagnostics) + precondition?.let { validateEntryPoint(it, "precondition", diagnostics) } + return diagnostics.toResult() +} + +private fun validateDomain( + domain: PropertyDomain, + path: String, + diagnostics: MutableList, +) { + when (domain) { + BooleanDomain -> Unit + is IntegerDomain -> if (domain.min > domain.max) { + diagnostics += diagnostic("domain.integer.bounds", "Integer minimum exceeds maximum", path) + } + + is NumberDomain -> validateNumberDomain(domain, path, diagnostics) + is StringDomain -> validateLengths( + minLength = domain.minLength, + maxLength = domain.maxLength, + code = "domain.string.length", + description = "String", + path = path, + diagnostics = diagnostics, + ) + + is ConstantDomain -> validateJsValue(domain.value, "$path.value", diagnostics) + is OptionalDomain -> { + if (domain.nil != JsValue.Undefined && domain.nil != JsValue.Null) { + diagnostics += diagnostic( + "domain.optional.nil", + "Optional nil must be null or undefined", + "$path.nil", + ) + } + validateJsValue(domain.nil, "$path.nil", diagnostics) + validateDomain(domain.value, "$path.value", diagnostics) + } + + is TupleDomain -> { + if (domain.elements.isEmpty()) { + diagnostics += diagnostic("domain.tuple.empty", "Tuple domain must not be empty", path) + } + domain.elements.forEachIndexed { index, element -> + validateDomain(element, "$path.elements[$index]", diagnostics) + } + } + + is ArrayDomain -> { + validateLengths( + minLength = domain.minLength, + maxLength = domain.maxLength, + code = "domain.array.length", + description = "Array", + path = path, + diagnostics = diagnostics, + ) + validateDomain(domain.element, "$path.element", diagnostics) + } + } +} + +private fun validateNumberDomain( + domain: NumberDomain, + path: String, + diagnostics: MutableList, +) { + val minValid = validateJsNumber(domain.min, "$path.min", diagnostics) + val maxValid = validateJsNumber(domain.max, "$path.max", diagnostics) + if (domain.min.value == JsNumberKind.NAN) { + diagnostics += diagnostic("domain.number.bound.nan", "Number minimum must not be NaN", "$path.min") + } + if (domain.max.value == JsNumberKind.NAN) { + diagnostics += diagnostic("domain.number.bound.nan", "Number maximum must not be NaN", "$path.max") + } + if (minValid && maxValid && domain.min.value != JsNumberKind.NAN && domain.max.value != JsNumberKind.NAN && + domain.min.toDouble() > domain.max.toDouble() + ) { + diagnostics += diagnostic("domain.number.bounds", "Number minimum exceeds maximum", path) + } + + val bounded = domain.min != JsNumber.negativeInfinity() || domain.max != JsNumber.positiveInfinity() + if (bounded && domain.allowNaN) { + diagnostics += diagnostic( + "domain.number.nan-bounded", + "Bounded number domains must exclude NaN", + "$path.allowNaN", + ) + } +} + +private fun validateJsValue( + value: JsValue, + path: String, + diagnostics: MutableList, +) { + if (value is JsValue.Number) { + validateJsNumber(value.number, path, diagnostics) + } +} + +private fun validateJsNumber( + number: JsNumber, + path: String, + diagnostics: MutableList, +): Boolean { + val valid = when (number.value) { + JsNumberKind.FINITE -> number.bits?.matches(FINITE_NUMBER_BITS_REGEX) == true + else -> number.bits == null + } + if (!valid) { + diagnostics += diagnostic( + "js-number.encoding.invalid", + "Invalid tagged JavaScript number encoding", + path, + ) + } + return valid +} + +private fun validateLengths( + minLength: Int, + maxLength: Int, + code: String, + description: String, + path: String, + diagnostics: MutableList, +) { + if (minLength < 0 || maxLength < 0 || minLength > maxLength) { + diagnostics += diagnostic(code, "$description length bounds are invalid", path) + } +} + +private fun validateEntryPoint( + entryPoint: TypeScriptEntryPoint, + path: String, + diagnostics: MutableList, +) { + if (!isProjectRelativePosixPath(entryPoint.module)) { + diagnostics += diagnostic("entrypoint.module.invalid", "Invalid TypeScript module path", "$path.module") + } + if (!isJavaScriptIdentifier(entryPoint.exportName)) { + diagnostics += diagnostic("entrypoint.export.invalid", "Invalid TypeScript export name", "$path.exportName") + } +} + +private fun isProjectRelativePosixPath(path: String): Boolean = + path.isNotBlank() && + !path.startsWith('/') && + '\\' !in path && + path.split('/').none { it.isEmpty() || it == "." || it == ".." } + +private fun isJavaScriptIdentifier(value: String): Boolean { + if (value.isEmpty()) return false + var index = 0 + var first = true + while (index < value.length) { + val codePoint = value.codePointAt(index) + val valid = if (first) { + codePoint == '$'.code || codePoint == '_'.code || Character.isUnicodeIdentifierStart(codePoint) + } else { + codePoint == '$'.code || + codePoint == '_'.code || + codePoint == ZERO_WIDTH_NON_JOINER || + codePoint == ZERO_WIDTH_JOINER || + Character.isUnicodeIdentifierPart(codePoint) + } + if (!valid) return false + first = false + index += Character.charCount(codePoint) + } + return true +} + +private fun MutableList.toResult(): PropertyValidationResult = + sortedWith(compareBy(ValidationDiagnostic::path, ValidationDiagnostic::code)) + .let(::PropertyValidationResult) + +private fun diagnostic(code: String, message: String, path: String) = ValidationDiagnostic(code, message, path) + +private val FINITE_NUMBER_BITS_REGEX = Regex("[0-9a-f]{16}") +private const val ZERO_WIDTH_NON_JOINER = 0x200C +private const val ZERO_WIDTH_JOINER = 0x200D diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/manifest/PropertyManifestTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/manifest/PropertyManifestTest.kt new file mode 100644 index 0000000000..7143551a93 --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/manifest/PropertyManifestTest.kt @@ -0,0 +1,48 @@ +package org.usvm.ts.pbt.manifest + +import org.junit.jupiter.api.Test +import org.usvm.ts.pbt.model.IntegerDomain +import org.usvm.ts.pbt.model.PropertyDefinition +import org.usvm.ts.pbt.model.PropertyId +import org.usvm.ts.pbt.model.PropertyInput +import org.usvm.ts.pbt.model.TypeScriptEntryPoint +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class PropertyManifestTest { + @Test + fun `manifest round trip contains only engine neutral property data`() { + val definition = PropertyDefinition( + id = PropertyId("math.commutative"), + inputs = listOf( + PropertyInput("left", IntegerDomain(-10, 10)), + PropertyInput("right", IntegerDomain(-10, 10)), + ), + predicate = TypeScriptEntryPoint("properties/math.ts", "isCommutative"), + ) + + val manifest = definition.toManifest() + val encoded = PropertyManifestJson.encode(manifest) + + assertEquals(manifest, PropertyManifestJson.decode(encoded)) + assertFalse("fast-check" in encoded) + assertFalse("backend" in encoded) + assertFalse("seed" in encoded) + } + + @Test + fun `manifest serializes resolved integer bounds and schema version`() { + val definition = PropertyDefinition( + id = PropertyId("integer.defaults"), + inputs = listOf(PropertyInput("value", IntegerDomain())), + predicate = TypeScriptEntryPoint("properties/integer.ts", "holds"), + ) + + val encoded = PropertyManifestJson.encode(definition.toManifest()) + + assertEquals( + """{"schemaVersion":1,"propertyId":"integer.defaults","inputs":[{"name":"value","domain":{"kind":"integer","min":-2147483648,"max":2147483647}}],"predicate":{"module":"properties/integer.ts","exportName":"holds","executionKind":"sync"}}""", + encoded, + ) + } +} diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/JsValueTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/JsValueTest.kt new file mode 100644 index 0000000000..b7ee37bd04 --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/JsValueTest.kt @@ -0,0 +1,33 @@ +package org.usvm.ts.pbt.model + +import kotlinx.serialization.encodeToString +import org.junit.jupiter.api.Test +import org.usvm.ts.pbt.manifest.PropertyManifestJson +import kotlin.test.assertEquals + +class JsValueTest { + @Test + fun `special JavaScript numbers keep their semantics through JSON`() { + val values = listOf( + JsValue.Number(JsNumber.fromDouble(-0.0)), + JsValue.Number(JsNumber.fromDouble(Double.NaN)), + JsValue.Number(JsNumber.fromDouble(Double.POSITIVE_INFINITY)), + JsValue.Number(JsNumber.fromDouble(Double.NEGATIVE_INFINITY)), + ) + + values.forEach { value -> + val encoded = PropertyManifestJson.json.encodeToString(value) + val decoded = PropertyManifestJson.json.decodeFromString(encoded) + assertEquals(value, decoded) + } + + val negativeZero = values.first() as JsValue.Number + assertEquals((-0.0).toRawBits(), negativeZero.toDouble().toRawBits()) + } + + @Test + fun `finite JavaScript numbers use sixteen lowercase hexadecimal digits`() { + assertEquals("3ff8000000000000", JsNumber.finite(1.5).bits) + assertEquals("8000000000000000", JsNumber.finite(-0.0).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 new file mode 100644 index 0000000000..e13fbe0408 --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/validation/PropertyValidationTest.kt @@ -0,0 +1,89 @@ +package org.usvm.ts.pbt.validation + +import org.junit.jupiter.api.Test +import org.usvm.ts.pbt.manifest.PROPERTY_MANIFEST_SCHEMA_VERSION +import org.usvm.ts.pbt.manifest.PropertyManifest +import org.usvm.ts.pbt.model.IntegerDomain +import org.usvm.ts.pbt.model.JsValue +import org.usvm.ts.pbt.model.OptionalDomain +import org.usvm.ts.pbt.model.PropertyDefinition +import org.usvm.ts.pbt.model.PropertyId +import org.usvm.ts.pbt.model.PropertyInput +import org.usvm.ts.pbt.model.StringDomain +import org.usvm.ts.pbt.model.TypeScriptEntryPoint +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class PropertyValidationTest { + @Test + fun `validation reports independent structural errors in deterministic order`() { + val definition = PropertyDefinition( + id = PropertyId.unchecked(" bad id "), + inputs = listOf( + PropertyInput("value", IntegerDomain(10, -10)), + PropertyInput("value", StringDomain(-1, 0)), + ), + predicate = TypeScriptEntryPoint("../escape.ts", "not-valid-name"), + ) + + val result = validatePropertyDefinition(definition) + + assertFalse(result.isValid) + assertEquals( + listOf( + "domain.integer.bounds", + "input.name.duplicate", + "domain.string.length", + "entrypoint.export.invalid", + "entrypoint.module.invalid", + "property.id.invalid", + ), + result.diagnostics.map { it.code }, + ) + } + + @Test + fun `property ID rejects invalid canonical text at construction`() { + assertFailsWith { PropertyId(" bad id ") } + } + + @Test + fun `optional domain accepts only null or undefined as nil`() { + val definition = validDefinition( + OptionalDomain(IntegerDomain(), JsValue.String("none")), + ) + + assertEquals( + listOf("domain.optional.nil"), + validatePropertyDefinition(definition).diagnostics.map { it.code }, + ) + } + + @Test + fun `manifest validation rejects unknown schema version`() { + val manifest = PropertyManifest( + schemaVersion = PROPERTY_MANIFEST_SCHEMA_VERSION + 1, + propertyId = "valid.id", + inputs = listOf(PropertyInput("value", IntegerDomain())), + predicate = TypeScriptEntryPoint("properties/value.ts", "holds"), + ) + + assertEquals( + listOf("manifest.schema.unsupported"), + validatePropertyManifest(manifest).diagnostics.map { it.code }, + ) + } + + @Test + fun `valid definition has no diagnostics`() { + assertTrue(validatePropertyDefinition(validDefinition(IntegerDomain(-5, 5))).isValid) + } + + private fun validDefinition(domain: org.usvm.ts.pbt.model.PropertyDomain) = PropertyDefinition( + id = PropertyId("valid.id"), + inputs = listOf(PropertyInput("value", domain)), + predicate = TypeScriptEntryPoint("properties/value.ts", "holds"), + ) +} From 7e46f07f1b2666419d0d30cfd3b187f117131c8f Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 22 Aug 2026 21:18:31 +0300 Subject: [PATCH 04/11] feat(ts-pbt): add backend capability model --- .../ts/pbt/backend/ProjectionCapability.kt | 67 ++++++++++ .../pbt/backend/ProjectionCapabilityTest.kt | 119 ++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/backend/ProjectionCapability.kt create mode 100644 usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/backend/ProjectionCapabilityTest.kt diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/backend/ProjectionCapability.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/backend/ProjectionCapability.kt new file mode 100644 index 0000000000..a1a9a67173 --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/backend/ProjectionCapability.kt @@ -0,0 +1,67 @@ +package org.usvm.ts.pbt.backend + +enum class ProjectionLevel { + EXACT, + APPROXIMATE, + UNSUPPORTED, +} + +enum class PropertyCapabilityLevel { + EXACT, + APPROXIMATE, + CONCRETE_ONLY, + UNSUPPORTED, +} + +data class CapabilityDiagnostic( + val code: String, + val message: String, + val path: String, +) + +data class ProjectionCapability( + val backendId: String, + val backendVersion: String, + val level: ProjectionLevel, + val diagnostics: List = emptyList(), +) { + init { + require(backendId.isNotBlank()) { "Backend ID must not be blank" } + require(backendVersion.isNotBlank()) { "Backend version must not be blank" } + require(level == ProjectionLevel.EXACT || diagnostics.isNotEmpty()) { + "A non-exact projection requires at least one diagnostic" + } + } +} + +fun aggregateProjectionCapabilities( + backendId: String, + backendVersion: String, + capabilities: List, +): ProjectionCapability { + require(capabilities.all { it.backendId == backendId && it.backendVersion == backendVersion }) { + "All projection capabilities must belong to $backendId $backendVersion" + } + val level = capabilities.maxOfOrNull { it.level } ?: ProjectionLevel.EXACT + val diagnostics = capabilities + .flatMap(ProjectionCapability::diagnostics) + .sortedWith(compareBy(CapabilityDiagnostic::path, CapabilityDiagnostic::code)) + return ProjectionCapability( + backendId = backendId, + backendVersion = backendVersion, + level = level, + diagnostics = diagnostics, + ) +} + +fun classifyPropertyCapability( + concrete: ProjectionCapability, + symbolic: ProjectionCapability, +): PropertyCapabilityLevel = when { + concrete.level == ProjectionLevel.UNSUPPORTED -> PropertyCapabilityLevel.UNSUPPORTED + symbolic.level == ProjectionLevel.UNSUPPORTED -> PropertyCapabilityLevel.CONCRETE_ONLY + concrete.level == ProjectionLevel.APPROXIMATE || symbolic.level == ProjectionLevel.APPROXIMATE -> + PropertyCapabilityLevel.APPROXIMATE + + else -> PropertyCapabilityLevel.EXACT +} diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/backend/ProjectionCapabilityTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/backend/ProjectionCapabilityTest.kt new file mode 100644 index 0000000000..ea1739bf9c --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/backend/ProjectionCapabilityTest.kt @@ -0,0 +1,119 @@ +package org.usvm.ts.pbt.backend + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class ProjectionCapabilityTest { + @Test + fun `least capable nested projection wins and diagnostics are deterministic`() { + val capability = aggregateProjectionCapabilities( + backendId = FAST_CHECK_ID, + backendVersion = FAST_CHECK_VERSION, + capabilities = listOf( + exact(), + approximate("inputs[1].domain", "domain.string.approximate"), + approximate("inputs[0].domain", "domain.number.approximate"), + ), + ) + + assertEquals(FAST_CHECK_ID, capability.backendId) + assertEquals(FAST_CHECK_VERSION, capability.backendVersion) + assertEquals(ProjectionLevel.APPROXIMATE, capability.level) + assertEquals( + listOf("domain.number.approximate", "domain.string.approximate"), + capability.diagnostics.map { it.code }, + ) + } + + @Test + fun `unsupported nested projection wins over approximate projection`() { + val capability = aggregateProjectionCapabilities( + backendId = FAST_CHECK_ID, + backendVersion = FAST_CHECK_VERSION, + capabilities = listOf( + approximate("inputs[0].domain", "domain.number.approximate"), + unsupported("inputs[1].domain", "domain.object.unsupported"), + ), + ) + + assertEquals(ProjectionLevel.UNSUPPORTED, capability.level) + } + + @Test + fun `non exact capability requires a diagnostic reason`() { + assertFailsWith { + ProjectionCapability( + backendId = FAST_CHECK_ID, + backendVersion = FAST_CHECK_VERSION, + level = ProjectionLevel.APPROXIMATE, + ) + } + } + + @Test + fun `supported concrete and unsupported symbolic is concrete only`() { + assertEquals( + PropertyCapabilityLevel.CONCRETE_ONLY, + classifyPropertyCapability( + concrete = exact(), + symbolic = unsupported("predicate", "entrypoint.async", backendId = "usvm"), + ), + ) + } + + @Test + fun `property classification accounts for both projections`() { + assertEquals( + PropertyCapabilityLevel.EXACT, + classifyPropertyCapability(exact(), exact(backendId = "usvm")), + ) + assertEquals( + PropertyCapabilityLevel.APPROXIMATE, + classifyPropertyCapability( + exact(), + approximate("inputs[0].domain", "domain.approximate", backendId = "usvm"), + ), + ) + assertEquals( + PropertyCapabilityLevel.UNSUPPORTED, + classifyPropertyCapability( + unsupported("inputs[0].domain", "domain.unsupported"), + exact(backendId = "usvm"), + ), + ) + } + + private fun exact(backendId: String = FAST_CHECK_ID) = ProjectionCapability( + backendId = backendId, + backendVersion = FAST_CHECK_VERSION, + level = ProjectionLevel.EXACT, + ) + + private fun approximate( + path: String, + code: String, + backendId: String = FAST_CHECK_ID, + ) = ProjectionCapability( + backendId = backendId, + backendVersion = FAST_CHECK_VERSION, + level = ProjectionLevel.APPROXIMATE, + diagnostics = listOf(CapabilityDiagnostic(code, "Approximate projection", path)), + ) + + private fun unsupported( + path: String, + code: String, + backendId: String = FAST_CHECK_ID, + ) = ProjectionCapability( + backendId = backendId, + backendVersion = FAST_CHECK_VERSION, + level = ProjectionLevel.UNSUPPORTED, + diagnostics = listOf(CapabilityDiagnostic(code, "Unsupported projection", path)), + ) + + private companion object { + const val FAST_CHECK_ID = "fast-check" + const val FAST_CHECK_VERSION = "4.9.0" + } +} From 8a76230db61b752fcb46bd725ce2036e8d4f5504 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 22 Aug 2026 21:27:36 +0300 Subject: [PATCH 05/11] feat(ts-pbt): project domains to fast-check --- usvm-ts-pbt/fast-check-adapter/.gitignore | 1 + .../fast-check-adapter/package-lock.json | 56 ++++++++ usvm-ts-pbt/fast-check-adapter/package.json | 15 ++ .../fast-check-adapter/src/js-value.mjs | 106 ++++++++++++++ .../fast-check-adapter/src/project-domain.mjs | 133 ++++++++++++++++++ .../fast-check-adapter/src/projection-cli.mjs | 101 +++++++++++++ .../fast-check-adapter/test/js-value.test.mjs | 39 +++++ .../test/project-domain.test.mjs | 130 +++++++++++++++++ .../test/projection-cli.test.mjs | 107 ++++++++++++++ 9 files changed, 688 insertions(+) create mode 100644 usvm-ts-pbt/fast-check-adapter/.gitignore create mode 100644 usvm-ts-pbt/fast-check-adapter/package-lock.json create mode 100644 usvm-ts-pbt/fast-check-adapter/package.json create mode 100644 usvm-ts-pbt/fast-check-adapter/src/js-value.mjs create mode 100644 usvm-ts-pbt/fast-check-adapter/src/project-domain.mjs create mode 100644 usvm-ts-pbt/fast-check-adapter/src/projection-cli.mjs create mode 100644 usvm-ts-pbt/fast-check-adapter/test/js-value.test.mjs create mode 100644 usvm-ts-pbt/fast-check-adapter/test/project-domain.test.mjs create mode 100644 usvm-ts-pbt/fast-check-adapter/test/projection-cli.test.mjs diff --git a/usvm-ts-pbt/fast-check-adapter/.gitignore b/usvm-ts-pbt/fast-check-adapter/.gitignore new file mode 100644 index 0000000000..c2658d7d1b --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/usvm-ts-pbt/fast-check-adapter/package-lock.json b/usvm-ts-pbt/fast-check-adapter/package-lock.json new file mode 100644 index 0000000000..93d83fec14 --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/package-lock.json @@ -0,0 +1,56 @@ +{ + "name": "@usvm/fast-check-adapter", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@usvm/fast-check-adapter", + "version": "0.1.0", + "dependencies": { + "fast-check": "4.9.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/fast-check": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + } + } +} diff --git a/usvm-ts-pbt/fast-check-adapter/package.json b/usvm-ts-pbt/fast-check-adapter/package.json new file mode 100644 index 0000000000..115493e233 --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/package.json @@ -0,0 +1,15 @@ +{ + "name": "@usvm/fast-check-adapter", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test" + }, + "dependencies": { + "fast-check": "4.9.0" + }, + "engines": { + "node": ">=18.18.0" + } +} diff --git a/usvm-ts-pbt/fast-check-adapter/src/js-value.mjs b/usvm-ts-pbt/fast-check-adapter/src/js-value.mjs new file mode 100644 index 0000000000..b04709b004 --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/src/js-value.mjs @@ -0,0 +1,106 @@ +export function decodeJsValue(value, path = 'value') { + requireObject(value, 'js-value.invalid', 'Tagged JavaScript value must be an object', path); + switch (value.kind) { + case 'undefined': + return undefined; + case 'null': + return null; + case 'boolean': + if (typeof value.value !== 'boolean') { + throw protocolError('js-value.boolean.invalid', 'Boolean value must contain a boolean', path); + } + return value.value; + case 'string': + if (typeof value.value !== 'string') { + throw protocolError('js-value.string.invalid', 'String value must contain a string', path); + } + return value.value; + case 'number': + return decodeJsNumber(value, path); + default: + throw protocolError('js-value.kind.unknown', `Unknown JavaScript value kind: ${String(value.kind)}`, path); + } +} + +export function encodeJsValue(value) { + if (value === undefined) return { kind: 'undefined' }; + if (value === null) return { kind: 'null' }; + if (typeof value === 'boolean') return { kind: 'boolean', value }; + if (typeof value === 'string') return { kind: 'string', value }; + if (typeof value === 'number') return { kind: 'number', ...encodeJsNumber(value) }; + throw protocolError( + 'js-value.type.unsupported', + `Unsupported JavaScript value type: ${typeof value}`, + 'value', + ); +} + +export function decodeJsNumber(number, path = 'number') { + requireObject(number, 'js-number.invalid', 'Tagged JavaScript number must be an object', path); + switch (number.value) { + case 'finite': + if (typeof number.bits !== 'string' || !/^[0-9a-f]{16}$/.test(number.bits)) { + throw protocolError( + 'js-number.encoding.invalid', + 'Finite JavaScript numbers require sixteen lowercase hexadecimal digits', + path, + ); + } + return bitsToDouble(number.bits); + case 'nan': + requireNoBits(number, path); + return Number.NaN; + case 'positive-infinity': + requireNoBits(number, path); + return Number.POSITIVE_INFINITY; + case 'negative-infinity': + requireNoBits(number, path); + return Number.NEGATIVE_INFINITY; + default: + throw protocolError( + 'js-number.kind.unknown', + `Unknown JavaScript number kind: ${String(number.value)}`, + path, + ); + } +} + +export function encodeJsNumber(value) { + if (Number.isNaN(value)) return { value: 'nan' }; + if (value === Number.POSITIVE_INFINITY) return { value: 'positive-infinity' }; + if (value === Number.NEGATIVE_INFINITY) return { value: 'negative-infinity' }; + return { value: 'finite', bits: doubleToBits(value) }; +} + +export function protocolError(code, message, path) { + const error = new Error(`${code}: ${message}`); + error.code = code; + error.path = path; + return error; +} + +function bitsToDouble(bits) { + const buffer = new ArrayBuffer(8); + const view = new DataView(buffer); + view.setBigUint64(0, BigInt(`0x${bits}`), false); + return view.getFloat64(0, false); +} + +function doubleToBits(value) { + const buffer = new ArrayBuffer(8); + const view = new DataView(buffer); + view.setFloat64(0, value, false); + return view.getBigUint64(0, false).toString(16).padStart(16, '0'); +} + +function requireNoBits(number, path) { + if (number.bits !== undefined) { + throw protocolError('js-number.encoding.invalid', 'Non-finite JavaScript numbers must not contain bits', path); + } +} + +function requireObject(value, code, message, path) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw protocolError(code, message, path); + } +} diff --git a/usvm-ts-pbt/fast-check-adapter/src/project-domain.mjs b/usvm-ts-pbt/fast-check-adapter/src/project-domain.mjs new file mode 100644 index 0000000000..0c051ae77c --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/src/project-domain.mjs @@ -0,0 +1,133 @@ +import fc from 'fast-check'; +import { + decodeJsNumber, + decodeJsValue, + protocolError, +} from './js-value.mjs'; + +export const FAST_CHECK_BACKEND_ID = 'fast-check'; +export const FAST_CHECK_BACKEND_VERSION = '4.9.0'; + +export function projectDomain(domain, path = 'domain') { + requireDomainObject(domain, path); + switch (domain.kind) { + case 'boolean': + return fc.boolean(); + case 'integer': + validateIntegerDomain(domain, path); + return fc.integer({ min: domain.min, max: domain.max }); + case 'number': + return projectNumber(domain, path); + case 'string': + validateLengths(domain, path); + return fc.array(fc.integer({ min: 0, max: 0xffff }), { + minLength: domain.minLength, + maxLength: domain.maxLength, + }).map((units) => units.map((unit) => String.fromCharCode(unit)).join('')); + case 'constant': + return fc.constant(decodeJsValue(domain.value, `${path}.value`)); + case 'optional': { + const nil = decodeJsValue(domain.nil, `${path}.nil`); + if (nil !== undefined && nil !== null) { + throw protocolError('domain.optional.nil', 'Optional nil must be null or undefined', `${path}.nil`); + } + return fc.option(projectDomain(domain.value, `${path}.value`), { nil }); + } + case 'tuple': + if (!Array.isArray(domain.elements) || domain.elements.length === 0) { + throw protocolError('domain.tuple.empty', 'Tuple domain must contain elements', path); + } + return fc.tuple(...domain.elements.map((element, index) => projectDomain(element, `${path}.elements[${index}]`))); + case 'array': + validateLengths(domain, path); + return fc.array(projectDomain(domain.element, `${path}.element`), { + minLength: domain.minLength, + maxLength: domain.maxLength, + }); + default: + throw protocolError('domain.kind.unknown', `Unknown property domain kind: ${String(domain.kind)}`, path); + } +} + +export function projectionCapability(domain, path = 'domain') { + try { + projectDomain(domain, path); + return { + backendId: FAST_CHECK_BACKEND_ID, + backendVersion: FAST_CHECK_BACKEND_VERSION, + level: 'exact', + diagnostics: [], + }; + } catch (error) { + if (typeof error?.code !== 'string') throw error; + return { + backendId: FAST_CHECK_BACKEND_ID, + backendVersion: FAST_CHECK_BACKEND_VERSION, + level: 'unsupported', + diagnostics: [{ + code: error.code, + message: error.message.slice(error.message.indexOf(':') + 2), + path: error.path ?? path, + }], + }; + } +} + +function projectNumber(domain, path) { + if (typeof domain.allowNaN !== 'boolean') { + throw protocolError('domain.number.allow-nan.invalid', 'allowNaN must be a boolean', `${path}.allowNaN`); + } + const min = decodeJsNumber(domain.min, `${path}.min`); + const max = decodeJsNumber(domain.max, `${path}.max`); + if (Number.isNaN(min) || Number.isNaN(max)) { + throw protocolError('domain.number.bound.nan', 'Number bounds must not be NaN', path); + } + if (min > max) { + throw protocolError('domain.number.bounds', 'Number minimum exceeds maximum', path); + } + const bounded = min !== Number.NEGATIVE_INFINITY || max !== Number.POSITIVE_INFINITY; + if (bounded && domain.allowNaN) { + throw protocolError('domain.number.nan-bounded', 'Bounded number domains must exclude NaN', `${path}.allowNaN`); + } + + const finiteMin = min === Number.NEGATIVE_INFINITY ? -Number.MAX_VALUE : min; + const finiteMax = max === Number.POSITIVE_INFINITY ? Number.MAX_VALUE : max; + const arbitraries = [fc.double({ + min: finiteMin, + max: finiteMax, + noNaN: true, + noDefaultInfinity: true, + })]; + if (domain.allowNaN) arbitraries.push(fc.constant(Number.NaN)); + if (min === Number.NEGATIVE_INFINITY) arbitraries.push(fc.constant(Number.NEGATIVE_INFINITY)); + if (max === Number.POSITIVE_INFINITY) arbitraries.push(fc.constant(Number.POSITIVE_INFINITY)); + if (min <= 0 && max >= 0) arbitraries.push(fc.constant(-0)); + return arbitraries.length === 1 ? arbitraries[0] : fc.oneof(...arbitraries); +} + +function validateIntegerDomain(domain, path) { + const valid = Number.isInteger(domain.min) + && Number.isInteger(domain.max) + && domain.min >= -0x80000000 + && domain.max <= 0x7fffffff + && domain.min <= domain.max; + if (!valid) { + throw protocolError('domain.integer.bounds', 'Integer bounds must be an inclusive signed 32-bit range', path); + } +} + +function validateLengths(domain, path) { + const valid = Number.isInteger(domain.minLength) + && Number.isInteger(domain.maxLength) + && domain.minLength >= 0 + && domain.minLength <= domain.maxLength; + if (!valid) { + throw protocolError('domain.length.invalid', 'Domain length bounds are invalid', path); + } +} + +function requireDomainObject(domain, path) { + if (domain === null || typeof domain !== 'object' || Array.isArray(domain)) { + throw protocolError('domain.invalid', 'Property domain must be an object', path); + } +} diff --git a/usvm-ts-pbt/fast-check-adapter/src/projection-cli.mjs b/usvm-ts-pbt/fast-check-adapter/src/projection-cli.mjs new file mode 100644 index 0000000000..0446dedf53 --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/src/projection-cli.mjs @@ -0,0 +1,101 @@ +import fc from 'fast-check'; +import { + encodeJsValue, + protocolError, +} from './js-value.mjs'; +import { projectDomain } from './project-domain.mjs'; + +const PROTOCOL_VERSION = 1; + +let parsedRequest; +let response; +try { + const input = await readStdin(); + try { + parsedRequest = JSON.parse(input); + } catch { + throw protocolError('protocol.json.invalid', 'Standard input is not valid JSON', 'request'); + } + const request = validateRequest(parsedRequest); + const arbitrary = fc.tuple( + ...request.domains.map((domain, index) => projectDomain(domain, `domains[${index}]`)), + ); + const tuples = fc.sample(arbitrary, { + seed: request.seed, + numRuns: request.numSamples, + }); + response = { + protocolVersion: PROTOCOL_VERSION, + requestId: request.requestId, + status: 'ok', + samples: tuples.map((tuple) => tuple.map(encodeJsValue)), + }; +} catch (error) { + response = protocolErrorResponse(error, parsedRequest); +} + +process.stdout.write(`${JSON.stringify(response)}\n`); + +async function readStdin() { + process.stdin.setEncoding('utf8'); + let input = ''; + for await (const chunk of process.stdin) input += chunk; + return input; +} + +function validateRequest(request) { + if (request === null || typeof request !== 'object' || Array.isArray(request)) { + throw protocolError('protocol.request.invalid', 'Request must be a JSON object', 'request'); + } + if (request.protocolVersion !== PROTOCOL_VERSION) { + throw protocolError( + 'protocol.version.unsupported', + `Unsupported protocol version: ${String(request.protocolVersion)}`, + 'protocolVersion', + ); + } + if (request.operation !== 'sample') { + throw protocolError( + 'protocol.operation.unsupported', + `Unsupported protocol operation: ${String(request.operation)}`, + 'operation', + ); + } + const valid = typeof request.requestId === 'string' + && request.requestId.length > 0 + && Number.isInteger(request.seed) + && request.seed >= -0x80000000 + && request.seed <= 0x7fffffff + && Number.isInteger(request.numSamples) + && request.numSamples >= 1 + && request.numSamples <= 10_000 + && Array.isArray(request.domains) + && request.domains.length > 0; + if (!valid) { + throw protocolError( + 'protocol.request.invalid', + 'Request requires a non-empty ID and domains, an Int seed, and numSamples in 1..10000', + 'request', + ); + } + return request; +} + +function protocolErrorResponse(error, request) { + const code = typeof error?.code === 'string' ? error.code : 'protocol.request.invalid'; + const rawMessage = error instanceof Error ? error.message : String(error); + const message = rawMessage.startsWith(`${code}: `) ? rawMessage.slice(code.length + 2) : rawMessage; + const result = { + protocolVersion: PROTOCOL_VERSION, + status: 'error', + diagnostics: [{ + code, + message, + path: typeof error?.path === 'string' ? error.path : 'request', + }], + }; + if (request !== null && typeof request === 'object' && typeof request.requestId === 'string') { + result.requestId = request.requestId; + } + return result; +} diff --git a/usvm-ts-pbt/fast-check-adapter/test/js-value.test.mjs b/usvm-ts-pbt/fast-check-adapter/test/js-value.test.mjs new file mode 100644 index 0000000000..6e7bda8970 --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/test/js-value.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + decodeJsValue, + encodeJsValue, +} from '../src/js-value.mjs'; + +test('tagged JavaScript primitives round trip without losing semantics', () => { + const cases = [ + [{ kind: 'undefined' }, (value) => value === undefined], + [{ kind: 'null' }, (value) => value === null], + [{ kind: 'boolean', value: true }, (value) => value === true], + [{ kind: 'string', value: 'text' }, (value) => value === 'text'], + [{ kind: 'number', value: 'finite', bits: '0000000000000000' }, (value) => Object.is(value, 0)], + [{ kind: 'number', value: 'finite', bits: '8000000000000000' }, (value) => Object.is(value, -0)], + [{ kind: 'number', value: 'nan' }, Number.isNaN], + [{ kind: 'number', value: 'positive-infinity' }, (value) => value === Number.POSITIVE_INFINITY], + [{ kind: 'number', value: 'negative-infinity' }, (value) => value === Number.NEGATIVE_INFINITY], + ]; + + for (const [tagged, predicate] of cases) { + const decoded = decodeJsValue(tagged); + assert.ok(predicate(decoded), `decoded value does not match ${JSON.stringify(tagged)}`); + assert.deepEqual(encodeJsValue(decoded), tagged); + } +}); + +test('tagged finite numbers require exactly sixteen lowercase hexadecimal digits', () => { + for (const bits of [undefined, '0', '000000000000000G', '800000000000000A']) { + assert.throws( + () => decodeJsValue({ kind: 'number', value: 'finite', bits }), + /js-number\.encoding\.invalid/, + ); + } +}); + +test('unknown tagged value kinds are rejected explicitly', () => { + assert.throws(() => decodeJsValue({ kind: 'symbol' }), /js-value\.kind\.unknown/); +}); diff --git a/usvm-ts-pbt/fast-check-adapter/test/project-domain.test.mjs b/usvm-ts-pbt/fast-check-adapter/test/project-domain.test.mjs new file mode 100644 index 0000000000..007626aff4 --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/test/project-domain.test.mjs @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import fc from 'fast-check'; +import { + projectDomain, + projectionCapability, +} from '../src/project-domain.mjs'; + +test('bounded integers use a real fast-check arbitrary', () => { + const samples = sample({ kind: 'integer', min: -3, max: 7 }); + assert.ok(samples.every((value) => Number.isInteger(value) && value >= -3 && value <= 7)); +}); + +test('strings are arbitrary UTF-16 code-unit sequences with declared lengths', () => { + const samples = sample({ kind: 'string', minLength: 2, maxLength: 4 }); + assert.ok(samples.every((value) => typeof value === 'string' && value.length >= 2 && value.length <= 4)); +}); + +test('unbounded numbers include ECMAScript special values', () => { + const samples = sample( + { + kind: 'number', + min: { value: 'negative-infinity' }, + max: { value: 'positive-infinity' }, + allowNaN: true, + }, + 500, + ); + + assert.ok(samples.some(Number.isNaN)); + assert.ok(samples.includes(Number.NEGATIVE_INFINITY)); + assert.ok(samples.includes(Number.POSITIVE_INFINITY)); + assert.ok(samples.some((value) => Object.is(value, -0))); +}); + +test('bounded numbers exclude NaN and values outside their inclusive bounds', () => { + const samples = sample({ + kind: 'number', + min: taggedNumber(-1.5), + max: taggedNumber(2.5), + allowNaN: false, + }); + assert.ok(samples.every((value) => !Number.isNaN(value) && value >= -1.5 && value <= 2.5)); +}); + +for (const [name, domain, predicate] of [ + ['boolean', { kind: 'boolean' }, (value) => typeof value === 'boolean'], + [ + 'constant -0', + { kind: 'constant', value: { kind: 'number', value: 'finite', bits: '8000000000000000' } }, + (value) => Object.is(value, -0), + ], + [ + 'optional undefined', + { kind: 'optional', value: { kind: 'integer', min: -2, max: 2 }, nil: { kind: 'undefined' } }, + (value) => value === undefined || (Number.isInteger(value) && value >= -2 && value <= 2), + ], + [ + 'optional null', + { kind: 'optional', value: { kind: 'boolean' }, nil: { kind: 'null' } }, + (value) => value === null || typeof value === 'boolean', + ], + [ + 'tuple', + { kind: 'tuple', elements: [{ kind: 'boolean' }, { kind: 'integer', min: 0, max: 3 }] }, + (value) => Array.isArray(value) && value.length === 2 && typeof value[0] === 'boolean', + ], + [ + 'nested array', + { + kind: 'array', + element: { kind: 'array', element: { kind: 'integer', min: 0, max: 3 }, minLength: 1, maxLength: 2 }, + minLength: 1, + maxLength: 4, + }, + (value) => Array.isArray(value) + && value.length >= 1 + && value.length <= 4 + && value.every((inner) => inner.length >= 1 && inner.length <= 2), + ], +]) { + test(`${name} projects to values satisfying the common domain`, () => { + assert.ok(sample(domain).every(predicate)); + }); +} + +test('fast-check capability is exact for supported recursive domains', () => { + assert.deepEqual( + projectionCapability({ + kind: 'array', + element: { kind: 'tuple', elements: [{ kind: 'boolean' }, { kind: 'string', minLength: 0, maxLength: 3 }] }, + minLength: 0, + maxLength: 2, + }), + { + backendId: 'fast-check', + backendVersion: '4.9.0', + level: 'exact', + diagnostics: [], + }, + ); +}); + +test('unknown domain kinds are rejected and reported as unsupported', () => { + assert.throws(() => projectDomain({ kind: 'object' }), /domain\.kind\.unknown/); + assert.deepEqual( + projectionCapability({ kind: 'object' }, 'inputs[0].domain'), + { + backendId: 'fast-check', + backendVersion: '4.9.0', + level: 'unsupported', + diagnostics: [{ + code: 'domain.kind.unknown', + message: 'Unknown property domain kind: object', + path: 'inputs[0].domain', + }], + }, + ); +}); + +function sample(domain, numRuns = 100) { + return fc.sample(projectDomain(domain), { seed: 42, numRuns }); +} + +function taggedNumber(value) { + const buffer = new ArrayBuffer(8); + const view = new DataView(buffer); + view.setFloat64(0, value, false); + return { value: 'finite', bits: view.getBigUint64(0, false).toString(16).padStart(16, '0') }; +} diff --git a/usvm-ts-pbt/fast-check-adapter/test/projection-cli.test.mjs b/usvm-ts-pbt/fast-check-adapter/test/projection-cli.test.mjs new file mode 100644 index 0000000000..1ff519f823 --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/test/projection-cli.test.mjs @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const cliPath = fileURLToPath(new URL('../src/projection-cli.mjs', import.meta.url)); + +test('sample response echoes request identity and returns deterministic tagged values', async () => { + const request = { + protocolVersion: 1, + requestId: 'sample-1', + operation: 'sample', + seed: 42, + numSamples: 4, + domains: [{ kind: 'integer', min: -1, max: 1 }], + }; + + const first = await invokeCli(JSON.stringify(request)); + const second = await invokeCli(JSON.stringify(request)); + + assert.equal(first.exitCode, 0); + assert.equal(first.stderr, ''); + assert.equal(first.stdout.trim().split('\n').length, 1); + assert.deepEqual(first.response, second.response); + assert.equal(first.response.protocolVersion, 1); + assert.equal(first.response.requestId, 'sample-1'); + assert.equal(first.response.status, 'ok'); + assert.equal(first.response.samples.length, 4); + assert.ok(first.response.samples.every((tuple) => tuple.length === 1 && tuple[0].kind === 'number')); +}); + +for (const [name, input, code, requestId, path] of [ + [ + 'unsupported protocol version', + { protocolVersion: 2, requestId: 'wrong-version', operation: 'sample', seed: 1, numSamples: 1, domains: [{ kind: 'boolean' }] }, + 'protocol.version.unsupported', + 'wrong-version', + 'protocolVersion', + ], + [ + 'unsupported operation', + { protocolVersion: 1, requestId: 'wrong-operation', operation: 'check', seed: 1, numSamples: 1, domains: [{ kind: 'boolean' }] }, + 'protocol.operation.unsupported', + 'wrong-operation', + 'operation', + ], + [ + 'invalid request', + { protocolVersion: 1, requestId: '', operation: 'sample', seed: 1.5, numSamples: 0, domains: [] }, + 'protocol.request.invalid', + '', + 'request', + ], +]) { + test(`${name} returns a typed protocol error`, async () => { + const result = await invokeCli(JSON.stringify(input)); + assert.equal(result.exitCode, 0); + assert.deepEqual(result.response, { + protocolVersion: 1, + requestId, + status: 'error', + diagnostics: [{ + code, + message: result.response.diagnostics[0].message, + path, + }], + }); + }); +} + +test('malformed JSON produces one clean protocol error document', async () => { + const result = await invokeCli('{not-json'); + + assert.equal(result.exitCode, 0); + assert.equal(result.stderr, ''); + assert.equal(result.stdout.trim().split('\n').length, 1); + assert.equal(result.response.protocolVersion, 1); + assert.equal(result.response.status, 'error'); + assert.equal(result.response.diagnostics[0].code, 'protocol.json.invalid'); + assert.ok(!('requestId' in result.response)); +}); + +async function invokeCli(input) { + const child = spawn(process.execPath, [cliPath], { stdio: ['pipe', 'pipe', 'pipe'] }); + child.stdin.end(input); + const [exitCode, stdout, stderr] = await Promise.all([ + new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', resolve); + }), + collect(child.stdout), + collect(child.stderr), + ]); + let response; + try { + response = JSON.parse(stdout); + } catch { + response = undefined; + } + return { exitCode, stdout, stderr, response }; +} + +async function collect(stream) { + const chunks = []; + for await (const chunk of stream) chunks.push(chunk); + return Buffer.concat(chunks).toString('utf8'); +} From 064a367fe21f4e74b8e08020a489678bb6a6d7af Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 22 Aug 2026 21:49:12 +0300 Subject: [PATCH 06/11] feat(ts-pbt): integrate Kotlin property projection --- ...08-22-kotlin-first-property-abstraction.md | 25 +-- ...otlin-first-property-abstraction-design.md | 25 ++- usvm-ts-pbt/README.md | 128 +++++++++++-- usvm-ts-pbt/build.gradle.kts | 33 ++++ .../fast-check-adapter/src/js-value.mjs | 6 + .../fast-check-adapter/test/js-value.test.mjs | 7 + .../fastcheck/FastCheckProjectionClient.kt | 129 +++++++++++++ .../fastcheck/FastCheckProjectionProtocol.kt | 46 +++++ .../model/{JsValue.kt => JsConcreteValue.kt} | 83 ++++++--- .../org/usvm/ts/pbt/model/PropertyDomain.kt | 4 +- .../ts/pbt/validation/PropertyValidation.kt | 62 +++++-- .../ts/pbt/examples/ExamplePropertiesTest.kt | 90 +++++++++ .../FastCheckProjectionClientTest.kt | 172 ++++++++++++++++++ .../ts/pbt/manifest/PropertyManifestTest.kt | 4 +- .../usvm/ts/pbt/model/JsConcreteValueTest.kt | 47 +++++ .../org/usvm/ts/pbt/model/JsValueTest.kt | 33 ---- .../pbt/validation/PropertyValidationTest.kt | 17 +- .../properties/examples/PropertyExamples.ts | 19 ++ 18 files changed, 810 insertions(+), 120 deletions(-) create mode 100644 usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClient.kt create mode 100644 usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionProtocol.kt rename usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/{JsValue.kt => JsConcreteValue.kt} (61%) create mode 100644 usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/examples/ExamplePropertiesTest.kt create mode 100644 usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClientTest.kt create mode 100644 usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/JsConcreteValueTest.kt delete mode 100644 usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/JsValueTest.kt create mode 100644 usvm-ts-pbt/src/test/resources/properties/examples/PropertyExamples.ts diff --git a/docs/superpowers/plans/2026-08-22-kotlin-first-property-abstraction.md b/docs/superpowers/plans/2026-08-22-kotlin-first-property-abstraction.md index cbf7cf2709..a602b88e12 100644 --- a/docs/superpowers/plans/2026-08-22-kotlin-first-property-abstraction.md +++ b/docs/superpowers/plans/2026-08-22-kotlin-first-property-abstraction.md @@ -20,6 +20,7 @@ - String length counts arbitrary UTF-16 code units; default maximum length is `10`. - Array default maximum length is `10`. - Bounded number domains reject NaN; all JavaScript special numbers use tagged encoding. +- Tuple and array samples use recursive tagged `JsConcreteValue.Array`; `ConstantDomain` remains primitive-only. - Capability is separate from `PropertyManifest` and is keyed by backend ID and version. - The Node adapter is private and pins fast-check `4.9.0`. - Existing `FrontendBaselineTest` must remain green. @@ -31,18 +32,18 @@ **Files:** - Modify: `usvm-ts-pbt/build.gradle.kts` -- Create: `usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsValue.kt` +- Create: `usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsConcreteValue.kt` - Create: `usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDomain.kt` - Create: `usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDefinition.kt` - Create: `usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/manifest/PropertyManifest.kt` - Create: `usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/validation/PropertyValidation.kt` -- Test: `usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/JsValueTest.kt` +- Test: `usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/JsConcreteValueTest.kt` - Test: `usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/manifest/PropertyManifestTest.kt` - Test: `usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/validation/PropertyValidationTest.kt` **Interfaces:** -- Produces: `PropertyDefinition`, `PropertyDomain`, `JsValue`, `JsNumber`, `PropertyManifest`, `PropertyManifestJson`, `validatePropertyDefinition`, and `validatePropertyManifest`. +- Produces: `PropertyDefinition`, `PropertyDomain`, `JsConcreteValue`, `JsNumber`, `PropertyManifest`, `PropertyManifestJson`, `validatePropertyDefinition`, and `validatePropertyManifest`. - Consumes: only Kotlin stdlib and kotlinx.serialization; it has no Node, fast-check, JacoDB, or USVM dependency. - [ ] **Step 1: Enable Kotlin serialization and add the JSON runtime** @@ -66,11 +67,11 @@ dependencies { ```kotlin @Test fun `negative zero keeps its raw IEEE bits through JSON`() { - val value = JsValue.Number(JsNumber.finite(-0.0)) - val encoded = PropertyManifestJson.json.encodeToString(JsValue.serializer(), value) - val decoded = PropertyManifestJson.json.decodeFromString(JsValue.serializer(), encoded) + val value = JsConcreteValue.Number(JsNumber.finite(-0.0)) + val encoded = PropertyManifestJson.json.encodeToString(JsConcreteValue.serializer(), value) + val decoded = PropertyManifestJson.json.decodeFromString(JsConcreteValue.serializer(), encoded) assertEquals(value, decoded) - assertEquals((-0.0).toRawBits(), (decoded as JsValue.Number).number.toDouble().toRawBits()) + assertEquals((-0.0).toRawBits(), (decoded as JsConcreteValue.Number).number.toDouble().toRawBits()) } @Test @@ -96,11 +97,11 @@ Run: ```shell env -u ARKANALYZER_DIR ETS_IR_PROVIDER=ts-frontend \ ./gradlew --no-daemon :usvm-ts-pbt:test \ - --tests 'org.usvm.ts.pbt.model.JsValueTest' \ + --tests 'org.usvm.ts.pbt.model.JsConcreteValueTest' \ --tests 'org.usvm.ts.pbt.manifest.PropertyManifestTest' ``` -Expected: compilation fails because `JsValue`, `PropertyDefinition`, and manifest APIs do not exist. +Expected: compilation fails because `JsConcreteValue`, `PropertyDefinition`, and manifest APIs do not exist. - [ ] **Step 4: Implement tagged JavaScript primitives and domain algebra** @@ -158,13 +159,13 @@ data class StringDomain( @Serializable @SerialName("constant") -data class ConstantDomain(val value: JsValue) : PropertyDomain +data class ConstantDomain(val value: JsConcreteValue) : PropertyDomain @Serializable @SerialName("optional") data class OptionalDomain( val value: PropertyDomain, - val nil: JsValue = JsValue.Undefined, + val nil: JsConcreteValue = JsConcreteValue.Undefined, ) : PropertyDomain @Serializable @@ -385,7 +386,7 @@ git commit -m "feat(ts-pbt): add backend capability model" **Interfaces:** -- Consumes: schema-version-1 domain and `JsValue` JSON produced by Task 1. +- Consumes: schema-version-1 domain and `JsConcreteValue` JSON produced by Task 1. - Produces: `decodeJsValue`, `encodeJsValue`, `projectDomain`, `projectionCapability`, and a one-shot `sample` protocol executable. - [ ] **Step 1: Add the private adapter package and lock fast-check 4.9.0** diff --git a/docs/superpowers/specs/2026-08-22-kotlin-first-property-abstraction-design.md b/docs/superpowers/specs/2026-08-22-kotlin-first-property-abstraction-design.md index 1f12a2cf75..ca6cfba12e 100644 --- a/docs/superpowers/specs/2026-08-22-kotlin-first-property-abstraction-design.md +++ b/docs/superpowers/specs/2026-08-22-kotlin-first-property-abstraction-design.md @@ -2,7 +2,7 @@ **Issue:** [#347](https://github.com/UnitTestBot/usvm/issues/347) -**Status:** Kotlin-first architecture approved in chat; detailed specification pending review +**Status:** Kotlin-first architecture approved; implementation in progress ## Context @@ -134,12 +134,12 @@ data class StringDomain( ) : PropertyDomain data class ConstantDomain( - val value: JsValue, + val value: JsConcreteValue, ) : PropertyDomain data class OptionalDomain( val value: PropertyDomain, - val nil: JsValue = JsValue.Undefined, + val nil: JsConcreteValue = JsConcreteValue.Undefined, ) : PropertyDomain data class TupleDomain( @@ -159,13 +159,13 @@ data class ArrayDomain( `StringDomain` contains arbitrary UTF-16 code-unit sequences, including valid surrogate pairs and unpaired surrogates. Length bounds count UTF-16 code units, matching JavaScript `String.length`. The fast-check adapter constructs this domain from arrays of integers in `0..0xffff` instead of inheriting changing `fc.string()` defaults. -The initial `ConstantDomain` supports JavaScript primitives only. Objects, functions, symbols, and bigints are rejected rather than coerced. `OptionalDomain.nil` must be either `JsValue.Undefined` or `JsValue.Null`; other sentinel values are rejected. +The initial `ConstantDomain` supports JavaScript primitives only. Objects, functions, symbols, and bigints are rejected rather than coerced. `OptionalDomain.nil` must be either `JsConcreteValue.Undefined` or `JsConcreteValue.Null`; other sentinel values are rejected. Tuple and array nesting is recursive. Cycles cannot occur because the model is immutable and value-based. ## JavaScript Value Encoding -Ordinary JSON cannot distinguish or preserve `undefined`, NaN, infinities, and negative zero. All values crossing a manifest or backend protocol use a tagged `JsValue` representation: +Ordinary JSON cannot distinguish or preserve `undefined`, NaN, infinities, and negative zero. All values crossing a manifest or backend protocol use a tagged `JsConcreteValue` representation: ```json { "kind": "undefined" } @@ -176,10 +176,17 @@ Ordinary JSON cannot distinguish or preserve `undefined`, NaN, infinities, and n { "kind": "number", "value": "nan" } { "kind": "number", "value": "positive-infinity" } { "kind": "number", "value": "negative-infinity" } +{ "kind": "array", "elements": [{ "kind": "undefined" }, { "kind": "null" }] } ``` Finite doubles use their exact unsigned 64-bit hexadecimal IEEE-754 representation. This preserves negative zero and avoids decimal round-trip ambiguity. NaN uses one semantic tag because the pipeline does not expose NaN payloads. +`JsConcreteValue` represents one concrete JavaScript value rather than a domain or JacoDB IR value. +`JsConcreteValue.Array` +recursively encodes tuple and array samples crossing the Kotlin-to-Node protocol. It does not expand +`ConstantDomain`: constants remain restricted to JavaScript primitives and validation rejects a composite +constant. + ## Property Manifest `PropertyManifest` is the canonical engine-neutral serialization of a validated property definition: @@ -279,7 +286,7 @@ Requests and responses use one JSON document on standard input/output: } ``` -Successful responses echo `protocolVersion` and `requestId`, contain `status: "ok"`, and encode sample values as `JsValue`. Validation failures return `status: "error"` with stable diagnostic codes. Process startup failures and invalid non-JSON output are reported by the Kotlin caller as transport errors. +Successful responses echo `protocolVersion` and `requestId`, contain `status: "ok"`, and encode sample values as `JsConcreteValue`. Validation failures return `status: "error"` with stable diagnostic codes. Process startup failures and invalid non-JSON output are reported by the Kotlin caller as transport errors. The adapter writes protocol output only to stdout. Human-readable logging goes to stderr so it cannot corrupt the protocol. @@ -309,8 +316,8 @@ The planned responsibilities are: ```text usvm-ts-pbt/ src/main/kotlin/org/usvm/ts/pbt/ - model/ PropertyDefinition, entry points, domain algebra - manifest/ versioned DTOs, JsValue, serialization, validation + model/ PropertyDefinition, entry points, domain algebra, JsConcreteValue + manifest/ versioned DTOs and serialization backend/ projection capability contracts and aggregation fastcheck/ Kotlin protocol DTOs and one-shot process client src/test/kotlin/org/usvm/ts/pbt/ @@ -339,7 +346,7 @@ Tests follow red-green TDD during implementation. - validate every domain variant and invalid constraint; - verify deterministic diagnostic ordering and paths; -- round-trip every manifest and tagged JavaScript value; +- round-trip every manifest and tagged JavaScript value, including recursive protocol arrays; - preserve finite double bits, negative zero, NaN, and infinities; - aggregate nested domain and property capabilities; - classify a supported concrete plus unsupported symbolic projection as `concrete-only`. diff --git a/usvm-ts-pbt/README.md b/usvm-ts-pbt/README.md index 2194743127..a91af34adb 100644 --- a/usvm-ts-pbt/README.md +++ b/usvm-ts-pbt/README.md @@ -1,27 +1,127 @@ # USVM TypeScript property-based testing -`usvm-ts-pbt` is the integration baseline for the fast-check and symbolic-execution pipeline tracked by -[issue #346](https://github.com/UnitTestBot/usvm/issues/346). Property execution is introduced by later issues. +`usvm-ts-pbt` is the Kotlin-owned integration layer for concrete property-based testing backends and USVM. +Kotlin defines each property once; fast-check is the first replaceable concrete backend. -## Design +## Architecture -- TypeScript is parsed by the repository's default JacoDB native `ts-frontend`; ArkAnalyzer is not required. -- The module follows the repository-wide JacoDB dependency without a separate version pin. -- The smoke test loads a TypeScript method into EtsIR and verifies its CFG and `EtsSourceSpan` origins. +```text +Kotlin PropertyDefinition + | + +--> versioned PropertyManifest + | + +--> PBT projection ----------> private fast-check Node adapter + | + +--> symbolic projection -----> USVM (#351) +``` -## Run +Kotlin owns property identity, ordered inputs, domain semantics, TypeScript entry-point references, validation, +capability aggregation, and later orchestration. Common Kotlin code never contains `fc.Arbitrary` or another +backend-native generator type. -Requires JDK 11, Node.js 18.18 or newer, and the repository's Gradle wrapper. +Predicate and precondition bodies remain exported TypeScript functions. Kotlin refers to each function by a +normalized project-relative module path, export name, and synchronous or asynchronous execution kind. Issue #347 +validates and serializes those references but does not load or execute the functions. -```shell -env -u ARKANALYZER_DIR ETS_IR_PROVIDER=ts-frontend \ - ./gradlew --no-daemon :usvm-ts-pbt:clean :usvm-ts-pbt:check +## Kotlin property model + +```kotlin +val property = PropertyDefinition( + id = PropertyId("array.reverse-twice"), + inputs = listOf( + PropertyInput( + name = "values", + domain = ArrayDomain(IntegerDomain(-100, 100), minLength = 0, maxLength = 20), + ), + ), + predicate = TypeScriptEntryPoint( + module = "properties/arrays.ts", + exportName = "reverseTwicePreservesValues", + ), +) + +val manifest = property.toManifest() ``` -To substitute a local JacoDB checkout: +Input order is significant because TypeScript parameters are positional. Names are unique and are retained in +diagnostics and artifacts. + +| Domain | Semantics and defaults | +| --- | --- | +| `BooleanDomain` | JavaScript booleans | +| `IntegerDomain` | Inclusive signed 32-bit range; defaults to `Int.MIN_VALUE..Int.MAX_VALUE` | +| `NumberDomain` | ECMAScript binary64; defaults to both infinities and `allowNaN = true`; bounded domains reject NaN | +| `StringDomain` | Arbitrary UTF-16 code units; length is JavaScript `String.length`; defaults to `0..10` | +| `ConstantDomain` | One tagged JavaScript primitive | +| `OptionalDomain` | Nested domain plus exactly `undefined` or `null` as the nil value | +| `TupleDomain` | Non-empty ordered recursive domains | +| `ArrayDomain` | Recursive element domain; defaults to length `0..10` | + +`PropertyDomain` describes a set of allowed inputs. `JsConcreteValue` describes one concrete JavaScript value used as a +constant or returned sample; it is unrelated to JacoDB IR values. Its tagged encoding preserves `undefined`, +`null`, NaN, both infinities, and the raw IEEE-754 bits of finite numbers, including negative zero. Protocol +samples also use recursive tagged arrays so tuple and array values cross JSON without losing nested special +values. `ConstantDomain` still rejects composite values. + +## Manifest and capability are separate + +`PropertyManifest` is schema-versioned engine-neutral data. It contains property semantics and TypeScript +entry-point references, but no backend name, fast-check configuration, seed, replay path, shrink data, coverage, +or USVM expression. + +`ProjectionCapability` is a backend-and-version-specific report with `EXACT`, `APPROXIMATE`, or `UNSUPPORTED` +level and stable diagnostics. Recursive composition selects the least capable child. A concrete projection that +is supported while the selected USVM projection is unsupported is classified by the pipeline as `CONCRETE_ONLY`; +that classification is not stored in the manifest. + +## Private fast-check adapter + +`fast-check-adapter` is a private ECMAScript module pinned to fast-check 4.9.0. It recursively reconstructs real +`fc.Arbitrary` objects from common domain descriptors. Kotlin invokes its one-shot `sample` operation over one +JSON request on stdin and one JSON response on stdout. The adapter does not discover properties, load predicates, +run campaigns, select USVM, or orchestrate the pipeline. + +Both manifest and protocol versions start at `1`. Kotlin validates outgoing request sizes and verifies process +exit status, JSON shape, protocol version, request identity, sample shape, and typed backend diagnostics. + +## Extension rules + +- A new PBT backend consumes the common manifest and implements projection/capability reporting. Existing + `PropertyDefinition` instances and USVM code must not change for already-supported domains. +- A new common domain needs explicit Kotlin semantics and validation, serialization, a capability decision from + every backend, and conformance tests. +- A backend-specific extension must be namespaced and must be reported as unsupported by backends that do not + implement it. +- Backend-native arbitrary objects, arbitrary TypeScript closures, silent approximation, and backend defaults in + the common model are rejected extension mechanisms. + +## Verification + +Requires JDK 11, Node.js 18.18 or newer, npm, and the repository Gradle wrapper. The full Gradle check installs the +pinned private adapter, runs Node tests, runs Kotlin/Node protocol tests, and retains the native `ts-frontend` +baseline from #346. ```shell +npm ci --prefix usvm-ts-pbt/fast-check-adapter --ignore-scripts +npm test --prefix usvm-ts-pbt/fast-check-adapter + env -u ARKANALYZER_DIR ETS_IR_PROVIDER=ts-frontend \ - ./gradlew --no-daemon -PuseLocalJacodb=/absolute/path/to/jacodb \ - :usvm-ts-pbt:clean :usvm-ts-pbt:check + ./gradlew --no-daemon :usvm-ts-pbt:test + +env -u ARKANALYZER_DIR ETS_IR_PROVIDER=ts-frontend \ + ./gradlew --no-daemon :usvm-ts-pbt:clean :usvm-ts-pbt:check ``` + +To substitute a local JacoDB checkout, add +`-PuseLocalJacodb=/absolute/path/to/jacodb` to the Gradle command. + +## Issue boundaries + +- #348 loads TypeScript entry points and executes Kotlin definitions through `fc.check`. +- #349 records backend-neutral per-property coverage. +- #350 maps entry points and coverage locations to EtsIR. +- #351 projects common domains and preconditions into USVM. +- #352 searches for property violations with USVM. +- #353 replays USVM witnesses and delegates shrinking to a capable PBT backend. +- #354 assembles the Kotlin-orchestrated end-to-end pipeline. +- #355–#357 build runtime hints, benchmarks, and evaluation on backend-identified artifacts. diff --git a/usvm-ts-pbt/build.gradle.kts b/usvm-ts-pbt/build.gradle.kts index 1197ec28fa..4bda553c62 100644 --- a/usvm-ts-pbt/build.gradle.kts +++ b/usvm-ts-pbt/build.gradle.kts @@ -10,3 +10,36 @@ dependencies { testImplementation(Libs.logback) } + +val fastCheckAdapterDir = layout.projectDirectory.dir("fast-check-adapter") +val npmExecutable = if (System.getProperty("os.name").lowercase().contains("windows")) "npm.cmd" else "npm" + +val installFastCheckAdapter = tasks.register("installFastCheckAdapter") { + workingDir(fastCheckAdapterDir) + commandLine(npmExecutable, "ci", "--ignore-scripts") + inputs.files( + fastCheckAdapterDir.file("package.json"), + fastCheckAdapterDir.file("package-lock.json"), + ) + outputs.dir(fastCheckAdapterDir.dir("node_modules")) +} + +val testFastCheckAdapter = tasks.register("testFastCheckAdapter") { + dependsOn(installFastCheckAdapter) + workingDir(fastCheckAdapterDir) + commandLine(npmExecutable, "test") + inputs.dir(fastCheckAdapterDir.dir("src")) + inputs.dir(fastCheckAdapterDir.dir("test")) +} + +tasks.test { + dependsOn(installFastCheckAdapter) +} + +tasks.check { + dependsOn(testFastCheckAdapter) +} + +tasks.withType().configureEach { + jvmTarget = JavaVersion.VERSION_1_8.toString() +} diff --git a/usvm-ts-pbt/fast-check-adapter/src/js-value.mjs b/usvm-ts-pbt/fast-check-adapter/src/js-value.mjs index b04709b004..4bedded9fe 100644 --- a/usvm-ts-pbt/fast-check-adapter/src/js-value.mjs +++ b/usvm-ts-pbt/fast-check-adapter/src/js-value.mjs @@ -17,6 +17,11 @@ export function decodeJsValue(value, path = 'value') { return value.value; case 'number': return decodeJsNumber(value, path); + case 'array': + if (!Array.isArray(value.elements)) { + throw protocolError('js-value.array.invalid', 'Array value must contain elements', path); + } + return value.elements.map((element, index) => decodeJsValue(element, `${path}.elements[${index}]`)); default: throw protocolError('js-value.kind.unknown', `Unknown JavaScript value kind: ${String(value.kind)}`, path); } @@ -28,6 +33,7 @@ export function encodeJsValue(value) { if (typeof value === 'boolean') return { kind: 'boolean', value }; if (typeof value === 'string') return { kind: 'string', value }; if (typeof value === 'number') return { kind: 'number', ...encodeJsNumber(value) }; + if (Array.isArray(value)) return { kind: 'array', elements: value.map(encodeJsValue) }; throw protocolError( 'js-value.type.unsupported', `Unsupported JavaScript value type: ${typeof value}`, diff --git a/usvm-ts-pbt/fast-check-adapter/test/js-value.test.mjs b/usvm-ts-pbt/fast-check-adapter/test/js-value.test.mjs index 6e7bda8970..f8eef3a657 100644 --- a/usvm-ts-pbt/fast-check-adapter/test/js-value.test.mjs +++ b/usvm-ts-pbt/fast-check-adapter/test/js-value.test.mjs @@ -16,6 +16,13 @@ test('tagged JavaScript primitives round trip without losing semantics', () => { [{ kind: 'number', value: 'nan' }, Number.isNaN], [{ kind: 'number', value: 'positive-infinity' }, (value) => value === Number.POSITIVE_INFINITY], [{ kind: 'number', value: 'negative-infinity' }, (value) => value === Number.NEGATIVE_INFINITY], + [ + { + kind: 'array', + elements: [{ kind: 'undefined' }, { kind: 'number', value: 'finite', bits: '8000000000000000' }], + }, + (value) => Array.isArray(value) && value[0] === undefined && Object.is(value[1], -0), + ], ]; for (const [tagged, predicate] of cases) { 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 new file mode 100644 index 0000000000..8563d72f34 --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClient.kt @@ -0,0 +1,129 @@ +package org.usvm.ts.pbt.fastcheck + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import org.usvm.ts.pbt.manifest.PropertyManifestJson +import java.io.IOException +import java.nio.file.Path + +class FastCheckProjectionClient( + private val nodeExecutable: String = "node", + private val adapterEntryPoint: Path, +) { + fun sample(request: FastCheckProjectionRequest): FastCheckProjectionResponse { + validateRequest(request) + val response = decodeResponse(invokeAdapter(request)) + validateResponseIdentity(request, response) + throwBackendError(response) + validateSuccessfulResponse(request, response) + return FastCheckProjectionResponse( + protocolVersion = response.protocolVersion, + requestId = requireNotNull(response.requestId), + samples = response.samples, + ) + } + + private fun validateResponseIdentity( + request: FastCheckProjectionRequest, + response: FastCheckProjectionWireResponse, + ) { + if (response.protocolVersion != FAST_CHECK_PROTOCOL_VERSION || response.requestId != request.requestId) { + throw FastCheckProjectionException( + code = "backend.response.mismatch", + message = "fast-check response identity does not match the request", + ) + } + } + + private fun throwBackendError(response: FastCheckProjectionWireResponse) { + if (response.status == "error") { + val diagnostic = response.diagnostics.firstOrNull() + ?: invalidResponse("fast-check error response does not contain a diagnostic") + throw FastCheckProjectionException( + code = diagnostic.code, + message = diagnostic.message, + path = diagnostic.path, + ) + } + } + + private fun validateSuccessfulResponse( + request: FastCheckProjectionRequest, + response: FastCheckProjectionWireResponse, + ) { + if (response.status != "ok" || response.samples.size != request.numSamples || + response.samples.any { it.size != request.domains.size } + ) { + throw FastCheckProjectionException( + code = "backend.response.invalid", + message = "fast-check adapter returned an invalid successful response", + ) + } + } + + private fun invokeAdapter(request: FastCheckProjectionRequest): String { + val process = startAdapter() + process.outputWriter(Charsets.UTF_8).use { writer -> + writer.write(PropertyManifestJson.json.encodeToString(request)) + } + val stdout = process.inputReader(Charsets.UTF_8).readText() + val stderr = process.errorReader(Charsets.UTF_8).readText() + val exitCode = process.waitFor() + if (exitCode != 0) { + throw FastCheckProjectionException( + code = "backend.process.failed", + message = "fast-check adapter exited with code $exitCode: ${stderr.trim()}", + ) + } + if (stdout.isBlank()) { + throw FastCheckProjectionException( + code = "backend.response.empty", + message = "fast-check adapter returned an empty response", + ) + } + return stdout + } + + private fun startAdapter(): Process = try { + ProcessBuilder(nodeExecutable, adapterEntryPoint.toString()).start() + } catch (error: IOException) { + throw FastCheckProjectionException( + code = "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) { + throw FastCheckProjectionException( + code = "backend.response.invalid", + message = "fast-check adapter returned invalid JSON: ${error.message}", + cause = error, + ) + } + + private fun invalidResponse(message: String): Nothing = throw FastCheckProjectionException( + code = "backend.response.invalid", + message = message, + ) + + private fun validateRequest(request: FastCheckProjectionRequest) { + val valid = request.requestId.isNotEmpty() && + request.operation == "sample" && + request.numSamples in 1..MAX_SAMPLES && + request.domains.isNotEmpty() + if (!valid) { + throw FastCheckProjectionException( + code = "protocol.request.invalid", + message = "Request requires a non-empty ID and domains, operation sample, and numSamples in 1..10000", + path = "request", + ) + } + } + + private companion object { + const val MAX_SAMPLES = 10_000 + } +} diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionProtocol.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionProtocol.kt new file mode 100644 index 0000000000..8db4a0c44d --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionProtocol.kt @@ -0,0 +1,46 @@ +package org.usvm.ts.pbt.fastcheck + +import kotlinx.serialization.Serializable +import org.usvm.ts.pbt.model.JsConcreteValue +import org.usvm.ts.pbt.model.PropertyDomain + +const val FAST_CHECK_PROTOCOL_VERSION = 1 + +@Serializable +data class FastCheckProjectionRequest( + val protocolVersion: Int = FAST_CHECK_PROTOCOL_VERSION, + val requestId: String, + val operation: String = "sample", + val seed: Int, + val numSamples: Int, + val domains: List, +) + +data class FastCheckProjectionResponse( + val protocolVersion: Int, + val requestId: String, + val samples: List>, +) + +@Serializable +internal data class FastCheckProjectionWireResponse( + val protocolVersion: Int, + val requestId: String? = null, + val status: String, + val samples: List> = emptyList(), + val diagnostics: List = emptyList(), +) + +@Serializable +internal data class FastCheckProtocolDiagnostic( + val code: String, + val message: String, + val path: String? = null, +) + +class FastCheckProjectionException( + val code: String, + message: String, + val path: String? = null, + cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsValue.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsConcreteValue.kt similarity index 61% rename from usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsValue.kt rename to usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsConcreteValue.kt index 2230986e99..5f1d07ee72 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsValue.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsConcreteValue.kt @@ -8,10 +8,12 @@ import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonEncoder 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 @@ -39,7 +41,7 @@ data class JsNumber( fun toDouble(): Double = when (value) { JsNumberKind.FINITE -> Double.fromBits( requireNotNull(bits) { "A finite JavaScript number requires IEEE-754 bits" } - .toULong(16) + .toULong(JS_NUMBER_HEX_RADIX) .toLong(), ) @@ -53,7 +55,8 @@ data class JsNumber( require(value.isFinite()) { "Use a tagged representation for non-finite JavaScript numbers" } return JsNumber( value = JsNumberKind.FINITE, - bits = value.toRawBits().toULong().toString(16).padStart(JS_NUMBER_HEX_DIGITS, '0'), + bits = value.toRawBits().toULong().toString(JS_NUMBER_HEX_RADIX) + .padStart(JS_NUMBER_HEX_DIGITS, '0'), ) } @@ -72,66 +75,85 @@ data class JsNumber( } } -@Serializable(with = JsValueSerializer::class) -sealed interface JsValue { - data object Undefined : JsValue +@Serializable(with = JsConcreteValueSerializer::class) +sealed interface JsConcreteValue { + data object Undefined : JsConcreteValue - data object Null : JsValue + data object Null : JsConcreteValue - data class Boolean(val value: kotlin.Boolean) : JsValue + data class Boolean(val value: kotlin.Boolean) : JsConcreteValue - data class String(val value: kotlin.String) : JsValue + data class String(val value: kotlin.String) : JsConcreteValue - data class Number(val number: JsNumber) : JsValue { + data class Number(val number: JsNumber) : JsConcreteValue { fun toDouble(): Double = number.toDouble() } + + data class Array(val elements: List) : JsConcreteValue } -object JsValueSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("JsValue") +object JsConcreteValueSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("JsConcreteValue") - override fun serialize(encoder: Encoder, value: JsValue) { + override fun serialize(encoder: Encoder, value: JsConcreteValue) { val jsonEncoder = encoder as? JsonEncoder - ?: throw SerializationException("JsValue supports JSON serialization only") + ?: throw SerializationException("JsConcreteValue supports JSON serialization only") jsonEncoder.encodeJsonElement( buildJsonObject { when (value) { - JsValue.Undefined -> put("kind", "undefined") - JsValue.Null -> put("kind", "null") - is JsValue.Boolean -> { + JsConcreteValue.Undefined -> { + put("kind", "undefined") + } + + JsConcreteValue.Null -> { + put("kind", "null") + } + is JsConcreteValue.Boolean -> { put("kind", "boolean") put("value", value.value) } - is JsValue.String -> { + is JsConcreteValue.String -> { put("kind", "string") put("value", value.value) } - is JsValue.Number -> { + is JsConcreteValue.Number -> { put("kind", "number") put("value", value.number.value.serialName) value.number.bits?.let { put("bits", it) } } + + is JsConcreteValue.Array -> { + put("kind", "array") + put( + "elements", + JsonArray( + value.elements.map { element -> + jsonEncoder.json.encodeToJsonElement(JsConcreteValueSerializer, element) + }, + ), + ) + } } }, ) } - override fun deserialize(decoder: Decoder): JsValue { + override fun deserialize(decoder: Decoder): JsConcreteValue { val jsonDecoder = decoder as? JsonDecoder - ?: throw SerializationException("JsValue supports JSON deserialization only") + ?: throw SerializationException("JsConcreteValue supports JSON deserialization only") val value = jsonDecoder.decodeJsonElement().jsonObject return when (val kind = value.requiredString("kind")) { - "undefined" -> JsValue.Undefined - "null" -> JsValue.Null - "boolean" -> JsValue.Boolean( + "undefined" -> JsConcreteValue.Undefined + "null" -> JsConcreteValue.Null + "boolean" -> JsConcreteValue.Boolean( value["value"]?.jsonPrimitive?.booleanOrNull - ?: throw SerializationException("Boolean JsValue requires a boolean value"), + ?: throw SerializationException("Boolean JsConcreteValue requires a boolean value"), ) - "string" -> JsValue.String(value.requiredString("value")) - "number" -> JsValue.Number( + "string" -> JsConcreteValue.String(value.requiredString("value")) + "number" -> JsConcreteValue.Number( JsNumber( value = when (val numberKind = value.requiredString("value")) { "finite" -> JsNumberKind.FINITE @@ -144,6 +166,12 @@ object JsValueSerializer : KSerializer { ), ) + "array" -> JsConcreteValue.Array( + value["elements"]?.jsonArray?.map { element -> + jsonDecoder.json.decodeFromJsonElement(JsConcreteValueSerializer, element) + } ?: throw SerializationException("Array JsConcreteValue requires elements"), + ) + else -> throw SerializationException("Unknown JavaScript value kind: $kind") } } @@ -159,6 +187,7 @@ private val JsNumberKind.serialName: kotlin.String private fun kotlinx.serialization.json.JsonObject.requiredString(name: kotlin.String): kotlin.String = get(name)?.jsonPrimitive?.content - ?: throw SerializationException("JsValue requires a $name field") + ?: throw SerializationException("JsConcreteValue requires a $name field") private const val JS_NUMBER_HEX_DIGITS = 16 +private const val JS_NUMBER_HEX_RADIX = 16 diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDomain.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDomain.kt index 957fb64462..19cdaca473 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDomain.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDomain.kt @@ -37,13 +37,13 @@ data class StringDomain( @Serializable @SerialName("constant") -data class ConstantDomain(val value: JsValue) : PropertyDomain +data class ConstantDomain(val value: JsConcreteValue) : PropertyDomain @Serializable @SerialName("optional") data class OptionalDomain( val value: PropertyDomain, - val nil: JsValue = JsValue.Undefined, + val nil: JsConcreteValue = JsConcreteValue.Undefined, ) : PropertyDomain @Serializable 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 d898777ccc..3d10bc51ac 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 @@ -6,9 +6,9 @@ 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.JsNumber import org.usvm.ts.pbt.model.JsNumberKind -import org.usvm.ts.pbt.model.JsValue import org.usvm.ts.pbt.model.NumberDomain import org.usvm.ts.pbt.model.OptionalDomain import org.usvm.ts.pbt.model.PropertyDefinition @@ -102,31 +102,50 @@ private fun validateDomain( diagnostics: MutableList, ) { when (domain) { - BooleanDomain -> Unit - is IntegerDomain -> if (domain.min > domain.max) { - diagnostics += diagnostic("domain.integer.bounds", "Integer minimum exceeds maximum", path) + BooleanDomain -> { + Unit } - is NumberDomain -> validateNumberDomain(domain, path, diagnostics) - is StringDomain -> validateLengths( - minLength = domain.minLength, - maxLength = domain.maxLength, - code = "domain.string.length", - description = "String", - path = path, - diagnostics = diagnostics, - ) + is IntegerDomain -> { + if (domain.min > domain.max) { + diagnostics += diagnostic("domain.integer.bounds", "Integer minimum exceeds maximum", path) + } + } + + is NumberDomain -> { + validateNumberDomain(domain, path, diagnostics) + } - is ConstantDomain -> validateJsValue(domain.value, "$path.value", diagnostics) + is StringDomain -> { + validateLengths( + minLength = domain.minLength, + maxLength = domain.maxLength, + code = "domain.string.length", + description = "String", + path = path, + diagnostics = diagnostics, + ) + } + + is ConstantDomain -> { + if (domain.value is JsConcreteValue.Array) { + diagnostics += diagnostic( + "domain.constant.unsupported", + "Constant domains support JavaScript primitives only", + path, + ) + } + validateJsConcreteValue(domain.value, "$path.value", diagnostics) + } is OptionalDomain -> { - if (domain.nil != JsValue.Undefined && domain.nil != JsValue.Null) { + if (domain.nil != JsConcreteValue.Undefined && domain.nil != JsConcreteValue.Null) { diagnostics += diagnostic( "domain.optional.nil", "Optional nil must be null or undefined", "$path.nil", ) } - validateJsValue(domain.nil, "$path.nil", diagnostics) + validateJsConcreteValue(domain.nil, "$path.nil", diagnostics) validateDomain(domain.value, "$path.value", diagnostics) } @@ -182,14 +201,19 @@ private fun validateNumberDomain( } } -private fun validateJsValue( - value: JsValue, +private fun validateJsConcreteValue( + value: JsConcreteValue, path: String, diagnostics: MutableList, ) { - if (value is JsValue.Number) { + if (value is JsConcreteValue.Number) { validateJsNumber(value.number, path, diagnostics) } + if (value is JsConcreteValue.Array) { + value.elements.forEachIndexed { index, element -> + validateJsConcreteValue(element, "$path.elements[$index]", diagnostics) + } + } } private fun validateJsNumber( diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/examples/ExamplePropertiesTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/examples/ExamplePropertiesTest.kt new file mode 100644 index 0000000000..66dd6b5d48 --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/examples/ExamplePropertiesTest.kt @@ -0,0 +1,90 @@ +package org.usvm.ts.pbt.examples + +import org.junit.jupiter.api.Test +import org.usvm.ts.pbt.fastcheck.FastCheckProjectionClient +import org.usvm.ts.pbt.fastcheck.FastCheckProjectionRequest +import org.usvm.ts.pbt.manifest.PropertyManifestJson +import org.usvm.ts.pbt.manifest.toManifest +import org.usvm.ts.pbt.model.ArrayDomain +import org.usvm.ts.pbt.model.IntegerDomain +import org.usvm.ts.pbt.model.PropertyDefinition +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.validation.validatePropertyDefinition +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.absolute +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ExamplePropertiesTest { + @Test + fun `four Kotlin property shapes validate serialize and project through fast-check`() { + assertNotNull(javaClass.getResource("/properties/examples/PropertyExamples.ts")) + val client = FastCheckProjectionClient(adapterEntryPoint = adapterEntryPoint()) + + examples.forEachIndexed { index, definition -> + assertTrue(validatePropertyDefinition(definition).isValid, definition.id.value) + val manifest = definition.toManifest() + assertEquals(manifest, PropertyManifestJson.decode(PropertyManifestJson.encode(manifest))) + + val response = client.sample( + FastCheckProjectionRequest( + requestId = "example-$index", + seed = 42, + numSamples = 5, + domains = definition.inputs.map(PropertyInput::domain), + ), + ) + assertEquals(5, response.samples.size) + assertTrue(response.samples.all { it.size == definition.inputs.size }) + } + } + + private companion object { + const val MODULE = "properties/examples/PropertyExamples.ts" + + val examples = listOf( + PropertyDefinition( + id = PropertyId("example.relational"), + inputs = listOf( + PropertyInput("left", IntegerDomain()), + PropertyInput("right", IntegerDomain()), + ), + predicate = TypeScriptEntryPoint(MODULE, "isCommutative"), + ), + PropertyDefinition( + id = PropertyId("example.bounded"), + inputs = listOf(PropertyInput("value", IntegerDomain(-100, 100))), + predicate = TypeScriptEntryPoint(MODULE, "boundedValueStaysBounded"), + ), + PropertyDefinition( + id = PropertyId("example.precondition"), + inputs = listOf( + PropertyInput("dividend", IntegerDomain(-100, 100)), + PropertyInput("divisor", IntegerDomain(-10, 10)), + ), + predicate = TypeScriptEntryPoint(MODULE, "divisionRoundTrip"), + precondition = TypeScriptEntryPoint(MODULE, "nonZeroDivisor"), + ), + PropertyDefinition( + id = PropertyId("example.array"), + inputs = listOf( + PropertyInput("values", ArrayDomain(IntegerDomain(-5, 5), minLength = 0, maxLength = 5)), + ), + predicate = TypeScriptEntryPoint(MODULE, "reverseTwicePreservesValues"), + ), + ) + + fun adapterEntryPoint(): Path { + val candidates = listOf( + Path.of("fast-check-adapter/src/projection-cli.mjs"), + Path.of("usvm-ts-pbt/fast-check-adapter/src/projection-cli.mjs"), + ).map { it.absolute() } + return candidates.singleOrNull(Files::isRegularFile) + ?: error("Cannot locate fast-check adapter; checked $candidates") + } + } +} 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 new file mode 100644 index 0000000000..666f56ee2e --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClientTest.kt @@ -0,0 +1,172 @@ +package org.usvm.ts.pbt.fastcheck + +import org.junit.jupiter.api.Test +import org.usvm.ts.pbt.model.ArrayDomain +import org.usvm.ts.pbt.model.BooleanDomain +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 kotlin.io.path.absolute +import kotlin.io.path.createTempFile +import kotlin.io.path.deleteIfExists +import kotlin.io.path.writeText +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class FastCheckProjectionClientTest { + private val client = FastCheckProjectionClient(adapterEntryPoint = adapterEntryPoint()) + + @Test + fun `Kotlin domains produce deterministic tagged fast-check samples`() { + val request = FastCheckProjectionRequest( + requestId = "integration-1", + seed = 42, + numSamples = 20, + domains = listOf(IntegerDomain(-10, 10), ArrayDomain(BooleanDomain, 0, 3)), + ) + + val first = client.sample(request) + val second = client.sample(request) + + assertEquals(first, second) + assertEquals(FAST_CHECK_PROTOCOL_VERSION, first.protocolVersion) + assertEquals("integration-1", first.requestId) + assertEquals(20, first.samples.size) + first.samples.forEach { sample -> assertConforms(sample, request.domains) } + } + + @Test + fun `protocol version mismatch is a typed backend error`() { + val error = assertFailsWith { + client.sample(validRequest.copy(protocolVersion = 999)) + } + + assertEquals("protocol.version.unsupported", error.code) + assertEquals("protocolVersion", error.path) + } + + @Test + fun `invalid request is 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 = 0)) + } + + assertEquals("protocol.request.invalid", error.code) + } + + @Test + fun `process startup and exit failures are typed transport errors`() { + val startup = assertFailsWith { + FastCheckProjectionClient( + nodeExecutable = "definitely-not-a-node-executable", + adapterEntryPoint = adapterEntryPoint(), + ).sample(validRequest) + } + assertEquals("backend.process.start.failed", startup.code) + + val exit = assertFailsWith { + FastCheckProjectionClient( + adapterEntryPoint = Path.of("missing-adapter.mjs"), + ).sample(validRequest) + } + assertEquals("backend.process.failed", exit.code) + } + + @Test + fun `invalid protocol output is a typed transport error`() { + withTemporaryAdapter("process.stdout.write('not-json\\n')") { temporaryClient -> + val malformed = assertFailsWith { + temporaryClient.sample(validRequest) + } + assertEquals("backend.response.invalid", malformed.code) + } + + withTemporaryAdapter("") { temporaryClient -> + val empty = assertFailsWith { + temporaryClient.sample(validRequest) + } + assertEquals("backend.response.empty", empty.code) + } + } + + @Test + fun `response identity mismatch is rejected`() { + withTemporaryAdapter( + """ + process.stdout.write(JSON.stringify({ + protocolVersion: 1, + requestId: 'different-request', + status: 'ok', + samples: [] + })) + """.trimIndent(), + ) { temporaryClient -> + val error = assertFailsWith { + temporaryClient.sample(validRequest) + } + assertEquals("backend.response.mismatch", error.code) + } + } + + private fun assertConforms(values: List, domains: List) { + assertEquals(domains.size, values.size) + values.zip(domains).forEach { (value, domain) -> + when (domain) { + is IntegerDomain -> { + val number = (value as JsConcreteValue.Number).toDouble() + assertTrue(number % 1.0 == 0.0 && number >= domain.min && number <= domain.max) + } + + is ArrayDomain -> { + (value as JsConcreteValue.Array).elements.forEach { element -> + assertConforms(listOf(element), listOf(domain.element)) + } + } + + BooleanDomain -> { + assertTrue(value is JsConcreteValue.Boolean) + } + + else -> { + error("Unexpected test domain: $domain") + } + } + } + } + + private fun withTemporaryAdapter(source: String, block: (FastCheckProjectionClient) -> Unit) { + val script = createTempFile(prefix = "fast-check-adapter-", suffix = ".mjs") + try { + script.writeText(source) + block(FastCheckProjectionClient(adapterEntryPoint = script)) + } finally { + script.deleteIfExists() + } + } + + private companion object { + val validRequest = FastCheckProjectionRequest( + requestId = "valid-request", + seed = 42, + numSamples = 1, + domains = listOf(BooleanDomain), + ) + + fun adapterEntryPoint(): Path { + val candidates = listOf( + Path.of("fast-check-adapter/src/projection-cli.mjs"), + Path.of("usvm-ts-pbt/fast-check-adapter/src/projection-cli.mjs"), + ).map { it.absolute() } + return candidates.singleOrNull(Files::isRegularFile) + ?: error("Cannot locate fast-check adapter; checked $candidates") + } + } +} diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/manifest/PropertyManifestTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/manifest/PropertyManifestTest.kt index 7143551a93..20bf0bbbed 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/manifest/PropertyManifestTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/manifest/PropertyManifestTest.kt @@ -41,7 +41,9 @@ class PropertyManifestTest { val encoded = PropertyManifestJson.encode(definition.toManifest()) assertEquals( - """{"schemaVersion":1,"propertyId":"integer.defaults","inputs":[{"name":"value","domain":{"kind":"integer","min":-2147483648,"max":2147483647}}],"predicate":{"module":"properties/integer.ts","exportName":"holds","executionKind":"sync"}}""", + """{"schemaVersion":1,"propertyId":"integer.defaults","inputs":[""" + + """{"name":"value","domain":{"kind":"integer","min":-2147483648,"max":2147483647}}],""" + + """"predicate":{"module":"properties/integer.ts","exportName":"holds","executionKind":"sync"}}""", encoded, ) } 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 new file mode 100644 index 0000000000..9186a91931 --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/JsConcreteValueTest.kt @@ -0,0 +1,47 @@ +package org.usvm.ts.pbt.model + +import kotlinx.serialization.encodeToString +import org.junit.jupiter.api.Test +import org.usvm.ts.pbt.manifest.PropertyManifestJson +import kotlin.test.assertEquals + +class JsConcreteValueTest { + @Test + fun `special JavaScript numbers keep their semantics through JSON`() { + val values = listOf( + JsConcreteValue.Number(JsNumber.fromDouble(-0.0)), + JsConcreteValue.Number(JsNumber.fromDouble(Double.NaN)), + JsConcreteValue.Number(JsNumber.fromDouble(Double.POSITIVE_INFINITY)), + JsConcreteValue.Number(JsNumber.fromDouble(Double.NEGATIVE_INFINITY)), + ) + + values.forEach { value -> + val encoded = PropertyManifestJson.json.encodeToString(value) + val decoded = PropertyManifestJson.json.decodeFromString(encoded) + assertEquals(value, decoded) + } + + val negativeZero = values.first() as JsConcreteValue.Number + assertEquals((-0.0).toRawBits(), negativeZero.toDouble().toRawBits()) + } + + @Test + fun `finite JavaScript numbers use sixteen lowercase hexadecimal digits`() { + assertEquals("3ff8000000000000", JsNumber.finite(1.5).bits) + assertEquals("8000000000000000", JsNumber.finite(-0.0).bits) + } + + @Test + fun `recursive arrays keep tagged values through JSON`() { + val value = JsConcreteValue.Array( + listOf( + JsConcreteValue.Undefined, + JsConcreteValue.Array(listOf(JsConcreteValue.Number(JsNumber.finite(-0.0)))), + ), + ) + + val encoded = PropertyManifestJson.json.encodeToString(value) + + assertEquals(value, PropertyManifestJson.json.decodeFromString(encoded)) + } +} diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/JsValueTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/JsValueTest.kt deleted file mode 100644 index b7ee37bd04..0000000000 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/JsValueTest.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.usvm.ts.pbt.model - -import kotlinx.serialization.encodeToString -import org.junit.jupiter.api.Test -import org.usvm.ts.pbt.manifest.PropertyManifestJson -import kotlin.test.assertEquals - -class JsValueTest { - @Test - fun `special JavaScript numbers keep their semantics through JSON`() { - val values = listOf( - JsValue.Number(JsNumber.fromDouble(-0.0)), - JsValue.Number(JsNumber.fromDouble(Double.NaN)), - JsValue.Number(JsNumber.fromDouble(Double.POSITIVE_INFINITY)), - JsValue.Number(JsNumber.fromDouble(Double.NEGATIVE_INFINITY)), - ) - - values.forEach { value -> - val encoded = PropertyManifestJson.json.encodeToString(value) - val decoded = PropertyManifestJson.json.decodeFromString(encoded) - assertEquals(value, decoded) - } - - val negativeZero = values.first() as JsValue.Number - assertEquals((-0.0).toRawBits(), negativeZero.toDouble().toRawBits()) - } - - @Test - fun `finite JavaScript numbers use sixteen lowercase hexadecimal digits`() { - assertEquals("3ff8000000000000", JsNumber.finite(1.5).bits) - assertEquals("8000000000000000", JsNumber.finite(-0.0).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 e13fbe0408..9446c0df89 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 @@ -3,8 +3,9 @@ package org.usvm.ts.pbt.validation import org.junit.jupiter.api.Test import org.usvm.ts.pbt.manifest.PROPERTY_MANIFEST_SCHEMA_VERSION import org.usvm.ts.pbt.manifest.PropertyManifest +import org.usvm.ts.pbt.model.ConstantDomain import org.usvm.ts.pbt.model.IntegerDomain -import org.usvm.ts.pbt.model.JsValue +import org.usvm.ts.pbt.model.JsConcreteValue import org.usvm.ts.pbt.model.OptionalDomain import org.usvm.ts.pbt.model.PropertyDefinition import org.usvm.ts.pbt.model.PropertyId @@ -12,8 +13,8 @@ import org.usvm.ts.pbt.model.PropertyInput import org.usvm.ts.pbt.model.StringDomain import org.usvm.ts.pbt.model.TypeScriptEntryPoint import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertTrue class PropertyValidationTest { @@ -52,7 +53,7 @@ class PropertyValidationTest { @Test fun `optional domain accepts only null or undefined as nil`() { val definition = validDefinition( - OptionalDomain(IntegerDomain(), JsValue.String("none")), + OptionalDomain(IntegerDomain(), JsConcreteValue.String("none")), ) assertEquals( @@ -61,6 +62,16 @@ class PropertyValidationTest { ) } + @Test + fun `constant domain rejects composite JavaScript values`() { + val definition = validDefinition(ConstantDomain(JsConcreteValue.Array(listOf(JsConcreteValue.Null)))) + + assertEquals( + listOf("domain.constant.unsupported"), + validatePropertyDefinition(definition).diagnostics.map { it.code }, + ) + } + @Test fun `manifest validation rejects unknown schema version`() { val manifest = PropertyManifest( diff --git a/usvm-ts-pbt/src/test/resources/properties/examples/PropertyExamples.ts b/usvm-ts-pbt/src/test/resources/properties/examples/PropertyExamples.ts new file mode 100644 index 0000000000..06c91b5b9d --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/properties/examples/PropertyExamples.ts @@ -0,0 +1,19 @@ +export function isCommutative(left: number, right: number): boolean { + return left + right === right + left; +} + +export function boundedValueStaysBounded(value: number): boolean { + return value >= -100 && value <= 100; +} + +export function nonZeroDivisor(_dividend: number, divisor: number): boolean { + return divisor !== 0; +} + +export function divisionRoundTrip(dividend: number, divisor: number): boolean { + return (dividend / divisor) * divisor === dividend; +} + +export function reverseTwicePreservesValues(values: number[]): boolean { + return [...values].reverse().reverse().every((value, index) => value === values[index]); +} From 8b49fdbaef271f882f3797cdca66160abb7516a4 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 22 Aug 2026 21:52:14 +0300 Subject: [PATCH 07/11] fix(ts-pbt): project singleton infinity domains --- .../fast-check-adapter/src/project-domain.mjs | 15 +++++++++------ .../test/project-domain.test.mjs | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/usvm-ts-pbt/fast-check-adapter/src/project-domain.mjs b/usvm-ts-pbt/fast-check-adapter/src/project-domain.mjs index 0c051ae77c..a548da4979 100644 --- a/usvm-ts-pbt/fast-check-adapter/src/project-domain.mjs +++ b/usvm-ts-pbt/fast-check-adapter/src/project-domain.mjs @@ -92,12 +92,15 @@ function projectNumber(domain, path) { const finiteMin = min === Number.NEGATIVE_INFINITY ? -Number.MAX_VALUE : min; const finiteMax = max === Number.POSITIVE_INFINITY ? Number.MAX_VALUE : max; - const arbitraries = [fc.double({ - min: finiteMin, - max: finiteMax, - noNaN: true, - noDefaultInfinity: true, - })]; + const arbitraries = []; + if (finiteMin <= finiteMax) { + arbitraries.push(fc.double({ + min: finiteMin, + max: finiteMax, + noNaN: true, + noDefaultInfinity: true, + })); + } if (domain.allowNaN) arbitraries.push(fc.constant(Number.NaN)); if (min === Number.NEGATIVE_INFINITY) arbitraries.push(fc.constant(Number.NEGATIVE_INFINITY)); if (max === Number.POSITIVE_INFINITY) arbitraries.push(fc.constant(Number.POSITIVE_INFINITY)); diff --git a/usvm-ts-pbt/fast-check-adapter/test/project-domain.test.mjs b/usvm-ts-pbt/fast-check-adapter/test/project-domain.test.mjs index 007626aff4..ba6798c52f 100644 --- a/usvm-ts-pbt/fast-check-adapter/test/project-domain.test.mjs +++ b/usvm-ts-pbt/fast-check-adapter/test/project-domain.test.mjs @@ -43,6 +43,21 @@ test('bounded numbers exclude NaN and values outside their inclusive bounds', () assert.ok(samples.every((value) => !Number.isNaN(value) && value >= -1.5 && value <= 2.5)); }); +test('singleton infinity ranges project without an empty finite arbitrary', () => { + for (const [bound, expected] of [ + [{ value: 'negative-infinity' }, Number.NEGATIVE_INFINITY], + [{ value: 'positive-infinity' }, Number.POSITIVE_INFINITY], + ]) { + const samples = sample({ + kind: 'number', + min: bound, + max: bound, + allowNaN: false, + }); + assert.ok(samples.every((value) => value === expected)); + } +}); + for (const [name, domain, predicate] of [ ['boolean', { kind: 'boolean' }, (value) => typeof value === 'boolean'], [ From 9306dc084554543f466b23309b6d1686add7f57b Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 22 Aug 2026 22:05:50 +0300 Subject: [PATCH 08/11] docs: remove internal design artifacts --- ...08-22-kotlin-first-property-abstraction.md | 755 ------------------ ...otlin-first-property-abstraction-design.md | 416 ---------- 2 files changed, 1171 deletions(-) delete mode 100644 docs/superpowers/plans/2026-08-22-kotlin-first-property-abstraction.md delete mode 100644 docs/superpowers/specs/2026-08-22-kotlin-first-property-abstraction-design.md diff --git a/docs/superpowers/plans/2026-08-22-kotlin-first-property-abstraction.md b/docs/superpowers/plans/2026-08-22-kotlin-first-property-abstraction.md deleted file mode 100644 index a602b88e12..0000000000 --- a/docs/superpowers/plans/2026-08-22-kotlin-first-property-abstraction.md +++ /dev/null @@ -1,755 +0,0 @@ -# Kotlin-First Property Abstraction Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Implement the engine-neutral Kotlin property model, versioned manifest and capability contracts, plus a real fast-check domain projection behind a Kotlin-controlled Node protocol. - -**Architecture:** Kotlin owns property semantics, validation, serialization, and capability aggregation. A private Node adapter consumes only versioned domain descriptors and projects them to fast-check 4.9.0; it cannot orchestrate properties or USVM. Cross-language tests prove that Kotlin manifests reach real arbitraries without adding fast-check types to the common model. - -**Tech Stack:** Kotlin 2.1, kotlinx.serialization 1.7.3, JUnit 5/Kotlin test, Gradle 8.11, Node.js 18.18+, fast-check 4.9.0, Node built-in test runner. - -**Spec:** `docs/superpowers/specs/2026-08-22-kotlin-first-property-abstraction-design.md` - -## Global Constraints - -- Kotlin is the sole owner of property definitions and common artifacts. -- Common Kotlin model and manifest code must not import fast-check or backend-native generator types. -- Predicate and precondition bodies remain TypeScript module/export references; #347 does not execute them. -- Manifest schema version and Kotlin-to-Node protocol version are both exactly `1`. -- Integer domains are inclusive signed 32-bit ranges. -- String length counts arbitrary UTF-16 code units; default maximum length is `10`. -- Array default maximum length is `10`. -- Bounded number domains reject NaN; all JavaScript special numbers use tagged encoding. -- Tuple and array samples use recursive tagged `JsConcreteValue.Array`; `ConstantDomain` remains primitive-only. -- Capability is separate from `PropertyManifest` and is keyed by backend ID and version. -- The Node adapter is private and pins fast-check `4.9.0`. -- Existing `FrontendBaselineTest` must remain green. - ---- - -### Task 1: Kotlin Property Model, Tagged Values, Manifest, and Validation - -**Files:** - -- Modify: `usvm-ts-pbt/build.gradle.kts` -- Create: `usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsConcreteValue.kt` -- Create: `usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDomain.kt` -- Create: `usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDefinition.kt` -- Create: `usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/manifest/PropertyManifest.kt` -- Create: `usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/validation/PropertyValidation.kt` -- Test: `usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/JsConcreteValueTest.kt` -- Test: `usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/manifest/PropertyManifestTest.kt` -- Test: `usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/validation/PropertyValidationTest.kt` - -**Interfaces:** - -- Produces: `PropertyDefinition`, `PropertyDomain`, `JsConcreteValue`, `JsNumber`, `PropertyManifest`, `PropertyManifestJson`, `validatePropertyDefinition`, and `validatePropertyManifest`. -- Consumes: only Kotlin stdlib and kotlinx.serialization; it has no Node, fast-check, JacoDB, or USVM dependency. - -- [ ] **Step 1: Enable Kotlin serialization and add the JSON runtime** - -```kotlin -plugins { - id("usvm.kotlin-conventions") - kotlin("plugin.serialization") version Versions.kotlin -} - -dependencies { - implementation(project(":usvm-ts")) - implementation(Libs.jacodb_ets) - implementation(Libs.kotlinx_serialization_json) - testImplementation(Libs.logback) -} -``` - -- [ ] **Step 2: Write failing tagged-value and manifest round-trip tests** - -```kotlin -@Test -fun `negative zero keeps its raw IEEE bits through JSON`() { - val value = JsConcreteValue.Number(JsNumber.finite(-0.0)) - val encoded = PropertyManifestJson.json.encodeToString(JsConcreteValue.serializer(), value) - val decoded = PropertyManifestJson.json.decodeFromString(JsConcreteValue.serializer(), encoded) - assertEquals(value, decoded) - assertEquals((-0.0).toRawBits(), (decoded as JsConcreteValue.Number).number.toDouble().toRawBits()) -} - -@Test -fun `manifest round trip contains only common property data`() { - val definition = PropertyDefinition( - id = PropertyId("math.commutative"), - inputs = listOf( - PropertyInput("left", IntegerDomain(-10, 10)), - PropertyInput("right", IntegerDomain(-10, 10)), - ), - predicate = TypeScriptEntryPoint("properties/math.ts", "isCommutative"), - ) - val encoded = PropertyManifestJson.encode(definition.toManifest()) - assertEquals(definition.toManifest(), PropertyManifestJson.decode(encoded)) - assertFalse("fast-check" in encoded) -} -``` - -- [ ] **Step 3: Run the focused tests and verify RED** - -Run: - -```shell -env -u ARKANALYZER_DIR ETS_IR_PROVIDER=ts-frontend \ - ./gradlew --no-daemon :usvm-ts-pbt:test \ - --tests 'org.usvm.ts.pbt.model.JsConcreteValueTest' \ - --tests 'org.usvm.ts.pbt.manifest.PropertyManifestTest' -``` - -Expected: compilation fails because `JsConcreteValue`, `PropertyDefinition`, and manifest APIs do not exist. - -- [ ] **Step 4: Implement tagged JavaScript primitives and domain algebra** - -```kotlin -@Serializable -enum class JsNumberKind { - @SerialName("finite") FINITE, - @SerialName("nan") NAN, - @SerialName("positive-infinity") POSITIVE_INFINITY, - @SerialName("negative-infinity") NEGATIVE_INFINITY, -} - -@Serializable -data class JsNumber(val value: JsNumberKind, val bits: String? = null) { - fun toDouble(): Double = when (value) { - JsNumberKind.FINITE -> Double.fromBits(requireNotNull(bits).toULong(16).toLong()) - JsNumberKind.NAN -> Double.NaN - JsNumberKind.POSITIVE_INFINITY -> Double.POSITIVE_INFINITY - JsNumberKind.NEGATIVE_INFINITY -> Double.NEGATIVE_INFINITY - } - - companion object { - fun finite(value: Double) = JsNumber( - JsNumberKind.FINITE, - value.toRawBits().toULong().toString(16).padStart(16, '0'), - ) - } -} - -@Serializable -sealed interface PropertyDomain - -@Serializable -@SerialName("boolean") -data object BooleanDomain : PropertyDomain - -@Serializable -@SerialName("integer") -data class IntegerDomain(val min: Int = Int.MIN_VALUE, val max: Int = Int.MAX_VALUE) : PropertyDomain - -@Serializable -@SerialName("number") -data class NumberDomain( - val min: JsNumber = JsNumber.negativeInfinity(), - val max: JsNumber = JsNumber.positiveInfinity(), - val allowNaN: Boolean = true, -) : PropertyDomain - -@Serializable -@SerialName("string") -data class StringDomain( - val minLength: Int = 0, - val maxLength: Int = DEFAULT_MAX_STRING_LENGTH, -) : PropertyDomain - -@Serializable -@SerialName("constant") -data class ConstantDomain(val value: JsConcreteValue) : PropertyDomain - -@Serializable -@SerialName("optional") -data class OptionalDomain( - val value: PropertyDomain, - val nil: JsConcreteValue = JsConcreteValue.Undefined, -) : PropertyDomain - -@Serializable -@SerialName("tuple") -data class TupleDomain(val elements: List) : PropertyDomain - -@Serializable -@SerialName("array") -data class ArrayDomain( - val element: PropertyDomain, - val minLength: Int = 0, - val maxLength: Int = DEFAULT_MAX_ARRAY_LENGTH, -) : PropertyDomain -``` - -- [ ] **Step 5: Implement manifest serialization with strict schema versioning** - -```kotlin -@Serializable -data class PropertyManifest( - val schemaVersion: Int = PROPERTY_MANIFEST_SCHEMA_VERSION, - val propertyId: String, - val inputs: List, - val predicate: TypeScriptEntryPoint, - val precondition: TypeScriptEntryPoint? = null, -) - -object PropertyManifestJson { - val json = Json { - classDiscriminator = "kind" - encodeDefaults = true - explicitNulls = false - ignoreUnknownKeys = false - } - - fun encode(manifest: PropertyManifest): String = json.encodeToString(manifest) - fun decode(value: String): PropertyManifest = json.decodeFromString(value) - .also { requireValid(validatePropertyManifest(it)) } -} -``` - -- [ ] **Step 6: Write failing validation tests** - -```kotlin -@Test -fun `validation reports all structural errors in deterministic order`() { - val invalid = PropertyDefinition( - id = PropertyId.unchecked(" bad id "), - inputs = listOf( - PropertyInput("value", IntegerDomain(10, -10)), - PropertyInput("value", StringDomain(-1, 0)), - ), - predicate = TypeScriptEntryPoint("../escape.ts", "not-valid-name"), - ) - assertEquals( - listOf( - "property.id.invalid", - "input.name.duplicate", - "domain.integer.bounds", - "domain.string.length", - "entrypoint.module.invalid", - "entrypoint.export.invalid", - ), - validatePropertyDefinition(invalid).diagnostics.map { it.code }, - ) -} -``` - -- [ ] **Step 7: Run validation tests and verify RED** - -Run: - -```shell -./gradlew --no-daemon :usvm-ts-pbt:test \ - --tests 'org.usvm.ts.pbt.validation.PropertyValidationTest' -``` - -Expected: compilation fails because validation APIs do not exist. - -- [ ] **Step 8: Implement deterministic structural validation** - -```kotlin -data class ValidationDiagnostic(val code: String, val message: String, val path: String) - -data class PropertyValidationResult(val diagnostics: List) { - val isValid: Boolean get() = diagnostics.isEmpty() -} - -fun validatePropertyDefinition(definition: PropertyDefinition): PropertyValidationResult = - PropertyValidator.validate(definition).sortedWith(compareBy({ it.path }, { it.code })) - .let(::PropertyValidationResult) -``` - -Recursively validate every domain, exact finite-number bit encoding, optional nil values, entry-point paths/exports, duplicate inputs, and schema version. - -- [ ] **Step 9: Run all Task 1 tests and verify GREEN** - -```shell -./gradlew --no-daemon :usvm-ts-pbt:test \ - --tests 'org.usvm.ts.pbt.model.*' \ - --tests 'org.usvm.ts.pbt.manifest.*' \ - --tests 'org.usvm.ts.pbt.validation.*' -``` - -Expected: all selected tests pass with no warnings from project code. - -- [ ] **Step 10: Commit the model increment** - -```shell -git add usvm-ts-pbt/build.gradle.kts usvm-ts-pbt/src/main usvm-ts-pbt/src/test -git commit -m "feat(ts-pbt): add Kotlin property model" -``` - ---- - -### Task 2: Backend Projection Capability and Aggregate Classification - -**Files:** - -- Create: `usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/backend/ProjectionCapability.kt` -- Test: `usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/backend/ProjectionCapabilityTest.kt` - -**Interfaces:** - -- Consumes: structural paths and validated `PropertyDefinition` from Task 1. -- Produces: `ProjectionLevel`, `ProjectionCapability`, `CapabilityDiagnostic`, `PropertyCapabilityLevel`, `aggregateProjectionCapabilities`, and `classifyPropertyCapability`. - -- [ ] **Step 1: Write failing capability composition tests** - -```kotlin -@Test -fun `least capable nested projection wins`() { - val capability = aggregateProjectionCapabilities( - backendId = "fast-check", - backendVersion = "4.9.0", - capabilities = listOf(exact(), approximate("domain.string.approximate")), - ) - assertEquals(ProjectionLevel.APPROXIMATE, capability.level) -} - -@Test -fun `supported concrete and unsupported symbolic is concrete only`() { - assertEquals( - PropertyCapabilityLevel.CONCRETE_ONLY, - classifyPropertyCapability(exact("fast-check"), unsupported("usvm", "entrypoint.async")), - ) -} -``` - -- [ ] **Step 2: Run the test and verify RED** - -```shell -./gradlew --no-daemon :usvm-ts-pbt:test \ - --tests 'org.usvm.ts.pbt.backend.ProjectionCapabilityTest' -``` - -Expected: compilation fails because capability APIs do not exist. - -- [ ] **Step 3: Implement capability models and deterministic aggregation** - -```kotlin -enum class ProjectionLevel { EXACT, APPROXIMATE, UNSUPPORTED } -enum class PropertyCapabilityLevel { EXACT, APPROXIMATE, CONCRETE_ONLY, UNSUPPORTED } - -data class ProjectionCapability( - val backendId: String, - val backendVersion: String, - val level: ProjectionLevel, - val diagnostics: List = emptyList(), -) - -fun classifyPropertyCapability( - concrete: ProjectionCapability, - symbolic: ProjectionCapability, -): PropertyCapabilityLevel = when { - concrete.level == ProjectionLevel.UNSUPPORTED -> PropertyCapabilityLevel.UNSUPPORTED - symbolic.level == ProjectionLevel.UNSUPPORTED -> PropertyCapabilityLevel.CONCRETE_ONLY - concrete.level == ProjectionLevel.APPROXIMATE || symbolic.level == ProjectionLevel.APPROXIMATE -> - PropertyCapabilityLevel.APPROXIMATE - else -> PropertyCapabilityLevel.EXACT -} -``` - -Reject non-exact capabilities without diagnostics, sort diagnostics by path/code, and preserve backend identity/version. - -- [ ] **Step 4: Run Task 2 and Task 1 tests and verify GREEN** - -```shell -./gradlew --no-daemon :usvm-ts-pbt:test \ - --tests 'org.usvm.ts.pbt.backend.*' \ - --tests 'org.usvm.ts.pbt.model.*' \ - --tests 'org.usvm.ts.pbt.manifest.*' \ - --tests 'org.usvm.ts.pbt.validation.*' -``` - -- [ ] **Step 5: Commit the capability increment** - -```shell -git add usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/backend \ - usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/backend -git commit -m "feat(ts-pbt): add backend capability model" -``` - ---- - -### Task 3: Private fast-check Domain Projection Adapter - -**Files:** - -- Create: `usvm-ts-pbt/fast-check-adapter/package.json` -- Create: `usvm-ts-pbt/fast-check-adapter/package-lock.json` -- Create: `usvm-ts-pbt/fast-check-adapter/src/js-value.mjs` -- Create: `usvm-ts-pbt/fast-check-adapter/src/project-domain.mjs` -- Create: `usvm-ts-pbt/fast-check-adapter/src/projection-cli.mjs` -- Test: `usvm-ts-pbt/fast-check-adapter/test/js-value.test.mjs` -- Test: `usvm-ts-pbt/fast-check-adapter/test/project-domain.test.mjs` -- Test: `usvm-ts-pbt/fast-check-adapter/test/projection-cli.test.mjs` - -**Interfaces:** - -- Consumes: schema-version-1 domain and `JsConcreteValue` JSON produced by Task 1. -- Produces: `decodeJsValue`, `encodeJsValue`, `projectDomain`, `projectionCapability`, and a one-shot `sample` protocol executable. - -- [ ] **Step 1: Add the private adapter package and lock fast-check 4.9.0** - -```json -{ - "name": "@usvm/fast-check-adapter", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "test": "node --test" - }, - "dependencies": { - "fast-check": "4.9.0" - } -} -``` - -Run `npm install --package-lock-only --ignore-scripts` in `usvm-ts-pbt/fast-check-adapter` to generate the exact lock file, then `npm ci --ignore-scripts`. - -- [ ] **Step 2: Write failing Node tests for tagged values and every domain** - -```javascript -import assert from 'node:assert/strict'; -import test from 'node:test'; -import fc from 'fast-check'; -import { projectDomain } from '../src/project-domain.mjs'; - -test('bounded integers use a real fast-check arbitrary', () => { - const arbitrary = projectDomain({ kind: 'integer', min: -3, max: 7 }); - const samples = fc.sample(arbitrary, { seed: 42, numRuns: 100 }); - assert.ok(samples.every((value) => Number.isInteger(value) && value >= -3 && value <= 7)); -}); - -test('strings are arbitrary UTF-16 code-unit sequences', () => { - const arbitrary = projectDomain({ kind: 'string', minLength: 2, maxLength: 4 }); - const samples = fc.sample(arbitrary, { seed: 42, numRuns: 100 }); - assert.ok(samples.every((value) => value.length >= 2 && value.length <= 4)); -}); - -for (const [name, domain, predicate] of [ - ['boolean', { kind: 'boolean' }, (value) => typeof value === 'boolean'], - ['bounded number', boundedNumber(-1.5, 2.5), (value) => !Number.isNaN(value) && value >= -1.5 && value <= 2.5], - ['constant -0', constant(numberValue(-0)), (value) => Object.is(value, -0)], - ['optional undefined', optional(integer(-2, 2), undefinedValue()), - (value) => value === undefined || (Number.isInteger(value) && value >= -2 && value <= 2)], - ['tuple', tuple(booleanDomain(), integer(0, 3)), - (value) => Array.isArray(value) && value.length === 2 && typeof value[0] === 'boolean'], - ['array', array(integer(0, 3), 1, 4), - (value) => Array.isArray(value) && value.length >= 1 && value.length <= 4], -]) { - test(`${name} projects to values satisfying the common domain`, () => { - const samples = fc.sample(projectDomain(domain), { seed: 42, numRuns: 100 }); - assert.ok(samples.every(predicate)); - }); -} - -test('unknown domain kinds are rejected explicitly', () => { - assert.throws(() => projectDomain({ kind: 'object' }), /domain\.kind\.unknown/); -}); -``` - -Add a second optional case with null, a nested-array case, and a tagged-number table containing NaN, both infinities, positive zero, and negative zero using the same fixed-seed sampling pattern. - -- [ ] **Step 3: Run Node tests and verify RED** - -```shell -npm test --prefix usvm-ts-pbt/fast-check-adapter -``` - -Expected: tests fail with `ERR_MODULE_NOT_FOUND` for adapter source modules. - -- [ ] **Step 4: Implement exact tagged-value conversion** - -```javascript -export function decodeJsNumber(number) { - switch (number.value) { - case 'finite': return bitsToDouble(number.bits); - case 'nan': return Number.NaN; - case 'positive-infinity': return Number.POSITIVE_INFINITY; - case 'negative-infinity': return Number.NEGATIVE_INFINITY; - default: throw protocolError('js-number.kind.unknown'); - } -} - -export function encodeJsNumber(value) { - if (Number.isNaN(value)) return { value: 'nan' }; - if (value === Number.POSITIVE_INFINITY) return { value: 'positive-infinity' }; - if (value === Number.NEGATIVE_INFINITY) return { value: 'negative-infinity' }; - return { value: 'finite', bits: doubleToBits(value) }; -} -``` - -Use `DataView` with explicit big-endian order for stable 16-hex-digit double encoding. - -- [ ] **Step 5: Implement recursive domain projection** - -```javascript -export function projectDomain(domain) { - switch (domain.kind) { - case 'boolean': return fc.boolean(); - case 'integer': return fc.integer({ min: domain.min, max: domain.max }); - case 'string': - return fc.array(fc.integer({ min: 0, max: 0xffff }), { - minLength: domain.minLength, - maxLength: domain.maxLength, - }).map((units) => String.fromCharCode(...units)); - case 'constant': return fc.constant(decodeJsValue(domain.value)); - case 'optional': - return fc.option(projectDomain(domain.value), { nil: decodeJsValue(domain.nil) }); - case 'tuple': return fc.tuple(...domain.elements.map(projectDomain)); - case 'array': - return fc.array(projectDomain(domain.element), { - minLength: domain.minLength, - maxLength: domain.maxLength, - }); - default: throw protocolError('domain.kind.unknown'); - } -} - -function projectNumber(domain) { - const min = decodeJsNumber(domain.min); - const max = decodeJsNumber(domain.max); - const finite = fc.double({ min, max, noNaN: true, noDefaultInfinity: true }); - const specials = []; - if (domain.allowNaN) specials.push(fc.constant(Number.NaN)); - if (min === Number.NEGATIVE_INFINITY) specials.push(fc.constant(Number.NEGATIVE_INFINITY)); - if (max === Number.POSITIVE_INFINITY) specials.push(fc.constant(Number.POSITIVE_INFINITY)); - return specials.length === 0 ? finite : fc.oneof(finite, ...specials); -} -``` - -- [ ] **Step 6: Run domain tests and verify GREEN** - -```shell -npm test --prefix usvm-ts-pbt/fast-check-adapter -``` - -- [ ] **Step 7: Write failing one-shot protocol tests** - -```javascript -test('sample response echoes request identity and returns tagged values', async () => { - const response = await invokeCli({ - protocolVersion: 1, - requestId: 'sample-1', - operation: 'sample', - seed: 42, - numSamples: 4, - domains: [{ kind: 'integer', min: -1, max: 1 }], - }); - assert.equal(response.requestId, 'sample-1'); - assert.equal(response.status, 'ok'); - assert.equal(response.samples.length, 4); -}); -``` - -Also test protocol-version mismatch, unknown operations, malformed JSON, and that stderr logging never appears in stdout. - -- [ ] **Step 8: Implement `projection-cli.mjs` and verify GREEN** - -```javascript -const input = await readStdin(); -let response; -try { - const request = validateRequest(JSON.parse(input)); - const arbitrary = fc.tuple(...request.domains.map(projectDomain)); - const tuples = fc.sample(arbitrary, { seed: request.seed, numRuns: request.numSamples }); - response = { - protocolVersion: 1, - requestId: request.requestId, - status: 'ok', - samples: tuples.map((tuple) => tuple.map(encodeJsValue)), - }; -} catch (error) { - response = protocolErrorResponse(error); -} -process.stdout.write(`${JSON.stringify(response)}\n`); -``` - -`validateRequest` accepts only protocol version `1`, operation `sample`, a non-empty request ID and domains, an integer seed, and `numSamples` in `1..10000`. `protocolErrorResponse` preserves a parsed request ID when available and emits stable `protocol.version.unsupported`, `protocol.operation.unsupported`, `protocol.json.invalid`, and `protocol.request.invalid` codes. - -```shell -npm test --prefix usvm-ts-pbt/fast-check-adapter -``` - -- [ ] **Step 9: Commit the adapter increment** - -```shell -git add usvm-ts-pbt/fast-check-adapter -git commit -m "feat(ts-pbt): project domains to fast-check" -``` - ---- - -### Task 4: Kotlin-to-Node Protocol, Examples, Gradle Wiring, and Documentation - -**Files:** - -- Modify: `usvm-ts-pbt/build.gradle.kts` -- Modify: `usvm-ts-pbt/README.md` -- Create: `usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionProtocol.kt` -- Create: `usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClient.kt` -- Test: `usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClientTest.kt` -- Test: `usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/examples/ExamplePropertiesTest.kt` -- Create: `usvm-ts-pbt/src/test/resources/properties/examples/PropertyExamples.ts` - -**Interfaces:** - -- Consumes: validated manifests from Task 1, capabilities from Task 2, and the one-shot CLI from Task 3. -- Produces: versioned Kotlin protocol DTOs, `FastCheckProjectionClient.sample`, four executable test definitions, Gradle verification tasks, and user documentation. - -- [ ] **Step 1: Write failing Kotlin-to-Node integration tests** - -```kotlin -@Test -fun `Kotlin domains produce deterministic tagged fast-check samples`() { - val request = FastCheckProjectionRequest( - requestId = "integration-1", - seed = 42, - numSamples = 20, - domains = listOf(IntegerDomain(-10, 10), ArrayDomain(BooleanDomain, 0, 3)), - ) - val response = client.sample(request) - assertEquals("integration-1", response.requestId) - assertEquals(20, response.samples.size) - response.samples.forEach { sample -> assertConforms(sample, request.domains) } -} - -@Test -fun `protocol version mismatch is a typed backend error`() { - val error = assertFailsWith { - client.sample(validRequest.copy(protocolVersion = 999)) - } - assertEquals("protocol.version.unsupported", error.code) -} -``` - -- [ ] **Step 2: Run the focused integration tests and verify RED** - -```shell -./gradlew --no-daemon :usvm-ts-pbt:test \ - --tests 'org.usvm.ts.pbt.fastcheck.FastCheckProjectionClientTest' -``` - -Expected: compilation fails because protocol DTOs and client do not exist. - -- [ ] **Step 3: Implement protocol DTOs and the one-shot process client** - -```kotlin -@Serializable -data class FastCheckProjectionRequest( - val protocolVersion: Int = FAST_CHECK_PROTOCOL_VERSION, - val requestId: String, - val operation: String = "sample", - val seed: Int, - val numSamples: Int, - val domains: List, -) - -class FastCheckProjectionClient( - private val nodeExecutable: String = "node", - private val adapterEntryPoint: Path, -) { - fun sample(request: FastCheckProjectionRequest): FastCheckProjectionResponse { - val process = ProcessBuilder(nodeExecutable, adapterEntryPoint.toString()).start() - process.outputWriter(Charsets.UTF_8).use { it.write(protocolJson.encodeToString(request)) } - val stdout = process.inputReader(Charsets.UTF_8).readText() - val stderr = process.errorReader(Charsets.UTF_8).readText() - val exit = process.waitFor() - if (exit != 0) throw FastCheckProjectionException("backend.process.failed", stderr) - return decodeProjectionResponse(stdout) - } -} -``` - -Reject invalid request sizes before process launch and return typed errors for process startup, nonzero exit, empty output, malformed JSON, mismatched IDs, and protocol error responses. - -- [ ] **Step 4: Run the Kotlin-to-Node tests and verify GREEN** - -```shell -./gradlew --no-daemon :usvm-ts-pbt:test \ - --tests 'org.usvm.ts.pbt.fastcheck.FastCheckProjectionClientTest' -``` - -- [ ] **Step 5: Add Gradle npm installation and Node test tasks** - -```kotlin -val installFastCheckAdapter = tasks.register("installFastCheckAdapter") { - workingDir(fastCheckAdapterDir) - commandLine(npmExecutable, "ci", "--ignore-scripts") - inputs.files( - fastCheckAdapterDir.resolve("package.json"), - fastCheckAdapterDir.resolve("package-lock.json"), - ) - outputs.dir(fastCheckAdapterDir.resolve("node_modules")) -} - -val testFastCheckAdapter = tasks.register("testFastCheckAdapter") { - dependsOn(installFastCheckAdapter) - workingDir(fastCheckAdapterDir) - commandLine(npmExecutable, "test") - inputs.dir(fastCheckAdapterDir.resolve("src")) - inputs.dir(fastCheckAdapterDir.resolve("test")) -} - -tasks.test { dependsOn(installFastCheckAdapter) } -tasks.check { dependsOn(testFastCheckAdapter) } -``` - -Use `npm.cmd` on Windows. Track package files and adapter source/tests as task inputs; never silently skip Node verification when npm is absent. - -- [ ] **Step 6: Add four example Kotlin definitions and TypeScript export fixtures** - -```kotlin -val relational = PropertyDefinition( - id = PropertyId("example.relational"), - inputs = listOf( - PropertyInput("left", IntegerDomain()), - PropertyInput("right", IntegerDomain()), - ), - predicate = TypeScriptEntryPoint("properties/examples/PropertyExamples.ts", "isCommutative"), -) -``` - -Add bounded, precondition, and array definitions. Assert each validates, serializes, and projects through fast-check. The TypeScript resource exports `isCommutative`, the bounded predicate, the precondition, and the array predicate without executing them in this issue. - -- [ ] **Step 7: Update module documentation** - -Document: - -- Kotlin ownership and backend dependency direction; -- the domain table and exact defaults; -- `PropertyManifest` versus `ProjectionCapability`; -- TypeScript module/export references; -- the private fast-check adapter and protocol boundary; -- the supported and rejected extension mechanisms; -- focused Kotlin, Node, and full module verification commands; -- the explicit #348–#354 boundaries. - -- [ ] **Step 8: Run focused verification** - -```shell -npm test --prefix usvm-ts-pbt/fast-check-adapter -env -u ARKANALYZER_DIR ETS_IR_PROVIDER=ts-frontend \ - ./gradlew --no-daemon :usvm-ts-pbt:test -``` - -Expected: all Node and Kotlin tests pass, including `FrontendBaselineTest`. - -- [ ] **Step 9: Run full module and static verification** - -```shell -env -u ARKANALYZER_DIR ETS_IR_PROVIDER=ts-frontend \ - ./gradlew --no-daemon \ - :usvm-ts-pbt:clean :usvm-ts-pbt:check \ - :usvm-ts-pbt:detektMain :usvm-ts-pbt:detektTest -git diff --check origin/main...HEAD -``` - -- [ ] **Step 10: Commit the integration increment** - -```shell -git add usvm-ts-pbt docs/superpowers/plans/2026-08-22-kotlin-first-property-abstraction.md -git commit -m "feat(ts-pbt): integrate Kotlin property projection" -``` diff --git a/docs/superpowers/specs/2026-08-22-kotlin-first-property-abstraction-design.md b/docs/superpowers/specs/2026-08-22-kotlin-first-property-abstraction-design.md deleted file mode 100644 index ca6cfba12e..0000000000 --- a/docs/superpowers/specs/2026-08-22-kotlin-first-property-abstraction-design.md +++ /dev/null @@ -1,416 +0,0 @@ -# Kotlin-First Property Abstraction Design - -**Issue:** [#347](https://github.com/UnitTestBot/usvm/issues/347) - -**Status:** Kotlin-first architecture approved; implementation in progress - -## Context - -`usvm-ts-pbt` will combine USVM with concrete property-based testing engines. fast-check is the first concrete engine, but it is not the only possible PBT backend. USVM and the pipeline orchestration must remain usable with another backend without redefining properties or changing the common artifacts. - -The existing baseline module from #346 contains Kotlin/JacoDB integration and a native `ts-frontend` smoke test. It deliberately does not port the historical custom generator, concrete interpreter, or shrinking implementation. - -The original #347 design put the shared property API in TypeScript and let it construct both fast-check objects and a symbolic manifest. That makes fast-check and Node the architectural owner of the property definition. The revised architecture makes Kotlin the owner and treats fast-check as an adapter. - -## Goals - -1. Define a single Kotlin representation of a TypeScript property, its inputs, and its runtime entry points. -2. Keep the representation independent of fast-check, Node, and USVM implementation types. -3. Produce a versioned, serializable property manifest from the Kotlin definition. -4. Define a backend projection contract and capability reporting model. -5. Implement the initial projection from common domains to real fast-check `Arbitrary` objects through an internal Node adapter. -6. Preserve JavaScript primitive semantics across Kotlin/JSON/Node boundaries. -7. Validate definitions and protocol messages before a backend executes them. - -## Non-Goals - -Issue #347 will not implement: - -- property registry discovery or campaign execution; -- `fc.check`, replay, or shrinking; -- coverage collection; -- TypeScript source-to-EtsIR entry-point resolution; -- USVM symbolic value construction or predicate execution; -- the end-to-end pipeline or public CLI; -- arbitrary fast-check combinators in the common property model. - -Those responsibilities remain in #348–#354. - -## Architectural Ownership - -The dependency direction is fixed: - -```text -Kotlin PropertyDefinition - | - +--> PropertyManifest - | - +--> PBT backend projection --> fast-check Node adapter - | - +--> symbolic projection -----> USVM (implemented in #351) -``` - -Kotlin owns: - -- property identity and input ordering; -- domain semantics and constraints; -- TypeScript predicate and precondition references; -- manifest and protocol schemas; -- validation and capability aggregation; -- backend selection and orchestration in later issues. - -The internal Node adapter owns only the fast-check projection. It does not discover properties, select execution modes, invoke USVM, or define common artifacts. - -## Property Model - -### Property definition - -The public Kotlin model is immutable and serializable through a separate manifest projection: - -```kotlin -data class PropertyDefinition( - val id: PropertyId, - val inputs: List, - val predicate: TypeScriptEntryPoint, - val precondition: TypeScriptEntryPoint? = null, -) - -data class PropertyInput( - val name: String, - val domain: PropertyDomain, -) -``` - -Input order is semantically significant because TypeScript predicate parameters are positional. Input names are unique within a property and appear in diagnostics and artifacts. - -`PropertyId` is a validated value object. Its canonical text matches `[A-Za-z0-9][A-Za-z0-9._/-]*`. IDs are stable across backends and runs. - -### TypeScript entry points - -Predicate and precondition bodies remain in TypeScript so concrete execution uses the original JavaScript semantics and USVM analyzes the same source: - -```kotlin -data class TypeScriptEntryPoint( - val module: String, - val exportName: String, - val executionKind: ExecutionKind = ExecutionKind.SYNC, -) - -enum class ExecutionKind { - SYNC, - ASYNC, -} -``` - -`module` is a normalized, project-relative POSIX path. Absolute paths, empty path segments, and parent traversal are rejected. `exportName` must be a JavaScript identifier. The common model does not store a JavaScript closure. - -An asynchronous entry point may be supported by a concrete backend and unsupported by USVM. This is represented by per-backend capability, not by changing the property definition. - -## Domain Algebra - -`PropertyDomain` is a sealed Kotlin hierarchy. It describes the valid value set and all constraints explicitly; backend defaults must not silently change property semantics. - -The initial variants are: - -```kotlin -sealed interface PropertyDomain - -data object BooleanDomain : PropertyDomain - -data class IntegerDomain( - val min: Int = Int.MIN_VALUE, - val max: Int = Int.MAX_VALUE, -) : PropertyDomain - -data class NumberDomain( - val min: JsNumber = JsNumber.NegativeInfinity, - val max: JsNumber = JsNumber.PositiveInfinity, - val allowNaN: Boolean = true, -) : PropertyDomain - -data class StringDomain( - val minLength: Int = 0, - val maxLength: Int = DEFAULT_MAX_STRING_LENGTH, -) : PropertyDomain - -data class ConstantDomain( - val value: JsConcreteValue, -) : PropertyDomain - -data class OptionalDomain( - val value: PropertyDomain, - val nil: JsConcreteValue = JsConcreteValue.Undefined, -) : PropertyDomain - -data class TupleDomain( - val elements: List, -) : PropertyDomain - -data class ArrayDomain( - val element: PropertyDomain, - val minLength: Int = 0, - val maxLength: Int = DEFAULT_MAX_ARRAY_LENGTH, -) : PropertyDomain -``` - -`DEFAULT_MAX_STRING_LENGTH` and `DEFAULT_MAX_ARRAY_LENGTH` are both `10`. They are stable common-model constants, not values inherited from fast-check. A manifest always contains resolved length bounds, so another backend sees identical semantics. - -`IntegerDomain` uses the signed 32-bit integer set, matching TypeScript numbers that are exact for all values in the range. `NumberDomain` describes ECMAScript binary64 values. An unbounded number domain includes finite values, both infinities, negative zero, and optionally NaN. Setting either bound to a value other than its default makes the domain bounded; bounded domains must set `allowNaN = false` and accept only values satisfying their declared inclusive bounds. - -`StringDomain` contains arbitrary UTF-16 code-unit sequences, including valid surrogate pairs and unpaired surrogates. Length bounds count UTF-16 code units, matching JavaScript `String.length`. The fast-check adapter constructs this domain from arrays of integers in `0..0xffff` instead of inheriting changing `fc.string()` defaults. - -The initial `ConstantDomain` supports JavaScript primitives only. Objects, functions, symbols, and bigints are rejected rather than coerced. `OptionalDomain.nil` must be either `JsConcreteValue.Undefined` or `JsConcreteValue.Null`; other sentinel values are rejected. - -Tuple and array nesting is recursive. Cycles cannot occur because the model is immutable and value-based. - -## JavaScript Value Encoding - -Ordinary JSON cannot distinguish or preserve `undefined`, NaN, infinities, and negative zero. All values crossing a manifest or backend protocol use a tagged `JsConcreteValue` representation: - -```json -{ "kind": "undefined" } -{ "kind": "null" } -{ "kind": "boolean", "value": true } -{ "kind": "string", "value": "text" } -{ "kind": "number", "value": "finite", "bits": "8000000000000000" } -{ "kind": "number", "value": "nan" } -{ "kind": "number", "value": "positive-infinity" } -{ "kind": "number", "value": "negative-infinity" } -{ "kind": "array", "elements": [{ "kind": "undefined" }, { "kind": "null" }] } -``` - -Finite doubles use their exact unsigned 64-bit hexadecimal IEEE-754 representation. This preserves negative zero and avoids decimal round-trip ambiguity. NaN uses one semantic tag because the pipeline does not expose NaN payloads. - -`JsConcreteValue` represents one concrete JavaScript value rather than a domain or JacoDB IR value. -`JsConcreteValue.Array` -recursively encodes tuple and array samples crossing the Kotlin-to-Node protocol. It does not expand -`ConstantDomain`: constants remain restricted to JavaScript primitives and validation rejects a composite -constant. - -## Property Manifest - -`PropertyManifest` is the canonical engine-neutral serialization of a validated property definition: - -```json -{ - "schemaVersion": 1, - "propertyId": "array.reverse-twice", - "inputs": [ - { - "name": "values", - "domain": { - "kind": "array", - "element": { "kind": "integer", "min": -100, "max": 100 }, - "minLength": 0, - "maxLength": 20 - } - } - ], - "predicate": { - "module": "properties/arrays.ts", - "exportName": "reverseTwicePreservesValues", - "executionKind": "sync" - } -} -``` - -The manifest contains no fast-check type name, arbitrary configuration object, seed, replay path, shrink data, USVM expression, or backend capability result. - -`PropertyDefinition.toManifest()` produces a manifest only after validation. `PropertyManifestValidator` also validates deserialized input so stored artifacts cannot bypass invariants. - -## Backend Projection and Capability - -Backend support depends on the backend implementation and version. Capability is therefore a separate artifact rather than a field frozen into `PropertyManifest`: - -```kotlin -enum class ProjectionLevel { - EXACT, - APPROXIMATE, - UNSUPPORTED, -} - -data class ProjectionCapability( - val backendId: String, - val backendVersion: String, - val level: ProjectionLevel, - val diagnostics: List, -) - -data class CapabilityDiagnostic( - val code: String, - val message: String, - val path: String, -) -``` - -Domain composition takes the least capable child projection: - -```text -EXACT < APPROXIMATE < UNSUPPORTED -``` - -A property is `concrete-only` for a selected combination when the concrete PBT projection is not `UNSUPPORTED` and the USVM projection is `UNSUPPORTED`. `concrete-only` is an aggregate pipeline classification, not a fourth backend projection level. - -Diagnostics use stable codes and structural paths such as `inputs[0].domain.element`. A non-exact result must contain at least one diagnostic reason. - -Issue #347 defines the projection contract and implements the fast-check capability provider. The USVM provider is implemented in #351. Tests for aggregation use controlled capability providers and do not pretend that symbolic lowering already exists. - -## fast-check Adapter - -The adapter is an internal implementation detail under `usvm-ts-pbt/fast-check-adapter`. It is not a public TypeScript property API or a publishable npm package. - -It contains: - -- a private `package.json` that pins fast-check; -- an ECMAScript module that maps each manifest domain recursively to a real `fc.Arbitrary`; -- tagged JavaScript value encode/decode functions; -- a small one-shot protocol executable used by Kotlin integration tests; -- Node built-in tests for the projection. - -The initial protocol operation samples projected domains to prove that Kotlin definitions reach real fast-check arbitraries. It does not run predicates or campaigns. - -### Protocol envelope - -Requests and responses use one JSON document on standard input/output: - -```json -{ - "protocolVersion": 1, - "requestId": "projection-test-1", - "operation": "sample", - "seed": 42, - "numSamples": 10, - "domains": [ - { "kind": "integer", "min": -10, "max": 10 } - ] -} -``` - -Successful responses echo `protocolVersion` and `requestId`, contain `status: "ok"`, and encode sample values as `JsConcreteValue`. Validation failures return `status: "error"` with stable diagnostic codes. Process startup failures and invalid non-JSON output are reported by the Kotlin caller as transport errors. - -The adapter writes protocol output only to stdout. Human-readable logging goes to stderr so it cannot corrupt the protocol. - -## Validation and Error Handling - -Validation rejects definitions before backend execution when any of the following holds: - -- invalid or empty property ID; -- empty input list; -- duplicate or invalid input names; -- integer or number minimum greater than maximum; -- NaN used as a numeric bound; -- negative length or minimum length greater than maximum; -- empty tuple; -- unsupported constant value; -- absolute, escaping, or malformed TypeScript module path; -- invalid export name; -- unknown manifest schema version; -- unknown backend protocol version or operation. - -Validation returns all independent structural diagnostics in deterministic path/code order. Programmer-facing factory methods may throw a single `InvalidPropertyDefinitionException` containing the report; deserialization and backend boundaries return typed validation results instead of unchecked casts. - -## Source Layout - -The planned responsibilities are: - -```text -usvm-ts-pbt/ - src/main/kotlin/org/usvm/ts/pbt/ - model/ PropertyDefinition, entry points, domain algebra, JsConcreteValue - manifest/ versioned DTOs and serialization - backend/ projection capability contracts and aggregation - fastcheck/ Kotlin protocol DTOs and one-shot process client - src/test/kotlin/org/usvm/ts/pbt/ - model/ definition and validation tests - manifest/ serialization and round-trip tests - backend/ capability aggregation tests - fastcheck/ Kotlin-to-Node projection integration tests - src/test/resources/properties/ - examples/ TypeScript predicate/precondition fixtures - fast-check-adapter/ - package.json - package-lock.json - src/ - test/ -``` - -Kotlin serialization uses `kotlinx.serialization`. The Node adapter uses ECMAScript modules and Node's built-in test runner; TypeScript compilation and predicate loading are deferred to #348. - -Gradle owns installation and verification tasks for the private adapter. `:usvm-ts-pbt:check` runs Kotlin tests, Node adapter tests, and cross-language protocol tests. The existing native frontend baseline remains part of the same check. - -## Testing Strategy - -Tests follow red-green TDD during implementation. - -### Kotlin unit tests - -- validate every domain variant and invalid constraint; -- verify deterministic diagnostic ordering and paths; -- round-trip every manifest and tagged JavaScript value, including recursive protocol arrays; -- preserve finite double bits, negative zero, NaN, and infinities; -- aggregate nested domain and property capabilities; -- classify a supported concrete plus unsupported symbolic projection as `concrete-only`. - -### Node unit tests - -- project every supported domain to a real fast-check arbitrary; -- assert sampled values satisfy the declared constraints; -- decode constants and optional nil values exactly; -- reject unknown domain kinds and malformed tagged values; -- keep protocol stdout free of logs. - -### Cross-language integration tests - -Kotlin creates and serializes four example definitions: - -1. a two-input relational property; -2. a bounded-input property; -3. a property with a TypeScript precondition reference; -4. an array property. - -The test sends their domains through the Node adapter with a fixed seed and validates returned tagged samples against the original Kotlin domains. It also covers protocol version mismatch and malformed backend output. - -The example TypeScript exports are fixtures for manifest validation in #347; actual predicate execution starts in #348. - -## Extension Rules - -A new common domain requires: - -1. a semantic Kotlin model and validation rules; -2. a manifest schema change or backward-compatible variant; -3. an explicit capability decision from every backend; -4. conformance tests for each exact or approximate projection; -5. documentation of unsupported semantics. - -A backend-specific custom domain may be represented only through an explicitly namespaced extension descriptor. Other backends must report `UNSUPPORTED`; they must never guess or silently approximate it. - -A new PBT backend consumes `PropertyManifest` and implements the projection capability contract. It must not require changes to `PropertyDefinition`, the USVM backend, or common orchestration for already supported domains. - -## Rejected Alternatives - -### TypeScript-first shared API - -A TypeScript `defineProperty` API that owns fast-check arbitraries makes Node and fast-check the center of the model. Supporting another PBT backend would require translating or replacing fast-check objects. This contradicts the Kotlin-first pipeline boundary. - -### Kotlin definitions translated into fast-check CLI commands - -fast-check is a JavaScript library, not a declarative CLI. A stable one-shot Node adapter with a versioned JSON protocol is smaller and testable. It reconstructs library objects internally while Kotlin remains the caller. - -### Kotlin implementations of predicates - -Reimplementing predicates in Kotlin would create a second property body and would not execute the original JavaScript semantics. Kotlin stores only TypeScript module/export references. - -### Capability embedded in the manifest - -Backend support changes with backend versions. Freezing capability into the engine-neutral manifest would make identical property semantics serialize differently depending on installed engines. Capability is therefore a separate versioned report. - -## Issue Boundaries After #347 - -- #348 implements `FastCheckBackend`, TypeScript entry-point loading, `fc.check`, replay configuration, and structured concrete results. -- #349 adds backend-neutral coverage artifacts and c8/Istanbul support to the fast-check backend. -- #350 maps common entry points and coverage locations to EtsIR. -- #351 implements the USVM domain and precondition projection. -- #352 searches for predicate violations with USVM. -- #353 replays USVM witnesses and delegates shrinking to a capable PBT backend. -- #354 implements the Kotlin-orchestrated end-to-end pipeline. -- #355–#357 consume backend-identified artifacts for hints, benchmarks, and evaluation. From 9e7f907cc8a2f020ebf9f62e01e8f34e699b3859 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 22 Aug 2026 22:24:35 +0300 Subject: [PATCH 09/11] refactor(ts-pbt): migrate fast-check adapter to TypeScript --- usvm-ts-pbt/README.md | 16 +- usvm-ts-pbt/build.gradle.kts | 23 ++- usvm-ts-pbt/fast-check-adapter/.gitignore | 1 + .../fast-check-adapter/package-lock.json | 35 ++++ usvm-ts-pbt/fast-check-adapter/package.json | 11 +- .../src/{js-value.mjs => js-value.ts} | 88 ++++++--- .../{project-domain.mjs => project-domain.ts} | 71 +++++-- .../{projection-cli.mjs => projection-cli.ts} | 80 ++++++-- .../fast-check-adapter/test/js-value.test.mjs | 46 ----- .../fast-check-adapter/test/js-value.test.ts | 66 +++++++ ...domain.test.mjs => project-domain.test.ts} | 102 ++++++---- .../test/projection-cli.test.mjs | 107 ---------- .../test/projection-cli.test.ts | 182 ++++++++++++++++++ usvm-ts-pbt/fast-check-adapter/tsconfig.json | 20 ++ .../ts/pbt/examples/ExamplePropertiesTest.kt | 4 +- .../FastCheckProjectionClientTest.kt | 4 +- 16 files changed, 588 insertions(+), 268 deletions(-) rename usvm-ts-pbt/fast-check-adapter/src/{js-value.mjs => js-value.ts} (54%) rename usvm-ts-pbt/fast-check-adapter/src/{project-domain.mjs => project-domain.ts} (67%) rename usvm-ts-pbt/fast-check-adapter/src/{projection-cli.mjs => projection-cli.ts} (52%) delete mode 100644 usvm-ts-pbt/fast-check-adapter/test/js-value.test.mjs create mode 100644 usvm-ts-pbt/fast-check-adapter/test/js-value.test.ts rename usvm-ts-pbt/fast-check-adapter/test/{project-domain.test.mjs => project-domain.test.ts} (57%) delete mode 100644 usvm-ts-pbt/fast-check-adapter/test/projection-cli.test.mjs create mode 100644 usvm-ts-pbt/fast-check-adapter/test/projection-cli.test.ts create mode 100644 usvm-ts-pbt/fast-check-adapter/tsconfig.json diff --git a/usvm-ts-pbt/README.md b/usvm-ts-pbt/README.md index a91af34adb..492f23979e 100644 --- a/usvm-ts-pbt/README.md +++ b/usvm-ts-pbt/README.md @@ -76,10 +76,11 @@ that classification is not stored in the manifest. ## Private fast-check adapter -`fast-check-adapter` is a private ECMAScript module pinned to fast-check 4.9.0. It recursively reconstructs real -`fc.Arbitrary` objects from common domain descriptors. Kotlin invokes its one-shot `sample` operation over one -JSON request on stdin and one JSON response on stdout. The adapter does not discover properties, load predicates, -run campaigns, select USVM, or orchestrate the pipeline. +`fast-check-adapter` is a private TypeScript module pinned to fast-check 4.9.0. Gradle compiles it with `tsc` into +an ignored `dist` directory before Kotlin integration tests run. The adapter recursively reconstructs real +`fc.Arbitrary` objects from common domain descriptors. Kotlin invokes the compiled one-shot `sample` operation +over one JSON request on stdin and one JSON response on stdout. The adapter does not discover properties, load +predicates, run campaigns, select USVM, or orchestrate the pipeline. Both manifest and protocol versions start at `1`. Kotlin validates outgoing request sizes and verifies process exit status, JSON shape, protocol version, request identity, sample shape, and typed backend diagnostics. @@ -97,12 +98,13 @@ exit status, JSON shape, protocol version, request identity, sample shape, and t ## Verification -Requires JDK 11, Node.js 18.18 or newer, npm, and the repository Gradle wrapper. The full Gradle check installs the -pinned private adapter, runs Node tests, runs Kotlin/Node protocol tests, and retains the native `ts-frontend` -baseline from #346. +Requires JDK 11, Node.js 18.18 or newer, npm, and the repository Gradle wrapper. The full Gradle check installs and +compiles the pinned private adapter, runs its compiled Node tests, runs Kotlin/Node protocol tests, and retains the +native `ts-frontend` baseline from #346. ```shell npm ci --prefix usvm-ts-pbt/fast-check-adapter --ignore-scripts +npm run build --prefix usvm-ts-pbt/fast-check-adapter npm test --prefix usvm-ts-pbt/fast-check-adapter env -u ARKANALYZER_DIR ETS_IR_PROVIDER=ts-frontend \ diff --git a/usvm-ts-pbt/build.gradle.kts b/usvm-ts-pbt/build.gradle.kts index 4bda553c62..10a2a8036b 100644 --- a/usvm-ts-pbt/build.gradle.kts +++ b/usvm-ts-pbt/build.gradle.kts @@ -24,22 +24,39 @@ val installFastCheckAdapter = tasks.register("installFastCheckAdapter") { outputs.dir(fastCheckAdapterDir.dir("node_modules")) } -val testFastCheckAdapter = tasks.register("testFastCheckAdapter") { +val buildFastCheckAdapter = tasks.register("buildFastCheckAdapter") { dependsOn(installFastCheckAdapter) workingDir(fastCheckAdapterDir) - commandLine(npmExecutable, "test") + commandLine(npmExecutable, "run", "build") + inputs.files( + fastCheckAdapterDir.file("package.json"), + fastCheckAdapterDir.file("package-lock.json"), + fastCheckAdapterDir.file("tsconfig.json"), + ) inputs.dir(fastCheckAdapterDir.dir("src")) inputs.dir(fastCheckAdapterDir.dir("test")) + outputs.dir(fastCheckAdapterDir.dir("dist")) +} + +val testFastCheckAdapter = tasks.register("testFastCheckAdapter") { + dependsOn(buildFastCheckAdapter) + workingDir(fastCheckAdapterDir) + commandLine(npmExecutable, "run", "test:compiled") + inputs.dir(fastCheckAdapterDir.dir("dist")) } tasks.test { - dependsOn(installFastCheckAdapter) + dependsOn(buildFastCheckAdapter) } tasks.check { dependsOn(testFastCheckAdapter) } +tasks.clean { + delete(fastCheckAdapterDir.dir("dist")) +} + tasks.withType().configureEach { jvmTarget = JavaVersion.VERSION_1_8.toString() } diff --git a/usvm-ts-pbt/fast-check-adapter/.gitignore b/usvm-ts-pbt/fast-check-adapter/.gitignore index c2658d7d1b..b947077876 100644 --- a/usvm-ts-pbt/fast-check-adapter/.gitignore +++ b/usvm-ts-pbt/fast-check-adapter/.gitignore @@ -1 +1,2 @@ node_modules/ +dist/ diff --git a/usvm-ts-pbt/fast-check-adapter/package-lock.json b/usvm-ts-pbt/fast-check-adapter/package-lock.json index 93d83fec14..c1e53b916d 100644 --- a/usvm-ts-pbt/fast-check-adapter/package-lock.json +++ b/usvm-ts-pbt/fast-check-adapter/package-lock.json @@ -10,10 +10,24 @@ "dependencies": { "fast-check": "4.9.0" }, + "devDependencies": { + "@types/node": "18.19.130", + "typescript": "5.9.2" + }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, "node_modules/fast-check": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", @@ -51,6 +65,27 @@ } ], "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" } } } diff --git a/usvm-ts-pbt/fast-check-adapter/package.json b/usvm-ts-pbt/fast-check-adapter/package.json index 115493e233..96a9d74f78 100644 --- a/usvm-ts-pbt/fast-check-adapter/package.json +++ b/usvm-ts-pbt/fast-check-adapter/package.json @@ -4,11 +4,20 @@ "private": true, "type": "module", "scripts": { - "test": "node --test" + "clean": "node --input-type=module --eval \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\"", + "prebuild": "npm run clean", + "build": "tsc --project tsconfig.json", + "pretest": "npm run build", + "test": "npm run test:compiled", + "test:compiled": "node --test dist/test/js-value.test.js dist/test/project-domain.test.js dist/test/projection-cli.test.js" }, "dependencies": { "fast-check": "4.9.0" }, + "devDependencies": { + "@types/node": "18.19.130", + "typescript": "5.9.2" + }, "engines": { "node": ">=18.18.0" } diff --git a/usvm-ts-pbt/fast-check-adapter/src/js-value.mjs b/usvm-ts-pbt/fast-check-adapter/src/js-value.ts similarity index 54% rename from usvm-ts-pbt/fast-check-adapter/src/js-value.mjs rename to usvm-ts-pbt/fast-check-adapter/src/js-value.ts index 4bedded9fe..781ad8ae6b 100644 --- a/usvm-ts-pbt/fast-check-adapter/src/js-value.mjs +++ b/usvm-ts-pbt/fast-check-adapter/src/js-value.ts @@ -1,4 +1,37 @@ -export function decodeJsValue(value, path = 'value') { +export type JsConcreteValue = + | undefined + | null + | boolean + | string + | number + | JsConcreteValue[]; + +export type TaggedJsNumber = + | { value: 'finite'; bits: string } + | { value: 'nan' } + | { value: 'positive-infinity' } + | { value: 'negative-infinity' }; + +export type TaggedJsValue = + | { kind: 'undefined' } + | { kind: 'null' } + | { kind: 'boolean'; value: boolean } + | { kind: 'string'; value: string } + | ({ kind: 'number' } & TaggedJsNumber) + | { kind: 'array'; elements: TaggedJsValue[] }; + +export class ProtocolError extends Error { + constructor( + readonly code: string, + readonly diagnosticMessage: string, + readonly path: string, + ) { + super(`${code}: ${diagnosticMessage}`); + this.name = 'ProtocolError'; + } +} + +export function decodeJsValue(value: unknown, path = 'value'): JsConcreteValue { requireObject(value, 'js-value.invalid', 'Tagged JavaScript value must be an object', path); switch (value.kind) { case 'undefined': @@ -21,13 +54,18 @@ export function decodeJsValue(value, path = 'value') { if (!Array.isArray(value.elements)) { throw protocolError('js-value.array.invalid', 'Array value must contain elements', path); } - return value.elements.map((element, index) => decodeJsValue(element, `${path}.elements[${index}]`)); + return value.elements.map((element: unknown, index: number) => + decodeJsValue(element, `${path}.elements[${index}]`)); default: - throw protocolError('js-value.kind.unknown', `Unknown JavaScript value kind: ${String(value.kind)}`, path); + throw protocolError( + 'js-value.kind.unknown', + `Unknown JavaScript value kind: ${String(value.kind)}`, + path, + ); } } -export function encodeJsValue(value) { +export function encodeJsValue(value: unknown): TaggedJsValue { if (value === undefined) return { kind: 'undefined' }; if (value === null) return { kind: 'null' }; if (typeof value === 'boolean') return { kind: 'boolean', value }; @@ -41,71 +79,73 @@ export function encodeJsValue(value) { ); } -export function decodeJsNumber(number, path = 'number') { - requireObject(number, 'js-number.invalid', 'Tagged JavaScript number must be an object', path); - switch (number.value) { +export function decodeJsNumber(taggedNumber: unknown, path = 'number'): number { + requireObject(taggedNumber, 'js-number.invalid', 'Tagged JavaScript number must be an object', path); + switch (taggedNumber.value) { case 'finite': - if (typeof number.bits !== 'string' || !/^[0-9a-f]{16}$/.test(number.bits)) { + if (typeof taggedNumber.bits !== 'string' || !/^[0-9a-f]{16}$/.test(taggedNumber.bits)) { throw protocolError( 'js-number.encoding.invalid', 'Finite JavaScript numbers require sixteen lowercase hexadecimal digits', path, ); } - return bitsToDouble(number.bits); + return bitsToDouble(taggedNumber.bits); case 'nan': - requireNoBits(number, path); + requireNoBits(taggedNumber, path); return Number.NaN; case 'positive-infinity': - requireNoBits(number, path); + requireNoBits(taggedNumber, path); return Number.POSITIVE_INFINITY; case 'negative-infinity': - requireNoBits(number, path); + requireNoBits(taggedNumber, path); return Number.NEGATIVE_INFINITY; default: throw protocolError( 'js-number.kind.unknown', - `Unknown JavaScript number kind: ${String(number.value)}`, + `Unknown JavaScript number kind: ${String(taggedNumber.value)}`, path, ); } } -export function encodeJsNumber(value) { +export function encodeJsNumber(value: number): TaggedJsNumber { if (Number.isNaN(value)) return { value: 'nan' }; if (value === Number.POSITIVE_INFINITY) return { value: 'positive-infinity' }; if (value === Number.NEGATIVE_INFINITY) return { value: 'negative-infinity' }; return { value: 'finite', bits: doubleToBits(value) }; } -export function protocolError(code, message, path) { - const error = new Error(`${code}: ${message}`); - error.code = code; - error.path = path; - return error; +export function protocolError(code: string, message: string, path: string): ProtocolError { + return new ProtocolError(code, message, path); } -function bitsToDouble(bits) { +function bitsToDouble(bits: string): number { const buffer = new ArrayBuffer(8); const view = new DataView(buffer); view.setBigUint64(0, BigInt(`0x${bits}`), false); return view.getFloat64(0, false); } -function doubleToBits(value) { +function doubleToBits(value: number): string { const buffer = new ArrayBuffer(8); const view = new DataView(buffer); view.setFloat64(0, value, false); return view.getBigUint64(0, false).toString(16).padStart(16, '0'); } -function requireNoBits(number, path) { - if (number.bits !== undefined) { +function requireNoBits(taggedNumber: Record, path: string): void { + if (taggedNumber.bits !== undefined) { throw protocolError('js-number.encoding.invalid', 'Non-finite JavaScript numbers must not contain bits', path); } } -function requireObject(value, code, message, path) { +function requireObject( + value: unknown, + code: string, + message: string, + path: string, +): asserts value is Record { if (value === null || typeof value !== 'object' || Array.isArray(value)) { throw protocolError(code, message, path); } diff --git a/usvm-ts-pbt/fast-check-adapter/src/project-domain.mjs b/usvm-ts-pbt/fast-check-adapter/src/project-domain.ts similarity index 67% rename from usvm-ts-pbt/fast-check-adapter/src/project-domain.mjs rename to usvm-ts-pbt/fast-check-adapter/src/project-domain.ts index a548da4979..954ab21f1c 100644 --- a/usvm-ts-pbt/fast-check-adapter/src/project-domain.mjs +++ b/usvm-ts-pbt/fast-check-adapter/src/project-domain.ts @@ -2,13 +2,29 @@ import fc from 'fast-check'; import { decodeJsNumber, decodeJsValue, + ProtocolError, protocolError, -} from './js-value.mjs'; +} from './js-value.js'; export const FAST_CHECK_BACKEND_ID = 'fast-check'; export const FAST_CHECK_BACKEND_VERSION = '4.9.0'; -export function projectDomain(domain, path = 'domain') { +export interface ProjectionDiagnostic { + code: string; + message: string; + path: string; +} + +export interface ProjectionCapability { + backendId: string; + backendVersion: string; + level: 'exact' | 'unsupported'; + diagnostics: ProjectionDiagnostic[]; +} + +type DomainRecord = Record; + +export function projectDomain(domain: unknown, path = 'domain'): fc.Arbitrary { requireDomainObject(domain, path); switch (domain.kind) { case 'boolean': @@ -37,7 +53,9 @@ export function projectDomain(domain, path = 'domain') { if (!Array.isArray(domain.elements) || domain.elements.length === 0) { throw protocolError('domain.tuple.empty', 'Tuple domain must contain elements', path); } - return fc.tuple(...domain.elements.map((element, index) => projectDomain(element, `${path}.elements[${index}]`))); + return fc.tuple(...domain.elements.map( + (element: unknown, index: number) => projectDomain(element, `${path}.elements[${index}]`), + )); case 'array': validateLengths(domain, path); return fc.array(projectDomain(domain.element, `${path}.element`), { @@ -45,11 +63,15 @@ export function projectDomain(domain, path = 'domain') { maxLength: domain.maxLength, }); default: - throw protocolError('domain.kind.unknown', `Unknown property domain kind: ${String(domain.kind)}`, path); + throw protocolError( + 'domain.kind.unknown', + `Unknown property domain kind: ${String(domain.kind)}`, + path, + ); } } -export function projectionCapability(domain, path = 'domain') { +export function projectionCapability(domain: unknown, path = 'domain'): ProjectionCapability { try { projectDomain(domain, path); return { @@ -58,22 +80,22 @@ export function projectionCapability(domain, path = 'domain') { level: 'exact', diagnostics: [], }; - } catch (error) { - if (typeof error?.code !== 'string') throw error; + } catch (error: unknown) { + if (!(error instanceof ProtocolError)) throw error; return { backendId: FAST_CHECK_BACKEND_ID, backendVersion: FAST_CHECK_BACKEND_VERSION, level: 'unsupported', diagnostics: [{ code: error.code, - message: error.message.slice(error.message.indexOf(':') + 2), - path: error.path ?? path, + message: error.diagnosticMessage, + path: error.path, }], }; } } -function projectNumber(domain, path) { +function projectNumber(domain: DomainRecord, path: string): fc.Arbitrary { if (typeof domain.allowNaN !== 'boolean') { throw protocolError('domain.number.allow-nan.invalid', 'allowNaN must be a boolean', `${path}.allowNaN`); } @@ -92,7 +114,7 @@ function projectNumber(domain, path) { const finiteMin = min === Number.NEGATIVE_INFINITY ? -Number.MAX_VALUE : min; const finiteMax = max === Number.POSITIVE_INFINITY ? Number.MAX_VALUE : max; - const arbitraries = []; + const arbitraries: fc.Arbitrary[] = []; if (finiteMin <= finiteMax) { arbitraries.push(fc.double({ min: finiteMin, @@ -105,11 +127,21 @@ function projectNumber(domain, path) { if (min === Number.NEGATIVE_INFINITY) arbitraries.push(fc.constant(Number.NEGATIVE_INFINITY)); if (max === Number.POSITIVE_INFINITY) arbitraries.push(fc.constant(Number.POSITIVE_INFINITY)); if (min <= 0 && max >= 0) arbitraries.push(fc.constant(-0)); - return arbitraries.length === 1 ? arbitraries[0] : fc.oneof(...arbitraries); + + const [first, ...rest] = arbitraries; + if (first === undefined) { + throw protocolError('domain.number.empty', 'Number domain does not contain any values', path); + } + return rest.length === 0 ? first : fc.oneof(first, ...rest); } -function validateIntegerDomain(domain, path) { - const valid = Number.isInteger(domain.min) +function validateIntegerDomain( + domain: DomainRecord, + path: string, +): asserts domain is DomainRecord & { min: number; max: number } { + const valid = typeof domain.min === 'number' + && typeof domain.max === 'number' + && Number.isInteger(domain.min) && Number.isInteger(domain.max) && domain.min >= -0x80000000 && domain.max <= 0x7fffffff @@ -119,8 +151,13 @@ function validateIntegerDomain(domain, path) { } } -function validateLengths(domain, path) { - const valid = Number.isInteger(domain.minLength) +function validateLengths( + domain: DomainRecord, + path: string, +): asserts domain is DomainRecord & { minLength: number; maxLength: number } { + const valid = typeof domain.minLength === 'number' + && typeof domain.maxLength === 'number' + && Number.isInteger(domain.minLength) && Number.isInteger(domain.maxLength) && domain.minLength >= 0 && domain.minLength <= domain.maxLength; @@ -129,7 +166,7 @@ function validateLengths(domain, path) { } } -function requireDomainObject(domain, path) { +function requireDomainObject(domain: unknown, path: string): asserts domain is DomainRecord { if (domain === null || typeof domain !== 'object' || Array.isArray(domain)) { throw protocolError('domain.invalid', 'Property domain must be an object', path); } diff --git a/usvm-ts-pbt/fast-check-adapter/src/projection-cli.mjs b/usvm-ts-pbt/fast-check-adapter/src/projection-cli.ts similarity index 52% rename from usvm-ts-pbt/fast-check-adapter/src/projection-cli.mjs rename to usvm-ts-pbt/fast-check-adapter/src/projection-cli.ts index 0446dedf53..06e8af735b 100644 --- a/usvm-ts-pbt/fast-check-adapter/src/projection-cli.mjs +++ b/usvm-ts-pbt/fast-check-adapter/src/projection-cli.ts @@ -1,18 +1,49 @@ import fc from 'fast-check'; import { encodeJsValue, + ProtocolError, protocolError, -} from './js-value.mjs'; -import { projectDomain } from './project-domain.mjs'; +} from './js-value.js'; +import type { TaggedJsValue } from './js-value.js'; +import { projectDomain } from './project-domain.js'; const PROTOCOL_VERSION = 1; -let parsedRequest; -let response; +interface FastCheckProjectionRequest { + protocolVersion: 1; + requestId: string; + operation: 'sample'; + seed: number; + numSamples: number; + domains: unknown[]; +} + +interface FastCheckProjectionSuccess { + protocolVersion: 1; + requestId: string; + status: 'ok'; + samples: TaggedJsValue[][]; +} + +interface FastCheckProjectionFailure { + protocolVersion: 1; + requestId?: string; + status: 'error'; + diagnostics: Array<{ + code: string; + message: string; + path: string; + }>; +} + +type FastCheckProjectionWireResponse = FastCheckProjectionSuccess | FastCheckProjectionFailure; + +let parsedRequest: unknown = undefined; +let response: FastCheckProjectionWireResponse; try { const input = await readStdin(); try { - parsedRequest = JSON.parse(input); + parsedRequest = JSON.parse(input) as unknown; } catch { throw protocolError('protocol.json.invalid', 'Standard input is not valid JSON', 'request'); } @@ -30,21 +61,21 @@ try { status: 'ok', samples: tuples.map((tuple) => tuple.map(encodeJsValue)), }; -} catch (error) { +} catch (error: unknown) { response = protocolErrorResponse(error, parsedRequest); } process.stdout.write(`${JSON.stringify(response)}\n`); -async function readStdin() { +async function readStdin(): Promise { process.stdin.setEncoding('utf8'); let input = ''; for await (const chunk of process.stdin) input += chunk; return input; } -function validateRequest(request) { - if (request === null || typeof request !== 'object' || Array.isArray(request)) { +function validateRequest(request: unknown): FastCheckProjectionRequest { + if (!isRecord(request)) { throw protocolError('protocol.request.invalid', 'Request must be a JSON object', 'request'); } if (request.protocolVersion !== PROTOCOL_VERSION) { @@ -63,9 +94,11 @@ function validateRequest(request) { } const valid = typeof request.requestId === 'string' && request.requestId.length > 0 + && typeof request.seed === 'number' && Number.isInteger(request.seed) && request.seed >= -0x80000000 && request.seed <= 0x7fffffff + && typeof request.numSamples === 'number' && Number.isInteger(request.numSamples) && request.numSamples >= 1 && request.numSamples <= 10_000 @@ -78,24 +111,33 @@ function validateRequest(request) { 'request', ); } - return request; + return { + protocolVersion: PROTOCOL_VERSION, + requestId: request.requestId as string, + operation: 'sample', + seed: request.seed as number, + numSamples: request.numSamples as number, + domains: request.domains as unknown[], + }; } -function protocolErrorResponse(error, request) { - const code = typeof error?.code === 'string' ? error.code : 'protocol.request.invalid'; - const rawMessage = error instanceof Error ? error.message : String(error); - const message = rawMessage.startsWith(`${code}: `) ? rawMessage.slice(code.length + 2) : rawMessage; - const result = { +function protocolErrorResponse(error: unknown, request: unknown): FastCheckProjectionFailure { + const protocolFailure = error instanceof ProtocolError ? error : undefined; + const result: FastCheckProjectionFailure = { protocolVersion: PROTOCOL_VERSION, status: 'error', diagnostics: [{ - code, - message, - path: typeof error?.path === 'string' ? error.path : 'request', + code: protocolFailure?.code ?? 'protocol.request.invalid', + message: protocolFailure?.diagnosticMessage ?? (error instanceof Error ? error.message : String(error)), + path: protocolFailure?.path ?? 'request', }], }; - if (request !== null && typeof request === 'object' && typeof request.requestId === 'string') { + if (isRecord(request) && typeof request.requestId === 'string') { result.requestId = request.requestId; } return result; } + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/usvm-ts-pbt/fast-check-adapter/test/js-value.test.mjs b/usvm-ts-pbt/fast-check-adapter/test/js-value.test.mjs deleted file mode 100644 index f8eef3a657..0000000000 --- a/usvm-ts-pbt/fast-check-adapter/test/js-value.test.mjs +++ /dev/null @@ -1,46 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { - decodeJsValue, - encodeJsValue, -} from '../src/js-value.mjs'; - -test('tagged JavaScript primitives round trip without losing semantics', () => { - const cases = [ - [{ kind: 'undefined' }, (value) => value === undefined], - [{ kind: 'null' }, (value) => value === null], - [{ kind: 'boolean', value: true }, (value) => value === true], - [{ kind: 'string', value: 'text' }, (value) => value === 'text'], - [{ kind: 'number', value: 'finite', bits: '0000000000000000' }, (value) => Object.is(value, 0)], - [{ kind: 'number', value: 'finite', bits: '8000000000000000' }, (value) => Object.is(value, -0)], - [{ kind: 'number', value: 'nan' }, Number.isNaN], - [{ kind: 'number', value: 'positive-infinity' }, (value) => value === Number.POSITIVE_INFINITY], - [{ kind: 'number', value: 'negative-infinity' }, (value) => value === Number.NEGATIVE_INFINITY], - [ - { - kind: 'array', - elements: [{ kind: 'undefined' }, { kind: 'number', value: 'finite', bits: '8000000000000000' }], - }, - (value) => Array.isArray(value) && value[0] === undefined && Object.is(value[1], -0), - ], - ]; - - for (const [tagged, predicate] of cases) { - const decoded = decodeJsValue(tagged); - assert.ok(predicate(decoded), `decoded value does not match ${JSON.stringify(tagged)}`); - assert.deepEqual(encodeJsValue(decoded), tagged); - } -}); - -test('tagged finite numbers require exactly sixteen lowercase hexadecimal digits', () => { - for (const bits of [undefined, '0', '000000000000000G', '800000000000000A']) { - assert.throws( - () => decodeJsValue({ kind: 'number', value: 'finite', bits }), - /js-number\.encoding\.invalid/, - ); - } -}); - -test('unknown tagged value kinds are rejected explicitly', () => { - assert.throws(() => decodeJsValue({ kind: 'symbol' }), /js-value\.kind\.unknown/); -}); diff --git a/usvm-ts-pbt/fast-check-adapter/test/js-value.test.ts b/usvm-ts-pbt/fast-check-adapter/test/js-value.test.ts new file mode 100644 index 0000000000..15154526fd --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/test/js-value.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + decodeJsValue, + encodeJsValue, +} from '../src/js-value.js'; +import type { JsConcreteValue } from '../src/js-value.js'; + +interface RoundTripCase { + tagged: unknown; + matches: (value: JsConcreteValue) => boolean; +} + +test('tagged JavaScript primitives round trip without losing semantics', () => { + const cases: RoundTripCase[] = [ + { tagged: { kind: 'undefined' }, matches: (value) => value === undefined }, + { tagged: { kind: 'null' }, matches: (value) => value === null }, + { tagged: { kind: 'boolean', value: true }, matches: (value) => value === true }, + { tagged: { kind: 'string', value: 'text' }, matches: (value) => value === 'text' }, + { + tagged: { kind: 'number', value: 'finite', bits: '0000000000000000' }, + matches: (value) => Object.is(value, 0), + }, + { + tagged: { kind: 'number', value: 'finite', bits: '8000000000000000' }, + matches: (value) => Object.is(value, -0), + }, + { tagged: { kind: 'number', value: 'nan' }, matches: Number.isNaN }, + { + tagged: { kind: 'number', value: 'positive-infinity' }, + matches: (value) => value === Number.POSITIVE_INFINITY, + }, + { + tagged: { kind: 'number', value: 'negative-infinity' }, + matches: (value) => value === Number.NEGATIVE_INFINITY, + }, + { + tagged: { + kind: 'array', + elements: [{ kind: 'undefined' }, { kind: 'number', value: 'finite', bits: '8000000000000000' }], + }, + matches: (value) => Array.isArray(value) + && value[0] === undefined + && Object.is(value[1], -0), + }, + ]; + + for (const { tagged, matches } of cases) { + const decoded = decodeJsValue(tagged); + assert.ok(matches(decoded), `decoded value does not match ${JSON.stringify(tagged)}`); + assert.deepEqual(encodeJsValue(decoded), tagged); + } +}); + +test('tagged finite numbers require exactly sixteen lowercase hexadecimal digits', () => { + for (const bits of [undefined, '0', '000000000000000G', '800000000000000A']) { + assert.throws( + () => decodeJsValue({ kind: 'number', value: 'finite', bits }), + /js-number\.encoding\.invalid/, + ); + } +}); + +test('unknown tagged value kinds are rejected explicitly', () => { + assert.throws(() => decodeJsValue({ kind: 'symbol' }), /js-value\.kind\.unknown/); +}); diff --git a/usvm-ts-pbt/fast-check-adapter/test/project-domain.test.mjs b/usvm-ts-pbt/fast-check-adapter/test/project-domain.test.ts similarity index 57% rename from usvm-ts-pbt/fast-check-adapter/test/project-domain.test.mjs rename to usvm-ts-pbt/fast-check-adapter/test/project-domain.test.ts index ba6798c52f..3ae1be60b8 100644 --- a/usvm-ts-pbt/fast-check-adapter/test/project-domain.test.mjs +++ b/usvm-ts-pbt/fast-check-adapter/test/project-domain.test.ts @@ -4,16 +4,20 @@ import fc from 'fast-check'; import { projectDomain, projectionCapability, -} from '../src/project-domain.mjs'; +} from '../src/project-domain.js'; test('bounded integers use a real fast-check arbitrary', () => { const samples = sample({ kind: 'integer', min: -3, max: 7 }); - assert.ok(samples.every((value) => Number.isInteger(value) && value >= -3 && value <= 7)); + assert.ok(samples.every( + (value) => typeof value === 'number' && Number.isInteger(value) && value >= -3 && value <= 7, + )); }); test('strings are arbitrary UTF-16 code-unit sequences with declared lengths', () => { const samples = sample({ kind: 'string', minLength: 2, maxLength: 4 }); - assert.ok(samples.every((value) => typeof value === 'string' && value.length >= 2 && value.length <= 4)); + assert.ok(samples.every( + (value) => typeof value === 'string' && value.length >= 2 && value.length <= 4, + )); }); test('unbounded numbers include ECMAScript special values', () => { @@ -27,10 +31,10 @@ test('unbounded numbers include ECMAScript special values', () => { 500, ); - assert.ok(samples.some(Number.isNaN)); + assert.ok(samples.some((value) => typeof value === 'number' && Number.isNaN(value))); assert.ok(samples.includes(Number.NEGATIVE_INFINITY)); assert.ok(samples.includes(Number.POSITIVE_INFINITY)); - assert.ok(samples.some((value) => Object.is(value, -0))); + assert.ok(samples.some((value) => typeof value === 'number' && Object.is(value, -0))); }); test('bounded numbers exclude NaN and values outside their inclusive bounds', () => { @@ -40,14 +44,17 @@ test('bounded numbers exclude NaN and values outside their inclusive bounds', () max: taggedNumber(2.5), allowNaN: false, }); - assert.ok(samples.every((value) => !Number.isNaN(value) && value >= -1.5 && value <= 2.5)); + assert.ok(samples.every( + (value) => typeof value === 'number' && !Number.isNaN(value) && value >= -1.5 && value <= 2.5, + )); }); test('singleton infinity ranges project without an empty finite arbitrary', () => { - for (const [bound, expected] of [ + const bounds: Array = [ [{ value: 'negative-infinity' }, Number.NEGATIVE_INFINITY], [{ value: 'positive-infinity' }, Number.POSITIVE_INFINITY], - ]) { + ]; + for (const [bound, expected] of bounds) { const samples = sample({ kind: 'number', min: bound, @@ -58,44 +65,59 @@ test('singleton infinity ranges project without an empty finite arbitrary', () = } }); -for (const [name, domain, predicate] of [ - ['boolean', { kind: 'boolean' }, (value) => typeof value === 'boolean'], - [ - 'constant -0', - { kind: 'constant', value: { kind: 'number', value: 'finite', bits: '8000000000000000' } }, - (value) => Object.is(value, -0), - ], - [ - 'optional undefined', - { kind: 'optional', value: { kind: 'integer', min: -2, max: 2 }, nil: { kind: 'undefined' } }, - (value) => value === undefined || (Number.isInteger(value) && value >= -2 && value <= 2), - ], - [ - 'optional null', - { kind: 'optional', value: { kind: 'boolean' }, nil: { kind: 'null' } }, - (value) => value === null || typeof value === 'boolean', - ], - [ - 'tuple', - { kind: 'tuple', elements: [{ kind: 'boolean' }, { kind: 'integer', min: 0, max: 3 }] }, - (value) => Array.isArray(value) && value.length === 2 && typeof value[0] === 'boolean', - ], - [ - 'nested array', - { +interface ProjectionCase { + name: string; + domain: unknown; + matches: (value: unknown) => boolean; +} + +const projectionCases: ProjectionCase[] = [ + { + name: 'boolean', + domain: { kind: 'boolean' }, + matches: (value) => typeof value === 'boolean', + }, + { + name: 'constant -0', + domain: { kind: 'constant', value: { kind: 'number', value: 'finite', bits: '8000000000000000' } }, + matches: (value) => Object.is(value, -0), + }, + { + name: 'optional undefined', + domain: { kind: 'optional', value: { kind: 'integer', min: -2, max: 2 }, nil: { kind: 'undefined' } }, + matches: (value) => value === undefined + || (typeof value === 'number' && Number.isInteger(value) && value >= -2 && value <= 2), + }, + { + name: 'optional null', + domain: { kind: 'optional', value: { kind: 'boolean' }, nil: { kind: 'null' } }, + matches: (value) => value === null || typeof value === 'boolean', + }, + { + name: 'tuple', + domain: { kind: 'tuple', elements: [{ kind: 'boolean' }, { kind: 'integer', min: 0, max: 3 }] }, + matches: (value) => Array.isArray(value) + && value.length === 2 + && typeof value[0] === 'boolean', + }, + { + name: 'nested array', + domain: { kind: 'array', element: { kind: 'array', element: { kind: 'integer', min: 0, max: 3 }, minLength: 1, maxLength: 2 }, minLength: 1, maxLength: 4, }, - (value) => Array.isArray(value) + matches: (value) => Array.isArray(value) && value.length >= 1 && value.length <= 4 - && value.every((inner) => inner.length >= 1 && inner.length <= 2), - ], -]) { + && value.every((inner: unknown) => Array.isArray(inner) && inner.length >= 1 && inner.length <= 2), + }, +]; + +for (const { name, domain, matches } of projectionCases) { test(`${name} projects to values satisfying the common domain`, () => { - assert.ok(sample(domain).every(predicate)); + assert.ok(sample(domain).every(matches)); }); } @@ -133,11 +155,11 @@ test('unknown domain kinds are rejected and reported as unsupported', () => { ); }); -function sample(domain, numRuns = 100) { +function sample(domain: unknown, numRuns = 100): unknown[] { return fc.sample(projectDomain(domain), { seed: 42, numRuns }); } -function taggedNumber(value) { +function taggedNumber(value: number): { value: 'finite'; bits: string } { const buffer = new ArrayBuffer(8); const view = new DataView(buffer); view.setFloat64(0, value, false); diff --git a/usvm-ts-pbt/fast-check-adapter/test/projection-cli.test.mjs b/usvm-ts-pbt/fast-check-adapter/test/projection-cli.test.mjs deleted file mode 100644 index 1ff519f823..0000000000 --- a/usvm-ts-pbt/fast-check-adapter/test/projection-cli.test.mjs +++ /dev/null @@ -1,107 +0,0 @@ -import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; -import test from 'node:test'; - -const cliPath = fileURLToPath(new URL('../src/projection-cli.mjs', import.meta.url)); - -test('sample response echoes request identity and returns deterministic tagged values', async () => { - const request = { - protocolVersion: 1, - requestId: 'sample-1', - operation: 'sample', - seed: 42, - numSamples: 4, - domains: [{ kind: 'integer', min: -1, max: 1 }], - }; - - const first = await invokeCli(JSON.stringify(request)); - const second = await invokeCli(JSON.stringify(request)); - - assert.equal(first.exitCode, 0); - assert.equal(first.stderr, ''); - assert.equal(first.stdout.trim().split('\n').length, 1); - assert.deepEqual(first.response, second.response); - assert.equal(first.response.protocolVersion, 1); - assert.equal(first.response.requestId, 'sample-1'); - assert.equal(first.response.status, 'ok'); - assert.equal(first.response.samples.length, 4); - assert.ok(first.response.samples.every((tuple) => tuple.length === 1 && tuple[0].kind === 'number')); -}); - -for (const [name, input, code, requestId, path] of [ - [ - 'unsupported protocol version', - { protocolVersion: 2, requestId: 'wrong-version', operation: 'sample', seed: 1, numSamples: 1, domains: [{ kind: 'boolean' }] }, - 'protocol.version.unsupported', - 'wrong-version', - 'protocolVersion', - ], - [ - 'unsupported operation', - { protocolVersion: 1, requestId: 'wrong-operation', operation: 'check', seed: 1, numSamples: 1, domains: [{ kind: 'boolean' }] }, - 'protocol.operation.unsupported', - 'wrong-operation', - 'operation', - ], - [ - 'invalid request', - { protocolVersion: 1, requestId: '', operation: 'sample', seed: 1.5, numSamples: 0, domains: [] }, - 'protocol.request.invalid', - '', - 'request', - ], -]) { - test(`${name} returns a typed protocol error`, async () => { - const result = await invokeCli(JSON.stringify(input)); - assert.equal(result.exitCode, 0); - assert.deepEqual(result.response, { - protocolVersion: 1, - requestId, - status: 'error', - diagnostics: [{ - code, - message: result.response.diagnostics[0].message, - path, - }], - }); - }); -} - -test('malformed JSON produces one clean protocol error document', async () => { - const result = await invokeCli('{not-json'); - - assert.equal(result.exitCode, 0); - assert.equal(result.stderr, ''); - assert.equal(result.stdout.trim().split('\n').length, 1); - assert.equal(result.response.protocolVersion, 1); - assert.equal(result.response.status, 'error'); - assert.equal(result.response.diagnostics[0].code, 'protocol.json.invalid'); - assert.ok(!('requestId' in result.response)); -}); - -async function invokeCli(input) { - const child = spawn(process.execPath, [cliPath], { stdio: ['pipe', 'pipe', 'pipe'] }); - child.stdin.end(input); - const [exitCode, stdout, stderr] = await Promise.all([ - new Promise((resolve, reject) => { - child.once('error', reject); - child.once('close', resolve); - }), - collect(child.stdout), - collect(child.stderr), - ]); - let response; - try { - response = JSON.parse(stdout); - } catch { - response = undefined; - } - return { exitCode, stdout, stderr, response }; -} - -async function collect(stream) { - const chunks = []; - for await (const chunk of stream) chunks.push(chunk); - return Buffer.concat(chunks).toString('utf8'); -} diff --git a/usvm-ts-pbt/fast-check-adapter/test/projection-cli.test.ts b/usvm-ts-pbt/fast-check-adapter/test/projection-cli.test.ts new file mode 100644 index 0000000000..6f46a87566 --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/test/projection-cli.test.ts @@ -0,0 +1,182 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import type { Readable } from 'node:stream'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import type { TaggedJsValue } from '../src/js-value.js'; + +const cliPath = fileURLToPath(new URL('../src/projection-cli.js', import.meta.url)); + +interface SuccessResponse { + protocolVersion: number; + requestId: string; + status: 'ok'; + samples: TaggedJsValue[][]; +} + +interface ErrorResponse { + protocolVersion: number; + requestId?: string; + status: 'error'; + diagnostics: Array<{ + code: string; + message: string; + path: string; + }>; +} + +type WireResponse = SuccessResponse | ErrorResponse; + +interface InvocationResult { + exitCode: number | null; + stdout: string; + stderr: string; + response: WireResponse | undefined; +} + +test('sample response echoes request identity and returns deterministic tagged values', async () => { + const request = { + protocolVersion: 1, + requestId: 'sample-1', + operation: 'sample', + seed: 42, + numSamples: 4, + domains: [{ kind: 'integer', min: -1, max: 1 }], + }; + + const first = await invokeCli(JSON.stringify(request)); + const second = await invokeCli(JSON.stringify(request)); + const firstResponse = requireResponse(first); + const secondResponse = requireResponse(second); + + assert.equal(first.exitCode, 0); + assert.equal(first.stderr, ''); + assert.equal(first.stdout.trim().split('\n').length, 1); + assert.deepEqual(firstResponse, secondResponse); + assert.equal(firstResponse.protocolVersion, 1); + assert.equal(firstResponse.requestId, 'sample-1'); + assert.equal(firstResponse.status, 'ok'); + if (firstResponse.status !== 'ok') assert.fail('Expected a successful response'); + assert.equal(firstResponse.samples.length, 4); + assert.ok(firstResponse.samples.every( + (tuple) => tuple.length === 1 && tuple[0]?.kind === 'number', + )); +}); + +interface ProtocolErrorCase { + name: string; + input: unknown; + code: string; + requestId: string; + path: string; +} + +const protocolErrorCases: ProtocolErrorCase[] = [ + { + name: 'unsupported protocol version', + input: { + protocolVersion: 2, + requestId: 'wrong-version', + operation: 'sample', + seed: 1, + numSamples: 1, + domains: [{ kind: 'boolean' }], + }, + code: 'protocol.version.unsupported', + requestId: 'wrong-version', + path: 'protocolVersion', + }, + { + name: 'unsupported operation', + input: { + protocolVersion: 1, + requestId: 'wrong-operation', + operation: 'check', + seed: 1, + numSamples: 1, + domains: [{ kind: 'boolean' }], + }, + code: 'protocol.operation.unsupported', + requestId: 'wrong-operation', + path: 'operation', + }, + { + name: 'invalid request', + input: { + protocolVersion: 1, + requestId: '', + operation: 'sample', + seed: 1.5, + numSamples: 0, + domains: [], + }, + code: 'protocol.request.invalid', + requestId: '', + path: 'request', + }, +]; + +for (const { name, input, code, requestId, path } of protocolErrorCases) { + test(`${name} returns a typed protocol error`, async () => { + const result = await invokeCli(JSON.stringify(input)); + const response = requireResponse(result); + assert.equal(result.exitCode, 0); + assert.equal(response.status, 'error'); + if (response.status !== 'error') assert.fail('Expected an error response'); + assert.deepEqual(response, { + protocolVersion: 1, + requestId, + status: 'error', + diagnostics: [{ + code, + message: response.diagnostics[0]?.message, + path, + }], + }); + }); +} + +test('malformed JSON produces one clean protocol error document', async () => { + const result = await invokeCli('{not-json'); + const response = requireResponse(result); + + assert.equal(result.exitCode, 0); + assert.equal(result.stderr, ''); + assert.equal(result.stdout.trim().split('\n').length, 1); + assert.equal(response.protocolVersion, 1); + assert.equal(response.status, 'error'); + if (response.status !== 'error') assert.fail('Expected an error response'); + assert.equal(response.diagnostics[0]?.code, 'protocol.json.invalid'); + assert.ok(!('requestId' in response)); +}); + +async function invokeCli(input: string): Promise { + const child = spawn(process.execPath, [cliPath], { stdio: ['pipe', 'pipe', 'pipe'] }); + child.stdin.end(input); + const [exitCode, stdout, stderr] = await Promise.all([ + new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code) => resolve(code)); + }), + collect(child.stdout), + collect(child.stderr), + ]); + let response: WireResponse | undefined; + try { + response = JSON.parse(stdout) as WireResponse; + } catch { + response = undefined; + } + return { exitCode, stdout, stderr, response }; +} + +async function collect(stream: Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString('utf8'); +} + +function requireResponse(result: InvocationResult): WireResponse { + assert.ok(result.response, `CLI did not return JSON: ${result.stdout}`); + return result.response; +} diff --git a/usvm-ts-pbt/fast-check-adapter/tsconfig.json b/usvm-ts-pbt/fast-check-adapter/tsconfig.json new file mode 100644 index 0000000000..706906f1c2 --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": ".", + "outDir": "dist", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": true, + "noEmitOnError": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/examples/ExamplePropertiesTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/examples/ExamplePropertiesTest.kt index 66dd6b5d48..e4493515a2 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/examples/ExamplePropertiesTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/examples/ExamplePropertiesTest.kt @@ -80,8 +80,8 @@ class ExamplePropertiesTest { fun adapterEntryPoint(): Path { val candidates = listOf( - Path.of("fast-check-adapter/src/projection-cli.mjs"), - Path.of("usvm-ts-pbt/fast-check-adapter/src/projection-cli.mjs"), + Path.of("fast-check-adapter/dist/src/projection-cli.js"), + Path.of("usvm-ts-pbt/fast-check-adapter/dist/src/projection-cli.js"), ).map { it.absolute() } return candidates.singleOrNull(Files::isRegularFile) ?: error("Cannot locate fast-check adapter; checked $candidates") 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 666f56ee2e..cb1d149589 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 @@ -162,8 +162,8 @@ class FastCheckProjectionClientTest { fun adapterEntryPoint(): Path { val candidates = listOf( - Path.of("fast-check-adapter/src/projection-cli.mjs"), - Path.of("usvm-ts-pbt/fast-check-adapter/src/projection-cli.mjs"), + Path.of("fast-check-adapter/dist/src/projection-cli.js"), + Path.of("usvm-ts-pbt/fast-check-adapter/dist/src/projection-cli.js"), ).map { it.absolute() } return candidates.singleOrNull(Files::isRegularFile) ?: error("Cannot locate fast-check adapter; checked $candidates") From 3121d8cec455edbf8b9081c4656cabde8c5cda39 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 23 Aug 2026 00:58:31 +0300 Subject: [PATCH 10/11] fix(ts-pbt): address review feedback --- .../src/main/kotlin/DetektConfiguration.kt | 12 +- detekt/baselines/usvm-ts-Main.yml | 157 ++++++++++++++++++ detekt/baselines/usvm-ts-Test.yml | 116 +++++++++++++ detekt/ts-config.yml | 15 ++ usvm-ts-pbt/README.md | 20 +-- usvm-ts-pbt/build.gradle.kts | 4 - .../ts/pbt/backend/ProjectionCapability.kt | 31 ++++ .../fastcheck/FastCheckProjectionClient.kt | 50 ++++-- .../fastcheck/FastCheckProjectionProtocol.kt | 3 + .../usvm/ts/pbt/manifest/PropertyManifest.kt | 6 + .../org/usvm/ts/pbt/model/JsConcreteValue.kt | 97 +++++++---- .../usvm/ts/pbt/model/PropertyDefinition.kt | 25 +++ .../org/usvm/ts/pbt/model/PropertyDomain.kt | 13 ++ .../ts/pbt/validation/PropertyValidation.kt | 59 +++++-- .../FastCheckProjectionClientTest.kt | 25 +++ .../pbt/validation/PropertyValidationTest.kt | 23 ++- 16 files changed, 571 insertions(+), 85 deletions(-) create mode 100644 detekt/baselines/usvm-ts-Main.yml create mode 100644 detekt/baselines/usvm-ts-Test.yml create mode 100644 detekt/ts-config.yml diff --git a/buildSrc/src/main/kotlin/DetektConfiguration.kt b/buildSrc/src/main/kotlin/DetektConfiguration.kt index b1fa87b9b9..926b917ef3 100644 --- a/buildSrc/src/main/kotlin/DetektConfiguration.kt +++ b/buildSrc/src/main/kotlin/DetektConfiguration.kt @@ -3,6 +3,7 @@ import gradle.kotlin.dsl.accessors._466a692754d3da37fc853e1c7ad8ae1e.detektPlugi import io.gitlab.arturbosch.detekt.Detekt import io.gitlab.arturbosch.detekt.DetektCreateBaselineTask import io.gitlab.arturbosch.detekt.report.ReportMergeTask +import org.gradle.api.JavaVersion import org.gradle.api.Project import org.gradle.api.tasks.TaskProvider import org.gradle.kotlin.dsl.assign @@ -34,17 +35,21 @@ fun Project.configureDetekt() { .resolve("${project}-${taskPostfix}.yml") } val configFile = rootDir.resolve("detekt").resolve("config.yml") + val tsConfigFile = rootDir.resolve("detekt").resolve("ts-config.yml") val reportFile = rootProject.layout.buildDirectory.file("reports/detekt/detekt.sarif") + val usesStrictTsRules = name in STRICT_TS_DETEKT_PROJECTS + val configFiles = if (usesStrictTsRules) listOf(configFile, tsConfigFile) else listOf(configFile) detekt { buildUponDefaultConfig = true - ignoreFailures = true + ignoreFailures = !usesStrictTsRules parallel = true - config.setFrom(configFile) + config.setFrom(configFiles) } tasks.withType { + jvmTarget = JavaVersion.VERSION_1_8.toString() setIncludes(includes) setExcludes(excludes) @@ -61,6 +66,7 @@ fun Project.configureDetekt() { } tasks.withType { + jvmTarget = JavaVersion.VERSION_1_8.toString() baseline = resolveBaselineFile(project.name, this@withType.name) } @@ -82,3 +88,5 @@ fun Project.configureDetekt() { setDependsOn(dependsOn.filterNot { it is TaskProvider<*> && it.name == "detekt" }) } } + +private val STRICT_TS_DETEKT_PROJECTS = setOf("usvm-ts", "usvm-ts-pbt") diff --git a/detekt/baselines/usvm-ts-Main.yml b/detekt/baselines/usvm-ts-Main.yml new file mode 100644 index 0000000000..24a7b7ba16 --- /dev/null +++ b/detekt/baselines/usvm-ts-Main.yml @@ -0,0 +1,157 @@ + + + + + ArgumentListWrapping:TsImports.kt$("File not found for relative path: '$importPath' from ${currentFile.signature.fileName}") + BracesOnWhenStatements:CallApproximations.kt$when + BracesOnWhenStatements:ReadField.kt$when + BracesOnWhenStatements:ReadLength.kt$when + BracesOnWhenStatements:TsBinaryOperator.kt$TsBinaryOperator.Add$when + BracesOnWhenStatements:TsBinaryOperator.kt$TsBinaryOperator.StrictEq$when + BracesOnWhenStatements:TsContext.kt$TsContext$when + BracesOnWhenStatements:TsInterpreter.kt$TsInterpreter$when + CascadingCallWrapping:TsExprResolver.kt$TsExprResolver$type + CascadingCallWrapping:TsState.kt$TsState$stmt.location.method.cfg.blocks.indexOfFirst { it.statements.contains(stmt) } .takeIf { it >= 0 } ?: error("Statement $stmt is not found in the method CFG") + CascadingCallWrapping:UnreachableCodeDetector.kt$UnreachableCodeDetector$toMap() + ChainWrapping:TsTypeSystem.kt$TsTypeSystem$&& + CommentSpacing:CallApproximations.kt$//! Note: `reversedArray` is a temporary object not used outside this function, + Filename:TsApproximations.kt$org.usvm.machine.expr.TsApproximations.kt + ImportOrdering:TsContext.kt$import io.ksmt.sort.KFp64Sort import io.ksmt.utils.asExpr import org.jacodb.ets.model.EtsAliasType import org.jacodb.ets.model.EtsAnyType import org.jacodb.ets.model.EtsArrayType import org.jacodb.ets.model.EtsBooleanLiteralType import org.jacodb.ets.model.EtsBooleanType import org.jacodb.ets.model.EtsEnumValueType import org.jacodb.ets.model.EtsGenericType import org.jacodb.ets.model.EtsLocal import org.jacodb.ets.model.EtsLexicalEnvType import org.jacodb.ets.model.EtsMethod import org.jacodb.ets.model.EtsNullType import org.jacodb.ets.model.EtsNumberLiteralType import org.jacodb.ets.model.EtsNumberType import org.jacodb.ets.model.EtsParameterRef import org.jacodb.ets.model.EtsRefType import org.jacodb.ets.model.EtsScene import org.jacodb.ets.model.EtsStringLiteralType import org.jacodb.ets.model.EtsStringType import org.jacodb.ets.model.EtsThis import org.jacodb.ets.model.EtsType import org.jacodb.ets.model.EtsUndefinedType import org.jacodb.ets.model.EtsUnionType import org.jacodb.ets.model.EtsUnknownType import org.jacodb.ets.model.EtsValue import org.usvm.UAddressSort import org.usvm.UBoolExpr import org.usvm.UBoolSort import org.usvm.UBv32Sort import org.usvm.UConcreteHeapRef import org.usvm.UContext import org.usvm.UExpr import org.usvm.UHeapRef import org.usvm.USort import org.usvm.api.allocateConcreteRef import org.usvm.api.allocateStaticRef import org.usvm.api.typeStreamOf import org.usvm.collection.field.UFieldLValue import org.usvm.isTrue import org.usvm.machine.Constants.Companion.MAGIC_OFFSET import org.usvm.machine.expr.TsUndefinedSort import org.usvm.machine.expr.TsUnresolvedSort import org.usvm.machine.expr.TsVoidSort import org.usvm.machine.expr.TsVoidValue import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.types.EtsFakeType import org.usvm.memory.UReadOnlyMemory import org.usvm.types.single import org.usvm.util.mkFieldLValue import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract + Indentation:TsTypeSystem.kt$TsTypeSystem$ + LargeClass:TsExprResolver.kt$TsExprResolver : Visitor + LongMethod:TsBinaryOperator.kt$TsBinaryOperator$fun <R : USort> TsContext.commonResolveFakeObject( lhs: UExpr<*>, rhs: UExpr<*>, scope: TsStepScope, resultSort: R, reduce: (List<ExprWithTypeConstraint<R>>) -> UExpr<R>, ): UExpr<R>? + LongMethod:TsInterpreter.kt$TsInterpreter$private fun visitVirtualMethodCall(scope: TsStepScope, stmt: TsVirtualMethodCallStmt) + LongMethod:TsTypeSystem.kt$TsTypeSystem$override fun isSupertype(supertype: EtsType, type: EtsType): Boolean + MagicNumber:CallApproximations.kt$3 + MagicNumber:EtsHierarchy.kt$EtsHierarchy$100 + MagicNumber:TsExprResolver.kt$TsExprResolver$3 + MagicNumber:TsInterpreter.kt$TsInterpreter$10 + MagicNumber:TsInterpreter.kt$TsInterpreter$5 + MatchingDeclarationName:TsApproximations.kt$TsExprApproximationResult + MatchingDeclarationName:TsPromise.kt$PromiseState + MaxChainedCallsOnSameLine:TsState.kt$TsState$stmt.location.method.cfg.blocks.indexOfFirst { it.statements.contains(stmt) } + MaxLineLength:TsExprResolver.kt$TsSimpleValueResolver$logger.error { "Cannot find symbol '$local' in '${resolutionResult.file.name}': ${resolutionResult.reason}" } + MaxLineLength:TsImports.kt$ImportResolutionResult.NotFound("File not found for relative path: '$importPath' from ${currentFile.signature.fileName}") + MaximumLineLength:TsExprResolver.kt$TsSimpleValueResolver$ + MaximumLineLength:TsImports.kt$ + MultiLineIfElse:TsInterpreter.kt$TsInterpreter$run { state.pathConstraints += mkNot(mkHeapRefEq(ref, mkTsNullValue())) state.pathConstraints += mkNot(mkHeapRefEq(ref, mkUndefinedValue())) if (parameterType is EtsArrayType) { state.pathConstraints += state.memory.types.evalIsSubtype(ref, parameterType) val lengthLValue = mkArrayLengthLValue(ref, parameterType) val length = state.memory.read(lengthLValue).asExpr(sizeSort) state.pathConstraints += mkBvSignedGreaterOrEqualExpr(length, mkBv(0)) state.pathConstraints += mkBvSignedLessOrEqualExpr(length, mkBv(options.maxArraySize)) return@run } val resolvedParameterType = graph.hierarchy.classesForType(parameterType) if (resolvedParameterType.isEmpty()) { logger.error("Cannot resolve class for parameter type: $parameterType") return@run // TODO should be an error } // Because of structural equality in TS we cannot determine the exact type // Therefore, we create information about the fields the type must consist val types = resolvedParameterType.mapNotNull { it.type.toAuxiliaryType(graph.hierarchy) } val auxiliaryType = EtsUnionType(types) // TODO error state.pathConstraints += state.memory.types.evalIsSubtype(ref, auxiliaryType) } + NestedBlockDepth:Call.kt$internal fun TsExprResolver.handleInstanceCall( expr: EtsInstanceCallExpr, ): UExpr<*>? + NestedBlockDepth:ReadArray.kt$internal fun TsExprResolver.handleArrayAccess( value: EtsArrayAccess, ): UExpr<*>? + NestedBlockDepth:ReadField.kt$internal fun TsExprResolver.handleInstanceFieldRef( value: EtsInstanceFieldRef, ): UExpr<*>? + NestedBlockDepth:TsExprResolver.kt$TsExprResolver$override fun visit(expr: EtsAwaitExpr): UExpr<out USort>? + NestedBlockDepth:TsExprResolver.kt$TsExprResolver$override fun visit(expr: EtsPtrCallExpr): UExpr<out USort>? + NestedBlockDepth:TsExprResolver.kt$TsSimpleValueResolver$private fun resolveLocal(local: EtsValue): UExpr<*>? + NestedBlockDepth:TsInterpreter.kt$TsInterpreter$private fun assignToInDfltDflt( scope: TsStepScope, lhv: EtsLValue, expr: UExpr<*>, ): Unit? + NestedBlockDepth:TsInterpreter.kt$TsInterpreter$private fun visitVirtualMethodCall(scope: TsStepScope, stmt: TsVirtualMethodCallStmt) + NestedBlockDepth:WriteField.kt$internal fun TsExprResolver.handleAssignToInstanceField( lhv: EtsInstanceFieldRef, expr: UExpr<*>, ): Unit? + NoBlankLineInList:TsState.kt$TsState$ + NoEmptyFirstLineInMethodBlock:TsInterpreter.kt$TsInterpreter$ + NoMultipleSpaces:TsTypeSystem.kt$TsTypeSystem$ + NoNameShadowing:EtsFieldResolver.kt$field + NoNameShadowing:EtsFieldResolver.kt${ it.name != CONSTRUCTOR_NAME } + NoNameShadowing:EtsFieldResolver.kt${ it.name } + NoNameShadowing:EtsHierarchy.kt$EtsHierarchy$result + NoNameShadowing:TsBinaryOperator.kt$TsBinaryOperator.Eq$lhs + NoNameShadowing:TsBinaryOperator.kt$TsBinaryOperator.Eq$rhs + NoNameShadowing:TsInterpreter.kt$TsInterpreter$ref + NoNameShadowing:TsInterpreter.kt$TsInterpreter${ it.methods.any { it.name == stmt.callee.name } } + NoNameShadowing:TsInterpreter.kt$TsInterpreter${ it.name == stmt.callee.name } + NoNameShadowing:WriteField.kt$field + NoSemicolons:TsPromise.kt$PromiseState.REJECTED$; + TooGenericExceptionCaught:TsImports.kt$e: Exception + TooGenericExceptionCaught:TsInterpreter.kt$TsInterpreter$e: Exception + TrailingCommaOnDeclarationSite:TsPromise.kt$PromiseState + UnderscoresInNumericLiterals:TsContext.kt$Constants.Companion$1000000 + UndocumentedPublicClass:EtsFakeType.kt$EtsFakeType : EtsType + UndocumentedPublicClass:EtsFakeType.kt$ExprWithTypeConstraint<Sort : USort> + UndocumentedPublicClass:EtsFieldResolver.kt$TsResolutionResult$Empty : TsResolutionResult + UndocumentedPublicClass:EtsFieldResolver.kt$TsResolutionResult<out T> + UndocumentedPublicClass:EtsHierarchy.kt$EtsHierarchy + UndocumentedPublicClass:ReachabilityObserver.kt$ReachabilityObserver : UMachineObserver + UndocumentedPublicClass:TsApproximations.kt$TsExprApproximationResult + UndocumentedPublicClass:TsApproximations.kt$TsExprApproximationResult$NoApproximation : TsExprApproximationResult + UndocumentedPublicClass:TsApproximations.kt$TsExprApproximationResult$ResolveFailure : TsExprApproximationResult + UndocumentedPublicClass:TsBinaryOperator.kt$TsBinaryOperator + UndocumentedPublicClass:TsBinaryOperator.kt$TsBinaryOperator$Add : TsBinaryOperator + UndocumentedPublicClass:TsBinaryOperator.kt$TsBinaryOperator$And : TsBinaryOperator + UndocumentedPublicClass:TsBinaryOperator.kt$TsBinaryOperator$Div : TsArithmeticOperator + UndocumentedPublicClass:TsBinaryOperator.kt$TsBinaryOperator$Eq : TsBinaryOperator + UndocumentedPublicClass:TsBinaryOperator.kt$TsBinaryOperator$Gt : TsBinaryOperator + UndocumentedPublicClass:TsBinaryOperator.kt$TsBinaryOperator$Lt : TsBinaryOperator + UndocumentedPublicClass:TsBinaryOperator.kt$TsBinaryOperator$Mul : TsArithmeticOperator + UndocumentedPublicClass:TsBinaryOperator.kt$TsBinaryOperator$Neq : TsBinaryOperator + UndocumentedPublicClass:TsBinaryOperator.kt$TsBinaryOperator$Or : TsBinaryOperator + UndocumentedPublicClass:TsBinaryOperator.kt$TsBinaryOperator$Rem : TsArithmeticOperator + UndocumentedPublicClass:TsBinaryOperator.kt$TsBinaryOperator$StrictEq : TsBinaryOperator + UndocumentedPublicClass:TsBinaryOperator.kt$TsBinaryOperator$StrictNeq : TsBinaryOperator + UndocumentedPublicClass:TsBinaryOperator.kt$TsBinaryOperator$Sub : TsArithmeticOperator + UndocumentedPublicClass:TsBinaryOperator.kt$TsBinaryOperator$TsArithmeticOperator : TsBinaryOperator + UndocumentedPublicClass:TsComponents.kt$TsComponents : UComponents + UndocumentedPublicClass:TsContext.kt$Constants + UndocumentedPublicClass:TsContext.kt$IntermediateLValueField + UndocumentedPublicClass:TsContext.kt$TsContext : UContext + UndocumentedPublicClass:TsExprResolver.kt$TsExprResolver : Visitor + UndocumentedPublicClass:TsExprResolver.kt$TsSimpleValueResolver : Visitor + UndocumentedPublicClass:TsExpressions.kt$TsUndefinedSort : USort + UndocumentedPublicClass:TsExpressions.kt$TsUndefinedValue : UExpr + UndocumentedPublicClass:TsExpressions.kt$TsVoidSort : USort + UndocumentedPublicClass:TsExpressions.kt$TsVoidValue : UExpr + UndocumentedPublicClass:TsFunction.kt$TsFunction + UndocumentedPublicClass:TsGraph.kt$TsGraph : ApplicationGraph + UndocumentedPublicClass:TsImports.kt$ImportResolutionResult + UndocumentedPublicClass:TsImports.kt$SymbolResolutionResult + UndocumentedPublicClass:TsInterpreter.kt$TsInterpreter : UInterpreter + UndocumentedPublicClass:TsInterpreterObserver.kt$TsInterpreterObserver : UInterpreterObserver + UndocumentedPublicClass:TsMachine.kt$TsMachine : UMachine + UndocumentedPublicClass:TsMethodCall.kt$TsConcreteMethodCallStmt : TsMethodCall + UndocumentedPublicClass:TsMethodCall.kt$TsMethodCall : EtsStmt + UndocumentedPublicClass:TsMethodCall.kt$TsVirtualMethodCallStmt : TsMethodCall + UndocumentedPublicClass:TsMethodResult.kt$TsMethodResult$Success : TsMethodResult + UndocumentedPublicClass:TsOptions.kt$TsOptions + UndocumentedPublicClass:TsPromise.kt$PromiseState + UndocumentedPublicClass:TsStateVisualizer.kt$TsStateVisualizer : TsInterpreterObserverUMachineObserver + UndocumentedPublicClass:TsTarget.kt$TsReachabilityTarget : TsTarget + UndocumentedPublicClass:TsTarget.kt$TsTarget : UTarget + UndocumentedPublicClass:TsTest.kt$GlobalFieldValue + UndocumentedPublicClass:TsTest.kt$NoCoverage : TsMethodCoverage + UndocumentedPublicClass:TsTest.kt$TsMethodCoverage + UndocumentedPublicClass:TsTest.kt$TsParametersState + UndocumentedPublicClass:TsTest.kt$TsTest + UndocumentedPublicClass:TsTest.kt$TsTestValue + UndocumentedPublicClass:TsTest.kt$TsTestValue$TsAny : TsTestValue + UndocumentedPublicClass:TsTest.kt$TsTestValue$TsException : TsTestValue + UndocumentedPublicClass:TsTest.kt$TsTestValue$TsNull : TsTestValue + UndocumentedPublicClass:TsTest.kt$TsTestValue$TsNumber : TsTestValue + UndocumentedPublicClass:TsTest.kt$TsTestValue$TsUndefined : TsTestValue + UndocumentedPublicClass:TsTest.kt$TsTestValue$TsUnknown : TsTestValue + UndocumentedPublicClass:TsTest.kt$TsTestValue.TsException$UnknownException : TsException + UndocumentedPublicClass:TsTransformer.kt$TsComposer : UComposerTsTransformer + UndocumentedPublicClass:TsTransformer.kt$TsExprTranslator : UExprTranslatorTsTransformer + UndocumentedPublicClass:TsTransformer.kt$TsTransformer : UTransformer + UndocumentedPublicClass:TsTypeSystem.kt$TsTypeSystem : UTypeSystem + UndocumentedPublicClass:TsUnaryOperator.kt$TsUnaryOperator + UndocumentedPublicClass:TsUnaryOperator.kt$TsUnaryOperator$Neg : TsUnaryOperator + UndocumentedPublicClass:TsUnaryOperator.kt$TsUnaryOperator$Not : TsUnaryOperator + UndocumentedPublicClass:UnreachableCodeDetector.kt$UncoveredIfSuccessors + UndocumentedPublicClass:UnreachableCodeDetector.kt$UnreachableCodeDetector : TsInterpreterObserverUMachineObserver + UnreachableCode:FakeExprUtil.kt$memory.read(lValue) to typeCondition + UnsafeCallOnNullableType:CallStatic.kt$it.enclosingClass!! + UnsafeCallOnNullableType:CallStatic.kt$resolved.property.enclosingClass!! + UnsafeCallOnNullableType:TsExprResolver.kt$TsSimpleValueResolver$currentMethod.enclosingClass!! + UnsafeCallOnNullableType:TsExprResolver.kt$TsSimpleValueResolver$currentMethod.enclosingClass!!.declaringFile!! + UnsafeCallOnNullableType:TsInterpreter.kt$TsInterpreter$lastEnteredMethod.enclosingClass!! + UnsafeCallOnNullableType:TsInterpreter.kt$TsInterpreter$lastEnteredMethod.enclosingClass!!.declaringFile!! + UnsafeCallOnNullableType:TsInterpreter.kt$TsInterpreter$method.enclosingClass!! + UnsafeCallOnNullableType:WriteLocal.kt$currentMethod.enclosingClass!! + UnsafeCallOnNullableType:WriteLocal.kt$currentMethod.enclosingClass!!.declaringFile!! + UnusedParameter:CallApproximations.kt$arrayType: EtsArrayType + UnusedParameter:CallApproximations.kt$elementSort: USort + UnusedParameter:TsStatic.kt$clazz: EtsClassSignature + UnusedPrivateProperty:CallApproximations.kt$val searchElement = resolve(expr.args.single()) ?: return null + UnusedPrivateProperty:TsExprResolver.kt$TsExprResolver$val property = resolve(expr.left) ?: return null + UseOrEmpty:EtsHierarchy.kt$EtsHierarchy$suitableClasses[signature]?.let { setOf(it) } ?: emptySet() + UseOrEmpty:TsTypeSystem.kt$fields.reduceOrNull { acc, set -> acc.intersect(set) } ?: emptySet() + UseOrEmpty:TsTypeSystem.kt$methods.reduceOrNull { acc, set -> acc.intersect(set) } ?: emptySet() + UtilityClassWithPublicConstructor:TsContext.kt$Constants + + diff --git a/detekt/baselines/usvm-ts-Test.yml b/detekt/baselines/usvm-ts-Test.yml new file mode 100644 index 0000000000..60c90e1179 --- /dev/null +++ b/detekt/baselines/usvm-ts-Test.yml @@ -0,0 +1,116 @@ + + + + + ArgumentListWrapping:TsMethodTestRunner.kt$TsMethodTestRunner$( typeTransformer(T1::class), typeTransformer(T2::class), typeTransformer(R::class) ) + BlockCommentInitialStarAlignment:TsMethodTestRunner.kt$TsMethodTestRunner$/* Both KClass and TsObject instances come here because only KClass<TsObject> is available to match different objects. However, this method is also used in parent TestRunner class and passes here TsObject instances. So this check on current level is required. */ + BracesOnWhenStatements:TsMethodTestRunner.kt$TsMethodTestRunner$when + BracesOnWhenStatements:TsTestResolver.kt$TsTestStateResolver$when + CascadingCallWrapping:InheritanceReachabilityTest.kt$InheritanceReachabilityTest$filter { it.name == "process" } + CascadingCallWrapping:TsMethodTestRunner.kt$TsMethodTestRunner$filter { it.name == methodName } + CascadingCallWrapping:TsMethodTestRunner.kt$TsMethodTestRunner$single { it.name == className } + ChainWrapping:Add.kt$Add$&& + ChainWrapping:Add.kt$Add$|| + ChainWrapping:ArrayMethods.kt$ArrayMethods$&& + CommentSpacing:LoadEts.kt$//----------------------------------------------------------------------------- + Filename:DemoCalc.kt$org.usvm.project.DemoCalc.kt + Filename:DemoPhotos.kt$org.usvm.project.DemoPhotos.kt + Filename:ImportExportResolution.kt$org.usvm.machine.ImportExportResolution.kt + Filename:ImportResolver.kt$org.usvm.project.ImportResolver.kt + Filename:UnreachableCodeDetector.kt$org.usvm.checkers.UnreachableCodeDetector.kt + ForbiddenMethodCall:DemoCalc.kt$RunOnDemoCalcProject$println("${es.first()}") + ForbiddenMethodCall:DemoCalc.kt$RunOnDemoCalcProject$println("Total classes: ${classes.size}") + ForbiddenMethodCall:DemoCalc.kt$RunOnDemoCalcProject$println("Total exceptions: ${exc.size}") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println( " Available exports: ${ exportNames.joinToString(", ") }${ if (availableExports.size > 5) " ..." else "" }" ) + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println( " ✅ '${importInfo.name}' from '${importInfo.from}' -> '${result.file.signature.fileName}'" + " (type: ${importInfo.type})" ) + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println( " ❌ '${importInfo.name}' from '${importInfo.from}' -> ${result.reason}" + " (type: ${importInfo.type})" ) + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println( " 🎯 '${importInfo.name}' from '${importInfo.from}' -> '${result.file.signature.fileName}'" + " exports: ${getExportDescription(exportInfo)} (import type: ${importInfo.type})" ) + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println( " 📁❌ '${importInfo.name}' from '${importInfo.from}' -> ${result.reason}" + " (import type: ${importInfo.type})" ) + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println( " 🔍❌ '${importInfo.name}' from '${importInfo.from}' -> ${result.reason}" + " (import type: ${importInfo.type})" ) + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println(" $status '$input' -> '$result' (expected: '$expected')") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println(" ✓ '$importPath' resolved to '${result.file.signature.fileName}'") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println(" ✗ '$importPath' failed: ${result.reason}") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("Complete symbol resolution success rate: $symbolSuccessRate%") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("Failed to resolve files: $failedImports") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("File not found: $fileNotFoundSymbols") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("File resolution success rate: $successRate%") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("Loading SDK from path: $sdkPath") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("Loading SDK from resource: $it") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("Loading project from path: $projectPath") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("Loading project from resources: $path") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("Merging project and SDK files...") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("Project files by extension: ${projectFilesByExtension.mapValues { it.value.size }}") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("Project files: ${scene.projectFiles.size}") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("Project loaded: ${projectScene.projectName} with ${projectScene.projectFiles.size} files") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("SDK files by extension: ${sdkFilesByExtension.mapValues { it.value.size }}") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("SDK files: ${scene.sdkFiles.size}") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("SDK loaded: ${sdkScene.projectName} with ${sdkScene.projectFiles.size} files") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("Scene loaded with ${scene.projectFiles.size} project files and ${scene.sdkFiles.size} SDK files") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("Successfully resolved files: $successfulImports") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("Successfully resolved symbols: $successfulSymbols") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("Symbol not found in file: $symbolNotFoundSymbols") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("Total files: ${scene.projectAndSdkClasses.size}") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("Total imports found: $totalImports") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("Total imports in project files: ${scene.projectFiles.sumOf { it.importInfos.size }}") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("Total symbols to resolve: $totalSymbols") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("Using test file: ${testFile.signature.fileName}") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("\n$category:") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("\n--- Complete Symbol Resolution Summary ---") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("\n--- File Import Resolution Summary ---") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("\n--- Setting up scene for import resolution tests ---") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("\n--- Summary ---") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("\n--- Testing common import patterns ---") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("\n--- Testing complete symbol resolver ---") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("\n--- Testing file-level import resolver ---") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("\nPath Normalization Tests:") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("\n[${index + 1}/${allFiles.size}] File: $fileName (${imports.size} imports)") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("\n[${index + 1}/${allFiles.size}] File: $fileName (no imports)") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("\n[${index + 1}/${allFiles.size}] File: '$fileName' (${imports.size} imports)") + ForbiddenMethodCall:ImportResolver.kt$ImportResolverTest$println("\n[${index + 1}/${allFiles.size}] File: '$fileName' (no imports)") + FunctionOnlyReturningConstant:Truthy.kt$fun isTruthy(x: TsTestValue.TsClass): Boolean + ImportOrdering:StaticOverloads.kt$import org.jacodb.ets.model.EtsScene import org.junit.jupiter.api.Test import org.usvm.api.TsTestValue import org.usvm.machine.TsMachine import org.usvm.machine.TsOptions import org.usvm.machine.state.TsState import org.usvm.statistics.UMachineObserver import org.usvm.test.util.checkers.eq as exactly import org.usvm.util.TsMethodTestRunner import org.usvm.util.eq import kotlin.test.assertEquals + LongMethod:Division.kt$Division$@Test fun `test number div number`() + MatchingDeclarationName:ImportExportResolution.kt$ImportExportResolutionTest + MatchingDeclarationName:UnreachableCodeDetector.kt$UnreachableCodeDetectorTest + MaxLineLength:Division.kt$Division$(a.number == Double.NEGATIVE_INFINITY) && b.number.isFinite() && (b.number < 0) && (r.number == Double.POSITIVE_INFINITY) + MaxLineLength:Division.kt$Division$(a.number == Double.NEGATIVE_INFINITY) && b.number.isFinite() && (b.number > 0) && (r.number == Double.NEGATIVE_INFINITY) + MaxLineLength:Division.kt$Division$(a.number == Double.POSITIVE_INFINITY) && b.number.isFinite() && (b.number < 0) && (r.number == Double.NEGATIVE_INFINITY) + MaxLineLength:Division.kt$Division$(a.number == Double.POSITIVE_INFINITY) && b.number.isFinite() && (b.number > 0) && (r.number == Double.POSITIVE_INFINITY) + MaxLineLength:Division.kt$Division$a.number.isFinite() && (a.number < 0) && b.number.isFinite() && (b.number < 0) && (r.number == a.number / b.number) + MaxLineLength:Division.kt$Division$a.number.isFinite() && (a.number < 0) && b.number.isFinite() && (b.number < 0) && (r.number >= 0) && (r.number == a.number / b.number) + MaxLineLength:Division.kt$Division$a.number.isFinite() && (a.number < 0) && b.number.isFinite() && (b.number > 0) && (r.number <= 0) && (r.number == a.number / b.number) + MaxLineLength:Division.kt$Division$a.number.isFinite() && (a.number > 0) && b.number.isFinite() && (b.number > 0) && (r.number == a.number / b.number) + MaxLineLength:ImportResolver.kt$ImportResolverTest$" ✅ '${importInfo.name}' from '${importInfo.from}' -> '${result.file.signature.fileName}'" + MaxLineLength:ImportResolver.kt$ImportResolverTest$" 🎯 '${importInfo.name}' from '${importInfo.from}' -> '${result.file.signature.fileName}'" + MaxLineLength:ImportResolver.kt$ImportResolverTest$"All path normalization tests should pass (expected: ${normalizationTests.size}, actual: $correctNormalizations)" + MaxLineLength:InheritanceReachabilityTest.kt$InheritanceReachabilityTest$// const obj = new ConcreteA(value) -> const specificResult = obj.specificMethodA() -> if (specificResult === 1) -> return 1 + MaxLineLength:InstanceMethods.kt$InstanceMethods$discoverProperties + MaxLineLength:RecursionReachabilityTest.kt$RecursionReachabilityTest$// if (input > 0 && input < 5) -> const evenResult = this.isEven(input) -> if (evenResult && input === 4) -> return 1 + MaxLineLength:StaticMethods.kt$StaticMethods$discoverProperties + MaxLineLength:TsMethodTestRunner.kt$TsMethodTestRunner$protected inline + MaxLineLength:TypeGuardsReachabilityTest.kt$TypeGuardsReachabilityTest$// if (typeof value === "object" && value !== null) -> if (value instanceof Date) -> if (value.getFullYear() > 2020) -> return 1 + MaximumLineLength:Division.kt$Division$ + MaximumLineLength:ImportResolver.kt$ImportResolverTest$ + MaximumLineLength:InstanceMethods.kt$InstanceMethods$ + MaximumLineLength:StaticMethods.kt$StaticMethods$ + MaximumLineLength:TsMethodTestRunner.kt$TsMethodTestRunner$ + MultiLineIfElse:TypeStream.kt$TypeStream$true + NestedBlockDepth:TsTestResolver.kt$TsTestStateResolver$private fun resolvePrimitive( expr: UExpr<out USort>, type: EtsPrimitiveType, ): TsTestValue + NoBlankLineInList:ImportExportResolution.kt$ImportExportResolutionTest$ + NoEmptyFirstLineInMethodBlock:And.kt$And$ + NoEmptyFirstLineInMethodBlock:NullishCoalescing.kt$NullishCoalescing$ + NoEmptyFirstLineInMethodBlock:Truthy.kt$Truthy$ + NoNameShadowing:NullishCoalescing.kt$NullishCoalescing$a + NoTrailingSpaces:LoopsReachabilityTest.kt$LoopsReachabilityTest$ + NoUnusedImports:ArrayReachabilityTest.kt$org.usvm.reachability.ArrayReachabilityTest.kt + NoUnusedImports:DemoPhotos.kt$org.usvm.project.DemoPhotos.kt + UnderscoresInNumericLiterals:Bitwise.kt$Bitwise$2147483647 + UnderscoresInNumericLiterals:ReachabilityChecker.kt$ReachabilityChecker$15000000 + UnderscoresInNumericLiterals:TsMethodTestRunner.kt$TsMethodTestRunner$1000000000 + UnusedParameter:Truthy.kt$x: TsTestValue.TsClass + UseCheckNotNull:UnreachableCodeDetector.kt$UnreachableCodeDetectorTest$check(uncoveredStatements != null) { "Uncovered statements are incorrect, results are $uncoveredStatements" } + VarCouldBeVal:HigherOrderFunctionsReachabilityTest.kt$HigherOrderFunctionsReachabilityTest$var target: TsTarget = initialTarget + Wrapping:Add.kt$Add$( + Wrapping:ImportResolver.kt$ImportResolverTest$( + + diff --git a/detekt/ts-config.yml b/detekt/ts-config.yml new file mode 100644 index 0000000000..003c5075c3 --- /dev/null +++ b/detekt/ts-config.yml @@ -0,0 +1,15 @@ +comments: + UndocumentedPublicClass: + active: true + searchInNestedClass: false + excludes: + - '**/test/**' + +complexity: + ComplexCondition: + active: true + threshold: 4 + +style: + CascadingCallWrapping: + active: true diff --git a/usvm-ts-pbt/README.md b/usvm-ts-pbt/README.md index 492f23979e..174b207336 100644 --- a/usvm-ts-pbt/README.md +++ b/usvm-ts-pbt/README.md @@ -46,16 +46,16 @@ val manifest = property.toManifest() Input order is significant because TypeScript parameters are positional. Names are unique and are retained in diagnostics and artifacts. -| Domain | Semantics and defaults | -| --- | --- | -| `BooleanDomain` | JavaScript booleans | -| `IntegerDomain` | Inclusive signed 32-bit range; defaults to `Int.MIN_VALUE..Int.MAX_VALUE` | -| `NumberDomain` | ECMAScript binary64; defaults to both infinities and `allowNaN = true`; bounded domains reject NaN | -| `StringDomain` | Arbitrary UTF-16 code units; length is JavaScript `String.length`; defaults to `0..10` | -| `ConstantDomain` | One tagged JavaScript primitive | -| `OptionalDomain` | Nested domain plus exactly `undefined` or `null` as the nil value | -| `TupleDomain` | Non-empty ordered recursive domains | -| `ArrayDomain` | Recursive element domain; defaults to length `0..10` | +| Domain | Semantics and defaults | +| ---------------- | -------------------------------------------------------------------------------------------------- | +| `BooleanDomain` | JavaScript booleans | +| `IntegerDomain` | Inclusive signed 32-bit range; defaults to `Int.MIN_VALUE..Int.MAX_VALUE` | +| `NumberDomain` | ECMAScript binary64; defaults to both infinities and `allowNaN = true`; bounded domains reject NaN | +| `StringDomain` | Arbitrary UTF-16 code units; length is JavaScript `String.length`; defaults to `0..10` | +| `ConstantDomain` | One tagged JavaScript primitive | +| `OptionalDomain` | Nested domain plus exactly `undefined` or `null` as the nil value | +| `TupleDomain` | Non-empty ordered recursive domains | +| `ArrayDomain` | Recursive element domain; defaults to length `0..10` | `PropertyDomain` describes a set of allowed inputs. `JsConcreteValue` describes one concrete JavaScript value used as a constant or returned sample; it is unrelated to JacoDB IR values. Its tagged encoding preserves `undefined`, diff --git a/usvm-ts-pbt/build.gradle.kts b/usvm-ts-pbt/build.gradle.kts index 10a2a8036b..56503aa6b4 100644 --- a/usvm-ts-pbt/build.gradle.kts +++ b/usvm-ts-pbt/build.gradle.kts @@ -56,7 +56,3 @@ tasks.check { tasks.clean { delete(fastCheckAdapterDir.dir("dist")) } - -tasks.withType().configureEach { - jvmTarget = JavaVersion.VERSION_1_8.toString() -} diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/backend/ProjectionCapability.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/backend/ProjectionCapability.kt index a1a9a67173..2537881a7c 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/backend/ProjectionCapability.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/backend/ProjectionCapability.kt @@ -1,24 +1,53 @@ package org.usvm.ts.pbt.backend +/** Describes how faithfully a backend can project a Kotlin property domain. */ enum class ProjectionLevel { + /** Every value produced by the backend has the declared Kotlin domain semantics. */ EXACT, + + /** The backend can run the domain, but its values differ from the declared semantics. */ APPROXIMATE, + + /** The backend cannot project the domain. */ UNSUPPORTED, } +/** Describes which execution modes remain available for a complete property. */ enum class PropertyCapabilityLevel { + /** Both concrete and symbolic projections preserve the declared property semantics. */ EXACT, + + /** Both projections are available, but at least one is approximate. */ APPROXIMATE, + + /** Concrete PBT execution is available, but symbolic execution is not. */ CONCRETE_ONLY, + + /** Concrete PBT execution is unavailable, so the property cannot be executed. */ UNSUPPORTED, } +/** + * Explains why a projection is not exact. + * + * @property code stable machine-readable diagnostic code + * @property message human-readable description of the limitation + * @property path location of the affected value in the property model + */ data class CapabilityDiagnostic( val code: String, val message: String, val path: String, ) +/** + * Reports whether one backend version can represent a property domain. + * + * @property backendId stable backend identifier + * @property backendVersion backend version used to evaluate support + * @property level semantic fidelity of the projection + * @property diagnostics limitations that explain a non-exact [level] + */ data class ProjectionCapability( val backendId: String, val backendVersion: String, @@ -34,6 +63,7 @@ data class ProjectionCapability( } } +/** Combines domain-level [capabilities] into one deterministic backend capability report. */ fun aggregateProjectionCapabilities( backendId: String, backendVersion: String, @@ -54,6 +84,7 @@ fun aggregateProjectionCapabilities( ) } +/** Derives the execution modes available when concrete and symbolic projections are considered together. */ fun classifyPropertyCapability( concrete: ProjectionCapability, symbolic: ProjectionCapability, 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 8563d72f34..1ce5ce3ac9 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 @@ -5,11 +5,19 @@ import kotlinx.serialization.encodeToString import org.usvm.ts.pbt.manifest.PropertyManifestJson import java.io.IOException import java.nio.file.Path +import java.util.concurrent.Executors +/** + * Synchronous Kotlin client for the private fast-check Node adapter. + * + * Each request starts a fresh adapter process, writes one JSON request, and validates the single JSON response + * before exposing sampled values to Kotlin callers. + */ class FastCheckProjectionClient( private val nodeExecutable: String = "node", private val adapterEntryPoint: Path, ) { + /** Projects the requested domains to fast-check and returns the generated samples. */ fun sample(request: FastCheckProjectionRequest): FastCheckProjectionResponse { validateRequest(request) val response = decodeResponse(invokeAdapter(request)) @@ -63,25 +71,33 @@ class FastCheckProjectionClient( private fun invokeAdapter(request: FastCheckProjectionRequest): String { val process = startAdapter() - process.outputWriter(Charsets.UTF_8).use { writer -> - writer.write(PropertyManifestJson.json.encodeToString(request)) + val errorReaderExecutor = Executors.newSingleThreadExecutor() + val stderr = errorReaderExecutor.submit { + process.errorStream.bufferedReader(Charsets.UTF_8).use { reader -> reader.readText() } } - val stdout = process.inputReader(Charsets.UTF_8).readText() - val stderr = process.errorReader(Charsets.UTF_8).readText() - val exitCode = process.waitFor() - if (exitCode != 0) { - throw FastCheckProjectionException( - code = "backend.process.failed", - message = "fast-check adapter exited with code $exitCode: ${stderr.trim()}", - ) - } - if (stdout.isBlank()) { - throw FastCheckProjectionException( - code = "backend.response.empty", - message = "fast-check adapter returned an empty response", - ) + try { + process.outputStream.bufferedWriter(Charsets.UTF_8).use { writer -> + writer.write(PropertyManifestJson.json.encodeToString(request)) + } + val stdout = process.inputStream.bufferedReader(Charsets.UTF_8).use { reader -> reader.readText() } + val exitCode = process.waitFor() + val stderrText = stderr.get() + if (exitCode != 0) { + throw FastCheckProjectionException( + code = "backend.process.failed", + message = "fast-check adapter exited with code $exitCode: ${stderrText.trim()}", + ) + } + if (stdout.isBlank()) { + throw FastCheckProjectionException( + code = "backend.response.empty", + message = "fast-check adapter returned an empty response", + ) + } + return stdout + } finally { + errorReaderExecutor.shutdownNow() } - return stdout } private fun startAdapter(): Process = try { diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionProtocol.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionProtocol.kt index 8db4a0c44d..33900a4620 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionProtocol.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionProtocol.kt @@ -6,6 +6,7 @@ import org.usvm.ts.pbt.model.PropertyDomain const val FAST_CHECK_PROTOCOL_VERSION = 1 +/** One versioned request sent from Kotlin to the private fast-check adapter process. */ @Serializable data class FastCheckProjectionRequest( val protocolVersion: Int = FAST_CHECK_PROTOCOL_VERSION, @@ -16,6 +17,7 @@ data class FastCheckProjectionRequest( val domains: List, ) +/** Validated samples returned by fast-check in positional input order. */ data class FastCheckProjectionResponse( val protocolVersion: Int, val requestId: String, @@ -38,6 +40,7 @@ internal data class FastCheckProtocolDiagnostic( val path: String? = null, ) +/** Typed failure reported by the fast-check process or its Kotlin protocol boundary. */ class FastCheckProjectionException( val code: String, message: String, diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/manifest/PropertyManifest.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/manifest/PropertyManifest.kt index cfc42a33b3..26fe1886d4 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/manifest/PropertyManifest.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/manifest/PropertyManifest.kt @@ -13,6 +13,11 @@ import org.usvm.ts.pbt.validation.validatePropertyManifest const val PROPERTY_MANIFEST_SCHEMA_VERSION = 1 +/** + * Versioned transport representation of a validated [PropertyDefinition]. + * + * The manifest is the boundary shared with replaceable concrete PBT adapters and later symbolic projections. + */ @Serializable data class PropertyManifest( val schemaVersion: Int = PROPERTY_MANIFEST_SCHEMA_VERSION, @@ -32,6 +37,7 @@ fun PropertyDefinition.toManifest(): PropertyManifest { ) } +/** Strict JSON codec for versioned property manifests. */ object PropertyManifestJson { val json = Json { classDiscriminator = "kind" 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 5f1d07ee72..dd53ce0943 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 @@ -11,6 +11,7 @@ import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonArray @@ -18,21 +19,27 @@ 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. */ @Serializable enum class JsNumberKind { + /** Finite value represented by its raw IEEE-754 bits. */ @SerialName("finite") FINITE, + /** JavaScript NaN value. */ @SerialName("nan") NAN, + /** Positive infinity. */ @SerialName("positive-infinity") POSITIVE_INFINITY, + /** Negative infinity. */ @SerialName("negative-infinity") NEGATIVE_INFINITY, } +/** Lossless tagged representation of a JavaScript number, including NaN, infinities, and negative zero. */ @Serializable data class JsNumber( val value: JsNumberKind, @@ -55,7 +62,10 @@ data class JsNumber( require(value.isFinite()) { "Use a tagged representation for non-finite JavaScript numbers" } return JsNumber( value = JsNumberKind.FINITE, - bits = value.toRawBits().toULong().toString(JS_NUMBER_HEX_RADIX) + bits = value + .toRawBits() + .toULong() + .toString(JS_NUMBER_HEX_RADIX) .padStart(JS_NUMBER_HEX_DIGITS, '0'), ) } @@ -75,23 +85,31 @@ data class JsNumber( } } +/** Lossless transport value for JavaScript primitives and recursively nested arrays. */ @Serializable(with = JsConcreteValueSerializer::class) sealed interface JsConcreteValue { + /** JavaScript `undefined`. */ data object Undefined : JsConcreteValue + /** JavaScript `null`. */ data object Null : JsConcreteValue + /** Concrete JavaScript boolean value. */ data class Boolean(val value: kotlin.Boolean) : JsConcreteValue + /** Concrete JavaScript UTF-16 string value. */ data class String(val value: kotlin.String) : JsConcreteValue + /** Concrete JavaScript binary64 number with lossless special-value encoding. */ data class Number(val number: JsNumber) : JsConcreteValue { fun toDouble(): Double = number.toDouble() } + /** Ordered recursively tagged elements of one concrete JavaScript array. */ data class Array(val elements: List) : JsConcreteValue } +/** JSON serializer for the tagged [JsConcreteValue] wire representation. */ object JsConcreteValueSerializer : KSerializer { override val descriptor: SerialDescriptor = buildClassSerialDescriptor("JsConcreteValue") @@ -108,6 +126,7 @@ object JsConcreteValueSerializer : KSerializer { JsConcreteValue.Null -> { put("kind", "null") } + is JsConcreteValue.Boolean -> { put("kind", "boolean") put("value", value.value) @@ -125,15 +144,13 @@ object JsConcreteValueSerializer : KSerializer { } is JsConcreteValue.Array -> { + val elements = value.elements.map { element -> + jsonEncoder.json.encodeToJsonElement(JsConcreteValueSerializer, element) + } + val jsonElements = JsonArray(elements) + put("kind", "array") - put( - "elements", - JsonArray( - value.elements.map { element -> - jsonEncoder.json.encodeToJsonElement(JsConcreteValueSerializer, element) - }, - ), - ) + put("elements", jsonElements) } } }, @@ -147,37 +164,47 @@ object JsConcreteValueSerializer : KSerializer { return when (val kind = value.requiredString("kind")) { "undefined" -> JsConcreteValue.Undefined "null" -> JsConcreteValue.Null - "boolean" -> JsConcreteValue.Boolean( - value["value"]?.jsonPrimitive?.booleanOrNull - ?: throw SerializationException("Boolean JsConcreteValue requires a boolean value"), - ) - + "boolean" -> deserializeBoolean(value) "string" -> JsConcreteValue.String(value.requiredString("value")) - "number" -> JsConcreteValue.Number( - JsNumber( - value = when (val numberKind = value.requiredString("value")) { - "finite" -> JsNumberKind.FINITE - "nan" -> JsNumberKind.NAN - "positive-infinity" -> JsNumberKind.POSITIVE_INFINITY - "negative-infinity" -> JsNumberKind.NEGATIVE_INFINITY - else -> throw SerializationException("Unknown JavaScript number kind: $numberKind") - }, - bits = value["bits"]?.jsonPrimitive?.content, - ), - ) - - "array" -> JsConcreteValue.Array( - value["elements"]?.jsonArray?.map { element -> - jsonDecoder.json.decodeFromJsonElement(JsConcreteValueSerializer, element) - } ?: throw SerializationException("Array JsConcreteValue requires elements"), - ) - + "number" -> deserializeNumber(value) + "array" -> deserializeArray(jsonDecoder, value) else -> throw SerializationException("Unknown JavaScript value kind: $kind") } } } -private val JsNumberKind.serialName: kotlin.String +private fun deserializeBoolean(value: JsonObject): JsConcreteValue.Boolean { + val booleanValue = value["value"]?.jsonPrimitive?.booleanOrNull + ?: throw SerializationException("Boolean JsConcreteValue requires a boolean value") + return JsConcreteValue.Boolean(booleanValue) +} + +private fun deserializeNumber(value: JsonObject): JsConcreteValue.Number { + val numberKindName = value.requiredString("value") + val numberKind = when (numberKindName) { + "finite" -> JsNumberKind.FINITE + "nan" -> JsNumberKind.NAN + "positive-infinity" -> JsNumberKind.POSITIVE_INFINITY + "negative-infinity" -> JsNumberKind.NEGATIVE_INFINITY + else -> throw SerializationException("Unknown JavaScript number kind: $numberKindName") + } + val bits = value["bits"]?.jsonPrimitive?.content + 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 + ?: throw SerializationException("Array JsConcreteValue requires elements") + val elements = jsonElements.map { element -> + jsonDecoder.json.decodeFromJsonElement(JsConcreteValueSerializer, element) + } + + return JsConcreteValue.Array(elements) +} + +private val JsNumberKind.serialName: String get() = when (this) { JsNumberKind.FINITE -> "finite" JsNumberKind.NAN -> "nan" @@ -185,7 +212,7 @@ private val JsNumberKind.serialName: kotlin.String JsNumberKind.NEGATIVE_INFINITY -> "negative-infinity" } -private fun kotlinx.serialization.json.JsonObject.requiredString(name: kotlin.String): kotlin.String = +private fun JsonObject.requiredString(name: String): String = get(name)?.jsonPrimitive?.content ?: throw SerializationException("JsConcreteValue requires a $name field") diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDefinition.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDefinition.kt index a5c73fbfce..b9234fba98 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDefinition.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDefinition.kt @@ -3,6 +3,7 @@ package org.usvm.ts.pbt.model import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +/** Stable canonical identifier used to correlate one property across artifacts and backends. */ @JvmInline @Serializable value class PropertyId private constructor(val value: String) { @@ -18,6 +19,14 @@ value class PropertyId private constructor(val value: String) { } } +/** + * Backend-independent Kotlin definition of one property. + * + * @property id stable identity of the property + * @property inputs ordered domains matching positional TypeScript parameters + * @property predicate TypeScript function that must hold for generated inputs + * @property precondition optional TypeScript function that filters inputs before evaluation + */ @Serializable data class PropertyDefinition( val id: PropertyId, @@ -26,12 +35,25 @@ data class PropertyDefinition( val precondition: TypeScriptEntryPoint? = null, ) +/** + * Names one positional property input and declares its backend-independent domain. + * + * @property name JavaScript identifier used in diagnostics and artifacts + * @property domain values that concrete and symbolic backends may produce + */ @Serializable data class PropertyInput( val name: String, val domain: PropertyDomain, ) +/** + * References an exported TypeScript function without loading or executing it. + * + * @property module normalized project-relative POSIX module path + * @property exportName JavaScript identifier exported by [module] + * @property executionKind whether invoking the function returns directly or asynchronously + */ @Serializable data class TypeScriptEntryPoint( val module: String, @@ -39,11 +61,14 @@ data class TypeScriptEntryPoint( val executionKind: ExecutionKind = ExecutionKind.SYNC, ) +/** Describes how a referenced TypeScript function completes. */ @Serializable enum class ExecutionKind { + /** The function returns its result directly. */ @SerialName("sync") SYNC, + /** The function returns an awaitable result. */ @SerialName("async") ASYNC, } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDomain.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDomain.kt index 19cdaca473..f7a9270775 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDomain.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/PropertyDomain.kt @@ -6,13 +6,16 @@ import kotlinx.serialization.Serializable const val DEFAULT_MAX_STRING_LENGTH = 10 const val DEFAULT_MAX_ARRAY_LENGTH = 10 +/** Backend-independent set of JavaScript values that a property input may receive. */ @Serializable sealed interface PropertyDomain +/** Domain containing both JavaScript boolean values. */ @Serializable @SerialName("boolean") data object BooleanDomain : PropertyDomain +/** Inclusive domain of signed 32-bit integers. */ @Serializable @SerialName("integer") data class IntegerDomain( @@ -20,6 +23,11 @@ data class IntegerDomain( val max: Int = Int.MAX_VALUE, ) : PropertyDomain +/** + * Inclusive ECMAScript binary64 domain. + * + * Tagged infinities are valid bounds; [allowNaN] controls whether NaN belongs to an otherwise unbounded domain. + */ @Serializable @SerialName("number") data class NumberDomain( @@ -28,6 +36,7 @@ data class NumberDomain( val allowNaN: Boolean = true, ) : PropertyDomain +/** Domain of arbitrary UTF-16 code-unit sequences within the inclusive length bounds. */ @Serializable @SerialName("string") data class StringDomain( @@ -35,10 +44,12 @@ data class StringDomain( val maxLength: Int = DEFAULT_MAX_STRING_LENGTH, ) : PropertyDomain +/** Singleton domain containing one tagged JavaScript primitive. */ @Serializable @SerialName("constant") data class ConstantDomain(val value: JsConcreteValue) : PropertyDomain +/** Domain containing [value] plus exactly one nullish [nil] value. */ @Serializable @SerialName("optional") data class OptionalDomain( @@ -46,10 +57,12 @@ data class OptionalDomain( val nil: JsConcreteValue = JsConcreteValue.Undefined, ) : PropertyDomain +/** Fixed-length ordered product of non-empty recursive domains. */ @Serializable @SerialName("tuple") data class TupleDomain(val elements: List) : PropertyDomain +/** Recursive array domain with inclusive JavaScript length bounds. */ @Serializable @SerialName("array") data class ArrayDomain( 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 3d10bc51ac..00078b0053 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 @@ -19,17 +19,26 @@ import org.usvm.ts.pbt.model.TupleDomain import org.usvm.ts.pbt.model.TypeScriptEntryPoint import org.usvm.ts.pbt.model.isCanonicalPropertyId +/** + * One deterministic validation failure. + * + * @property code stable machine-readable diagnostic code + * @property message human-readable description of the invalid value + * @property path location of the invalid value in the property model + */ data class ValidationDiagnostic( val code: String, val message: String, val path: String, ) +/** Ordered validation diagnostics and their derived validity state. */ data class PropertyValidationResult(val diagnostics: List) { val isValid: Boolean get() = diagnostics.isEmpty() } +/** Thrown when an operation requires a valid property but receives [result] with diagnostics. */ class InvalidPropertyDefinitionException( val result: PropertyValidationResult, ) : IllegalArgumentException(result.diagnostics.joinToString(separator = "; ") { "${it.path}: ${it.message}" }) @@ -177,17 +186,21 @@ private fun validateNumberDomain( path: String, diagnostics: MutableList, ) { - val minValid = validateJsNumber(domain.min, "$path.min", diagnostics) - val maxValid = validateJsNumber(domain.max, "$path.max", diagnostics) + val minimumEncodingIsValid = validateJsNumber(domain.min, "$path.min", diagnostics) + val maximumEncodingIsValid = validateJsNumber(domain.max, "$path.max", diagnostics) + if (domain.min.value == JsNumberKind.NAN) { diagnostics += diagnostic("domain.number.bound.nan", "Number minimum must not be NaN", "$path.min") } if (domain.max.value == JsNumberKind.NAN) { diagnostics += diagnostic("domain.number.bound.nan", "Number maximum must not be NaN", "$path.max") } - if (minValid && maxValid && domain.min.value != JsNumberKind.NAN && domain.max.value != JsNumberKind.NAN && - domain.min.toDouble() > domain.max.toDouble() - ) { + + val encodingsAreValid = minimumEncodingIsValid && maximumEncodingIsValid + val boundsAreNotNaN = domain.min.value != JsNumberKind.NAN && domain.max.value != JsNumberKind.NAN + val boundsCanBeCompared = encodingsAreValid && boundsAreNotNaN + val minimumExceedsMaximum = boundsCanBeCompared && domain.min.toDouble() > domain.max.toDouble() + if (minimumExceedsMaximum) { diagnostics += diagnostic("domain.number.bounds", "Number minimum exceeds maximum", path) } @@ -269,32 +282,46 @@ private fun isProjectRelativePosixPath(path: String): Boolean = private fun isJavaScriptIdentifier(value: String): Boolean { if (value.isEmpty()) return false + var index = 0 var first = true + while (index < value.length) { val codePoint = value.codePointAt(index) val valid = if (first) { - codePoint == '$'.code || codePoint == '_'.code || Character.isUnicodeIdentifierStart(codePoint) + isJavaScriptIdentifierStart(codePoint) } else { - codePoint == '$'.code || - codePoint == '_'.code || - codePoint == ZERO_WIDTH_NON_JOINER || - codePoint == ZERO_WIDTH_JOINER || - Character.isUnicodeIdentifierPart(codePoint) + isJavaScriptIdentifierPart(codePoint) } if (!valid) return false + first = false index += Character.charCount(codePoint) } + return true } -private fun MutableList.toResult(): PropertyValidationResult = - sortedWith(compareBy(ValidationDiagnostic::path, ValidationDiagnostic::code)) - .let(::PropertyValidationResult) +private fun isJavaScriptIdentifierStart(codePoint: Int): Boolean = + codePoint == '$'.code || + codePoint == '_'.code || + Character.isUnicodeIdentifierStart(codePoint) + +private fun isJavaScriptIdentifierPart(codePoint: Int): Boolean = + isJavaScriptIdentifierStart(codePoint) || + codePoint == ZERO_WIDTH_NON_JOINER_CODE_POINT || + codePoint == ZERO_WIDTH_JOINER_CODE_POINT || + Character.isUnicodeIdentifierPart(codePoint) + +private fun MutableList.toResult(): PropertyValidationResult { + val orderedDiagnostics = sortedWith(compareBy(ValidationDiagnostic::path, ValidationDiagnostic::code)) + return PropertyValidationResult(orderedDiagnostics) +} private fun diagnostic(code: String, message: String, path: String) = ValidationDiagnostic(code, message, path) private val FINITE_NUMBER_BITS_REGEX = Regex("[0-9a-f]{16}") -private const val ZERO_WIDTH_NON_JOINER = 0x200C -private const val ZERO_WIDTH_JOINER = 0x200D + +// ECMAScript permits these otherwise invisible Unicode characters after the first identifier character. +private const val ZERO_WIDTH_NON_JOINER_CODE_POINT = 0x200C +private const val ZERO_WIDTH_JOINER_CODE_POINT = 0x200D 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 cb1d149589..e401303162 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 @@ -116,6 +116,31 @@ class FastCheckProjectionClientTest { } } + @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), () => { + clearTimeout(timeout) + process.stdout.write(JSON.stringify({ + protocolVersion: 1, + requestId: 'valid-request', + status: 'ok', + samples: [[{ kind: 'boolean', value: true }]] + })) + }) + """.trimIndent(), + ) { temporaryClient -> + val response = temporaryClient.sample(validRequest) + + assertEquals( + listOf(listOf(JsConcreteValue.Boolean(true))), + response.samples, + ) + } + } + private fun assertConforms(values: List, domains: List) { assertEquals(domains.size, values.size) values.zip(domains).forEach { (value, domain) -> 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 9446c0df89..b991ef5e72 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 @@ -6,8 +6,12 @@ import org.usvm.ts.pbt.manifest.PropertyManifest 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.JsNumber +import org.usvm.ts.pbt.model.JsNumberKind +import org.usvm.ts.pbt.model.NumberDomain import org.usvm.ts.pbt.model.OptionalDomain import org.usvm.ts.pbt.model.PropertyDefinition +import org.usvm.ts.pbt.model.PropertyDomain import org.usvm.ts.pbt.model.PropertyId import org.usvm.ts.pbt.model.PropertyInput import org.usvm.ts.pbt.model.StringDomain @@ -72,6 +76,23 @@ class PropertyValidationTest { ) } + @Test + fun `invalid number encodings are diagnosed without comparing the bounds`() { + val invalidMinimum = JsNumber(JsNumberKind.FINITE, bits = "invalid") + val definition = validDefinition( + NumberDomain( + min = invalidMinimum, + max = JsNumber.finite(1.0), + allowNaN = false, + ), + ) + + assertEquals( + listOf("js-number.encoding.invalid"), + validatePropertyDefinition(definition).diagnostics.map { it.code }, + ) + } + @Test fun `manifest validation rejects unknown schema version`() { val manifest = PropertyManifest( @@ -92,7 +113,7 @@ class PropertyValidationTest { assertTrue(validatePropertyDefinition(validDefinition(IntegerDomain(-5, 5))).isValid) } - private fun validDefinition(domain: org.usvm.ts.pbt.model.PropertyDomain) = PropertyDefinition( + private fun validDefinition(domain: PropertyDomain) = PropertyDefinition( id = PropertyId("valid.id"), inputs = listOf(PropertyInput("value", domain)), predicate = TypeScriptEntryPoint("properties/value.ts", "holds"), From da3c5d8d0b19f71d579b878eb3f5204bab4fd552 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 23 Aug 2026 01:10:06 +0300 Subject: [PATCH 11/11] fix(ts): include current main lint baseline --- detekt/baselines/usvm-ts-Test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/detekt/baselines/usvm-ts-Test.yml b/detekt/baselines/usvm-ts-Test.yml index 60c90e1179..6553b38732 100644 --- a/detekt/baselines/usvm-ts-Test.yml +++ b/detekt/baselines/usvm-ts-Test.yml @@ -6,6 +6,8 @@ BlockCommentInitialStarAlignment:TsMethodTestRunner.kt$TsMethodTestRunner$/* Both KClass and TsObject instances come here because only KClass<TsObject> is available to match different objects. However, this method is also used in parent TestRunner class and passes here TsObject instances. So this check on current level is required. */ BracesOnWhenStatements:TsMethodTestRunner.kt$TsMethodTestRunner$when BracesOnWhenStatements:TsTestResolver.kt$TsTestStateResolver$when + CascadingCallWrapping:CallFallbackBaselineTest.kt$CallFallbackBaselineTest$single { it.name == methodName } + CascadingCallWrapping:CallFallbackBaselineTest.kt$CallFallbackBaselineTest$stmts CascadingCallWrapping:InheritanceReachabilityTest.kt$InheritanceReachabilityTest$filter { it.name == "process" } CascadingCallWrapping:TsMethodTestRunner.kt$TsMethodTestRunner$filter { it.name == methodName } CascadingCallWrapping:TsMethodTestRunner.kt$TsMethodTestRunner$single { it.name == className }