diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 9e465a8ec..700801642 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -6,7 +6,7 @@ object Versions { const val clikt = "5.0.0" const val detekt = "1.23.7" const val ini4j = "0.5.4" - const val jacodb = "9ea33879c9" + const val jacodb = "ddb127d9ef" const val juliet = "1.3.2" const val junit = "5.9.3" const val kotlin = "2.1.0" diff --git a/usvm-ts-pbt/DESIGN.md b/usvm-ts-pbt/DESIGN.md index 82dfe6214..369275e9e 100644 --- a/usvm-ts-pbt/DESIGN.md +++ b/usvm-ts-pbt/DESIGN.md @@ -8,6 +8,8 @@ API and CLI examples, see [README.md](README.md). - Kotlin owns property definitions, validation, registries, orchestration, and public results. - Node is a thin adapter around fast-check and direct TypeScript loading. - Per-property source coverage is an optional backend capability collected by Kotlin through an isolated c8 run. +- A backend-neutral Kotlin mapping layer connects manifests and source coverage to EtsIR without changing the + declarative property model. - The JSON exchange is one request and one response from the same packaged distribution; it has no persistence or compatibility negotiation. - Failures are typed without exposing runtime-dependent Node stack traces. @@ -25,6 +27,7 @@ flowchart LR Backend[FastCheckBackend] Process[FastCheckProcessClient] Projection[FastCheckProjectionClient] + Mapping[PropertyEtsMapper] end subgraph Node_adapter[Private Node adapter] @@ -41,6 +44,7 @@ flowchart LR Tsx[tsx] C8[c8 and Istanbul JSON] UserTS[User TypeScript source] + EtsIR[EtsScene and EtsSourceSpan] CLI --> Registry CLI --> Backend @@ -48,6 +52,9 @@ flowchart LR Registry --> Model Backend --> Model Backend --> Process + Model --> Mapping + Process --> Mapping + Mapping --> EtsIR Process --> ExecutionCLI Process --> C8 C8 --> ExecutionCLI @@ -72,6 +79,7 @@ flowchart LR | Registry and CLI | Select Kotlin-defined properties and turn user options into a run configuration. | | `FastCheckBackend` | Validate examples, resolve source roots, and create the adapter request. | | `FastCheckProcessClient` | Supervise Node with coroutines and optionally decode one isolated c8 report. | +| `PropertyEtsMapper` | Resolve property entry points and backend-neutral coverage to explicit EtsIR targets. | | `execution-cli.ts` | Read one JSON request, protect protocol stdout from user logging, and write one response. | | `execute-property.ts` | Build the fast-check property, run it, and translate `RunDetails` into the common result. | | `project-domain.ts` | Translate domain descriptors into real `fc.Arbitrary` instances. | @@ -213,11 +221,60 @@ A successful or falsified property exits the bridge normally, allowing c8 to flu invalid protocol responses, and hard kills do not produce a completed property result. The workspace is removed in all cases, and a new workspace is used for every property. +## Property-to-EtsIR mapping + +The mapping layer consumes common Kotlin artifacts only: `PropertyManifest`, optional `PropertyCoverageArtifact`, +an `EtsScene`, and source roots. It does not depend on `FastCheckBackend` or its private runtime representation. +The result is a `PropertyEtsMappingArtifact` that keeps the manifest property ID, backend coverage provenance, +mapping coordinate and branch-order provenance, resolved predicate and precondition targets, coverage targets, and +stable diagnostic reasons. + +Entry-point resolution starts from the manifest module/export pair and follows named or bare-star TypeScript +re-exports. Direct function exports resolve only in the file-level `%dflt` class. Namespace-star exports are not +callable methods, bare-star traversal excludes `default`, explicit runtime exports take precedence over bare-star +exports, and duplicate paths to one EtsIR method are deduplicated. Type-alias exports do not mask bare-star runtime +exports. The pinned EtsIR model preserves `isTypeOnly` independently of declaration kind, so type-only named and +star re-exports do not mask a bare-star runtime fallback. +Module candidates mirror the frontend's `.ts`, `.ets`, `.d.ts`, and directory-index suffix rules. +Predicate and precondition resolution are independent. A resolved method carries `EtsEntryPointBindings`: receiver +slot zero, ordered input-to-parameter bindings in subsequent slots, and the result type. A mismatch between +manifest inputs and EtsIR parameters is unsupported, as is coverage carrying another property ID. + +Existing source roots and files are canonicalized with real paths; an unresolvable root makes entry-point mapping +unsupported. Istanbul lines are converted from one-based to zero-based, columns stay zero-based, and offsets are +calculated in UTF-16 code units using TypeScript's LF, CRLF, CR, U+2028, and U+2029 line terminators. Statement mapping first looks +for an exact `EtsSourceSpan`; if normalized EtsIR statements share that span, all remain exact targets. A containing +coverage range with one distinct origin is also exact, several distinct origins are ambiguous, and no origin match +is unmapped. Missing source text, invalid coordinates, or an EtsIR file whose statements have no origins are +unsupported. + +Branch mapping currently accepts an Istanbul `if` with exactly two ordered arms and resolves conditions to +`EtsIfStmt`. The first CFG successor is recorded as true and the second as false. Several EtsIR conditions with one +shared origin are exact; several distinct condition origins are ambiguous. Other branch types, non-binary arm +shapes, and EtsIR conditions without two ordered successors are unsupported rather than inferred. +An invalid arm is reported independently while a successfully resolved condition remains available, and aggregate +coverage status includes both conditions and arms. + +The JVM taint-analysis `PositionResolver` and `ConditionResolver` were reviewed as architectural prior art. Their +useful separation is preserved: declarative receiver/argument/result positions are distinct from runtime-bound +values, and condition interpretation is distinct from position resolution. The TypeScript mapper expresses this +with EtsIR-specific binding and mapping records and has no dependency on `usvm-jvm` or the taint-analysis module. + The execution client starts stdout, stderr, and stdin work concurrently on the coroutine I/O dispatcher. Requests and stdout are limited to 4 MiB; stderr is limited to 64 KiB. These are transport safety bounds, not property-policy -limits. The hard deadline is the property timeout plus two seconds for transport, followed by a 250 ms graceful -shutdown before force-kill. The only run-control maximum is `2^31 - 1` milliseconds because Node timers use signed -32-bit delays; runs, examples, and replay paths have no arbitrary count or length caps. +limits. The hard deadline is the property timeout plus two seconds for transport, followed by up to 250 ms of +graceful shutdown before force-kill, bounded by the absolute deadline. The private process tree is: + +```text +Kotlin client -> process supervisor -> detached group-owner worker -> adapter or command -> descendants +``` + +The supervisor accepts explicit `--adapter` and `--command` modes and stays outside the owned process group so it can +escalate shutdown. It installs signal handlers before spawning the group owner, so an immediate cancellation is +remembered until the process-group ID becomes available. The stable group owner reports adapter or command exit over +IPC; the supervisor then force-removes remaining descendants. If the supervisor disappears first, the IPC disconnect +handler performs the same cleanup. The only run-control maximum is `2^31 - 1` milliseconds because Node timers use +signed 32-bit delays; runs, examples, and replay paths have no arbitrary count or length caps. ## Runtime packaging @@ -238,6 +295,9 @@ classifier because `tsx` depends on a native esbuild package. shrinking, explicit examples, preconditions, async predicates, and timeouts. - Coverage golden tests assert literal TypeScript statement and branch outcomes for successful and falsified runs, cross-property isolation, scope and glob filtering, and source-map/report diagnostics. +- Mapping golden tests load stable TypeScript fixtures through the native frontend and cover predicate, + precondition, re-export, UTF-16 normalization, shared spans, exact/ambiguous/unmapped branches, unsupported + source data, and backend-without-coverage behavior. ## Non-goals @@ -245,5 +305,5 @@ classifier because `tsx` depends on a native esbuild package. - Discovering properties by scanning TypeScript source roots. - Compiling user TypeScript as part of the PBT workflow. - Reimplementing generation, replay, skip accounting, or shrinking in Kotlin. -- Mapping Node source locations to EtsIR or constructing symbolic targets from coverage. -- Combining Node source coverage with future EtsIR replay coverage. +- Constructing symbolic inputs or executing mapped properties in USVM. +- Combining backend source coverage with future EtsIR replay coverage. diff --git a/usvm-ts-pbt/README.md b/usvm-ts-pbt/README.md index 14a942a9c..e75987fd3 100644 --- a/usvm-ts-pbt/README.md +++ b/usvm-ts-pbt/README.md @@ -137,6 +137,61 @@ Missing or malformed reports use `coverage.report.missing` and `coverage.report. remap produces `coverage.source-map.missing` or `coverage.source-map.invalid`; a missing packaged c8 runtime produces `coverage.collector.not-found`. +## Property-to-EtsIR mapping + +`PropertyEtsMapper` combines a backend-neutral `PropertyManifest`, an `EtsScene`, and optional +`PropertyCoverageArtifact` into one `PropertyEtsMappingArtifact` per property: + +```kotlin +val mapping = PropertyEtsMapper( + scene = etsScene, + sourceRoots = sourceRoots, +).map( + manifest = property.toManifest(), + coverage = result.coverage, +) +``` + +Predicate and optional precondition exports are resolved independently, including named and bare-star TypeScript +re-exports and extensionless `.ts`, `.ets`, `.d.ts`, and directory-index module paths. Direct function exports map +only to file-level EtsIR methods; namespace-star exports are not treated as functions, bare-star exports do not +forward `default`, explicit runtime exports take precedence over bare-star exports, and duplicate re-export paths +to the same method collapse to one target. Type-alias exports do not mask bare-star runtime exports. The current +EtsIR export model preserves `isTypeOnly` independently of the declaration kind, so type-only named and star +re-exports do not mask a bare-star runtime fallback. +Every resolved entry point has explicit receiver, ordered input, and result bindings. The receiver uses stack slot +zero and property inputs follow it in manifest order. A coverage artifact for another property is rejected rather +than combined with the manifest. + +Existing source roots and files are canonicalized through real paths, so symlinked frontend inputs align with +backend coverage; an unresolvable root is `UNSUPPORTED`. Istanbul's one-based lines and zero-based columns become +zero-based half-open ranges with UTF-16 offsets, matching TypeScript and EtsIR source spans. CRLF, lone CR, LF, +U+2028, and U+2029 are recognized as TypeScript line terminators. +Statement ranges are compared with `EtsSourceSpan` origins. Several normalized EtsIR statements sharing one exact +origin remain one `EXACT` mapping with several targets; several distinct origins inside a covered range are +`AMBIGUOUS`. + +Binary Istanbul branches map to `EtsIfStmt`. Arm zero is the true CFG successor and arm one is the false successor, +as recorded by `EtsMappingProvenance`. Other branch shapes are `UNSUPPORTED`; the mapper does not guess switch, +logical-expression, or backend-specific arm semantics. + +| Status | Meaning | +| --- | --- | +| `EXACT` | One source identity was established; normalized statements may produce several EtsIR targets with that shared identity. | +| `AMBIGUOUS` | Several distinct entry points or source origins match, and every candidate is preserved. | +| `UNMAPPED` | The input is supported, but no EtsIR target matches it. | +| `UNSUPPORTED` | The input cannot be interpreted safely, for example because coverage, source text, origins, coordinates, bindings, or branch shape are unsupported. | + +Stable mapping diagnostics include `mapping.entry-point.unmapped`, `mapping.entry-point.ambiguous`, +`mapping.entry-point.bindings.unsupported`, `mapping.coverage.unavailable`, +`mapping.coverage.property-id.mismatch`, `mapping.statement.unmapped`, `mapping.statement.ambiguous`, +`mapping.branch.unmapped`, `mapping.branch.ambiguous`, `mapping.branch.shape.unsupported`, +`mapping.branch.cfg.unsupported`, +`mapping.source.unavailable`, `mapping.source.location.unsupported`, and +`mapping.source-origins.unsupported`, and `mapping.source-root.unsupported`. Backend provenance is preserved +separately from mapping provenance and +backend diagnostics are copied without reinterpretation. + ## Registries and CLI The CLI loads Kotlin property registries through `ServiceLoader`: diff --git a/usvm-ts-pbt/fast-check-adapter/package.json b/usvm-ts-pbt/fast-check-adapter/package.json index de025f782..fdac67984 100644 --- a/usvm-ts-pbt/fast-check-adapter/package.json +++ b/usvm-ts-pbt/fast-check-adapter/package.json @@ -9,7 +9,7 @@ "build": "tsc --project tsconfig.json", "pretest": "npm run build", "test": "npm run test:compiled", - "test:compiled": "node --test dist/test/entry-point.test.js dist/test/execute-property.test.js dist/test/execution-cli.test.js dist/test/js-value.test.js dist/test/project-domain.test.js dist/test/projection-cli.test.js" + "test:compiled": "node --test dist/test/entry-point.test.js dist/test/execute-property.test.js dist/test/execution-cli.test.js dist/test/js-value.test.js dist/test/process-group-shutdown.test.js dist/test/process-supervisor.test.js dist/test/project-domain.test.js dist/test/projection-cli.test.js" }, "dependencies": { "c8": "10.1.3", diff --git a/usvm-ts-pbt/fast-check-adapter/src/execute-property.ts b/usvm-ts-pbt/fast-check-adapter/src/execute-property.ts index a21ea2379..552e5027e 100644 --- a/usvm-ts-pbt/fast-check-adapter/src/execute-property.ts +++ b/usvm-ts-pbt/fast-check-adapter/src/execute-property.ts @@ -83,7 +83,7 @@ export async function executeProperty(requestValue: unknown): Promise, + arbitrary: fc.Arbitrary, predicate: LoadedEntryPoint, precondition: LoadedEntryPoint | undefined, -): fc.IProperty<[unknown[]]> | fc.IAsyncProperty<[unknown[]]> { +): fc.IProperty<[JsConcreteValue[]]> | fc.IAsyncProperty<[JsConcreteValue[]]> { const asynchronous = predicate.executionKind === 'async' || precondition?.executionKind === 'async'; if (asynchronous) { - return fc.asyncProperty(arbitrary, async (values: unknown[]): Promise => { - const argumentsList = values as JsConcreteValue[]; + return fc.asyncProperty(arbitrary, async (values: JsConcreteValue[]): Promise => { + if (precondition !== undefined && !(await precondition.invoke(cloneArguments(values)))) fc.pre(false); - if (precondition !== undefined && !(await precondition.invoke(argumentsList))) fc.pre(false); - - return await predicate.invoke(argumentsList); + return await predicate.invoke(cloneArguments(values)); }); } - return fc.property(arbitrary, (values: unknown[]): boolean => { - const argumentsList = values as JsConcreteValue[]; - - if (precondition !== undefined && !precondition.invoke(argumentsList)) fc.pre(false); + return fc.property(arbitrary, (values: JsConcreteValue[]): boolean => { + if (precondition !== undefined && !precondition.invoke(cloneArguments(values))) fc.pre(false); - return predicate.invoke(argumentsList) as boolean; + return predicate.invoke(cloneArguments(values)) as boolean; }); } -function buildParameters(request: FastCheckExecutionRequest): Parameters<[unknown[]]> { - const decodedExamples = request.examples.map((example, exampleIndex) => { +async function checkProperty( + property: fc.IProperty<[JsConcreteValue[]]> | fc.IAsyncProperty<[JsConcreteValue[]]>, + parameters: Parameters<[JsConcreteValue[]]>, + replayPath: string | undefined, +): Promise> { + try { + return await Promise.resolve(fc.check(property, parameters)); + } catch (error: unknown) { + if (replayPath !== undefined && isFastCheckReplayFailure(error)) { + throw protocolError( + adapterDiagnostic.protocolReplayPathInvalid, + 'Replay path cannot be applied to this property run', + 'replayPath', + ); + } + + throw error; + } +} + +/** + * User callbacks must not mutate fast-check's sample, which it retains for shrinking and replay. + * A shared clone map preserves aliases and cycles within one invocation while isolating separate invocations. + */ +function cloneArguments(values: JsConcreteValue[]): JsConcreteValue[] { + return cloneRecursiveArrays(values, new Map()); +} + +function cloneRecursiveArrays( + value: JsConcreteValue[], + clones: Map, +): JsConcreteValue[]; +function cloneRecursiveArrays( + value: JsConcreteValue, + clones: Map, +): JsConcreteValue; +function cloneRecursiveArrays( + value: JsConcreteValue, + clones: Map, +): JsConcreteValue { + if (!Array.isArray(value)) return value; + + const existing = clones.get(value); + if (existing !== undefined) return existing; + + const clone: JsConcreteValue[] = []; + clones.set(value, clone); + value.forEach((element) => clone.push(cloneRecursiveArrays(element, clones))); + + return clone; +} + +function buildParameters(request: FastCheckExecutionRequest): Parameters<[JsConcreteValue[]]> { + const decodedExamples = request.examples.map((example, exampleIndex): [JsConcreteValue[]] => { if (example.length !== request.manifest.inputs.length) { throw protocolError( adapterDiagnostic.protocolExamplesArity, @@ -136,10 +184,10 @@ function buildParameters(request: FastCheckExecutionRequest): Parameters<[unknow const values = example.map((value, valueIndex) => decodeJsValue(value, `examples[${exampleIndex}][${valueIndex}]`)); - return [values] as [unknown[]]; + return [values]; }); - const parameters: Parameters<[unknown[]]> = { + const parameters: Parameters<[JsConcreteValue[]]> = { numRuns: request.numRuns, timeout: request.timeoutMillis, interruptAfterTimeLimit: request.timeoutMillis, @@ -155,7 +203,7 @@ function buildParameters(request: FastCheckExecutionRequest): Parameters<[unknow function toRunResult( propertyId: string, - details: RunDetails<[unknown[]]>, + details: RunDetails<[JsConcreteValue[]]>, executionTimeMillis: number, ): FastCheckRunResult { const counterexampleValues = details.counterexample?.[0]; @@ -178,7 +226,7 @@ function toRunResult( }; } -function failureDetails(details: RunDetails<[unknown[]]>): FastCheckFailureDetails { +function failureDetails(details: RunDetails<[JsConcreteValue[]]>): FastCheckFailureDetails { const error = details.errorInstance; const timeout = (details.interrupted && details.counterexample === null) || isFastCheckTimeout(error); @@ -198,17 +246,32 @@ function failureDetails(details: RunDetails<[unknown[]]>): FastCheckFailureDetai }; } + if (details.counterexample === null) { + return { + kind: 'property', + errorName: 'PropertyFailure', + message: 'Property could not satisfy its precondition within the skip limit', + }; + } + return { kind: 'property', - errorName: 'PropertyFailure', - message: details.counterexample === null - ? 'Property could not satisfy its precondition within the skip limit' - : 'Property predicate returned false', + errorName: 'ThrownValue', + message: String(error), }; } function isFastCheckTimeout(error: unknown): boolean { - return error instanceof Error && error.message.startsWith('Property timeout:'); + return hasFastCheckMessagePrefix(error, FAST_CHECK_TIMEOUT_PREFIX); +} + +function isFastCheckReplayFailure(error: unknown): boolean { + return hasFastCheckMessagePrefix(error, FAST_CHECK_REPLAY_FAILURE_PREFIX); +} + +/** fast-check 3.x exposes these two failure categories only through stable message prefixes. */ +function hasFastCheckMessagePrefix(error: unknown, prefix: string): boolean { + return error instanceof Error && error.message.startsWith(prefix); } function validateRequest(value: unknown): FastCheckExecutionRequest { @@ -248,7 +311,8 @@ function validateRequest(value: unknown): FastCheckExecutionRequest { ); } - const invalidReplayPath = request.replayPath !== undefined && typeof request.replayPath !== 'string'; + const invalidReplayPath = request.replayPath !== undefined + && (typeof request.replayPath !== 'string' || !REPLAY_PATH_PATTERN.test(request.replayPath)); if (invalidReplayPath) { throw protocolError( adapterDiagnostic.protocolReplayPathInvalid, @@ -389,3 +453,6 @@ function isSignedInt(value: unknown): value is number { // Node timers use signed 32-bit millisecond delays; larger values are clamped to one millisecond. const MAX_TIMER_DELAY_MILLIS = 2 ** 31 - 1; +const REPLAY_PATH_PATTERN = /^\d+(?::\d+)*$/; +const FAST_CHECK_REPLAY_FAILURE_PREFIX = 'Unable to replay,'; +const FAST_CHECK_TIMEOUT_PREFIX = 'Property timeout:'; diff --git a/usvm-ts-pbt/fast-check-adapter/src/process-group-shutdown.ts b/usvm-ts-pbt/fast-check-adapter/src/process-group-shutdown.ts new file mode 100644 index 000000000..5ff8d6039 --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/src/process-group-shutdown.ts @@ -0,0 +1,77 @@ +import { spawnSync } from 'node:child_process'; + +export type ProcessGroupTermination = 'graceful' | 'forceful'; +export type ProcessGroupTerminator = (pid: number, termination: ProcessGroupTermination) => void; + +/** Coordinates a two-phase shutdown even when the signal arrives before spawn returns a PID. */ +export class ProcessGroupShutdown { + private processGroupPid: number | undefined; + private shutdownRequested = false; + private shutdownStarted = false; + private forceKillTimer: NodeJS.Timeout | undefined; + + constructor( + private readonly forceKillDelayMillis: number, + private readonly terminate: ProcessGroupTerminator, + ) {} + + attach(processGroupPid: number): void { + if (this.processGroupPid !== undefined) throw new Error('Process group is already attached'); + + this.processGroupPid = processGroupPid; + this.startIfReady(); + } + + request(): void { + this.shutdownRequested = true; + this.startIfReady(); + } + + cancel(): void { + if (this.forceKillTimer !== undefined) clearTimeout(this.forceKillTimer); + } + + private startIfReady(): void { + if (!this.shutdownRequested || this.shutdownStarted || this.processGroupPid === undefined) return; + + const processGroupPid = this.processGroupPid; + this.shutdownStarted = true; + this.terminate(processGroupPid, 'graceful'); + this.forceKillTimer = setTimeout(() => { + this.terminate(processGroupPid, 'forceful'); + }, this.forceKillDelayMillis); + } +} + +/** Terminates a detached worker together with every process that it owns. */ +export function terminateOwnedProcessGroup(pid: number, termination: ProcessGroupTermination): void { + const force = termination === 'forceful'; + + if (process.platform === 'win32') { + const arguments_ = ['/PID', String(pid), '/T']; + if (force) arguments_.push('/F'); + + spawnSync('taskkill', arguments_, { + stdio: 'ignore', + windowsHide: true, + }); + + return; + } + + try { + process.kill(-pid, force ? 'SIGKILL' : 'SIGTERM'); + } catch (error: unknown) { + if (!isMissingProcess(error)) throw error; + } +} + +export function terminateOwnProcessGroup(): void { + terminateOwnedProcessGroup(process.pid, 'forceful'); +} + +function isMissingProcess(error: unknown): boolean { + return error instanceof Error + && 'code' in error + && error.code === 'ESRCH'; +} diff --git a/usvm-ts-pbt/fast-check-adapter/src/process-supervisor.ts b/usvm-ts-pbt/fast-check-adapter/src/process-supervisor.ts new file mode 100644 index 000000000..02a63b088 --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/src/process-supervisor.ts @@ -0,0 +1,282 @@ +import { spawn } from 'node:child_process'; +import { unlinkSync, writeFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import { isMainThread, Worker, workerData } from 'node:worker_threads'; +import { + ProcessGroupShutdown, + terminateOwnProcessGroup, + terminateOwnedProcessGroup, +} from './process-group-shutdown.js'; + +interface WorkerExitMessage { + type: 'worker-exit'; + code: number; +} + +interface AdapterWorkerData { + adapterEntryPoint: string; +} + +interface SupervisedWorker { + label: string; + arguments: string[]; +} + +type Command = [string, ...string[]]; + +/** + * Process tree: + * Kotlin client -> supervisor -> detached group owner -> adapter/command -> any descendants. + * The supervisor stays outside the owned group so it can escalate shutdown. The group owner stays alive over IPC + * until the adapter or command reports its exit, then the supervisor removes every remaining descendant at once. + */ + +const adapterModeFlag = '--adapter'; +const commandModeFlag = '--command'; +const adapterWorkerFlag = '--adapter-worker'; +const commandWorkerFlag = '--command-worker'; +const MAX_TIMER_DELAY_MILLIS = 2 ** 31 - 1; + +if (!isMainThread) { + const data = requireAdapterWorkerData(workerData); + + await runAdapterThread(data.adapterEntryPoint); +} else { + runProcess(process.argv.slice(2)); +} + +function runProcess(arguments_: string[]): void { + const mode = requireArgument(arguments_[0], 'supervisor mode'); + + if (mode === adapterWorkerFlag) { + runAdapterWorker(requireArgument(arguments_[1], 'adapter entry point')); + return; + } + + if (mode === commandWorkerFlag) { + runCommandWorker(requireCommand(arguments_.slice(1))); + return; + } + + const forceKillDelayMillis = requireTimerDelay(arguments_[1], 'force-kill delay'); + const processGroupFile = requireArgument(arguments_[2], 'process-group file'); + + if (mode === adapterModeFlag) { + const adapterEntryPoint = requireArgument(arguments_[3], 'adapter entry point'); + runSupervisor( + { + label: 'adapter worker', + arguments: [adapterWorkerFlag, adapterEntryPoint], + }, + forceKillDelayMillis, + processGroupFile, + ); + return; + } + + if (mode === commandModeFlag) { + runSupervisor( + { + label: 'command worker', + arguments: [commandWorkerFlag, ...requireCommand(arguments_.slice(3))], + }, + forceKillDelayMillis, + processGroupFile, + ); + return; + } + + fail(`Unknown supervisor mode: ${mode}`); +} + +function runSupervisor( + workerSpec: SupervisedWorker, + forceKillDelayMillis: number, + processGroupFile: string, +): void { + const shutdown = new ProcessGroupShutdown(forceKillDelayMillis, terminateOwnedProcessGroup); + installSupervisorSignalHandlers(shutdown); + + const supervisorEntryPoint = requireArgument(process.argv[1], 'supervisor entry point'); + const worker = spawn( + process.execPath, + [supervisorEntryPoint, ...workerSpec.arguments], + { + detached: true, + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + }, + ); + const workerPid = requirePid(worker.pid, workerSpec.label); + const workerStdin = requireStream(worker.stdin, `${workerSpec.label} stdin`); + const workerStdout = requireStream(worker.stdout, `${workerSpec.label} stdout`); + const workerStderr = requireStream(worker.stderr, `${workerSpec.label} stderr`); + let reportedExitCode: number | undefined; + + shutdown.attach(workerPid); + writeFileSync(processGroupFile, String(workerPid)); + + process.stdin.pipe(workerStdin); + workerStdout.pipe(process.stdout); + workerStderr.pipe(process.stderr); + + worker.on('message', (message: unknown) => { + if (!isWorkerExitMessage(message)) return; + + reportedExitCode = message.code; + terminateOwnedProcessGroup(workerPid, 'forceful'); + }); + worker.on('error', (error: Error) => { + process.stderr.write(`Failed to start ${workerSpec.label}: ${error.message}\n`); + reportedExitCode = 1; + }); + worker.on('close', (code: number | null) => { + shutdown.cancel(); + removeProcessGroupFile(processGroupFile); + + process.exitCode = reportedExitCode ?? code ?? 1; + }); +} + +function installSupervisorSignalHandlers(shutdown: ProcessGroupShutdown): void { + process.on('SIGINT', () => shutdown.request()); + process.on('SIGTERM', () => shutdown.request()); +} + +function runAdapterWorker(adapterEntryPoint: string): void { + installProcessGroupOwnerHandlers(); + + const supervisorEntryPoint = requireArgument(process.argv[1], 'supervisor entry point'); + const reportExit = createWorkerExitReporter(); + const adapter = new Worker(supervisorEntryPoint, { + workerData: { adapterEntryPoint } satisfies AdapterWorkerData, + stdin: true, + }); + const adapterStdin = requireStream(adapter.stdin, 'adapter stdin'); + + process.stdin.pipe(adapterStdin); + + adapter.on('error', (error: Error) => { + process.stderr.write(`Failed to start projection adapter: ${error.message}\n`); + reportExit(1); + }); + adapter.on('exit', reportExit); +} + +function runCommandWorker(command: Command): void { + installProcessGroupOwnerHandlers(); + + const reportExit = createWorkerExitReporter(); + const child = spawn(command[0], command.slice(1), { + // Direct inheritance avoids a user-space forwarding buffer that could be truncated when the group is removed. + stdio: 'inherit', + }); + + child.on('error', (error: Error) => { + process.stderr.write(`Failed to start supervised command: ${error.message}\n`); + reportExit(1); + }); + child.on('exit', (code: number | null) => reportExit(code ?? 1)); +} + +function installProcessGroupOwnerHandlers(): void { + // Keep the process-group identity stable while shutdown propagates through the group. If the supervisor disappears, + // the IPC disconnect is the last reliable opportunity to remove the entire owned group. + process.on('SIGINT', () => undefined); + process.on('SIGTERM', () => undefined); + process.on('disconnect', terminateOwnProcessGroup); +} + +function createWorkerExitReporter(): (code: number) => void { + let reported = false; + + return (code: number): void => { + if (reported) return; + + reported = true; + const message: WorkerExitMessage = { type: 'worker-exit', code }; + process.send?.(message); + }; +} + +async function runAdapterThread(adapterEntryPoint: string): Promise { + try { + await import(pathToFileURL(adapterEntryPoint).href); + } finally { + process.stdin.destroy(); + } +} + +function isWorkerExitMessage(value: unknown): value is WorkerExitMessage { + if (value === null || typeof value !== 'object') return false; + + const record = value as Record; + + return record.type === 'worker-exit' + && typeof record.code === 'number' + && Number.isInteger(record.code); +} + +function removeProcessGroupFile(processGroupFile: string): void { + try { + unlinkSync(processGroupFile); + } catch (error: unknown) { + if (!isMissingFile(error)) throw error; + } +} + +function isMissingFile(error: unknown): boolean { + return error instanceof Error + && 'code' in error + && error.code === 'ENOENT'; +} + +function requireArgument(value: string | undefined, name: string): string { + if (value === undefined || value.length === 0) fail(`Missing ${name}`); + + return value; +} + +function requireCommand(command: string[]): Command { + const executable = requireArgument(command[0], 'command executable'); + + return [executable, ...command.slice(1)]; +} + +function requireTimerDelay(value: string | undefined, name: string): number { + const parsed = value === undefined ? Number.NaN : Number(value); + const valid = Number.isInteger(parsed) + && parsed > 0 + && parsed <= MAX_TIMER_DELAY_MILLIS; + if (!valid) fail(`Invalid ${name}: ${value ?? ''}`); + + return parsed; +} + +function requirePid(value: number | undefined, name: string): number { + if (value === undefined) fail(`Missing ${name} PID`); + + return value; +} + +function requireStream(value: T | null, name: string): T { + if (value === null) fail(`Missing ${name}`); + + return value; +} + +function requireAdapterWorkerData(value: unknown): AdapterWorkerData { + if (value === null || typeof value !== 'object') fail('Missing projection adapter worker data'); + + const record = value as Record; + const adapterEntryPoint = requireArgument( + typeof record.adapterEntryPoint === 'string' ? record.adapterEntryPoint : undefined, + 'adapter entry point', + ); + + return { adapterEntryPoint }; +} + +function fail(message: string): never { + process.stderr.write(`${message}\n`); + process.exit(1); +} diff --git a/usvm-ts-pbt/fast-check-adapter/src/project-domain.ts b/usvm-ts-pbt/fast-check-adapter/src/project-domain.ts index 0e421c764..391993901 100644 --- a/usvm-ts-pbt/fast-check-adapter/src/project-domain.ts +++ b/usvm-ts-pbt/fast-check-adapter/src/project-domain.ts @@ -3,6 +3,7 @@ import { adapterDiagnostic } from './diagnostics.js'; import { decodeJsNumber, decodeJsValue, + type JsConcreteValue, ProtocolError, protocolError, } from './js-value.js'; @@ -20,7 +21,7 @@ export interface ProjectionCapability { type DomainRecord = Record; -export function projectDomain(domain: unknown, path = 'domain'): fc.Arbitrary { +export function projectDomain(domain: unknown, path = 'domain'): fc.Arbitrary { requireDomainObject(domain, path); switch (domain.kind) { diff --git a/usvm-ts-pbt/fast-check-adapter/test/execute-property.test.ts b/usvm-ts-pbt/fast-check-adapter/test/execute-property.test.ts index ab1f69877..802922207 100644 --- a/usvm-ts-pbt/fast-check-adapter/test/execute-property.test.ts +++ b/usvm-ts-pbt/fast-check-adapter/test/execute-property.test.ts @@ -66,6 +66,30 @@ test('supports asynchronous predicates and preconditions', async () => { }); }); +test('reports exhausted preconditions as a property failure without a counterexample', async () => { + await withPropertyModule(async (sourceRoot) => { + const request = executionRequest(sourceRoot, 'alwaysTrue', { + precondition: { + module: 'properties.ts', + exportName: 'neverAccepts', + executionKind: 'sync', + }, + }); + request.numRuns = 1; + + const response = await executeProperty(request); + + assert.equal(response.result.status, 'failure'); + assert.equal(response.result.counterexample, null); + assert.equal(response.result.failure?.kind, 'property'); + assert.equal(response.result.failure?.errorName, 'PropertyFailure'); + assert.equal( + response.result.failure?.message, + 'Property could not satisfy its precondition within the skip limit', + ); + }); +}); + test('executes explicit examples through the same predicate', async () => { await withPropertyModule(async (sourceRoot) => { const request = executionRequest(sourceRoot, 'isNotSeven'); @@ -114,6 +138,95 @@ test('keeps a counterexample classified as a property failure when shrinking is }); }); +test('reports the original nested array when the predicate mutates its invocation to an object', async () => { + await withPropertyModule(async (sourceRoot) => { + const originalValue = [[1]]; + const request = executionRequest(sourceRoot, 'mutatesNestedArrayToObject', { + inputDomain: { kind: 'constant', value: encodeJsValue(originalValue) }, + }); + + const response = await executeProperty(request); + + assert.equal(response.result.status, 'failure'); + assert.deepEqual(response.result.counterexample, [encodeJsValue(originalValue)]); + }); +}); + +test('reports and replays the original array when the predicate creates a cycle', async () => { + await withPropertyModule(async (sourceRoot) => { + const originalValue = [1]; + const request = executionRequest(sourceRoot, 'mutatesArrayToCycle', { + inputDomain: { kind: 'constant', value: encodeJsValue(originalValue) }, + }); + + const first = await executeProperty(request); + assert.ok(first.result.replayPath); + + const replay = await executeProperty({ + ...request, + replayPath: first.result.replayPath, + seed: first.result.seed, + }); + + assert.equal(first.result.status, 'failure'); + assert.deepEqual(first.result.counterexample, [encodeJsValue(originalValue)]); + assert.deepEqual(replay.result.counterexample, first.result.counterexample); + }); +}); + +test('isolates predicate input from recursive array mutation in the precondition', async () => { + await withPropertyModule(async (sourceRoot) => { + const request = executionRequest(sourceRoot, 'receivesOriginalNestedArray', { + precondition: { + module: 'properties.ts', + exportName: 'mutatesNestedArrayAndAccepts', + executionKind: 'sync', + }, + inputDomain: { kind: 'constant', value: encodeJsValue([[1]]) }, + }); + + const response = await executeProperty(request); + + assert.equal(response.result.status, 'success'); + }); +}); + +test('isolates asynchronous predicate input from recursive array mutation in the precondition', async () => { + await withPropertyModule(async (sourceRoot) => { + const request = executionRequest(sourceRoot, 'asyncReceivesOriginalNestedArray', { + predicateExecutionKind: 'async', + precondition: { + module: 'properties.ts', + exportName: 'asyncMutatesNestedArrayAndAccepts', + executionKind: 'async', + }, + inputDomain: { kind: 'constant', value: encodeJsValue([[1]]) }, + }); + + const response = await executeProperty(request); + + assert.equal(response.result.status, 'success'); + }); +}); + +test('preserves non-Error thrown values including falsy primitives', async () => { + await withPropertyModule(async (sourceRoot) => { + const cases = ['boom', '', 0, false, null, undefined] as const; + + for (const thrownValue of cases) { + const request = executionRequest(sourceRoot, 'throwsInput', { + inputDomain: { kind: 'constant', value: encodeJsValue(thrownValue) }, + }); + + const response = await executeProperty(request); + + assert.equal(response.result.status, 'failure'); + assert.equal(response.result.failure?.errorName, 'ThrownValue'); + assert.equal(response.result.failure?.message, String(thrownValue)); + } + }); +}); + interface RequestOverrides { predicateExecutionKind?: 'sync' | 'async'; precondition?: FastCheckExecutionRequest['manifest']['precondition']; @@ -155,25 +268,7 @@ async function withPropertyModule(block: (sourceRoot: string) => Promise): const sourceRoot = path.join(workspace, 'src'); await mkdir(sourceRoot); - await writeFile( - path.join(sourceRoot, 'properties.ts'), - [ - 'export function alwaysTrue(_value: number): boolean { return true; }', - 'export function isNegative(value: number): boolean { return value < 0; }', - 'export async function asyncAlwaysTrue(_value: number): Promise { return true; }', - 'export async function asyncIsOne(value: number): Promise { return value === 1; }', - 'export function isNotSeven(value: number): boolean { return value !== 7; }', - 'export function slowFailure(_value: number[]): boolean {', - ' const deadline = Date.now() + 10;', - ' while (Date.now() < deadline) {}', - ' return false;', - '}', - 'export async function neverCompletes(_value: number): Promise {', - ' await new Promise(() => undefined);', - ' return true;', - '}', - ].join('\n'), - ); + await writeFile(path.join(sourceRoot, 'properties.ts'), PROPERTY_MODULE_SOURCE); try { await block(sourceRoot); @@ -187,3 +282,61 @@ function semanticResult(result: FastCheckRunResult): Omit { return true; } +export async function asyncIsOne(value: number): Promise { return value === 1; } +export function neverAccepts(_value: number): boolean { return false; } +export function isNotSeven(value: number): boolean { return value !== 7; } + +export function slowFailure(_value: number[]): boolean { + const deadline = Date.now() + 10; + while (Date.now() < deadline) {} + + return false; +} + +export async function neverCompletes(_value: number): Promise { + await new Promise(() => undefined); + + return true; +} + +export function mutatesNestedArrayToObject(value: unknown[][]): boolean { + value[0]![0] = {}; + + return false; +} + +export function mutatesArrayToCycle(value: unknown[]): boolean { + value[0] = value; + + return false; +} + +export function mutatesNestedArrayAndAccepts(value: unknown[][]): boolean { + value[0]![0] = {}; + + return true; +} + +export function receivesOriginalNestedArray(value: unknown[][]): boolean { + return value[0]?.[0] === 1; +} + +export async function asyncMutatesNestedArrayAndAccepts(value: unknown[][]): Promise { + value[0]![0] = {}; + + return true; +} + +export async function asyncReceivesOriginalNestedArray(value: unknown[][]): Promise { + return value[0]?.[0] === 1; +} + +export function throwsInput(value: unknown): never { + throw value; +} +`.trimStart(); diff --git a/usvm-ts-pbt/fast-check-adapter/test/execution-cli.test.ts b/usvm-ts-pbt/fast-check-adapter/test/execution-cli.test.ts index 3c147cce0..1128155ae 100644 --- a/usvm-ts-pbt/fast-check-adapter/test/execution-cli.test.ts +++ b/usvm-ts-pbt/fast-check-adapter/test/execution-cli.test.ts @@ -80,6 +80,30 @@ test('execution CLI exits after writing a response when user code leaves an open } }); +for (const replayPath of ['garbage', '0:999999:0']) { + test(`execution CLI reports replay path ${replayPath} as a typed protocol error`, async () => { + const sourceRoot = await realpath(await mkdtemp(path.join(tmpdir(), 'usvm-execution-cli-'))); + try { + await writeFile(sourceRoot + '/property.ts', 'export function predicate(value: boolean) { return value; }\n'); + const request = executionRequest(sourceRoot); + request.replayPath = replayPath; + + const invocation = await invokeCli(JSON.stringify(request)); + const response = JSON.parse(invocation.stdout) as ExecutionErrorResponse; + + assert.equal(invocation.timedOut, false); + assert.equal(invocation.exitCode, 0); + assert.equal(invocation.stderr, ''); + assert.equal(response.status, 'error'); + assert.equal(response.diagnostics[0]?.kind, 'invalid-request'); + assert.equal(response.diagnostics[0]?.code, 'protocol.replay-path.invalid'); + assert.equal(response.diagnostics[0]?.path, 'replayPath'); + } finally { + await rm(sourceRoot, { recursive: true, force: true }); + } + }); +} + interface ExecutionErrorResponse { status: string; diagnostics: ProtocolDiagnostic[]; diff --git a/usvm-ts-pbt/fast-check-adapter/test/process-group-shutdown.test.ts b/usvm-ts-pbt/fast-check-adapter/test/process-group-shutdown.test.ts new file mode 100644 index 000000000..5c2ef4648 --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/test/process-group-shutdown.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { ProcessGroupShutdown } from '../src/process-group-shutdown.js'; + +test('starts a shutdown requested before the process group is attached', () => { + const terminations: Array<{ pid: number; termination: string }> = []; + const shutdown = new ProcessGroupShutdown( + 1_000, + (pid, termination) => terminations.push({ pid, termination }), + ); + + shutdown.request(); + assert.deepEqual(terminations, []); + + shutdown.attach(42); + assert.deepEqual(terminations, [{ pid: 42, termination: 'graceful' }]); + + shutdown.cancel(); +}); diff --git a/usvm-ts-pbt/fast-check-adapter/test/process-supervisor.test.ts b/usvm-ts-pbt/fast-check-adapter/test/process-supervisor.test.ts new file mode 100644 index 000000000..aadf463f8 --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/test/process-supervisor.test.ts @@ -0,0 +1,179 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import type { Readable } from 'node:stream'; +import { setTimeout as delay } from 'node:timers/promises'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const supervisorPath = fileURLToPath(new URL('../src/process-supervisor.js', import.meta.url)); + +test('adapter runs inside the stable process-group owner', { timeout: 3_000 }, async () => { + const workspace = await mkdtemp(path.join(tmpdir(), 'usvm-projection-supervisor-')); + const adapterPath = path.join(workspace, 'adapter.mjs'); + const adapterPidFile = path.join(workspace, 'adapter.pid'); + const processGroupFile = path.join(workspace, 'process-group.pid'); + await writeFile( + adapterPath, + `import { writeFileSync } from 'node:fs';\n` + + `writeFileSync(${JSON.stringify(adapterPidFile)}, String(process.pid));\n` + + `setInterval(() => undefined, 1000);\n`, + ); + const supervisor = spawn( + process.execPath, + [supervisorPath, '--adapter', '25', processGroupFile, adapterPath], + { stdio: 'ignore' }, + ); + const supervisorExit = new Promise((resolve) => supervisor.once('close', () => resolve())); + + try { + const [adapterPid, processGroupPid] = await Promise.all([ + readTextEventually(adapterPidFile), + readTextEventually(processGroupFile), + ]); + + assert.equal(adapterPid, processGroupPid); + } finally { + supervisor.kill('SIGTERM'); + await Promise.race([ + supervisorExit, + delay(2_000).then(() => supervisor.kill('SIGKILL')), + ]); + await rm(workspace, { recursive: true, force: true }); + } +}); + +test('command exit removes descendants that retain inherited pipes', { timeout: 10_000 }, async () => { + const workspace = await mkdtemp(path.join(tmpdir(), 'usvm-command-supervisor-')); + const adapterPath = path.join(workspace, 'adapter.mjs'); + const childPidFile = path.join(workspace, 'child.pid'); + const processGroupFile = path.join(workspace, 'process-group.pid'); + await writeFile( + adapterPath, + `import { spawn } from 'node:child_process';\n` + + `import { writeFileSync } from 'node:fs';\n` + + `const child = spawn(process.execPath, ['-e', 'setInterval(() => undefined, 1000)'], ` + + `{ stdio: ['ignore', 'inherit', 'inherit'] });\n` + + `writeFileSync(${JSON.stringify(childPidFile)}, String(child.pid));\n` + + `child.unref();\n` + + `setTimeout(() => undefined, 100);\n`, + ); + const supervisor = spawn( + process.execPath, + [supervisorPath, '--command', '25', processGroupFile, process.execPath, adapterPath], + { stdio: 'ignore' }, + ); + const supervisorExit = new Promise((resolve, reject) => { + supervisor.once('error', reject); + supervisor.once('close', resolve); + }); + let childPid: number | undefined; + + try { + const [childPidText, processGroupPidText] = await Promise.all([ + readTextEventually(childPidFile), + readTextEventually(processGroupFile), + ]); + childPid = Number(childPidText); + + assert.notEqual(childPidText, processGroupPidText); + assert.equal(await supervisorExit, 0); + + assert.equal(isProcessAlive(childPid), false); + } finally { + supervisor.kill('SIGKILL'); + if (childPid !== undefined) terminateProcess(childPid); + await rm(workspace, { recursive: true, force: true }); + } +}); + +test('rejects a force-kill delay outside the Node timer range', async () => { + const workspace = await mkdtemp(path.join(tmpdir(), 'usvm-command-supervisor-')); + const processGroupFile = path.join(workspace, 'process-group.pid'); + const supervisor = spawn( + process.execPath, + [ + supervisorPath, + '--command', + String(2 ** 31), + processGroupFile, + process.execPath, + '-e', + 'process.exit(0)', + ], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + + try { + const [exitCode, stdout, stderr] = await Promise.all([ + new Promise((resolve, reject) => { + supervisor.once('error', reject); + supervisor.once('close', resolve); + }), + collectText(supervisor.stdout), + collectText(supervisor.stderr), + ]); + + assert.equal(exitCode, 1); + assert.equal(stdout, ''); + assert.match(stderr, /Invalid force-kill delay/); + } finally { + supervisor.kill('SIGKILL'); + await rm(workspace, { recursive: true, force: true }); + } +}); + +async function readTextEventually(file: string): Promise { + const deadline = Date.now() + 2_000; + + while (true) { + try { + return await readFile(file, 'utf8'); + } catch (error: unknown) { + if (!isMissingFile(error) || Date.now() >= deadline) throw error; + } + + await delay(10); + } +} + +function isMissingFile(error: unknown): boolean { + return error instanceof Error + && 'code' in error + && error.code === 'ENOENT'; +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + + return true; + } catch (error: unknown) { + if (isMissingProcess(error)) return false; + + throw error; + } +} + +function terminateProcess(pid: number): void { + try { + process.kill(pid, 'SIGKILL'); + } catch (error: unknown) { + if (!isMissingProcess(error)) throw error; + } +} + +function isMissingProcess(error: unknown): boolean { + return error instanceof Error + && 'code' in error + && error.code === 'ESRCH'; +} + +async function collectText(stream: Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(Buffer.from(chunk)); + + return Buffer.concat(chunks).toString('utf8'); +} diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/PbtDiagnosticCode.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/PbtDiagnosticCode.kt index 7ac238b1e..65dbf5170 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/PbtDiagnosticCode.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/PbtDiagnosticCode.kt @@ -45,6 +45,22 @@ internal object PbtDiagnosticCode { const val COVERAGE_SOURCE_MAP_INVALID = "coverage.source-map.invalid" const val COVERAGE_SOURCE_MAP_MISSING = "coverage.source-map.missing" + const val MAPPING_BRANCH_AMBIGUOUS = "mapping.branch.ambiguous" + const val MAPPING_BRANCH_CFG_UNSUPPORTED = "mapping.branch.cfg.unsupported" + const val MAPPING_BRANCH_SHAPE_UNSUPPORTED = "mapping.branch.shape.unsupported" + const val MAPPING_BRANCH_UNMAPPED = "mapping.branch.unmapped" + const val MAPPING_COVERAGE_PROPERTY_ID_MISMATCH = "mapping.coverage.property-id.mismatch" + const val MAPPING_COVERAGE_UNAVAILABLE = "mapping.coverage.unavailable" + const val MAPPING_ENTRY_POINT_AMBIGUOUS = "mapping.entry-point.ambiguous" + const val MAPPING_ENTRY_POINT_BINDINGS_UNSUPPORTED = "mapping.entry-point.bindings.unsupported" + const val MAPPING_ENTRY_POINT_UNMAPPED = "mapping.entry-point.unmapped" + const val MAPPING_SOURCE_LOCATION_UNSUPPORTED = "mapping.source.location.unsupported" + const val MAPPING_SOURCE_ORIGINS_UNSUPPORTED = "mapping.source-origins.unsupported" + const val MAPPING_SOURCE_ROOT_UNSUPPORTED = "mapping.source-root.unsupported" + const val MAPPING_SOURCE_UNAVAILABLE = "mapping.source.unavailable" + const val MAPPING_STATEMENT_AMBIGUOUS = "mapping.statement.ambiguous" + const val MAPPING_STATEMENT_UNMAPPED = "mapping.statement.unmapped" + const val PROTOCOL_REQUEST_INVALID = "protocol.request.invalid" const val SOURCE_ROOT_INVALID = "source-root.invalid" diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/backend/PropertyBasedTestingBackend.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/backend/PropertyBasedTestingBackend.kt index c3df828e1..4e666df08 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/backend/PropertyBasedTestingBackend.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/backend/PropertyBasedTestingBackend.kt @@ -73,7 +73,6 @@ data class PropertyFailureDetails( ) { init { require(errorName.isNotBlank()) { "Failure error name must not be blank" } - require(message.isNotBlank()) { "Failure message must not be blank" } } } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/CoveragePathFilter.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/CoveragePathFilter.kt index 7643b439f..831c25813 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/CoveragePathFilter.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/CoveragePathFilter.kt @@ -10,7 +10,7 @@ internal fun matchesCoveragePath( add(path) sourceRoots.forEach { sourceRoot -> if (isWithin(path, sourceRoot) && path != sourceRoot) { - add(path.removePrefix("$sourceRoot/")) + add(path.removePrefix(rootPrefix(sourceRoot))) } } } @@ -74,7 +74,9 @@ private fun coverageGlobToRegex(pattern: String): Regex { return Regex(expression.toString()) } -internal fun isWithin(path: String, root: String): Boolean = path == root || path.startsWith("$root/") +internal fun isWithin(path: String, root: String): Boolean = path == root || path.startsWith(rootPrefix(root)) + +private fun rootPrefix(root: String): String = if (root.endsWith('/')) root else "$root/" private const val REGEX_SPECIAL_CHARACTERS = ".+()^$|{}[]" private const val DOUBLE_WILDCARD_LENGTH = 2 diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageDecoder.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageDecoder.kt index c2a64f707..099d8c60c 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageDecoder.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageDecoder.kt @@ -2,7 +2,6 @@ package org.usvm.ts.pbt.coverage import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject -import org.usvm.ts.pbt.PbtDiagnosticCode import org.usvm.ts.pbt.backend.CoverageArtifactKind import org.usvm.ts.pbt.backend.CoverageDiagnostic import org.usvm.ts.pbt.backend.CoverageProvenance @@ -77,23 +76,9 @@ internal class IstanbulCoverageDecoder( private fun sourceMapDiagnostic(path: String): CoverageDiagnostic { val sourceMapPath = Path.of("$path.map") - val sourceMapExists = Files.exists(sourceMapPath) - - val diagnosticCode = if (sourceMapExists) { - PbtDiagnosticCode.COVERAGE_SOURCE_MAP_INVALID - } else { - PbtDiagnosticCode.COVERAGE_SOURCE_MAP_MISSING - } - val diagnosticMessage = if (sourceMapExists) { - "Executed JavaScript has a source map that c8 could not remap to its original source" - } else { - "Executed JavaScript below a TypeScript source root has no source map" - } - - return CoverageDiagnostic( - code = diagnosticCode, - message = diagnosticMessage, + return buildSourceMapDiagnostic( path = path, + sourceMapExists = Files.exists(sourceMapPath), ) } @@ -155,11 +140,5 @@ internal class IstanbulCoverageDecoder( private companion object { val GENERATED_JAVASCRIPT_EXTENSIONS = hashSetOf("js", "mjs", "cjs") - - fun normalizeCoveragePath(path: String): String = Path.of(path) - .toAbsolutePath() - .normalize() - .toString() - .replace('\\', '/') } } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportReader.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportReader.kt index e401833fa..a43b97bb9 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportReader.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportReader.kt @@ -71,6 +71,6 @@ internal object IstanbulCoverageReportReader { path = reportPath.toString(), cause = error, ) - - private const val MAX_COVERAGE_REPORT_BYTES = 64L * 1024 * 1024 } + +internal const val MAX_COVERAGE_REPORT_BYTES = 64L * 1024 * 1024 diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspector.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspector.kt new file mode 100644 index 000000000..db6edf74e --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspector.kt @@ -0,0 +1,474 @@ +package org.usvm.ts.pbt.coverage + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.usvm.ts.pbt.PbtDiagnosticCode +import org.usvm.ts.pbt.backend.CoverageDiagnostic +import org.usvm.ts.pbt.manifest.PropertyManifestJson +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.net.URI +import java.net.URISyntaxException +import java.nio.ByteBuffer +import java.nio.charset.CharacterCodingException +import java.nio.file.Files +import java.nio.file.Path + +/** Reads bounded raw V8 source-map caches that c8 does not retain in its final Istanbul report. */ +internal fun inspectRawV8SourceMapDiagnostics( + rawDirectory: Path, + sourceRoots: List, + maxReportFiles: Int = MAX_RAW_V8_REPORT_FILES, + maxReportBytes: Long = MAX_COVERAGE_REPORT_BYTES, +): List { + require(maxReportFiles > 0) { "Raw V8 report file limit must be positive" } + require(maxReportBytes > 0) { "Raw V8 report byte limit must be positive" } + + if (!Files.isDirectory(rawDirectory)) { + throw CoverageArtifactException.create( + code = PbtDiagnosticCode.COVERAGE_REPORT_MISSING, + message = "c8 did not produce the expected raw V8 coverage directory: $rawDirectory", + path = rawDirectory.toString(), + ) + } + + val reportPaths = listRawReportPaths(rawDirectory, maxReportFiles) + if (reportPaths.isEmpty()) { + throw CoverageArtifactException.create( + code = PbtDiagnosticCode.COVERAGE_REPORT_MISSING, + message = "c8 did not produce any raw V8 coverage reports in $rawDirectory", + path = rawDirectory.toString(), + ) + } + requireAllowedRawReportSizes(reportPaths, maxReportBytes) + val normalizedSourceRoots = sourceRoots.map(::normalizeCoveragePath) + val reportReader = RawV8ReportReader(maxReportBytes = maxReportBytes) + val diagnostics = reportPaths.flatMap { reportPath -> + inspectRawReport( + reportPath = reportPath, + sourceRoots = normalizedSourceRoots, + reportReader = reportReader, + ) + } + + return coalesceSourceMapDiagnostics(diagnostics) +} + +/** Raw source-map evidence takes precedence over final-report guesses for the same generated script. */ +internal fun mergeCoverageDiagnostics( + finalDiagnostics: List, + rawDiagnostics: List, +): List { + val rawSourceMapPaths = rawDiagnostics + .filter(::isSourceMapDiagnostic) + .mapNotNullTo(hashSetOf(), CoverageDiagnostic::path) + val retainedFinalDiagnostics = finalDiagnostics.filterNot { diagnostic -> + isSourceMapDiagnostic(diagnostic) && diagnostic.path in rawSourceMapPaths + } + + return coalesceSourceMapDiagnostics(retainedFinalDiagnostics + rawDiagnostics) +} + +internal fun buildSourceMapDiagnostic(path: String, sourceMapExists: Boolean): CoverageDiagnostic { + val diagnosticCode = if (sourceMapExists) { + PbtDiagnosticCode.COVERAGE_SOURCE_MAP_INVALID + } else { + PbtDiagnosticCode.COVERAGE_SOURCE_MAP_MISSING + } + val diagnosticMessage = if (sourceMapExists) { + "Executed JavaScript has a source map that c8 could not remap to its original source" + } else { + "Executed JavaScript below a TypeScript source root has no source map" + } + + return CoverageDiagnostic( + code = diagnosticCode, + message = diagnosticMessage, + path = path, + ) +} + +internal fun normalizeCoveragePath(path: String): String = Path.of(path) + .toAbsolutePath() + .normalize() + .toString() + .replace('\\', '/') + +/** Reads raw reports under one aggregate byte budget, including bytes consumed after preflight. */ +internal class RawV8ReportReader(private val maxReportBytes: Long) { + private var consumedBytes = 0L + + init { + require(maxReportBytes > 0) { "Raw V8 report byte limit must be positive" } + } + + fun readText(reportPath: Path): String { + val reportBytes = try { + readBytes(reportPath) + } catch (error: IOException) { + throw invalidRawReport( + message = "Cannot read raw V8 coverage report: ${error.message}", + path = reportPath, + cause = error, + ) + } + + return try { + Charsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(reportBytes)).toString() + } catch (error: CharacterCodingException) { + throw invalidRawReport( + message = "Cannot decode raw V8 coverage report as UTF-8: ${error.message}", + path = reportPath, + cause = error, + ) + } + } + + private fun readBytes(reportPath: Path): ByteArray { + val output = ByteArrayOutputStream() + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + + Files.newInputStream(reportPath).use { input -> + while (true) { + val remainingBytes = maxReportBytes - consumedBytes + val readLength = if (remainingBytes >= buffer.size) { + buffer.size + } else { + remainingBytes.toInt() + 1 + } + val readBytes = input.read(buffer, 0, readLength) + if (readBytes < 0) break + + if (readBytes > remainingBytes) { + throw invalidRawReport( + message = "Raw V8 coverage reports exceed $maxReportBytes bytes", + path = reportPath, + ) + } + + output.write(buffer, 0, readBytes) + consumedBytes += readBytes + } + } + + return output.toByteArray() + } +} + +private fun listRawReportPaths(rawDirectory: Path, maxReportFiles: Int): List = try { + val reportPaths = mutableListOf() + Files.newDirectoryStream(rawDirectory, "*.json").use { entries -> + for (entry in entries) { + addRawReportPath( + reportPaths = reportPaths, + reportPath = entry, + rawDirectory = rawDirectory, + maxReportFiles = maxReportFiles, + ) + } + } + + reportPaths.sortedBy { path -> path.fileName.toString() } +} catch (error: CoverageArtifactException) { + throw error +} catch (error: IOException) { + throw invalidRawReport( + message = "Cannot list raw V8 coverage reports: ${error.message}", + path = rawDirectory, + cause = error, + ) +} + +private fun addRawReportPath( + reportPaths: MutableList, + reportPath: Path, + rawDirectory: Path, + maxReportFiles: Int, +) { + if (reportPaths.size == maxReportFiles) { + throw CoverageArtifactException.create( + code = PbtDiagnosticCode.COVERAGE_REPORT_INVALID, + message = "Raw V8 coverage contains more than $maxReportFiles report files", + path = rawDirectory.toString(), + ) + } + + reportPaths.add(reportPath) +} + +private fun requireAllowedRawReportSizes(reportPaths: List, maxReportBytes: Long) { + var totalBytes = 0L + for (reportPath in reportPaths) { + val reportBytes = try { + Files.size(reportPath) + } catch (error: IOException) { + throw invalidRawReport( + message = "Cannot read raw V8 coverage report size: ${error.message}", + path = reportPath, + cause = error, + ) + } + + if (reportBytes > maxReportBytes - totalBytes) { + throw invalidRawReport( + message = "Raw V8 coverage reports exceed $maxReportBytes bytes", + path = reportPath, + ) + } + + totalBytes += reportBytes + } +} + +private fun inspectRawReport( + reportPath: Path, + sourceRoots: List, + reportReader: RawV8ReportReader, +): List { + val report = readRawReport(reportPath, reportReader) + val sourceMapCache = readSourceMapCache(report, reportPath) ?: return emptyList() + + return sourceMapCache.mapNotNull { (scriptUrl, cacheEntryElement) -> + inspectSourceMapCacheEntry( + reportPath = reportPath, + scriptUrl = scriptUrl, + cacheEntry = requireSourceMapCacheEntry( + reportPath = reportPath, + scriptUrl = scriptUrl, + element = cacheEntryElement, + ), + sourceRoots = sourceRoots, + ) + } +} + +private fun readRawReport(reportPath: Path, reportReader: RawV8ReportReader): JsonObject { + val reportText = reportReader.readText(reportPath) + + return parseRawReport(reportText, reportPath) +} + +private fun parseRawReport(reportText: String, reportPath: Path): JsonObject = try { + PropertyManifestJson.json.parseToJsonElement(reportText) as? JsonObject + ?: throw invalidRawReport( + message = "Raw V8 coverage report must be a JSON object", + path = reportPath, + ) +} catch (error: CoverageArtifactException) { + throw error +} catch (error: IllegalArgumentException) { + throw invalidRawReport( + message = "Raw V8 coverage report is not valid JSON: ${error.message}", + path = reportPath, + cause = error, + ) +} + +private fun readSourceMapCache(report: JsonObject, reportPath: Path): JsonObject? { + val sourceMapCacheElement = report["source-map-cache"] ?: return null + return sourceMapCacheElement as? JsonObject + ?: throw invalidRawReport( + message = "Raw V8 source-map-cache must be a JSON object", + path = "$reportPath.source-map-cache", + ) +} + +private fun requireSourceMapCacheEntry( + reportPath: Path, + scriptUrl: String, + element: JsonElement, +): JsonObject = element as? JsonObject + ?: throw invalidRawReport( + message = "Raw V8 source-map cache entry must be a JSON object", + path = "$reportPath.source-map-cache[$scriptUrl]", + ) + +private fun inspectSourceMapCacheEntry( + reportPath: Path, + scriptUrl: String, + cacheEntry: JsonObject, + sourceRoots: List, +): CoverageDiagnostic? { + val data = cacheEntry["data"] + ?: throw invalidRawReport( + message = "Raw V8 source-map cache entry is missing data", + path = "$reportPath.source-map-cache[$scriptUrl].data", + ) + requireValidSourceMapDataShape(reportPath, scriptUrl, data) + if (data != JsonNull) return null + + val scriptUri = parseScriptUri(reportPath, scriptUrl) + if (scriptUri.scheme != "file") return null + + val scriptPath = scriptUri.toCoveragePath(reportPath, scriptUrl) + if (!isGeneratedJavaScriptBelowSourceRoot(scriptPath, sourceRoots)) return null + + val referencedUrl = cacheEntry["url"] as? JsonPrimitive + if (referencedUrl == null || !referencedUrl.isString || referencedUrl.content.isBlank()) { + throw invalidRawReport( + message = "Raw V8 source-map cache entry must contain a source-map URL", + path = "$reportPath.source-map-cache[$scriptUrl].url", + ) + } + + val sourceMapPath = resolveSourceMapPath( + scriptUri = scriptUri, + scriptPath = Path.of(scriptPath), + referencedUrl = referencedUrl.content, + ) + val sourceMapExists = sourceMapPath == null || Files.exists(sourceMapPath) + + return buildSourceMapDiagnostic( + path = scriptPath, + sourceMapExists = sourceMapExists, + ) +} + +private fun requireValidSourceMapDataShape(reportPath: Path, scriptUrl: String, data: JsonElement) { + if (data == JsonNull || data is JsonObject) return + + throw invalidRawReport( + message = "Raw V8 source-map cache entry data must be a JSON object when non-null", + path = "$reportPath.source-map-cache[$scriptUrl].data", + ) +} + +private fun parseScriptUri(reportPath: Path, scriptUrl: String): URI = try { + URI(scriptUrl) +} catch (error: IllegalArgumentException) { + throw invalidRawReport( + message = "Raw V8 source-map cache key is not a valid script URL: ${error.message}", + path = "$reportPath.source-map-cache[$scriptUrl]", + cause = error, + ) +} catch (error: URISyntaxException) { + throw invalidRawReport( + message = "Raw V8 source-map cache key is not a valid script URL: ${error.message}", + path = "$reportPath.source-map-cache[$scriptUrl]", + cause = error, + ) +} + +private fun URI.toCoveragePath(reportPath: Path, scriptUrl: String): String { + return try { + normalizeCoveragePath(toLocalFilePath().toString()) + } catch (error: IllegalArgumentException) { + throw invalidScriptUrlPath(reportPath, scriptUrl, error) + } catch (error: URISyntaxException) { + throw invalidScriptUrlPath(reportPath, scriptUrl, error) + } +} + +private fun invalidScriptUrlPath( + reportPath: Path, + scriptUrl: String, + error: Exception, +): CoverageArtifactException = invalidRawReport( + message = "Raw V8 script URL cannot be converted to a path: ${error.message}", + path = "$reportPath.source-map-cache[$scriptUrl]", + cause = error, +) + +private fun resolveSourceMapPath(scriptUri: URI, scriptPath: Path, referencedUrl: String): Path? { + if (referencedUrl.startsWith("data:")) return null + + val referenceUri = try { + URI(referencedUrl) + } catch (_: IllegalArgumentException) { + return resolveSourceMapPathFallback(scriptPath, referencedUrl) + } catch (_: URISyntaxException) { + return resolveSourceMapPathFallback(scriptPath, referencedUrl) + } + val resolvedUri = scriptUri.resolve(referenceUri) + val hasRemoteAuthority = !resolvedUri.authority.isNullOrEmpty() + if (resolvedUri.scheme != "file" || hasRemoteAuthority) return null + + return try { + resolvedUri.toLocalFilePath().normalize() + } catch (_: IllegalArgumentException) { + null + } catch (_: URISyntaxException) { + null + } +} + +@Throws(URISyntaxException::class) +private fun URI.toLocalFilePath(): Path { + val localFileUri = URI(scheme, authority, path, null, null) + + return Path.of(localFileUri) +} + +private fun resolveSourceMapPathFallback(scriptPath: Path, referencedUrl: String): Path? = + runCatching { + val referencedPath = Path.of(referencedUrl) + + if (referencedPath.isAbsolute) { + referencedPath.normalize() + } else { + scriptPath.parent.resolve(referencedPath).normalize() + } + }.getOrNull() + +private fun isGeneratedJavaScriptBelowSourceRoot(path: String, sourceRoots: List): Boolean { + val extension = path.substringAfterLast('.', missingDelimiterValue = "").lowercase() + + return extension in GENERATED_JAVASCRIPT_EXTENSIONS && sourceRoots.any { root -> isWithin(path, root) } +} + +private fun isSourceMapDiagnostic(diagnostic: CoverageDiagnostic): Boolean = + diagnostic.code == PbtDiagnosticCode.COVERAGE_SOURCE_MAP_MISSING || + diagnostic.code == PbtDiagnosticCode.COVERAGE_SOURCE_MAP_INVALID + +private fun coalesceSourceMapDiagnostics(diagnostics: List): List { + val diagnosticsByScriptPath = hashMapOf() + val unkeyedDiagnostics = mutableListOf() + + diagnostics.forEach { diagnostic -> + val scriptPath = diagnostic.path + if (!isSourceMapDiagnostic(diagnostic) || scriptPath == null) { + unkeyedDiagnostics += diagnostic + return@forEach + } + + val previous = diagnosticsByScriptPath[scriptPath] + if (previous == null || diagnostic.isInvalidInsteadOfMissing(previous)) { + diagnosticsByScriptPath[scriptPath] = diagnostic + } + } + + return (unkeyedDiagnostics + diagnosticsByScriptPath.values) + .distinct() + .sortedWith(COVERAGE_DIAGNOSTIC_ORDER) +} + +private fun CoverageDiagnostic.isInvalidInsteadOfMissing(other: CoverageDiagnostic): Boolean = + code == PbtDiagnosticCode.COVERAGE_SOURCE_MAP_INVALID && + other.code == PbtDiagnosticCode.COVERAGE_SOURCE_MAP_MISSING + +private fun invalidRawReport( + message: String, + path: Path, + cause: Throwable? = null, +): CoverageArtifactException = invalidRawReport( + message = message, + path = path.toString(), + cause = cause, +) + +private fun invalidRawReport( + message: String, + path: String, + cause: Throwable? = null, +): CoverageArtifactException = CoverageArtifactException.create( + code = PbtDiagnosticCode.COVERAGE_REPORT_INVALID, + message = message, + path = path, + cause = cause, +) + +private const val MAX_RAW_V8_REPORT_FILES = 1_024 +private val GENERATED_JAVASCRIPT_EXTENSIONS = hashSetOf("js", "mjs", "cjs") +private val COVERAGE_DIAGNOSTIC_ORDER = compareBy(CoverageDiagnostic::path, CoverageDiagnostic::code) diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClient.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClient.kt index 85085343b..30d39354e 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClient.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClient.kt @@ -17,6 +17,8 @@ import org.usvm.ts.pbt.backend.PropertyRunResult import org.usvm.ts.pbt.coverage.CoverageArtifactException import org.usvm.ts.pbt.coverage.IstanbulCoverageContext import org.usvm.ts.pbt.coverage.decodeIstanbulCoverageReport +import org.usvm.ts.pbt.coverage.inspectRawV8SourceMapDiagnostics +import org.usvm.ts.pbt.coverage.mergeCoverageDiagnostics import org.usvm.ts.pbt.manifest.PropertyManifestJson import org.usvm.ts.pbt.model.PropertyId import java.io.ByteArrayOutputStream @@ -34,6 +36,12 @@ internal class FastCheckProcessClient( private val shutdownGraceMillis: Long = DEFAULT_SHUTDOWN_GRACE_MILLIS, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { + init { + require(shutdownGraceMillis in 1..Int.MAX_VALUE.toLong()) { + "Shutdown grace period must fit the positive delay range supported by Node timers" + } + } + /** Executes one request and exposes only a fully validated common result. */ fun check(request: FastCheckExecutionRequest): PropertyRunResult = try { runBlocking { checkSuspending(request) } @@ -54,42 +62,61 @@ internal class FastCheckProcessClient( val coverageRuntimeVersion = request.coverageRequest?.let { nodeVersion(request) } val coverageWorkspace = request.coverageRequest?.let { createCoverageWorkspace(request) } - var process: Process? = null + val deadlineNanos = deadlineAfter(safeAdd(request.timeoutMillis, transportGraceMillis)) + val operationDeadlineNanos = deadlineBefore( + deadlineNanos = deadlineNanos, + durationMillis = minOf(FORCED_TERMINATION_RESERVE_MILLIS, transportGraceMillis), + ) + var managedProcess: ManagedFastCheckProcess? = null + var stdout: Deferred? = null + var stderr: Deferred? = null + var writer: Deferred? = null try { val startedProcess = startAdapter(request, coverageWorkspace) - process = startedProcess - val stdout = async(ioDispatcher) { startedProcess.inputStream.readBounded(MAX_STDOUT_BYTES) } - val stderr = async(ioDispatcher) { startedProcess.errorStream.readBounded(MAX_STDERR_BYTES) } - val writer = async(ioDispatcher) { - startedProcess.outputStream.bufferedWriter(Charsets.UTF_8).use { output -> + managedProcess = startedProcess + val process = startedProcess.process + val stdoutTask = async(ioDispatcher) { process.inputStream.readBounded(MAX_STDOUT_BYTES) } + stdout = stdoutTask + val stderrTask = async(ioDispatcher) { process.errorStream.readBounded(MAX_STDERR_BYTES) } + stderr = stderrTask + val writerTask = async(ioDispatcher) { + process.outputStream.bufferedWriter(Charsets.UTF_8).use { output -> output.write(encodedRequest) } } + writer = writerTask - awaitProcess(startedProcess, request) + awaitProcess( + process = process, + deadlineNanos = operationDeadlineNanos, + request = request, + ) awaitIo( - task = writer, + task = writerTask, operation = "writing the fast-check request", failureCode = PbtDiagnosticCode.BACKEND_PROCESS_WRITE_FAILED, + deadlineNanos = operationDeadlineNanos, request = request, ) val stdoutText = awaitIo( - task = stdout, + task = stdoutTask, operation = "reading fast-check stdout", failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + deadlineNanos = operationDeadlineNanos, request = request, ) val stderrText = awaitIo( - task = stderr, + task = stderrTask, operation = "reading fast-check stderr", failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + deadlineNanos = operationDeadlineNanos, request = request, ) - validateProcessExit(startedProcess, stderrText, request) + validateProcessExit(process, stderrText, request) validateStdout(stdoutText, request) val response = decodeResponse(stdoutText.text, request) @@ -105,7 +132,14 @@ internal class FastCheckProcessClient( ) } ?: result } finally { - process?.takeIf(Process::isAlive)?.let(::terminate) + writer?.cancel() + stdout?.cancel() + stderr?.cancel() + managedProcess?.let { startedProcess -> + terminate(startedProcess, deadlineNanos) + closeStreams(startedProcess.process) + runCatching { Files.deleteIfExists(startedProcess.processGroupFile) } + } coverageWorkspace?.root?.toFile()?.deleteRecursively() } } @@ -125,22 +159,17 @@ internal class FastCheckProcessClient( return encodedRequest } - private suspend fun awaitProcess(process: Process, request: FastCheckExecutionRequest) { - val hardTimeoutMillis = safeAdd(request.timeoutMillis, transportGraceMillis) - val exitCode = withTimeoutOrNull(hardTimeoutMillis) { + private suspend fun awaitProcess( + process: Process, + deadlineNanos: Long, + request: FastCheckExecutionRequest, + ) { + val completed = withTimeoutOrNull(remainingMillis(deadlineNanos)) { runInterruptible(ioDispatcher) { process.waitFor() } + true } - if (exitCode == null) { - terminate(process) - - throw backendError( - kind = BackendErrorKind.TIMEOUT, - code = PbtDiagnosticCode.BACKEND_PROCESS_TIMEOUT, - message = "fast-check adapter exceeded the ${request.timeoutMillis} ms timeout", - request = request, - ) - } + if (completed == null) executionTimeout(request) } private fun validateProcessExit( @@ -183,16 +212,57 @@ internal class FastCheckProcessClient( private fun startAdapter( request: FastCheckExecutionRequest, coverageWorkspace: CoverageWorkspace?, - ): Process = try { - ProcessBuilder(adapterCommand(request, coverageWorkspace)).start() - } catch (error: IOException) { - throw backendError( - kind = BackendErrorKind.PROCESS_FAILURE, - code = PbtDiagnosticCode.BACKEND_PROCESS_START_FAILED, - message = "Failed to start fast-check adapter: ${error.message}", - request = request, - cause = error, - ) + ): ManagedFastCheckProcess { + val processGroupFile = try { + Files.createTempFile("usvm-execution-process-group-", ".pid") + } catch (error: IOException) { + throw processStartFailure(request, error) + } + var processStarted = false + + try { + val process = ProcessBuilder( + supervisedAdapterCommand( + request = request, + coverageWorkspace = coverageWorkspace, + processGroupFile = processGroupFile, + ), + ).start() + processStarted = true + + return ManagedFastCheckProcess( + process = process, + processGroupFile = processGroupFile, + ) + } catch (error: IOException) { + throw processStartFailure(request, error) + } finally { + if (!processStarted) runCatching { Files.deleteIfExists(processGroupFile) } + } + } + + private fun processStartFailure( + request: FastCheckExecutionRequest, + error: IOException, + ) = backendError( + kind = BackendErrorKind.PROCESS_FAILURE, + code = PbtDiagnosticCode.BACKEND_PROCESS_START_FAILED, + message = "Failed to start fast-check adapter: ${error.message}", + request = request, + cause = error, + ) + + private fun supervisedAdapterCommand( + request: FastCheckExecutionRequest, + coverageWorkspace: CoverageWorkspace?, + processGroupFile: Path, + ): List = buildList { + add(nodeExecutable) + add(FastCheckRuntime.processSupervisorEntryPoint().toString()) + add(PROCESS_SUPERVISOR_COMMAND) + add(shutdownGraceMillis.toString()) + add(processGroupFile.toString()) + addAll(adapterCommand(request, coverageWorkspace)) } private fun adapterCommand( @@ -257,13 +327,17 @@ internal class FastCheckProcessClient( val entryPointPaths = hashSetOf() request.sourceRoots.forEach { sourceRoot -> val root = Path.of(sourceRoot) - entryPointPaths += root.resolve(request.manifest.predicate.module).normalize().toString() + entryPointPaths += canonicalizeExistingEntryPoint( + root.resolve(request.manifest.predicate.module).normalize(), + ) request.manifest.precondition?.let { precondition -> - entryPointPaths += root.resolve(precondition.module).normalize().toString() + entryPointPaths += canonicalizeExistingEntryPoint( + root.resolve(precondition.module).normalize(), + ) } } val artifact = try { - decodeIstanbulCoverageReport( + val finalArtifact = decodeIstanbulCoverageReport( reportPath = workspace.reportDirectory.resolve("coverage-final.json"), context = IstanbulCoverageContext( backendId = FastCheckBackend.FAST_CHECK_BACKEND_ID, @@ -277,6 +351,17 @@ internal class FastCheckProcessClient( request = coverageRequest, ), ) + val rawDiagnostics = inspectRawV8SourceMapDiagnostics( + rawDirectory = workspace.rawDirectory, + sourceRoots = request.sourceRoots, + ) + + finalArtifact.copy( + diagnostics = mergeCoverageDiagnostics( + finalDiagnostics = finalArtifact.diagnostics, + rawDiagnostics = rawDiagnostics, + ), + ) } catch (error: CoverageArtifactException) { throw backendError( kind = BackendErrorKind.COVERAGE, @@ -291,6 +376,9 @@ internal class FastCheckProcessClient( return result.copy(coverage = artifact) } + private fun canonicalizeExistingEntryPoint(candidate: Path): String = + if (Files.exists(candidate)) candidate.toRealPath().toString() else candidate.toString() + private suspend fun nodeVersion(request: FastCheckExecutionRequest): String { val process = startNodeVersionProcess(request) @@ -391,9 +479,12 @@ internal class FastCheckProcessClient( task: Deferred, operation: String, failureCode: String, + deadlineNanos: Long, request: FastCheckExecutionRequest, ): T = try { - task.await() + withTimeoutOrNull(remainingMillis(deadlineNanos)) { + task.await() + } ?: executionTimeout(request) } catch (error: CancellationException) { throw error } catch (error: IOException) { @@ -406,6 +497,13 @@ internal class FastCheckProcessClient( ) } + private fun executionTimeout(request: FastCheckExecutionRequest): Nothing = throw backendError( + kind = BackendErrorKind.TIMEOUT, + code = PbtDiagnosticCode.BACKEND_PROCESS_TIMEOUT, + message = "fast-check adapter exceeded the ${request.timeoutMillis} ms timeout", + request = request, + ) + private fun decodeResponse( stdout: String, request: FastCheckExecutionRequest, @@ -513,15 +611,103 @@ internal class FastCheckProcessClient( cause = cause, ) - private fun terminate(process: Process) { + private fun closeStreams(process: Process) { + runCatching { process.outputStream.close() } + runCatching { process.inputStream.close() } + runCatching { process.errorStream.close() } + } + + private fun terminate(managedProcess: ManagedFastCheckProcess, deadlineNanos: Long) { + val process = managedProcess.process + if (!process.isAlive) { + forceTerminateOwnedProcessGroup(managedProcess.processGroupFile, deadlineNanos) + + return + } + process.destroy() - if (!process.waitFor(shutdownGraceMillis, TimeUnit.MILLISECONDS)) { - process.destroyForcibly() - process.waitFor() + val gracefulDeadlineNanos = minOf( + deadlineBefore( + deadlineNanos = deadlineNanos, + durationMillis = FORCED_TERMINATION_RESERVE_MILLIS, + ), + deadlineAfter(shutdownGraceMillis), + ) + if (awaitProcessExit(process, gracefulDeadlineNanos)) return + + forceTerminateOwnedProcessGroup(managedProcess.processGroupFile, deadlineNanos) + process.destroyForcibly() + awaitProcessExit(process, deadlineNanos) + } + + private fun forceTerminateOwnedProcessGroup(processGroupFile: Path, deadlineNanos: Long) { + val processGroupId = runCatching { + Files.readString(processGroupFile).trim().toLong() + }.getOrNull() ?: return + val command = if (IS_WINDOWS) { + listOf("taskkill", "/PID", processGroupId.toString(), "/T", "/F") + } else { + listOf("/bin/kill", "-KILL", "--", "-$processGroupId") + } + val killer = runCatching { + ProcessBuilder(command) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start() + }.getOrNull() ?: return + val waitMillis = minOf(remainingMillis(deadlineNanos), PROCESS_GROUP_KILL_WAIT_MILLIS) + if (waitMillis == 0L) return + + try { + if (!killer.waitFor(waitMillis, TimeUnit.MILLISECONDS)) killer.destroyForcibly() + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + killer.destroyForcibly() } } + private fun awaitProcessExit(process: Process, deadlineNanos: Long): Boolean { + while (true) { + if (!process.isAlive) return true + + val waitMillis = minOf(remainingMillis(deadlineNanos), PROCESS_POLL_MILLIS) + if (waitMillis == 0L) return false + + try { + if (process.waitFor(waitMillis, TimeUnit.MILLISECONDS)) return true + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + + return false + } + } + } + + private fun deadlineAfter(timeoutMillis: Long): Long { + val timeoutNanos = TimeUnit.MILLISECONDS.toNanos(timeoutMillis) + val now = System.nanoTime() + + return if (now > Long.MAX_VALUE - timeoutNanos) Long.MAX_VALUE else now + timeoutNanos + } + + private fun deadlineBefore(deadlineNanos: Long, durationMillis: Long): Long { + if (deadlineNanos == Long.MAX_VALUE) return Long.MAX_VALUE + + val durationNanos = TimeUnit.MILLISECONDS.toNanos(durationMillis) + + return if (deadlineNanos < Long.MIN_VALUE + durationNanos) Long.MIN_VALUE else deadlineNanos - durationNanos + } + + private fun remainingMillis(deadlineNanos: Long): Long { + if (deadlineNanos == Long.MAX_VALUE) return Long.MAX_VALUE + + val remainingNanos = deadlineNanos - System.nanoTime() + if (remainingNanos <= 0) return 0 + + return TimeUnit.NANOSECONDS.toMillis(remainingNanos) + } + private companion object { const val MAX_REQUEST_BYTES = 4 * 1024 * 1024 const val MAX_STDOUT_BYTES = 4 * 1024 * 1024 @@ -529,12 +715,22 @@ internal class FastCheckProcessClient( const val DEFAULT_TRANSPORT_GRACE_MILLIS = 2_000L const val DEFAULT_SHUTDOWN_GRACE_MILLIS = 250L const val NODE_VERSION_TIMEOUT_MILLIS = 5_000L + const val PROCESS_POLL_MILLIS = 10L + const val FORCED_TERMINATION_RESERVE_MILLIS = 25L + const val PROCESS_GROUP_KILL_WAIT_MILLIS = 10L const val MINIMUM_NODE_MAJOR_VERSION = 18 const val MINIMUM_NODE_MINOR_VERSION = 18 + const val PROCESS_SUPERVISOR_COMMAND = "--command" val NODE_VERSION_PATTERN = Regex("""^v(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$""") + val IS_WINDOWS = System.getProperty("os.name").lowercase().contains("windows") } } +private data class ManagedFastCheckProcess( + val process: Process, + val processGroupFile: Path, +) + private data class CoverageWorkspace( val root: Path, val configPath: Path, diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClient.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClient.kt index e40beccc2..e841831ec 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClient.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClient.kt @@ -4,9 +4,37 @@ import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import org.usvm.ts.pbt.PbtDiagnosticCode import org.usvm.ts.pbt.manifest.PropertyManifestJson +import org.usvm.ts.pbt.model.contains +import java.io.ByteArrayOutputStream import java.io.IOException +import java.io.InputStream +import java.nio.file.Files import java.nio.file.Path +import java.util.concurrent.ExecutionException import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +/** Internal transport limits for bounded projection-process communication. */ +internal data class FastCheckProjectionTransportLimits( + val maxRequestBytes: Int, + val maxStdoutBytes: Int, + val maxStderrBytes: Int, + val wallClockTimeoutMillis: Long, + val shutdownGraceMillis: Long, +) { + init { + require(maxRequestBytes > 0) { "Maximum request size must be positive" } + require(maxStdoutBytes > 0) { "Maximum stdout size must be positive" } + require(maxStderrBytes > 0) { "Maximum stderr size must be positive" } + require(wallClockTimeoutMillis > 0) { "Projection wall-clock timeout must be positive" } + require(shutdownGraceMillis > 0) { "Projection shutdown grace period must be positive" } + require(shutdownGraceMillis <= Int.MAX_VALUE.toLong()) { + "Projection shutdown grace period exceeds the maximum delay supported by Node timers" + } + } +} /** * Synchronous Kotlin client for the private fast-check Node adapter. @@ -14,15 +42,39 @@ import java.util.concurrent.Executors * Each request starts a fresh adapter process, writes one JSON request, and validates the single JSON response * before exposing sampled values to Kotlin callers. */ -class FastCheckProjectionClient( - private val nodeExecutable: String = "node", - private val adapterEntryPoint: Path = FastCheckRuntime.projectionEntryPoint(), +class FastCheckProjectionClient private constructor( + private val nodeExecutable: String, + private val adapterEntryPoint: Path, + private val transportLimits: FastCheckProjectionTransportLimits, + @Suppress("UNUSED_PARAMETER") internalConstructorMarker: Unit, ) { + constructor( + nodeExecutable: String = "node", + adapterEntryPoint: Path = FastCheckRuntime.projectionEntryPoint(), + ) : this( + nodeExecutable = nodeExecutable, + adapterEntryPoint = adapterEntryPoint, + transportLimits = DEFAULT_TRANSPORT_LIMITS, + internalConstructorMarker = Unit, + ) + + internal constructor( + nodeExecutable: String = "node", + adapterEntryPoint: Path = FastCheckRuntime.projectionEntryPoint(), + transportLimits: FastCheckProjectionTransportLimits, + ) : this( + nodeExecutable = nodeExecutable, + adapterEntryPoint = adapterEntryPoint, + transportLimits = transportLimits, + internalConstructorMarker = Unit, + ) + /** Projects the requested domains to fast-check and returns the generated samples. */ fun sample(request: FastCheckProjectionRequest): FastCheckProjectionResponse { validateRequest(request) - val response = decodeResponse(invokeAdapter(request)) + val encodedRequest = encodeRequest(request) + val response = decodeResponse(invokeAdapter(encodedRequest)) throwBackendError(response) validateSuccessfulResponse(request, response) @@ -54,59 +106,344 @@ class FastCheckProjectionClient( val hasExpectedArity = response.samples.all { it.size == request.domains.size } if (!hasExpectedStatus || !hasExpectedSampleCount || !hasExpectedArity) { + invalidResponse("fast-check adapter returned an invalid successful response") + } + + response.samples.forEachIndexed { sampleIndex, sample -> + sample.forEachIndexed { inputIndex, value -> + if (value !in request.domains[inputIndex]) { + invalidResponse( + message = "fast-check adapter returned a value outside its requested domain", + path = "samples[$sampleIndex][$inputIndex]", + ) + } + } + } + } + + private fun encodeRequest(request: FastCheckProjectionRequest): String { + val encodedRequest = PropertyManifestJson.json.encodeToString(request) + if (encodedRequest.toByteArray(Charsets.UTF_8).size > transportLimits.maxRequestBytes) { throw FastCheckProjectionException( - code = PbtDiagnosticCode.BACKEND_RESPONSE_INVALID, - message = "fast-check adapter returned an invalid successful response", + code = PbtDiagnosticCode.BACKEND_REQUEST_TOO_LARGE, + message = "fast-check projection request exceeds ${transportLimits.maxRequestBytes} bytes", ) } + + return encodedRequest } - private fun invokeAdapter(request: FastCheckProjectionRequest): String { - val process = startAdapter() - val errorReaderExecutor = Executors.newSingleThreadExecutor() - val stderr = errorReaderExecutor.submit { - process.errorStream.bufferedReader(Charsets.UTF_8).use { reader -> reader.readText() } - } + private fun invokeAdapter(encodedRequest: String): String { + val managedProcess = startAdapter() + val process = managedProcess.process + val deadlineNanos = deadlineAfter(transportLimits.wallClockTimeoutMillis) + val ioExecutor = Executors.newFixedThreadPool(IO_TASKS) + var stdout: Future? = null + var stderr: Future? = null + var writer: Future<*>? = null try { - process.outputStream.bufferedWriter(Charsets.UTF_8).use { writer -> - writer.write(PropertyManifestJson.json.encodeToString(request)) + stdout = ioExecutor.submit { + process.inputStream.readProjectionBounded( + limit = transportLimits.maxStdoutBytes, + stream = "stdout", + ) + } + stderr = ioExecutor.submit { + process.errorStream.readProjectionBounded( + limit = transportLimits.maxStderrBytes, + stream = "stderr", + ) } + val writerTask = ioExecutor.submit { + process.outputStream.bufferedWriter(Charsets.UTF_8).use { output -> + output.write(encodedRequest) + } + } + writer = writerTask - val stdout = process.inputStream.bufferedReader(Charsets.UTF_8).use { reader -> reader.readText() } - val exitCode = process.waitFor() - val stderrText = stderr.get() + val output = awaitAdapter( + process = process, + writer = writerTask, + stdout = requireNotNull(stdout), + stderr = requireNotNull(stderr), + deadlineNanos = deadlineNanos, + ) - if (exitCode != 0) { + if (process.exitValue() != 0) { throw FastCheckProjectionException( code = PbtDiagnosticCode.BACKEND_PROCESS_FAILED, - message = "fast-check adapter exited with code $exitCode: ${stderrText.trim()}", + message = "fast-check adapter exited with code ${process.exitValue()}: " + + output.stderr.text.trim(), ) } - if (stdout.isBlank()) { + if (output.stdout.text.isBlank()) { throw FastCheckProjectionException( code = PbtDiagnosticCode.BACKEND_RESPONSE_EMPTY, message = "fast-check adapter returned an empty response", ) } - return stdout + return output.stdout.text } finally { - errorReaderExecutor.shutdownNow() + stdout?.cancel(true) + stderr?.cancel(true) + writer?.cancel(true) + + terminate(managedProcess = managedProcess, deadlineNanos = deadlineNanos) + closeStreams(process) + runCatching { Files.deleteIfExists(managedProcess.processGroupFile) } + ioExecutor.shutdownNow() + } + } + + private fun awaitAdapter( + process: Process, + writer: Future<*>, + stdout: Future, + stderr: Future, + deadlineNanos: Long, + ): ProjectionAdapterOutput { + while (true) { + checkCompletedIo( + task = stdout, + operation = "reading fast-check projection stdout", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + ) + checkCompletedIo( + task = stderr, + operation = "reading fast-check projection stderr", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + ) + checkCompletedIo( + task = writer, + operation = "writing the fast-check projection request", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_WRITE_FAILED, + ) + + val remainingMillis = remainingMillis(deadlineNanos) + if (remainingMillis <= FORCED_TERMINATION_RESERVE_MILLIS) projectionTimeout() + + val waitMillis = minOf(remainingMillis, PROCESS_POLL_MILLIS) + + val completed = try { + process.waitFor(waitMillis, TimeUnit.MILLISECONDS) + } catch (error: InterruptedException) { + Thread.currentThread().interrupt() + + throw FastCheckProjectionException( + code = PbtDiagnosticCode.BACKEND_PROCESS_INTERRUPTED, + message = "Interrupted while waiting for the fast-check projection adapter", + cause = error, + ) + } + + if (completed) { + return awaitIoAfterProcessExit( + writer = writer, + stdout = stdout, + stderr = stderr, + deadlineNanos = deadlineNanos, + ) + } + } + } + + private fun awaitIoAfterProcessExit( + writer: Future<*>, + stdout: Future, + stderr: Future, + deadlineNanos: Long, + ): ProjectionAdapterOutput { + while (!writer.isDone || !stdout.isDone || !stderr.isDone) { + checkCompletedIo( + task = stdout, + operation = "reading fast-check projection stdout", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + ) + checkCompletedIo( + task = stderr, + operation = "reading fast-check projection stderr", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + ) + checkCompletedIo( + task = writer, + operation = "writing the fast-check projection request", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_WRITE_FAILED, + ) + + val remainingMillis = remainingMillis(deadlineNanos) + if (remainingMillis <= FORCED_TERMINATION_RESERVE_MILLIS) projectionTimeout() + + val waitMillis = minOf(remainingMillis, IO_POLL_MILLIS) + + when { + !stdout.isDone -> awaitIo( + task = stdout, + operation = "reading fast-check projection stdout", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + waitMillis = waitMillis, + ) + + !stderr.isDone -> awaitIo( + task = stderr, + operation = "reading fast-check projection stderr", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + waitMillis = waitMillis, + ) + + else -> awaitIo( + task = writer, + operation = "writing the fast-check projection request", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_WRITE_FAILED, + waitMillis = waitMillis, + ) + } } + + awaitIo( + task = writer, + operation = "writing the fast-check projection request", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_WRITE_FAILED, + waitMillis = 0, + ) + + return ProjectionAdapterOutput( + stdout = requireNotNull( + awaitIo( + task = stdout, + operation = "reading fast-check projection stdout", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + waitMillis = 0, + ), + ), + stderr = requireNotNull( + awaitIo( + task = stderr, + operation = "reading fast-check projection stderr", + failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + waitMillis = 0, + ), + ), + ) } - private fun startAdapter(): Process = try { - ProcessBuilder(nodeExecutable, adapterEntryPoint.toString()).start() - } catch (error: IOException) { + private fun checkCompletedIo( + task: Future, + operation: String, + failureCode: String, + ) { + if (task.isDone) { + awaitIo( + task = task, + operation = operation, + failureCode = failureCode, + waitMillis = 0, + ) + } + } + + private fun awaitIo( + task: Future, + operation: String, + failureCode: String, + waitMillis: Long, + ): T? = try { + task.get(waitMillis, TimeUnit.MILLISECONDS) + } catch (_: TimeoutException) { + null + } catch (error: InterruptedException) { + Thread.currentThread().interrupt() + throw FastCheckProjectionException( - code = PbtDiagnosticCode.BACKEND_PROCESS_START_FAILED, - message = "Failed to start fast-check adapter: ${error.message}", + code = PbtDiagnosticCode.BACKEND_PROCESS_INTERRUPTED, + message = "Interrupted while $operation", cause = error, ) + } catch (error: ExecutionException) { + val cause = error.cause ?: error + if (cause is ProjectionOutputLimitExceeded) { + throw FastCheckProjectionException( + code = PbtDiagnosticCode.BACKEND_RESPONSE_TOO_LARGE, + message = "fast-check projection ${cause.stream} exceeds ${cause.limit} bytes", + cause = cause, + ) + } + + throw FastCheckProjectionException( + code = failureCode, + message = "Failed while $operation: ${cause.message}", + cause = cause, + ) + } + + private fun deadlineAfter(timeoutMillis: Long): Long { + val timeoutNanos = TimeUnit.MILLISECONDS.toNanos(timeoutMillis) + val now = System.nanoTime() + + return if (now > Long.MAX_VALUE - timeoutNanos) Long.MAX_VALUE else now + timeoutNanos + } + + private fun deadlineBefore(deadlineNanos: Long, durationMillis: Long): Long { + if (deadlineNanos == Long.MAX_VALUE) return Long.MAX_VALUE + + val durationNanos = TimeUnit.MILLISECONDS.toNanos(durationMillis) + + return if (deadlineNanos < Long.MIN_VALUE + durationNanos) Long.MIN_VALUE else deadlineNanos - durationNanos + } + + private fun remainingMillis(deadlineNanos: Long): Long { + if (deadlineNanos == Long.MAX_VALUE) return Long.MAX_VALUE + + val remainingNanos = deadlineNanos - System.nanoTime() + if (remainingNanos <= 0) return 0 + + return TimeUnit.NANOSECONDS.toMillis(remainingNanos) + } + + private fun projectionTimeout(): Nothing = throw FastCheckProjectionException( + code = PbtDiagnosticCode.BACKEND_PROCESS_TIMEOUT, + message = "fast-check projection adapter exceeded the ${transportLimits.wallClockTimeoutMillis} ms timeout", + ) + + private fun startAdapter(): ManagedProjectionProcess { + val processGroupFile = try { + Files.createTempFile("usvm-projection-process-group-", ".pid") + } catch (error: IOException) { + processStartFailure(error) + } + var processStarted = false + + try { + val supervisorEntryPoint = FastCheckRuntime.processSupervisorEntryPoint() + val process = ProcessBuilder( + nodeExecutable, + supervisorEntryPoint.toString(), + PROCESS_SUPERVISOR_ADAPTER, + transportLimits.shutdownGraceMillis.toString(), + processGroupFile.toString(), + adapterEntryPoint.toString(), + ).start() + processStarted = true + + return ManagedProjectionProcess( + process = process, + processGroupFile = processGroupFile, + ) + } catch (error: IOException) { + processStartFailure(error) + } finally { + if (!processStarted) runCatching { Files.deleteIfExists(processGroupFile) } + } } + private fun processStartFailure(error: IOException): Nothing = throw FastCheckProjectionException( + code = PbtDiagnosticCode.BACKEND_PROCESS_START_FAILED, + message = "Failed to start fast-check adapter: ${error.message}", + cause = error, + ) + private fun decodeResponse(stdout: String): FastCheckProjectionWireResponse = try { PropertyManifestJson.json.decodeFromString(stdout) } catch (error: IllegalArgumentException) { @@ -117,21 +454,162 @@ class FastCheckProjectionClient( ) } - private fun invalidResponse(message: String): Nothing = throw FastCheckProjectionException( + private fun invalidResponse(message: String, path: String? = null): Nothing = throw FastCheckProjectionException( code = PbtDiagnosticCode.BACKEND_RESPONSE_INVALID, message = message, + path = path, ) private fun validateRequest(request: FastCheckProjectionRequest) { - val hasValidSampleCount = request.numSamples > 0 + val hasValidSampleCount = request.numSamples in 1..MAX_SAMPLES val hasDomains = request.domains.isNotEmpty() if (!hasValidSampleCount || !hasDomains) { throw FastCheckProjectionException( code = PbtDiagnosticCode.PROTOCOL_REQUEST_INVALID, - message = "Request requires domains and a positive numSamples", + message = "Request requires domains and numSamples in 1..$MAX_SAMPLES", path = "request", ) } } + + private fun closeStreams(process: Process) { + runCatching { process.outputStream.close() } + runCatching { process.inputStream.close() } + runCatching { process.errorStream.close() } + } + + private fun terminate(managedProcess: ManagedProjectionProcess, deadlineNanos: Long) { + val process = managedProcess.process + if (!process.isAlive) { + forceTerminateOwnedProcessGroup(managedProcess.processGroupFile, deadlineNanos) + + return + } + + process.destroy() + + val remainingMillis = remainingMillis(deadlineNanos) + if (remainingMillis <= FORCED_TERMINATION_RESERVE_MILLIS) { + forceTerminateOwnedProcessGroup(managedProcess.processGroupFile, deadlineNanos) + process.destroyForcibly() + awaitProcessExit(process, deadlineNanos) + + return + } + + val gracefulDeadlineNanos = minOf( + deadlineBefore( + deadlineNanos = deadlineNanos, + durationMillis = FORCED_TERMINATION_RESERVE_MILLIS, + ), + deadlineAfter(transportLimits.shutdownGraceMillis), + ) + if (awaitProcessExit(process, gracefulDeadlineNanos)) return + + forceTerminateOwnedProcessGroup(managedProcess.processGroupFile, deadlineNanos) + process.destroyForcibly() + awaitProcessExit(process, deadlineNanos) + } + + private fun forceTerminateOwnedProcessGroup(processGroupFile: Path, deadlineNanos: Long) { + val processGroupId = runCatching { + Files.readString(processGroupFile).trim().toLong() + }.getOrNull() ?: return + val command = if (IS_WINDOWS) { + listOf("taskkill", "/PID", processGroupId.toString(), "/T", "/F") + } else { + listOf("/bin/kill", "-KILL", "--", "-$processGroupId") + } + val killer = runCatching { + ProcessBuilder(command) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start() + }.getOrNull() ?: return + val waitMillis = minOf(remainingMillis(deadlineNanos), PROCESS_GROUP_KILL_WAIT_MILLIS) + + try { + if (!killer.waitFor(waitMillis, TimeUnit.MILLISECONDS)) killer.destroyForcibly() + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + killer.destroyForcibly() + } + } + + private fun awaitProcessExit(process: Process, deadlineNanos: Long): Boolean { + while (true) { + if (!process.isAlive) return true + + val waitMillis = minOf(remainingMillis(deadlineNanos), PROCESS_POLL_MILLIS) + if (waitMillis == 0L) return false + + try { + if (process.waitFor(waitMillis, TimeUnit.MILLISECONDS)) return true + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + + return false + } + } + } + + private companion object { + const val PROCESS_SUPERVISOR_ADAPTER = "--adapter" + const val MAX_SAMPLES = 10_000 + const val DEFAULT_MAX_REQUEST_BYTES = 4 * 1024 * 1024 + const val DEFAULT_MAX_STDOUT_BYTES = 4 * 1024 * 1024 + const val DEFAULT_MAX_STDERR_BYTES = 64 * 1024 + const val DEFAULT_WALL_CLOCK_TIMEOUT_MILLIS = 60_000L + const val DEFAULT_SHUTDOWN_GRACE_MILLIS = 250L + const val IO_TASKS = 3 + const val PROCESS_POLL_MILLIS = 10L + const val IO_POLL_MILLIS = 10L + const val FORCED_TERMINATION_RESERVE_MILLIS = 25L + const val PROCESS_GROUP_KILL_WAIT_MILLIS = 10L + + val IS_WINDOWS = System.getProperty("os.name").lowercase().contains("windows") + + val DEFAULT_TRANSPORT_LIMITS = FastCheckProjectionTransportLimits( + maxRequestBytes = DEFAULT_MAX_REQUEST_BYTES, + maxStdoutBytes = DEFAULT_MAX_STDOUT_BYTES, + maxStderrBytes = DEFAULT_MAX_STDERR_BYTES, + wallClockTimeoutMillis = DEFAULT_WALL_CLOCK_TIMEOUT_MILLIS, + shutdownGraceMillis = DEFAULT_SHUTDOWN_GRACE_MILLIS, + ) + } +} + +private data class ProjectionAdapterOutput( + val stdout: ProjectionBoundedText, + val stderr: ProjectionBoundedText, +) + +private data class ManagedProjectionProcess( + val process: Process, + val processGroupFile: Path, +) + +private data class ProjectionBoundedText(val text: String) + +private class ProjectionOutputLimitExceeded( + val stream: String, + val limit: Int, +) : IOException("fast-check projection $stream exceeds $limit bytes") + +private fun InputStream.readProjectionBounded(limit: Int, stream: String): ProjectionBoundedText { + val output = ByteArrayOutputStream(minOf(limit, DEFAULT_BUFFER_SIZE)) + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + + while (true) { + val read = read(buffer) + if (read < 0) break + + val remaining = limit - output.size() + + if (remaining > 0) output.write(buffer, 0, minOf(read, remaining)) + if (read > remaining) throw ProjectionOutputLimitExceeded(stream, limit) + } + + return ProjectionBoundedText(text = output.toString(Charsets.UTF_8)) } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntime.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntime.kt index 32e95af37..860a04f57 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntime.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntime.kt @@ -10,6 +10,8 @@ internal object FastCheckRuntime { fun projectionEntryPoint(): Path = locateEntryPoint(PROJECTION_CLI) + fun processSupervisorEntryPoint(): Path = locateEntryPoint(PROCESS_SUPERVISOR) + private fun locateEntryPoint(fileName: String): Path { val candidates = runtimeDirectories().map { runtimeDirectory -> runtimeDirectory.resolve(ENTRY_POINT_DIRECTORY).resolve(fileName) @@ -46,5 +48,6 @@ internal object FastCheckRuntime { private const val ENTRY_POINT_DIRECTORY = "dist/src" private const val EXECUTION_CLI = "execution-cli.js" private const val PROJECTION_CLI = "projection-cli.js" + private const val PROCESS_SUPERVISOR = "process-supervisor.js" private const val INSTALLED_RUNTIME_DIRECTORY = "fast-check-adapter" } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsCoverageMapper.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsCoverageMapper.kt new file mode 100644 index 000000000..c6baa9c64 --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsCoverageMapper.kt @@ -0,0 +1,452 @@ +package org.usvm.ts.pbt.mapping + +import org.jacodb.ets.model.EtsFile +import org.jacodb.ets.model.EtsIfStmt +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.model.EtsStmt +import org.usvm.ts.pbt.PbtDiagnosticCode +import org.usvm.ts.pbt.backend.BranchArmCoverage +import org.usvm.ts.pbt.backend.BranchCoverage +import org.usvm.ts.pbt.backend.PropertyCoverageArtifact +import org.usvm.ts.pbt.backend.StatementCoverage +import org.usvm.ts.pbt.model.PropertyId +import java.nio.file.Path + +/** Maps Istanbul statement and branch locations to EtsIR statements and CFG edges. */ +internal class EtsCoverageMapper( + scene: EtsScene, + private val sourceLocations: SourceLocationNormalizer, +) { + private val sceneFileCandidates = scene.projectFiles.map { file -> + SceneFileCandidate( + file = file, + canonicalPaths = sourceLocations.normalizePath(file.name).mapTo(hashSetOf(), Path::toString), + ) + } + + fun map( + propertyId: PropertyId, + coverage: PropertyCoverageArtifact?, + ): EtsCoverageMapping { + if (coverage == null) return coverageUnavailable() + + if (coverage.propertyId != propertyId) { + return EtsCoverageMapping( + status = EtsMappingStatus.UNSUPPORTED, + backendProvenance = coverage.provenance, + diagnostics = listOf( + EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_COVERAGE_PROPERTY_ID_MISMATCH, + message = "Coverage property ${coverage.propertyId.value} does not match ${propertyId.value}", + ), + ), + ) + } + + val statements = coverage.files.flatMap { file -> + file.statements.map { statement -> mapStatementCoverage(file.path, statement) } + } + val branches = coverage.files.flatMap { file -> + file.branches.map { branch -> mapBranchCoverage(file.path, branch) } + } + val diagnostics = coverage.diagnostics.map { diagnostic -> + EtsMappingDiagnostic( + code = diagnostic.code, + message = diagnostic.message, + sourcePath = diagnostic.path, + ) + } + + return EtsCoverageMapping( + status = aggregateStatus( + statements.map { statement -> statement.mapping.status } + + branches.flatMap { branch -> + listOf(branch.mapping.status) + branch.arms.map { arm -> arm.mapping.status } + }, + ), + backendProvenance = coverage.provenance, + statements = statements, + branches = branches, + diagnostics = diagnostics, + ) + } + + private fun coverageUnavailable(): EtsCoverageMapping = EtsCoverageMapping( + status = EtsMappingStatus.UNSUPPORTED, + backendProvenance = null, + diagnostics = listOf( + EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_COVERAGE_UNAVAILABLE, + message = "The property backend returned no source coverage artifact", + ), + ), + ) + + private fun mapBranchCoverage( + sourcePath: String, + coverage: BranchCoverage, + ): EtsBranchCoverageMapping { + val normalization = runCatching { sourceLocations.normalizeRange(sourcePath, coverage.location) } + val location = normalization.getOrNull() + if (location == null) { + return unsupportedBranchCoverage( + sourcePath = sourcePath, + coverage = coverage, + location = null, + diagnostic = sourceNormalizationDiagnostic(sourcePath, normalization.exceptionOrNull()), + normalizeArmLocations = false, + ) + } + + if (coverage.type != ISTANBUL_IF_BRANCH_TYPE || coverage.arms.size != BINARY_BRANCH_ARM_COUNT) { + val diagnostic = EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_BRANCH_SHAPE_UNSUPPORTED, + message = "EtsIR branch mapping requires an if branch with exactly two ordered coverage arms", + sourcePath = location.path, + ) + + return unsupportedBranchCoverage( + sourcePath = sourcePath, + coverage = coverage, + location = location, + diagnostic = diagnostic, + normalizeArmLocations = true, + ) + } + + // Preserve the EtsFile groups: two frontend files for one canonical source path are ambiguous provenance. + val conditionGroupsInSourceFile = sceneFileCandidates + .filter { candidate -> location.path in candidate.canonicalPaths } + .map { candidate -> candidate.statements.filterIsInstance() } + val conditionsInSourceFile = conditionGroupsInSourceFile.flatten() + if (conditionsInSourceFile.isNotEmpty() && conditionsInSourceFile.none { it.location.origin != null }) { + val diagnostic = EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_SOURCE_ORIGINS_UNSUPPORTED, + message = "EtsIR conditions for the covered source file have no source origins", + sourcePath = location.path, + ) + + return unsupportedBranchCoverage( + sourcePath = sourcePath, + coverage = coverage, + location = location, + diagnostic = diagnostic, + normalizeArmLocations = true, + ) + } + + val conditionGroups = conditionGroupsInSourceFile + .map { statements -> statements.filter { statement -> statement.hasOriginWithin(location) } } + .filter { statements -> statements.isNotEmpty() } + val conditions = conditionGroups.flatten() + if (conditions.any { statement -> statement.successorCount() != BINARY_BRANCH_ARM_COUNT }) { + val diagnostic = EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_BRANCH_CFG_UNSUPPORTED, + message = "EtsIR branch mapping requires exactly two ordered CFG successors", + sourcePath = location.path, + ) + + return unsupportedBranchCoverage( + sourcePath = sourcePath, + coverage = coverage, + location = location, + diagnostic = diagnostic, + normalizeArmLocations = true, + ) + } + + val distinctOrigins = conditions + .mapNotNull { statement -> statement.location.origin } + .distinct() + val mapping = branchMapping( + location = location, + conditions = conditions, + distinctOriginCount = distinctOrigins.size, + sourceCandidateCount = conditionGroupsInSourceFile.size, + ) + val arms = coverage.arms.mapIndexed { index, arm -> + mapBranchArm(sourcePath, arm, index, mapping) + } + + return EtsBranchCoverageMapping( + coverage = coverage, + location = location, + mapping = mapping, + arms = arms, + ) + } + + private fun unsupportedBranchCoverage( + sourcePath: String, + coverage: BranchCoverage, + location: NormalizedSourceRange?, + diagnostic: EtsMappingDiagnostic, + normalizeArmLocations: Boolean, + ): EtsBranchCoverageMapping { + val mapping = unsupportedMapping(diagnostic) + val arms = if (normalizeArmLocations) { + coverage.arms.mapIndexed { index, arm -> + mapBranchArm(sourcePath, arm, index, mapping) + } + } else { + coverage.arms.map { arm -> + EtsBranchArmCoverageMapping( + coverage = arm, + location = null, + mapping = unsupportedMapping(diagnostic), + ) + } + } + + return EtsBranchCoverageMapping( + coverage = coverage, + location = location, + mapping = mapping, + arms = arms, + ) + } + + private fun branchMapping( + location: NormalizedSourceRange, + conditions: List, + distinctOriginCount: Int, + sourceCandidateCount: Int, + ): EtsMappingResult = when { + conditions.isEmpty() -> EtsMappingResult( + status = EtsMappingStatus.UNMAPPED, + targets = emptyList(), + diagnostics = listOf( + EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_BRANCH_UNMAPPED, + message = "No EtsIR condition belongs to the covered TypeScript branch", + sourcePath = location.path, + ), + ), + ) + + distinctOriginCount == 1 && sourceCandidateCount == 1 -> EtsMappingResult( + status = EtsMappingStatus.EXACT, + targets = conditions.map(::EtsBranchTarget), + ) + + distinctOriginCount > 1 || sourceCandidateCount > 1 -> EtsMappingResult( + status = EtsMappingStatus.AMBIGUOUS, + targets = conditions.map(::EtsBranchTarget), + diagnostics = listOf( + EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_BRANCH_AMBIGUOUS, + message = "The covered TypeScript branch contains several EtsIR conditions", + sourcePath = location.path, + ), + ), + ) + + else -> error("EtsIR branch mapping has targets without source provenance") + } + + private fun mapBranchArm( + sourcePath: String, + coverage: BranchArmCoverage, + armIndex: Int, + branchMapping: EtsMappingResult, + ): EtsBranchArmCoverageMapping { + val normalization = runCatching { sourceLocations.normalizeRange(sourcePath, coverage.location) } + val location = normalization.getOrNull() + if (location == null) { + return EtsBranchArmCoverageMapping( + coverage = coverage, + location = null, + mapping = unsupportedMapping( + sourceNormalizationDiagnostic(sourcePath, normalization.exceptionOrNull()), + ), + ) + } + + val targets = branchMapping.targets.map { branch -> + val graph = branch.statement.location.method.cfg + val successors = graph.successors(branch.statement).toList() + + // Istanbul if arms and the EtsIR CFG both use true-then-false order by contract. + EtsBranchArmTarget( + condition = branch.statement, + outcome = armIndex == TRUE_BRANCH_ARM_INDEX, + successor = successors[armIndex], + ) + } + + return EtsBranchArmCoverageMapping( + coverage = coverage, + location = location, + mapping = EtsMappingResult( + status = branchMapping.status, + targets = targets, + diagnostics = branchMapping.diagnostics, + ), + ) + } + + private fun mapStatementCoverage( + sourcePath: String, + coverage: StatementCoverage, + ): EtsStatementCoverageMapping { + val normalization = runCatching { sourceLocations.normalizeRange(sourcePath, coverage.location) } + val location = normalization.getOrNull() + if (location == null) { + return EtsStatementCoverageMapping( + coverage = coverage, + location = null, + mapping = unsupportedMapping( + sourceNormalizationDiagnostic(sourcePath, normalization.exceptionOrNull()), + ), + ) + } + + val statementGroupsInSourceFile = sceneFileCandidates + .filter { candidate -> location.path in candidate.canonicalPaths } + .map { candidate -> candidate.statements } + val statementsInSourceFile = statementGroupsInSourceFile.flatten() + if (statementsInSourceFile.isNotEmpty() && statementsInSourceFile.none { it.location.origin != null }) { + return EtsStatementCoverageMapping( + coverage = coverage, + location = location, + mapping = unsupportedMapping( + EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_SOURCE_ORIGINS_UNSUPPORTED, + message = "EtsIR statements for the covered source file have no source origins", + sourcePath = location.path, + ), + ), + ) + } + + // Exact spans win. A containing coverage range is exact only when every target shares one source origin. + val exactTargets = statementGroupsInSourceFile + .flatMap { statements -> statements.filter { statement -> statement.hasOrigin(location) } } + .map(::EtsStatementTarget) + val containedStatementGroups = statementGroupsInSourceFile + .map { statements -> statements.filter { statement -> statement.hasOriginWithin(location) } } + .filter { statements -> statements.isNotEmpty() } + val containedStatements = containedStatementGroups.flatten() + val distinctContainedOrigins = containedStatements + .mapNotNull { statement -> statement.location.origin } + .distinct() + val mapping = when { + statementGroupsInSourceFile.size > 1 && containedStatements.isNotEmpty() -> + ambiguousStatementMapping(location, containedStatements) + + exactTargets.isNotEmpty() -> EtsMappingResult( + status = EtsMappingStatus.EXACT, + targets = exactTargets, + ) + + distinctContainedOrigins.size == 1 -> EtsMappingResult( + status = EtsMappingStatus.EXACT, + targets = containedStatements.map(::EtsStatementTarget), + ) + + distinctContainedOrigins.size > 1 -> ambiguousStatementMapping(location, containedStatements) + + else -> EtsMappingResult( + status = EtsMappingStatus.UNMAPPED, + targets = emptyList(), + diagnostics = listOf( + EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_STATEMENT_UNMAPPED, + message = "No EtsIR statement has the covered TypeScript source span", + sourcePath = location.path, + ), + ), + ) + } + + return EtsStatementCoverageMapping( + coverage = coverage, + location = location, + mapping = mapping, + ) + } + + private fun ambiguousStatementMapping( + location: NormalizedSourceRange, + statements: List, + ): EtsMappingResult = EtsMappingResult( + status = EtsMappingStatus.AMBIGUOUS, + targets = statements.map(::EtsStatementTarget), + diagnostics = listOf( + EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_STATEMENT_AMBIGUOUS, + message = "The covered TypeScript range matches several EtsIR source candidates or spans", + sourcePath = location.path, + ), + ), + ) + + private fun sourceNormalizationDiagnostic( + sourcePath: String, + failure: Throwable?, + ): EtsMappingDiagnostic { + val diagnosticCode = if (failure is UnsupportedSourceLocationException) { + PbtDiagnosticCode.MAPPING_SOURCE_LOCATION_UNSUPPORTED + } else { + PbtDiagnosticCode.MAPPING_SOURCE_UNAVAILABLE + } + + return EtsMappingDiagnostic( + code = diagnosticCode, + message = "Cannot normalize covered source $sourcePath: ${failure?.message}", + sourcePath = sourcePath, + ) + } + + private fun unsupportedMapping(diagnostic: EtsMappingDiagnostic): EtsMappingResult = EtsMappingResult( + status = EtsMappingStatus.UNSUPPORTED, + targets = emptyList(), + diagnostics = listOf(diagnostic), + ) + + private fun EtsStmt.hasOrigin(location: NormalizedSourceRange): Boolean { + val origin = this.location.origin ?: return false + if (!origin.hasPath(location.path)) return false + + return origin.startLine == location.start.line && + origin.startColumn == location.start.column && + origin.startOffset == location.start.offset && + origin.endLine == location.end.line && + origin.endColumn == location.end.column && + origin.endOffset == location.end.offset + } + + private fun EtsStmt.hasOriginWithin(location: NormalizedSourceRange): Boolean { + val origin = this.location.origin ?: return false + + return origin.hasPath(location.path) && + origin.startOffset >= location.start.offset && + origin.endOffset <= location.end.offset + } + + private fun org.jacodb.ets.model.EtsSourceSpan.hasPath(path: String): Boolean = + sourceLocations.normalizePath(fileName).any { candidate -> candidate.toString() == path } +} + +private data class SceneFileCandidate( + val file: EtsFile, + val canonicalPaths: Set, +) { + val statements: List = file.allClasses + .flatMap { etsClass -> etsClass.methods } + .flatMap { method -> method.cfg.stmts } +} + +private fun EtsIfStmt.successorCount(): Int = location.method.cfg.successors(this).size + +private fun aggregateStatus(statuses: List): EtsMappingStatus = when { + statuses.isEmpty() -> EtsMappingStatus.EXACT + EtsMappingStatus.UNSUPPORTED in statuses -> EtsMappingStatus.UNSUPPORTED + EtsMappingStatus.AMBIGUOUS in statuses -> EtsMappingStatus.AMBIGUOUS + EtsMappingStatus.UNMAPPED in statuses -> EtsMappingStatus.UNMAPPED + else -> EtsMappingStatus.EXACT +} + +private const val BINARY_BRANCH_ARM_COUNT = 2 +private const val ISTANBUL_IF_BRANCH_TYPE = "if" +private const val TRUE_BRANCH_ARM_INDEX = 0 diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsEntryPointResolver.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsEntryPointResolver.kt new file mode 100644 index 000000000..49f05a5f7 --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsEntryPointResolver.kt @@ -0,0 +1,278 @@ +package org.usvm.ts.pbt.mapping + +import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsClass +import org.jacodb.ets.model.EtsClassType +import org.jacodb.ets.model.EtsExportInfo +import org.jacodb.ets.model.EtsExportType +import org.jacodb.ets.model.EtsFile +import org.jacodb.ets.model.EtsFunctionType +import org.jacodb.ets.model.EtsLocal +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsMethodSignature +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.model.EtsStaticFieldRef +import org.jacodb.ets.utils.ANONYMOUS_METHOD_PREFIX +import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME +import org.jacodb.ets.utils.DEFAULT_ARK_METHOD_NAME +import org.usvm.ts.pbt.PbtDiagnosticCode +import org.usvm.ts.pbt.manifest.PropertyManifest +import org.usvm.ts.pbt.model.TypeScriptEntryPoint +import java.util.IdentityHashMap + +/** Resolves TypeScript runtime exports to callable EtsIR methods and their stack bindings. */ +internal class EtsEntryPointResolver( + private val scene: EtsScene, + private val sourceLocations: SourceLocationNormalizer, +) { + fun resolve( + entryPoint: TypeScriptEntryPoint, + manifest: PropertyManifest, + ): EtsMappingResult { + if (sourceLocations.sourceRootDiagnostics.isNotEmpty()) { + return EtsMappingResult( + status = EtsMappingStatus.UNSUPPORTED, + targets = emptyList(), + diagnostics = sourceLocations.sourceRootDiagnostics, + ) + } + + val sourceCandidates = scene.projectFiles.filter { candidate -> candidate.matches(entryPoint.module) } + val candidateResolutions = sourceCandidates.map { file -> + resolveExportedMethods(file, entryPoint.exportName, visited = emptySet()) + } + val methods = candidateResolutions + .flatMap { resolution -> resolution.methods } + .distinctByIdentity() + val hasAmbiguousResolution = candidateResolutions.any { resolution -> resolution.isAmbiguous } || + sourceCandidates.size > 1 + + if (methods.any { method -> method.parameters.size != manifest.inputs.size }) { + return EtsMappingResult( + status = EtsMappingStatus.UNSUPPORTED, + targets = emptyList(), + diagnostics = listOf( + EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_ENTRY_POINT_BINDINGS_UNSUPPORTED, + message = "Property inputs do not match EtsIR parameters for ${entryPoint.exportName}", + sourcePath = entryPoint.module, + ), + ), + ) + } + + val targets = methods.map { method -> + EtsEntryPointTarget( + method = method, + bindings = method.bindingsFor(manifest), + ) + } + + if (targets.size == 1 && !hasAmbiguousResolution) { + return EtsMappingResult( + status = EtsMappingStatus.EXACT, + targets = targets, + ) + } + if (targets.isNotEmpty()) { + return EtsMappingResult( + status = EtsMappingStatus.AMBIGUOUS, + targets = targets, + diagnostics = listOf( + EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_ENTRY_POINT_AMBIGUOUS, + message = "Several EtsIR methods, source candidates, or export links match " + + "${entryPoint.module}#${entryPoint.exportName}", + sourcePath = entryPoint.module, + ), + ), + ) + } + + return EtsMappingResult( + status = EtsMappingStatus.UNMAPPED, + targets = emptyList(), + diagnostics = listOf( + EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_ENTRY_POINT_UNMAPPED, + message = "No EtsIR method matches ${entryPoint.module}#${entryPoint.exportName}", + sourcePath = entryPoint.module, + ), + ), + ) + } + + private fun resolveExportedMethods( + file: EtsFile, + exportName: String, + visited: Set, + ): MethodResolution { + if (file in visited) return MethodResolution.EMPTY + + val runtimeExports = file.exportInfos.filter { export -> + !export.isTypeOnly && export.type != EtsExportType.TYPE + } + val namedRuntimeExports = runtimeExports.filter { export -> + export.runtimeName == exportName + } + // TypeScript gives an explicit named export precedence over fallback exports from `export *`. + val matchingExports = namedRuntimeExports.ifEmpty { + runtimeExports.filter { export -> + export.isBareStarReExport && exportName != DEFAULT_EXPORT_NAME + } + } + val directMethodNames = matchingExports + .filter { export -> export.type == EtsExportType.METHOD && !export.isReExport } + .map { export -> export.originalName } + val directMethods = file.classes + .filter { etsClass -> etsClass.name == DEFAULT_ARK_CLASS_NAME } + .flatMap { etsClass -> etsClass.methods } + .filter { method -> method.name in directMethodNames } + val localResolutions = matchingExports + .filter { export -> export.type == EtsExportType.LOCAL && !export.isReExport } + .map { export -> resolveCallableLocal(file, export.originalName) } + val reExportedResolutions = matchingExports + .filter { export -> export.isReExport && !export.isNamespaceStarReExport } + .flatMap { export -> + val targetExportName = if (export.isBareStarReExport) exportName else export.originalName + val targetFiles = resolveReExportFiles(file, requireNotNull(export.from)) + + targetFiles.map { targetFile -> + val resolution = resolveExportedMethods(targetFile, targetExportName, visited + file) + + resolution.copy(isAmbiguous = resolution.isAmbiguous || targetFiles.size > 1) + } + } + val methods = ( + directMethods + + localResolutions.flatMap { resolution -> resolution.methods } + + reExportedResolutions.flatMap { resolution -> resolution.methods } + ).distinctByIdentity() + + return MethodResolution( + methods = methods, + isAmbiguous = localResolutions.any { resolution -> resolution.isAmbiguous } || + reExportedResolutions.any { resolution -> resolution.isAmbiguous }, + ) + } + + private fun resolveCallableLocal(file: EtsFile, localName: String): MethodResolution { + // The frontend lowers a callable local to a static-field assignment whose function signature identifies + // the lifted anonymous method. Keep every link so repeated or partial lowering remains visibly ambiguous. + val assignments = file.classes + .filter { etsClass -> etsClass.name == DEFAULT_ARK_CLASS_NAME } + .flatMap { defaultClass -> + defaultClass.methods + .filter { method -> method.name == DEFAULT_ARK_METHOD_NAME } + .flatMap { method -> method.cfg.stmts } + .filterIsInstance() + .mapNotNull { assignment -> + val field = assignment.lhv as? EtsStaticFieldRef ?: return@mapNotNull null + if (field.field.enclosingClass != defaultClass.signature || field.field.name != localName) { + return@mapNotNull null + } + + defaultClass to assignment + } + } + val callableAssignments = assignments.mapNotNull { (defaultClass, assignment) -> + val local = assignment.rhv as? EtsLocal ?: return@mapNotNull null + val functionType = local.type as? EtsFunctionType ?: return@mapNotNull null + + CallableLocalAssignment( + defaultClass = defaultClass, + functionSignature = functionType.signature, + ) + } + val linkedMethods = callableAssignments.flatMap { assignment -> + assignment.defaultClass.methods.filter { method -> + method.name.startsWith(ANONYMOUS_METHOD_PREFIX) && + method.signature == assignment.functionSignature + } + } + val methods = linkedMethods.distinctByIdentity() + val isExactLink = assignments.size == 1 && callableAssignments.size == 1 && linkedMethods.size == 1 + + return MethodResolution( + methods = methods, + isAmbiguous = methods.isNotEmpty() && !isExactLink, + ) + } + + private fun resolveReExportFiles(file: EtsFile, module: String): List { + val targetPaths = sourceLocations.normalizePath(file.name).flatMapTo(linkedSetOf()) { sourcePath -> + val targetPath = requireNotNull(sourcePath.parent).resolve(module).normalize() + + sourceLocations.modulePathCandidates(targetPath) + } + + return scene.projectFiles.filter { candidate -> + sourceLocations.normalizePath(candidate.name).any(targetPaths::contains) + } + } + + private fun EtsFile.matches(module: String): Boolean { + val modulePaths = sourceLocations.normalizePath(module).flatMapTo(linkedSetOf()) { path -> + sourceLocations.modulePathCandidates(path) + } + val filePaths = sourceLocations.normalizePath(name) + + return modulePaths.any(filePaths::contains) + } + + private fun EtsMethod.bindingsFor(manifest: PropertyManifest): EtsEntryPointBindings { + val receiverType = EtsClassType( + signature = signature.enclosingClass, + typeParameters = requireNotNull(enclosingClass).typeParameters, + ) + val inputBindings = manifest.inputs.zip(parameters).mapIndexed { index, (input, parameter) -> + EtsInputBinding( + propertyInputName = input.name, + parameter = parameter, + stackSlot = index + RECEIVER_STACK_SLOTS, + ) + } + + return EtsEntryPointBindings( + receiver = EtsReceiverBinding( + stackSlot = RECEIVER_STACK_SLOT, + type = receiverType, + ), + inputs = inputBindings, + result = EtsResultBinding(type = returnType), + ) + } +} + +private data class CallableLocalAssignment( + val defaultClass: EtsClass, + val functionSignature: EtsMethodSignature, +) + +private data class MethodResolution( + val methods: List, + val isAmbiguous: Boolean = false, +) { + companion object { + val EMPTY = MethodResolution(methods = emptyList()) + } +} + +private fun List.distinctByIdentity(): List { + val seen = IdentityHashMap() + + return filter { method -> seen.put(method, Unit) == null } +} + +private val EtsExportInfo.isBareStarReExport: Boolean + get() = isStarReExport && !isAliased + +private val EtsExportInfo.isNamespaceStarReExport: Boolean + get() = isStarReExport && isAliased + +private val EtsExportInfo.runtimeName: String + get() = if (!isReExport && isDefaultExport) DEFAULT_EXPORT_NAME else name + +private const val DEFAULT_EXPORT_NAME = "default" +private const val RECEIVER_STACK_SLOT = 0 +private const val RECEIVER_STACK_SLOTS = 1 diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsMappingModel.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsMappingModel.kt new file mode 100644 index 000000000..131772954 --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsMappingModel.kt @@ -0,0 +1,158 @@ +package org.usvm.ts.pbt.mapping + +import org.jacodb.ets.model.EtsIfStmt +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsMethodParameter +import org.jacodb.ets.model.EtsStmt +import org.jacodb.ets.model.EtsType +import org.usvm.ts.pbt.backend.BranchArmCoverage +import org.usvm.ts.pbt.backend.BranchCoverage +import org.usvm.ts.pbt.backend.CoverageProvenance +import org.usvm.ts.pbt.backend.StatementCoverage +import org.usvm.ts.pbt.model.PropertyId + +/** Classification shared by entry-point and source-coverage mapping results. */ +enum class EtsMappingStatus { + EXACT, + AMBIGUOUS, + UNMAPPED, + UNSUPPORTED, +} + +/** Source coordinate convention shared by TypeScript and native EtsIR origins. */ +enum class EtsSourceCoordinateSystem { + TYPESCRIPT_UTF16_ZERO_BASED_HALF_OPEN, +} + +/** Ordered-successor convention used to bind binary backend branch arms. */ +enum class EtsBranchSuccessorOrder { + TRUE_FALSE, +} + +/** Mapping-layer assumptions needed to interpret every target in one property artifact. */ +data class EtsMappingProvenance( + val sourceRoots: List, + val coordinates: EtsSourceCoordinateSystem, + val branchSuccessorOrder: EtsBranchSuccessorOrder, +) + +/** Stable reason explaining why a mapping could not produce one exact target. */ +data class EtsMappingDiagnostic( + val code: String, + val message: String, + val sourcePath: String? = null, +) { + init { + require(code.isNotBlank()) { "Mapping diagnostic code must not be blank" } + require(message.isNotBlank()) { "Mapping diagnostic message must not be blank" } + } +} + +/** One mapping decision together with every EtsIR target selected by that decision. */ +data class EtsMappingResult( + val status: EtsMappingStatus, + val targets: List, + val diagnostics: List = emptyList(), +) + +/** Explicit stack binding for the receiver reserved by the TypeScript interpreter. */ +data class EtsReceiverBinding( + val stackSlot: Int, + val type: EtsType, +) + +/** Connects one ordered property input to the corresponding EtsIR parameter and stack slot. */ +data class EtsInputBinding( + val propertyInputName: String, + val parameter: EtsMethodParameter, + val stackSlot: Int, +) + +/** Identifies the value produced when the mapped EtsIR method returns. */ +data class EtsResultBinding( + val type: EtsType, +) + +/** EtsIR value bindings required to execute one property entry point symbolically. */ +data class EtsEntryPointBindings( + val receiver: EtsReceiverBinding, + val inputs: List, + val result: EtsResultBinding, +) + +/** Resolved EtsIR method and its property-facing symbolic bindings. */ +data class EtsEntryPointTarget( + val method: EtsMethod, + val bindings: EtsEntryPointBindings, +) + +/** Zero-based TypeScript position with its UTF-16 source-file offset. */ +data class NormalizedSourcePosition( + val line: Int, + val column: Int, + val offset: Int, +) + +/** Canonical source path and half-open zero-based UTF-16 range. */ +data class NormalizedSourceRange( + val path: String, + val start: NormalizedSourcePosition, + val end: NormalizedSourcePosition, +) + +/** One EtsIR statement selected for a backend-neutral statement coverage location. */ +data class EtsStatementTarget( + val statement: EtsStmt, +) + +/** Source statement coverage paired with its normalized location and EtsIR mapping decision. */ +data class EtsStatementCoverageMapping( + val coverage: StatementCoverage, + val location: NormalizedSourceRange?, + val mapping: EtsMappingResult, +) + +/** EtsIR conditional selected for one backend-neutral branch location. */ +data class EtsBranchTarget( + val statement: EtsIfStmt, +) + +/** One explicit EtsIR control-flow edge associated with a covered branch arm. */ +data class EtsBranchArmTarget( + val condition: EtsIfStmt, + val outcome: Boolean, + val successor: EtsStmt, +) + +/** One backend branch arm paired with its normalized location and EtsIR edge mapping. */ +data class EtsBranchArmCoverageMapping( + val coverage: BranchArmCoverage, + val location: NormalizedSourceRange?, + val mapping: EtsMappingResult, +) + +/** Backend branch coverage paired with its EtsIR condition and ordered arm mappings. */ +data class EtsBranchCoverageMapping( + val coverage: BranchCoverage, + val location: NormalizedSourceRange?, + val mapping: EtsMappingResult, + val arms: List, +) + +/** Mapping state for optional backend-neutral source coverage. */ +data class EtsCoverageMapping( + val status: EtsMappingStatus, + val backendProvenance: CoverageProvenance?, + val statements: List = emptyList(), + val branches: List = emptyList(), + val diagnostics: List, +) + +/** Kotlin-owned mapping artifact for one analyzed property. */ +data class PropertyEtsMappingArtifact( + val propertyId: PropertyId, + val provenance: EtsMappingProvenance, + val predicate: EtsMappingResult, + val precondition: EtsMappingResult?, + val coverage: EtsCoverageMapping, +) diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsMapper.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsMapper.kt new file mode 100644 index 000000000..5b8ede881 --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsMapper.kt @@ -0,0 +1,41 @@ +package org.usvm.ts.pbt.mapping + +import org.jacodb.ets.model.EtsScene +import org.usvm.ts.pbt.backend.PropertyCoverageArtifact +import org.usvm.ts.pbt.manifest.PropertyManifest +import org.usvm.ts.pbt.model.PropertyId +import java.nio.file.Path + +/** Coordinates entry-point and coverage mapping for one project scene. */ +class PropertyEtsMapper( + scene: EtsScene, + sourceRoots: List, +) { + private val sourceLocations = SourceLocationNormalizer(sourceRoots) + private val entryPointResolver = EtsEntryPointResolver(scene, sourceLocations) + private val coverageMapper = EtsCoverageMapper(scene, sourceLocations) + + /** Produces a complete mapping artifact even when individual entry points or coverage locations do not map. */ + fun map( + manifest: PropertyManifest, + coverage: PropertyCoverageArtifact? = null, + ): PropertyEtsMappingArtifact { + val propertyId = PropertyId(manifest.propertyId) + val predicate = entryPointResolver.resolve(manifest.predicate, manifest) + val precondition = manifest.precondition?.let { entryPoint -> + entryPointResolver.resolve(entryPoint, manifest) + } + + return PropertyEtsMappingArtifact( + propertyId = propertyId, + provenance = EtsMappingProvenance( + sourceRoots = sourceLocations.normalizedSourceRoots.map(Path::toString), + coordinates = EtsSourceCoordinateSystem.TYPESCRIPT_UTF16_ZERO_BASED_HALF_OPEN, + branchSuccessorOrder = EtsBranchSuccessorOrder.TRUE_FALSE, + ), + predicate = predicate, + precondition = precondition, + coverage = coverageMapper.map(propertyId, coverage), + ) + } +} diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/SourceLocationNormalizer.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/SourceLocationNormalizer.kt new file mode 100644 index 000000000..fcf71918a --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/SourceLocationNormalizer.kt @@ -0,0 +1,155 @@ +package org.usvm.ts.pbt.mapping + +import org.usvm.ts.pbt.PbtDiagnosticCode +import org.usvm.ts.pbt.backend.SourcePosition +import org.usvm.ts.pbt.backend.SourceRange +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path + +internal class SourceLocationNormalizer(sourceRoots: List) { + private val sourceRootResolutions = sourceRoots.mapIndexed { index, root -> + normalizeSourceRoot(index, root) + } + + val normalizedSourceRoots: List = sourceRootResolutions.map { resolution -> resolution.path } + val sourceRootDiagnostics: List = sourceRootResolutions.mapNotNull { resolution -> + resolution.diagnostic + } + + fun normalizeRange(sourcePath: String, range: SourceRange): NormalizedSourceRange { + val path = normalizePath(sourcePath).single() + val source = Files.readString(path) + val lines = source.sourceLines() + val start = range.start.normalize(lines) + val end = range.end.normalize(lines) + if (end.offset < start.offset) { + throw UnsupportedSourceLocationException("Source range end precedes its start") + } + + return NormalizedSourceRange( + path = path.toString(), + start = start, + end = end, + ) + } + + fun normalizePath(value: String): Set { + val path = Path.of(value) + val candidates = if (path.isAbsolute) { + listOf(path) + } else { + normalizedSourceRoots.map { root -> root.resolve(path) } + } + + return candidates.mapTo(linkedSetOf()) { candidate -> candidate.canonicalizeIfExisting() } + } + + fun modulePathCandidates(path: Path): Set { + val candidates = buildList { + add(path) + val name = path.fileName?.toString().orEmpty() + if (name.endsWith(".ts") || name.endsWith(".ets")) return@buildList + + add(path.resolveSibling("$name.ts")) + add(path.resolveSibling("$name.ets")) + add(path.resolveSibling("$name.d.ts")) + add(path.resolve("index.ts")) + add(path.resolve("index.ets")) + add(path.resolve("index.d.ts")) + } + + return candidates.mapTo(linkedSetOf()) { candidate -> candidate.canonicalizeIfExisting() } + } + + private fun SourcePosition.normalize(lines: List): NormalizedSourcePosition { + val zeroBasedLine = line - ISTANBUL_LINE_BASE + val sourceLine = lines.getOrNull(zeroBasedLine) + ?: throw UnsupportedSourceLocationException("Source line $line is outside the file") + val offset = sourceLine.startOffset + column + if (offset > sourceLine.endOffset) { + throw UnsupportedSourceLocationException( + "Source column $column is outside line $line", + ) + } + + return NormalizedSourcePosition( + line = zeroBasedLine, + column = column, + offset = offset, + ) + } + + private fun String.sourceLines(): List = buildList { + var lineStart = 0 + var index = 0 + while (index < length) { + val terminatorLength = when (this@sourceLines[index]) { + '\r' -> if (this@sourceLines.getOrNull(index + 1) == '\n') 2 else 1 + '\n', '\u2028', '\u2029' -> 1 + else -> 0 + } + if (terminatorLength == 0) { + index++ + continue + } + + add(SourceLine(startOffset = lineStart, endOffset = index)) + index += terminatorLength + lineStart = index + } + + add(SourceLine(startOffset = lineStart, endOffset = length)) + } + + private fun normalizeSourceRoot(index: Int, root: Path): SourceRootResolution { + val normalizedRoot = root.toAbsolutePath().normalize() + + return try { + val realRoot = normalizedRoot.toRealPath() + if (Files.isDirectory(realRoot)) { + SourceRootResolution(path = realRoot) + } else { + unsupportedSourceRoot(index, normalizedRoot, "the path is not a directory") + } + } catch (error: IOException) { + unsupportedSourceRoot(index, normalizedRoot, error.message ?: "the path cannot be resolved") + } + } + + private fun unsupportedSourceRoot(index: Int, path: Path, reason: String): SourceRootResolution = + SourceRootResolution( + path = path, + diagnostic = EtsMappingDiagnostic( + code = PbtDiagnosticCode.MAPPING_SOURCE_ROOT_UNSUPPORTED, + message = "Cannot resolve TypeScript source root $index ($path): $reason", + sourcePath = path.toString(), + ), + ) + + private fun Path.canonicalizeIfExisting(): Path { + val absolutePath = if (isAbsolute) this else toAbsolutePath() + + return try { + absolutePath.toRealPath() + } catch (_: IOException) { + absolutePath.normalize() + } + } + + private companion object { + const val ISTANBUL_LINE_BASE = 1 + } +} + +private data class SourceLine( + val startOffset: Int, + val endOffset: Int, +) + +private data class SourceRootResolution( + val path: Path, + val diagnostic: EtsMappingDiagnostic? = null, +) + +internal class UnsupportedSourceLocationException(message: String) : IllegalArgumentException(message) diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsConcreteValue.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsConcreteValue.kt index 74384e18c..352cbfc65 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsConcreteValue.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsConcreteValue.kt @@ -12,11 +12,9 @@ import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put /** Tags the finite and non-finite cases of an ECMAScript binary64 value. */ @@ -169,22 +167,50 @@ object JsConcreteValueSerializer : KSerializer { val jsonDecoder = decoder as? JsonDecoder ?: throw SerializationException("JsConcreteValue supports JSON deserialization only") - val value = jsonDecoder.decodeJsonElement().jsonObject + val value = jsonDecoder.decodeJsonElement() as? JsonObject + ?: throw SerializationException("JsConcreteValue must be a JSON object") return when (val kind = value.requiredString("kind")) { - "undefined" -> JsConcreteValue.Undefined - "null" -> JsConcreteValue.Null - "boolean" -> deserializeBoolean(value) - "string" -> JsConcreteValue.String(value.requiredString("value")) - "number" -> deserializeNumber(value) - "array" -> deserializeArray(jsonDecoder, value) - else -> throw SerializationException("Unknown JavaScript value kind: $kind") + "undefined" -> { + value.requireExactKeys("kind") + JsConcreteValue.Undefined + } + + "null" -> { + value.requireExactKeys("kind") + JsConcreteValue.Null + } + + "boolean" -> { + value.requireExactKeys("kind", "value") + deserializeBoolean(value) + } + + "string" -> { + value.requireExactKeys("kind", "value") + JsConcreteValue.String(value.requiredString("value")) + } + + "number" -> { + deserializeNumber(value) + } + + "array" -> { + value.requireExactKeys("kind", "elements") + deserializeArray(jsonDecoder, value) + } + + else -> { + throw SerializationException("Unknown JavaScript value kind: $kind") + } } } } private fun deserializeBoolean(value: JsonObject): JsConcreteValue.Boolean { - val booleanValue = value["value"]?.jsonPrimitive?.booleanOrNull + val primitive = value["value"] as? JsonPrimitive + ?: throw SerializationException("Boolean JsConcreteValue requires a boolean value") + val booleanValue = primitive.takeUnless(JsonPrimitive::isString)?.booleanOrNull ?: throw SerializationException("Boolean JsConcreteValue requires a boolean value") return JsConcreteValue.Boolean(booleanValue) @@ -200,14 +226,27 @@ private fun deserializeNumber(value: JsonObject): JsConcreteValue.Number { else -> throw SerializationException("Unknown JavaScript number kind: $numberKindName") } - val bits = value["bits"]?.jsonPrimitive?.content + val bits = when (numberKind) { + JsNumberKind.FINITE -> { + value.requireExactKeys("kind", "value", "bits") + value.requiredFiniteBits() + } + + JsNumberKind.NAN, + JsNumberKind.POSITIVE_INFINITY, + JsNumberKind.NEGATIVE_INFINITY, + -> { + value.requireExactKeys("kind", "value") + null + } + } val number = JsNumber(value = numberKind, bits = bits) return JsConcreteValue.Number(number) } private fun deserializeArray(jsonDecoder: JsonDecoder, value: JsonObject): JsConcreteValue.Array { - val jsonElements = value["elements"]?.jsonArray + val jsonElements = value["elements"] as? JsonArray ?: throw SerializationException("Array JsConcreteValue requires elements") val elements = jsonElements.map { element -> @@ -225,9 +264,36 @@ private val JsNumberKind.serialName: String JsNumberKind.NEGATIVE_INFINITY -> "negative-infinity" } -private fun JsonObject.requiredString(name: String): String = - get(name)?.jsonPrimitive?.content - ?: throw SerializationException("JsConcreteValue requires a $name field") +private fun JsonObject.requireExactKeys(vararg expectedKeys: String) { + if (keys != expectedKeys.toSet()) { + throw SerializationException("JsConcreteValue has unexpected fields") + } +} + +private fun JsonObject.requiredString(name: String): String { + val value = get(name) as? JsonPrimitive + ?: throw SerializationException("JsConcreteValue requires a string $name field") + if (!value.isString) { + throw SerializationException("JsConcreteValue requires a string $name field") + } + + return value.content +} + +private fun JsonObject.requiredFiniteBits(): String { + val bits = requiredString("bits") + if (!bits.matches(FINITE_NUMBER_BITS_REGEX)) { + throw SerializationException("Finite JsConcreteValue requires sixteen lowercase hexadecimal bits") + } + + val number = Double.fromBits(bits.toULong(JS_NUMBER_HEX_RADIX).toLong()) + if (!number.isFinite()) { + throw SerializationException("Finite JsConcreteValue requires finite IEEE-754 bits") + } + + return bits +} private const val JS_NUMBER_HEX_DIGITS = 16 private const val JS_NUMBER_HEX_RADIX = 16 +private val FINITE_NUMBER_BITS_REGEX = Regex("[0-9a-f]{16}") diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/validation/PropertyValidation.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/validation/PropertyValidation.kt index aa85b7c48..09079ca16 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/validation/PropertyValidation.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/validation/PropertyValidation.kt @@ -262,7 +262,7 @@ private fun validateJsNumber( diagnostics: MutableList, ): Boolean { val valid = when (number.value) { - JsNumberKind.FINITE -> number.bits?.matches(FINITE_NUMBER_BITS_REGEX) == true + JsNumberKind.FINITE -> number.bits.isFiniteNumberBits() else -> number.bits == null } if (!valid) { @@ -275,6 +275,11 @@ private fun validateJsNumber( return valid } +private fun String?.isFiniteNumberBits(): Boolean = this + ?.takeIf { bits -> bits.matches(FINITE_NUMBER_BITS_REGEX) } + ?.let { bits -> Double.fromBits(bits.toULong(JS_NUMBER_HEX_RADIX).toLong()).isFinite() } + ?: false + private fun validateLengths( minLength: Int, maxLength: Int, @@ -364,6 +369,7 @@ private fun diagnostic(code: String, message: String, path: String) = Validation ) private val FINITE_NUMBER_BITS_REGEX = Regex("[0-9a-f]{16}") +private const val JS_NUMBER_HEX_RADIX = 16 // ECMAScript permits these otherwise invisible Unicode characters after the first identifier character. private const val ZERO_WIDTH_NON_JOINER_CODE_POINT = 0x200C diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/backend/PropertyBasedTestingBackendTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/backend/PropertyBasedTestingBackendTest.kt index 7e17af5bf..1ea7658c3 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/backend/PropertyBasedTestingBackendTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/backend/PropertyBasedTestingBackendTest.kt @@ -76,6 +76,17 @@ class PropertyBasedTestingBackendTest { } } + @Test + fun `failure details preserve an empty thrown value message`() { + val details = PropertyFailureDetails( + kind = PropertyFailureKind.PROPERTY, + errorName = "ThrownValue", + message = "", + ) + + assertEquals("", details.message) + } + @Test fun `result rejects negative counters and execution time`() { assertFailsWith { diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/CoveragePathFilterTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/CoveragePathFilterTest.kt new file mode 100644 index 000000000..4a915d36b --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/CoveragePathFilterTest.kt @@ -0,0 +1,36 @@ +package org.usvm.ts.pbt.coverage + +import org.junit.jupiter.api.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CoveragePathFilterTest { + @Test + fun `Unix filesystem root contains absolute descendants and produces relative candidates`() { + val path = "/workspace/src/property.ts" + + assertTrue(isWithin(path = path, root = "/")) + assertTrue( + matchesCoveragePath( + path = path, + patterns = listOf("workspace/src/*.ts"), + sourceRoots = listOf("/"), + ), + ) + } + + @Test + fun `normalized Windows drive root contains descendants and produces relative candidates`() { + val path = "C:/workspace/src/property.ts" + + assertTrue(isWithin(path = path, root = "C:/")) + assertTrue( + matchesCoveragePath( + path = path, + patterns = listOf("workspace/src/*.ts"), + sourceRoots = listOf("C:/"), + ), + ) + assertFalse(isWithin(path = "D:/workspace/src/property.ts", root = "C:/")) + } +} diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspectorTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspectorTest.kt new file mode 100644 index 000000000..098e8ea4d --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspectorTest.kt @@ -0,0 +1,426 @@ +package org.usvm.ts.pbt.coverage + +import org.junit.jupiter.api.Test +import org.usvm.ts.pbt.backend.CoverageDiagnostic +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.createDirectory +import kotlin.io.path.createTempDirectory +import kotlin.io.path.writeText +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class RawV8SourceMapInspectorTest { + @Test + fun `empty raw coverage directory is a typed missing report failure`() { + withRawDirectory { rawDirectory -> + val error = assertFailsWith { + inspectRawV8SourceMapDiagnostics( + rawDirectory = rawDirectory, + sourceRoots = listOf(rawDirectory.toString()), + ) + } + + assertEquals("coverage.report.missing", error.diagnostic.code) + assertEquals(rawDirectory.toString(), error.diagnostic.path) + } + } + + @Test + fun `raw report count is bounded before source-map caches are decoded`() { + withRawDirectory { rawDirectory -> + rawDirectory.resolve("first.json").writeText("{not-json") + rawDirectory.resolve("second.json").writeText("{not-json") + + val error = assertFailsWith { + inspectRawV8SourceMapDiagnostics( + rawDirectory = rawDirectory, + sourceRoots = listOf(rawDirectory.toString()), + maxReportFiles = 1, + maxReportBytes = 1_024, + ) + } + + assertEquals("coverage.report.invalid", error.diagnostic.code) + assertEquals(rawDirectory.toString(), error.diagnostic.path) + } + } + + @Test + fun `raw report bytes are bounded before the file is parsed`() { + withRawDirectory { rawDirectory -> + val rawReport = rawDirectory.resolve("coverage.json") + rawReport.writeText("{not-json-but-over-the-test-limit") + + val error = assertFailsWith { + inspectRawV8SourceMapDiagnostics( + rawDirectory = rawDirectory, + sourceRoots = listOf(rawDirectory.toString()), + maxReportFiles = 1, + maxReportBytes = 8, + ) + } + + assertEquals("coverage.report.invalid", error.diagnostic.code) + assertEquals(rawReport.toString(), error.diagnostic.path) + } + } + + @Test + fun `bounded reader enforces aggregate bytes after a report replacement`() { + withRawDirectory { rawDirectory -> + val firstReport = rawDirectory.resolve("first.json") + val replacedReport = rawDirectory.resolve("replaced.json") + firstReport.writeText("{}") + replacedReport.writeText("{}") + val preflightBytes = Files.size(firstReport) + Files.size(replacedReport) + replacedReport.writeText("""{"source-map-cache": {}, "replacement": "larger"}""") + val reader = RawV8ReportReader(maxReportBytes = preflightBytes) + + reader.readText(firstReport) + + val error = assertFailsWith { + reader.readText(replacedReport) + } + + assertEquals("coverage.report.invalid", error.diagnostic.code) + assertEquals(replacedReport.toString(), error.diagnostic.path) + } + } + + @Test + fun `malformed raw source-map-cache schema is a typed coverage failure`() { + withRawDirectory { rawDirectory -> + val rawReport = rawDirectory.resolve("coverage.json") + rawReport.writeText("""{"source-map-cache": []}""") + + val error = assertFailsWith { + inspectRawV8SourceMapDiagnostics( + rawDirectory = rawDirectory, + sourceRoots = listOf(rawDirectory.toString()), + ) + } + + assertEquals("coverage.report.invalid", error.diagnostic.code) + assertEquals("$rawReport.source-map-cache", error.diagnostic.path) + } + } + + @Test + fun `primitive raw source-map data is a typed coverage failure`() { + assertInvalidSourceMapData(dataJson = "true") + } + + @Test + fun `array raw source-map data is a typed coverage failure`() { + assertInvalidSourceMapData(dataJson = "[]") + } + + @Test + fun `malformed source-map cache key is a typed coverage failure`() { + withRawDirectory { rawDirectory -> + val rawReport = rawDirectory.resolve("coverage.json") + rawReport.writeText( + """ + { + "source-map-cache": { + "not a valid URI": { + "lineLengths": [1], + "data": null, + "url": "generated.js.map" + } + } + } + """.trimIndent(), + ) + + val error = assertFailsWith { + inspectRawV8SourceMapDiagnostics( + rawDirectory = rawDirectory, + sourceRoots = listOf(rawDirectory.toString()), + ) + } + + assertEquals("coverage.report.invalid", error.diagnostic.code) + assertEquals("$rawReport.source-map-cache[not a valid URI]", error.diagnostic.path) + } + } + + @Test + fun `raw diagnostics are deterministic across file order and duplicate cache entries`() { + withRawDirectory { rawDirectory -> + val sourceRoot = rawDirectory.resolve("source").createDirectory() + val firstScript = sourceRoot.resolve("first.js") + val secondScript = sourceRoot.resolve("second.js") + firstScript.writeText("export const first = 1") + secondScript.writeText("export const second = 2") + rawDirectory.resolve("z-last.json").writeText(rawReport(firstScript, secondScript)) + rawDirectory.resolve("a-first.json").writeText(rawReport(secondScript, firstScript)) + + val diagnostics = inspectRawV8SourceMapDiagnostics( + rawDirectory = rawDirectory, + sourceRoots = listOf(sourceRoot.toString()), + ) + + assertEquals( + listOf( + firstScript.toString() to "coverage.source-map.missing", + secondScript.toString() to "coverage.source-map.missing", + ), + diagnostics.map { diagnostic -> diagnostic.path to diagnostic.code }, + ) + } + } + + @Test + fun `invalid raw source-map diagnostic wins for one script regardless of report order`() { + withRawDirectory { rawDirectory -> + val sourceRoot = rawDirectory.resolve("source").createDirectory() + val script = sourceRoot.resolve("generated.js") + val firstReport = rawDirectory.resolve("a-first.json") + val secondReport = rawDirectory.resolve("z-second.json") + script.writeText("export const generated = 1") + val missingSourceMap = rawReport(script, referencedUrl = "generated.js.map") + val invalidSourceMap = rawReport( + script = script, + referencedUrl = "data:application/json;base64,e30=", + ) + val expected = listOf(script.toString() to "coverage.source-map.invalid") + + firstReport.writeText(missingSourceMap) + secondReport.writeText(invalidSourceMap) + val missingFirst = inspectRawV8SourceMapDiagnostics( + rawDirectory = rawDirectory, + sourceRoots = listOf(sourceRoot.toString()), + ) + + firstReport.writeText(invalidSourceMap) + secondReport.writeText(missingSourceMap) + val invalidFirst = inspectRawV8SourceMapDiagnostics( + rawDirectory = rawDirectory, + sourceRoots = listOf(sourceRoot.toString()), + ) + + assertEquals(expected, missingFirst.map { diagnostic -> diagnostic.path to diagnostic.code }) + assertEquals(expected, invalidFirst.map { diagnostic -> diagnostic.path to diagnostic.code }) + } + } + + @Test + fun `present referenced map with a URL query is classified as invalid`() { + withRawDirectory { rawDirectory -> + val sourceRoot = rawDirectory.resolve("source").createDirectory() + val script = sourceRoot.resolve("generated.js") + script.writeText("export const generated = 1") + sourceRoot.resolve("generated.js.map").writeText("{not-json") + rawDirectory.resolve("coverage.json").writeText( + """ + { + "source-map-cache": { + "${script.toUri()}": { + "lineLengths": [1], + "data": null, + "url": "generated.js.map?cache=1" + } + } + } + """.trimIndent(), + ) + + val diagnostic = inspectRawV8SourceMapDiagnostics( + rawDirectory = rawDirectory, + sourceRoots = listOf(sourceRoot.toString()), + ).single() + + assertEquals("coverage.source-map.invalid", diagnostic.code) + assertEquals(script.toString(), diagnostic.path) + } + } + + @Test + fun `source-map references are classified by URI semantics`() { + val cases = listOf( + SourceMapReferenceCase( + name = "remote file URI", + scriptPath = "generated.js", + referencedUrl = "file://coverage.example/maps/generated.js.map", + ), + SourceMapReferenceCase( + name = "network-path reference", + scriptPath = "generated.js", + referencedUrl = "//coverage.example/maps/generated.js.map", + ), + SourceMapReferenceCase( + name = "HTTP URL", + scriptPath = "generated.js", + referencedUrl = "https://coverage.example/maps/generated.js.map", + ), + SourceMapReferenceCase( + name = "local fragment", + scriptPath = "generated.js", + referencedUrl = "generated.js.map#section", + presentMapPath = "generated.js.map", + ), + SourceMapReferenceCase( + name = "local traversal", + scriptPath = "scripts/generated.js", + referencedUrl = "../maps/generated.js.map", + presentMapPath = "maps/generated.js.map", + ), + SourceMapReferenceCase( + name = "local path with invalid URI syntax", + scriptPath = "generated.js", + referencedUrl = "generated script.js.map", + presentMapPath = "generated script.js.map", + ), + ) + + cases.forEach { case -> + withRawDirectory { rawDirectory -> + val sourceRoot = rawDirectory.resolve("source").createDirectory() + val script = sourceRoot.resolve(case.scriptPath) + script.parent.createDirectories() + script.writeText("export const generated = 1") + case.presentMapPath?.let { presentMapPath -> + val sourceMap = sourceRoot.resolve(presentMapPath) + sourceMap.parent.createDirectories() + sourceMap.writeText("{not-json") + } + rawDirectory.resolve("coverage.json").writeText( + rawReport( + script = script, + referencedUrl = case.referencedUrl, + ), + ) + + val diagnostic = inspectRawV8SourceMapDiagnostics( + rawDirectory = rawDirectory, + sourceRoots = listOf(sourceRoot.toString()), + ).single() + + assertEquals("coverage.source-map.invalid", diagnostic.code, case.name) + assertEquals(script.toString(), diagnostic.path, case.name) + } + } + } + + @Test + fun `raw source-map diagnostics override final-report guesses and merge without duplicates`() { + val finalDiagnostics = listOf( + CoverageDiagnostic( + code = "coverage.source-map.missing", + message = "final missing", + path = "/workspace/second.js", + ), + ) + val rawDiagnostics = listOf( + CoverageDiagnostic( + code = "coverage.source-map.invalid", + message = "raw invalid", + path = "/workspace/second.js", + ), + CoverageDiagnostic( + code = "coverage.source-map.missing", + message = "raw missing", + path = "/workspace/first.js", + ), + CoverageDiagnostic( + code = "coverage.source-map.missing", + message = "raw missing", + path = "/workspace/first.js", + ), + ) + + val merged = mergeCoverageDiagnostics( + finalDiagnostics = finalDiagnostics, + rawDiagnostics = rawDiagnostics, + ) + + assertEquals( + listOf( + "/workspace/first.js" to "coverage.source-map.missing", + "/workspace/second.js" to "coverage.source-map.invalid", + ), + merged.map { diagnostic -> diagnostic.path to diagnostic.code }, + ) + } + + private fun rawReport(firstScript: Path, secondScript: Path): String = + """ + { + "source-map-cache": { + "${firstScript.toUri()}": { + "lineLengths": [1], + "data": null, + "url": "${firstScript.fileName}.map" + }, + "${secondScript.toUri()}": { + "lineLengths": [1], + "data": null, + "url": "${secondScript.fileName}.map" + } + } + } + """.trimIndent() + + private fun rawReport(script: Path, referencedUrl: String): String = + """ + { + "source-map-cache": { + "${script.toUri()}": { + "lineLengths": [1], + "data": null, + "url": "$referencedUrl" + } + } + } + """.trimIndent() + + private fun assertInvalidSourceMapData(dataJson: String) { + withRawDirectory { rawDirectory -> + val rawReport = rawDirectory.resolve("coverage.json") + val scriptUrl = "file:///generated.js" + rawReport.writeText( + """ + { + "source-map-cache": { + "$scriptUrl": { + "lineLengths": [1], + "data": $dataJson, + "url": "generated.js.map" + } + } + } + """.trimIndent(), + ) + + val error = assertFailsWith { + inspectRawV8SourceMapDiagnostics( + rawDirectory = rawDirectory, + sourceRoots = listOf(rawDirectory.toString()), + ) + } + + assertEquals("coverage.report.invalid", error.diagnostic.code) + assertEquals("$rawReport.source-map-cache[$scriptUrl].data", error.diagnostic.path) + } + } + + private fun withRawDirectory(block: (Path) -> Unit) { + val rawDirectory = createTempDirectory(prefix = "raw-v8-source-maps-") + + try { + block(rawDirectory) + } finally { + rawDirectory.toFile().deleteRecursively() + } + } + + private data class SourceMapReferenceCase( + val name: String, + val scriptPath: String, + val referencedUrl: String, + val presentMapPath: String? = null, + ) +} diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageTest.kt index 1e846000e..805b2eb69 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageTest.kt @@ -1,6 +1,7 @@ package org.usvm.ts.pbt.fastcheck import org.junit.jupiter.api.Test +import org.usvm.ts.pbt.backend.CoverageScope import org.usvm.ts.pbt.backend.PropertyCoverageRequest import org.usvm.ts.pbt.backend.PropertyRunConfiguration import org.usvm.ts.pbt.backend.PropertyRunStatus @@ -13,8 +14,13 @@ import org.usvm.ts.pbt.model.TypeScriptEntryPoint import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.absolute +import kotlin.io.path.createDirectory +import kotlin.io.path.createSymbolicLinkPointingTo +import kotlin.io.path.createTempDirectory +import kotlin.io.path.writeText import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertTrue class FastCheckCoverageTest { private val backend = FastCheckBackend( @@ -71,6 +77,85 @@ class FastCheckCoverageTest { assertEquals(listOf(5), zeroHitBranchLines(file)) } + @Test + fun `real c8 reports a missing referenced source map when the final report omits the script`() { + val module = "properties/coverage/missing-map-entry.js" + + val result = backend.run( + property = property( + module = module, + exportName = "missingMapPredicate", + domain = IntegerDomain(min = 1, max = 1), + ), + configuration = configuration, + ) + + val artifact = assertNotNull(result.coverage) + val diagnostic = artifact.diagnostics.single() + assertEquals("coverage.source-map.missing", diagnostic.code) + assertEquals(sourceRoot().resolve(module).toRealPath().toString(), diagnostic.path) + } + + @Test + fun `real c8 reports an invalid referenced source map when the final report omits the script`() { + val module = "properties/coverage/invalid-map-entry.js" + + val result = backend.run( + property = property( + module = module, + exportName = "invalidMapPredicate", + domain = IntegerDomain(min = 1, max = 1), + ), + configuration = configuration, + ) + + val artifact = assertNotNull(result.coverage) + val diagnostic = artifact.diagnostics.single() + assertEquals("coverage.source-map.invalid", diagnostic.code) + assertEquals(sourceRoot().resolve(module).toRealPath().toString(), diagnostic.path) + } + + @Test + fun `symlinked entry point is retained only in the entry-point scope`() { + val sourceRoot = createTempDirectory(prefix = "coverage-symlink-entry-") + try { + val realDirectory = sourceRoot.resolve("real").createDirectory() + val realEntryPoint = realDirectory.resolve("Property.ts") + realEntryPoint.writeText("export function predicate(value: number): boolean { return value > 0; }") + sourceRoot.resolve("Property.ts").createSymbolicLinkPointingTo(realEntryPoint) + val symlinkBackend = FastCheckBackend( + sourceRoots = listOf(sourceRoot), + adapterEntryPoint = adapterEntryPoint(), + ) + val symlinkProperty = property( + module = "Property.ts", + exportName = "predicate", + domain = IntegerDomain(min = 1, max = 1), + ) + + val sourceResult = symlinkBackend.run( + property = symlinkProperty, + configuration = configuration, + ) + val entryPointResult = symlinkBackend.run( + property = symlinkProperty, + configuration = configuration.copy( + coverageRequest = PropertyCoverageRequest( + scopes = setOf(CoverageScope.PROPERTY_ENTRY_POINTS), + ), + ), + ) + + assertTrue(assertNotNull(sourceResult.coverage).files.isEmpty()) + assertEquals( + listOf(realEntryPoint.toRealPath().toString()), + assertNotNull(entryPointResult.coverage).files.map { file -> file.path }, + ) + } finally { + sourceRoot.toFile().deleteRecursively() + } + } + private fun sourceUnderTest(result: org.usvm.ts.pbt.backend.PropertyRunResult): SourceFileCoverage { val artifact = assertNotNull(result.coverage) return artifact.files.single { file -> file.path.endsWith("properties/coverage/source-under-test.ts") } @@ -86,7 +171,11 @@ class FastCheckCoverageTest { .filter { line -> line > 1 } .sorted() - private fun property(exportName: String, domain: IntegerDomain) = PropertyDefinition( + private fun property( + exportName: String, + domain: IntegerDomain, + module: String = "properties/coverage/CoverageProperties.ts", + ) = PropertyDefinition( id = PropertyId("coverage.$exportName"), inputs = listOf( PropertyInput( @@ -95,7 +184,7 @@ class FastCheckCoverageTest { ), ), predicate = TypeScriptEntryPoint( - module = "properties/coverage/CoverageProperties.ts", + module = module, exportName = exportName, ), ) diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClientTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClientTest.kt index 008bd2d5f..2d7addcfb 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClientTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClientTest.kt @@ -2,6 +2,7 @@ package org.usvm.ts.pbt.fastcheck import org.junit.jupiter.api.Test import org.usvm.ts.pbt.backend.PropertyCoverageRequest +import org.usvm.ts.pbt.backend.PropertyRunStatus import org.usvm.ts.pbt.manifest.toManifest import org.usvm.ts.pbt.model.BooleanDomain import org.usvm.ts.pbt.model.PropertyDefinition @@ -11,6 +12,7 @@ import org.usvm.ts.pbt.model.TypeScriptEntryPoint import org.usvm.ts.pbt.testResourcesRoot import java.nio.file.Files import java.nio.file.Path +import java.util.concurrent.TimeUnit import kotlin.io.path.createDirectories import kotlin.io.path.createFile import kotlin.io.path.createTempDirectory @@ -20,9 +22,20 @@ import kotlin.io.path.readText import kotlin.io.path.writeText import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertTrue class FastCheckProcessClientTest { + @Test + fun `process client rejects a shutdown grace period outside the Node timer range`() { + assertFailsWith { + FastCheckProcessClient( + adapterEntryPoint = Path.of("unused"), + shutdownGraceMillis = 2_147_483_648L, + ) + } + } + @Test fun `process startup failure is typed`() { val startup = assertFailsWith { @@ -300,6 +313,65 @@ class FastCheckProcessClientTest { } } + @Test + fun `successful adapter exit terminates a descendant retaining an inherited pipe`() { + val childPidFile = createTempFile(prefix = "fast-check-inherited-pipe-pid-", suffix = ".txt") + val naturalExitFile = createTempFile(prefix = "fast-check-inherited-pipe-exit-", suffix = ".txt") + childPidFile.deleteIfExists() + naturalExitFile.deleteIfExists() + + try { + withTemporaryAdapter( + source = """ + import { spawn } from 'node:child_process' + import { writeFileSync } from 'node:fs' + + const childSource = `setTimeout( + () => require('node:fs').writeFileSync( + ${naturalExitFile.toJavaScriptStringLiteral()}, + 'done' + ), + 30000 + )` + const child = spawn( + process.execPath, + ['-e', childSource], + { stdio: ['ignore', 'inherit', 'inherit'] } + ) + writeFileSync(${childPidFile.toJavaScriptStringLiteral()}, String(child.pid)) + child.unref() + + process.stdout.write(JSON.stringify({ + status: 'ok', + result: { + propertyId: 'example.property', + status: 'success', + seed: 42, + replayPath: null, + counterexample: null, + numRuns: 1, + numSkips: 0, + numShrinks: 0, + failure: null, + executionTimeMillis: 1 + } + })) + """.trimIndent(), + transportGraceMillis = 100, + ) { client -> + val result = client.check(validRequest.copy(timeoutMillis = 100)) + + assertEquals(PropertyRunStatus.SUCCESS, result.status) + assertFalse(Files.exists(naturalExitFile), "Inherited-pipe descendant reached its natural exit") + assertFalse(processIsAlive(childPidFile), "Inherited-pipe descendant is still running") + } + } finally { + terminateProcess(childPidFile) + childPidFile.deleteIfExists() + naturalExitFile.deleteIfExists() + } + } + private fun withTemporaryAdapter( source: String, transportGraceMillis: Long = 2_000, @@ -327,6 +399,23 @@ class FastCheckProcessClientTest { } } + private fun Path.toJavaScriptStringLiteral(): String = "'${toString().replace("\\", "\\\\").replace("'", "\\'")}'" + + private fun processIsAlive(pidFile: Path): Boolean { + val pid = pidFile.takeIf(Files::exists)?.readText()?.trim()?.toLongOrNull() ?: return false + val process = ProcessHandle.of(pid).orElse(null) ?: return false + + return process.isAlive + } + + private fun terminateProcess(pidFile: Path) { + val pid = pidFile.takeIf(Files::exists)?.readText()?.trim()?.toLongOrNull() ?: return + val process = ProcessHandle.of(pid).orElse(null) ?: return + + process.destroyForcibly() + process.onExit().get(1, TimeUnit.SECONDS) + } + private companion object { data class InvalidResponseCase( val script: String, diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClientTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClientTest.kt index 6b3469e0f..89e8438dc 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClientTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClientTest.kt @@ -1,20 +1,34 @@ package org.usvm.ts.pbt.fastcheck import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout import org.usvm.ts.pbt.model.ArrayDomain import org.usvm.ts.pbt.model.BooleanDomain +import org.usvm.ts.pbt.model.ConstantDomain import org.usvm.ts.pbt.model.IntegerDomain import org.usvm.ts.pbt.model.JsConcreteValue import org.usvm.ts.pbt.model.PropertyDomain +import java.nio.file.Files import java.nio.file.Path +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException import kotlin.io.path.createTempFile import kotlin.io.path.deleteIfExists +import kotlin.io.path.readText import kotlin.io.path.writeText import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertTrue class FastCheckProjectionClientTest { + @Test + fun `transport limits reject a shutdown grace period outside the Node timer range`() { + assertFailsWith { + transportLimits(shutdownGraceMillis = 2_147_483_648L) + } + } + private val client = FastCheckProjectionClient() @Test @@ -47,6 +61,20 @@ class FastCheckProjectionClientTest { assertEquals("protocol.request.invalid", error.code) } + @Test + fun `requests above the projection sample cap are rejected before starting Node`() { + val missingAdapterClient = FastCheckProjectionClient( + nodeExecutable = "definitely-not-a-node-executable", + adapterEntryPoint = Path.of("missing-adapter.mjs"), + ) + + val error = assertFailsWith { + missingAdapterClient.sample(validRequest.copy(numSamples = 10_001)) + } + + assertEquals("protocol.request.invalid", error.code) + } + @Test fun `process startup and exit failures are typed transport errors`() { val startup = assertFailsWith { @@ -85,12 +113,223 @@ class FastCheckProjectionClientTest { } } + @Test + fun `successful samples outside their domains are rejected`() { + withTemporaryAdapter( + """ + process.stdout.write(JSON.stringify({ + status: 'ok', + samples: [[{ kind: 'boolean', value: true }]] + })) + """.trimIndent(), + ) { temporaryClient -> + val error = assertFailsWith { + temporaryClient.sample( + validRequest.copy(domains = listOf(IntegerDomain(min = 0, max = 1))), + ) + } + + assertEquals("backend.response.invalid", error.code) + assertEquals("samples[0][0]", error.path) + } + } + + @Test + fun `requests beyond the transport byte limit are rejected before starting Node`() { + withTemporaryAdapter( + source = "", + transportLimits = transportLimits(maxRequestBytes = 100), + ) { temporaryClient -> + val error = assertFailsWith { + temporaryClient.sample( + validRequest.copy( + domains = listOf(ConstantDomain(JsConcreteValue.String("x".repeat(101)))), + ), + ) + } + + assertEquals("backend.request.too-large", error.code) + } + } + + @Test + fun `stdout beyond the transport byte limit is rejected`() { + withTemporaryAdapter( + source = "process.stdout.write('x'.repeat(1025))", + transportLimits = transportLimits(maxStdoutBytes = 1_024), + ) { temporaryClient -> + val error = assertFailsWith { + temporaryClient.sample(validRequest) + } + + assertEquals("backend.response.too-large", error.code) + } + } + + @Test + fun `stderr beyond the transport byte limit is rejected`() { + withTemporaryAdapter( + source = """ + process.stderr.write('x'.repeat(1025)) + process.stdout.write(JSON.stringify({ + status: 'ok', + samples: [[{ kind: 'boolean', value: true }]] + })) + """.trimIndent(), + transportLimits = transportLimits(maxStderrBytes = 1_024), + ) { temporaryClient -> + val error = assertFailsWith { + temporaryClient.sample(validRequest) + } + + assertEquals("backend.response.too-large", error.code) + } + } + + @Test + @Timeout(value = 2, unit = TimeUnit.SECONDS) + fun `continuing stdout beyond the transport byte limit fails promptly`() { + val pidFile = createTempFile(prefix = "fast-check-stdout-pid-", suffix = ".txt") + pidFile.deleteIfExists() + + try { + withTemporaryAdapter( + source = """ + import { writeFileSync } from 'node:fs' + writeFileSync(${pidFile.toJavaScriptStringLiteral()}, String(process.pid)) + process.stdout.on('error', () => undefined) + process.on('SIGTERM', () => undefined) + setInterval(() => process.stdout.write('x'.repeat(1025)), 1) + """.trimIndent(), + transportLimits = transportLimits( + maxStdoutBytes = 1_024, + wallClockTimeoutMillis = 250, + shutdownGraceMillis = 500, + ), + ) { temporaryClient -> + val startedAt = System.nanoTime() + val error = assertFailsWith { + temporaryClient.sample(validRequest) + } + val elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000 + + assertEquals("backend.response.too-large", error.code) + assertTrue(elapsedMillis < 2_000, "Stdout limit took $elapsedMillis ms") + assertTrue(adapterIsTerminated(pidFile), "Stdout adapter is still running") + } + } finally { + terminateAdapter(pidFile) + pidFile.deleteIfExists() + } + } + + @Test + @Timeout(value = 2, unit = TimeUnit.SECONDS) + fun `continuing stderr beyond the transport byte limit fails promptly`() { + val pidFile = createTempFile(prefix = "fast-check-stderr-pid-", suffix = ".txt") + pidFile.deleteIfExists() + + try { + withTemporaryAdapter( + source = """ + import { writeFileSync } from 'node:fs' + writeFileSync(${pidFile.toJavaScriptStringLiteral()}, String(process.pid)) + setInterval(() => process.stderr.write('x'.repeat(1025)), 1) + """.trimIndent(), + transportLimits = transportLimits(maxStderrBytes = 1_024), + ) { temporaryClient -> + val startedAt = System.nanoTime() + val error = assertFailsWith { + temporaryClient.sample(validRequest) + } + val elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000 + + assertEquals("backend.response.too-large", error.code) + assertTrue(elapsedMillis < 2_000, "Stderr limit took $elapsedMillis ms") + assertTrue(adapterIsTerminated(pidFile), "Stderr adapter is still running") + } + } finally { + terminateAdapter(pidFile) + pidFile.deleteIfExists() + } + } + + @Test + @Timeout(value = 2, unit = TimeUnit.SECONDS) + fun `immediate parent exit still terminates a descendant retaining a pipe`() { + val childPidFile = createTempFile(prefix = "fast-check-descendant-pid-", suffix = ".txt") + childPidFile.deleteIfExists() + + try { + withTemporaryAdapter( + source = """ + import { spawn } from 'node:child_process' + import { writeFileSync } from 'node:fs' + const child = spawn(process.execPath, [ + '-e', + "process.on('SIGTERM', () => undefined); setInterval(() => undefined, 1000)" + ], { stdio: 'inherit' }) + writeFileSync(${childPidFile.toJavaScriptStringLiteral()}, String(child.pid)) + process.exit(0) + """.trimIndent(), + transportLimits = transportLimits( + wallClockTimeoutMillis = 250, + shutdownGraceMillis = 500, + ), + ) { temporaryClient -> + val startedAt = System.nanoTime() + val error = assertFailsWith { + temporaryClient.sample(validRequest) + } + val elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000 + + assertEquals("backend.response.empty", error.code) + assertTrue(elapsedMillis < 600, "Descendant cleanup took $elapsedMillis ms") + assertTrue(adapterIsTerminated(childPidFile), "Descendant is still running") + } + } finally { + assertTrue(terminateAdapter(childPidFile), "Test cleanup did not terminate descendant") + childPidFile.deleteIfExists() + } + } + + @Test + @Timeout(value = 2, unit = TimeUnit.SECONDS) + fun `wall clock timeout returns promptly and terminates the adapter`() { + val pidFile = createTempFile(prefix = "fast-check-adapter-pid-", suffix = ".txt") + pidFile.deleteIfExists() + + try { + withTemporaryAdapter( + source = """ + import { writeFileSync } from 'node:fs' + writeFileSync(${pidFile.toJavaScriptStringLiteral()}, String(process.pid)) + setInterval(() => undefined, 1_000) + """.trimIndent(), + transportLimits = transportLimits(wallClockTimeoutMillis = 250), + ) { temporaryClient -> + val startedAt = System.nanoTime() + val error = assertFailsWith { + temporaryClient.sample(validRequest) + } + val elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000 + + assertEquals("backend.process.timeout", error.code) + assertTrue(elapsedMillis < 600, "Projection timeout took $elapsedMillis ms") + assertTrue(adapterIsTerminated(pidFile), "Adapter is still running") + } + } finally { + terminateAdapter(pidFile) + pidFile.deleteIfExists() + } + } + @Test fun `large adapter stderr does not block a successful response`() { withTemporaryAdapter( """ const timeout = setTimeout(() => process.exit(2), 1000) - process.stderr.write('x'.repeat(1024 * 1024), () => { + process.stderr.write('x'.repeat(32 * 1024), () => { clearTimeout(timeout) process.stdout.write(JSON.stringify({ status: 'ok', @@ -138,17 +377,81 @@ class FastCheckProjectionClientTest { } } - private fun withTemporaryAdapter(source: String, block: (FastCheckProjectionClient) -> Unit) { + private fun withTemporaryAdapter( + source: String, + transportLimits: FastCheckProjectionTransportLimits? = null, + block: (FastCheckProjectionClient) -> Unit, + ) { val script = createTempFile(prefix = "fast-check-adapter-", suffix = ".mjs") try { script.writeText(source) - block(FastCheckProjectionClient(adapterEntryPoint = script)) + val client = transportLimits?.let { limits -> + FastCheckProjectionClient( + adapterEntryPoint = script, + transportLimits = limits, + ) + } ?: FastCheckProjectionClient(adapterEntryPoint = script) + + block(client) } finally { script.deleteIfExists() } } + private fun transportLimits( + maxRequestBytes: Int = 1_024, + maxStdoutBytes: Int = 1_024, + maxStderrBytes: Int = 1_024, + wallClockTimeoutMillis: Long = 1_000, + shutdownGraceMillis: Long = 25, + ) = FastCheckProjectionTransportLimits( + maxRequestBytes = maxRequestBytes, + maxStdoutBytes = maxStdoutBytes, + maxStderrBytes = maxStderrBytes, + wallClockTimeoutMillis = wallClockTimeoutMillis, + shutdownGraceMillis = shutdownGraceMillis, + ) + + private fun Path.toJavaScriptStringLiteral(): String = "'${toString().replace("\\", "\\\\").replace("'", "\\'")}'" + + private fun terminateAdapter(pidFile: Path): Boolean { + val pid = pidFile.takeIf(Files::exists)?.readText()?.trim()?.toLongOrNull() ?: return true + val process = ProcessHandle.of(pid).orElse(null) ?: return true + + process.destroyForcibly() + + try { + process.onExit().get(1, TimeUnit.SECONDS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + + return true + } + + return !process.isAlive + } + + private fun adapterIsTerminated(pidFile: Path): Boolean { + val pid = pidFile.readText().trim().toLong() + val process = ProcessHandle.of(pid).orElse(null) + if (process == null || !process.isAlive) return true + + try { + process.onExit().get(1, TimeUnit.SECONDS) + } catch (_: TimeoutException) { + return false + } catch (_: ExecutionException) { + return !process.isAlive + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + + return !process.isAlive + } + + return !process.isAlive + } + private companion object { val validRequest = FastCheckProjectionRequest( seed = 42, diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/EtsMappingModelTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/EtsMappingModelTest.kt new file mode 100644 index 000000000..5c56f7a87 --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/EtsMappingModelTest.kt @@ -0,0 +1,20 @@ +package org.usvm.ts.pbt.mapping + +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +class EtsMappingModelTest { + @Test + fun `mapping diagnostics require a non-blank code`() { + assertFailsWith { + EtsMappingDiagnostic(code = " ", message = "Mapping failed") + } + } + + @Test + fun `mapping diagnostics require a non-blank message`() { + assertFailsWith { + EtsMappingDiagnostic(code = "mapping.test", message = " ") + } + } +} diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsExportResolutionTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsExportResolutionTest.kt new file mode 100644 index 000000000..e276688e7 --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsExportResolutionTest.kt @@ -0,0 +1,293 @@ +package org.usvm.ts.pbt.mapping + +import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsFile +import org.jacodb.ets.model.EtsFunctionType +import org.jacodb.ets.model.EtsLocal +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.model.EtsStaticFieldRef +import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME +import org.jacodb.ets.utils.DEFAULT_ARK_METHOD_NAME +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.junit.jupiter.api.Test +import org.usvm.ts.pbt.manifest.PropertyManifest +import org.usvm.ts.pbt.model.IntegerDomain +import org.usvm.ts.pbt.model.PropertyInput +import org.usvm.ts.pbt.model.TypeScriptEntryPoint +import org.usvm.ts.pbt.testResourcePath +import java.nio.file.Path +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PropertyEtsExportResolutionTest { + @Test + fun `named default declaration resolves only through the default export name`() { + val source = testResourcePath("/mapping/exports/NamedDefaultDeclaration.ts") + val mapper = mapper(source) + + val defaultArtifact = mapper.map(manifest(module = source.fileName.toString(), exportName = "default")) + val sourceNameArtifact = mapper.map(manifest(module = source.fileName.toString(), exportName = "namedDefault")) + + assertEquals(EtsMappingStatus.EXACT, defaultArtifact.predicate.status) + val defaultMethod = defaultArtifact.predicate.targets.single().method + assertEquals("namedDefault", defaultMethod.name) + assertEquals(EtsMappingStatus.UNMAPPED, sourceNameArtifact.predicate.status) + assertEquals(emptyList(), sourceNameArtifact.predicate.targets) + } + + @Test + fun `direct function export ignores same-named class methods`() { + val source = testResourcePath("/mapping/exports/DirectExportFixture.ts") + val mapper = mapper(source) + + val artifact = mapper.map(manifest(module = source.fileName.toString(), exportName = "predicate")) + + assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) + val method = artifact.predicate.targets.single().method + assertEquals("predicate", method.name) + assertEquals("%dflt", method.signature.enclosingClass.name) + } + + @Test + fun `namespace star export is not a transparent named re-export`() { + val entrySource = testResourcePath("/mapping/exports/NamespaceEntry.ts") + val predicateSource = testResourcePath("/mapping/exports/Predicate.ts") + val mapper = mapper(entrySource, predicateSource) + + val artifact = mapper.map(manifest(module = entrySource.fileName.toString(), exportName = "corePredicate")) + + assertEquals(EtsMappingStatus.UNMAPPED, artifact.predicate.status) + assertEquals(emptyList(), artifact.predicate.targets) + } + + @Test + fun `explicit named re-export takes precedence over bare star export`() { + val sourceDirectory = testResourcePath("/mapping/exports") + val sources = listOf("ExplicitPrecedenceEntry.ts", "Predicate.ts", "StarPredicate.ts") + .map(sourceDirectory::resolve) + val mapper = mapper(*sources.toTypedArray()) + + val artifact = mapper.map(manifest(module = "ExplicitPrecedenceEntry.ts", exportName = "predicate")) + val target = artifact.predicate.targets.single() + + assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) + assertEquals("corePredicate", target.method.name) + } + + @Test + fun `type-only named export does not mask a bare star value export`() { + val sourceDirectory = testResourcePath("/mapping/exports") + val sources = listOf("TypeOnlyPrecedenceEntry.ts", "TypeOnlyPredicate.ts", "StarPredicate.ts") + .map(sourceDirectory::resolve) + val mapper = mapper(*sources.toTypedArray()) + + val artifact = mapper.map(manifest(module = "TypeOnlyPrecedenceEntry.ts", exportName = "predicate")) + + assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) + val target = artifact.predicate.targets.single() + val enclosingClass = target.method.signature.enclosingClass + val targetFileName = enclosingClass.file.fileName + assertEquals("predicate", target.method.name) + assertTrue(targetFileName.endsWith("StarPredicate.ts")) + } + + @Test + fun `type-only star export does not add a runtime candidate`() { + val sourceDirectory = testResourcePath("/mapping/exports") + val sources = listOf("TypeOnlyStarEntry.ts", "TypeOnlyPredicate.ts", "StarPredicate.ts") + .map(sourceDirectory::resolve) + val mapper = mapper(*sources.toTypedArray()) + + val artifact = mapper.map(manifest(module = "TypeOnlyStarEntry.ts", exportName = "predicate")) + + assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) + val target = artifact.predicate.targets.single() + val enclosingClass = target.method.signature.enclosingClass + val targetFileName = enclosingClass.file.fileName + assertTrue(targetFileName.endsWith("StarPredicate.ts")) + } + + @Test + fun `exported arrow local resolves through its lifted method`() { + val source = testResourcePath("/mapping/exports/CallableLocalFixture.ts") + val mapper = mapper(source) + + val artifact = mapper.map(manifest(module = source.fileName.toString(), exportName = "arrowPredicate")) + + assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) + val method = artifact.predicate.targets.single().method + assertTrue(method.name.startsWith("%AM")) + assertEquals(listOf("value"), method.parameters.map { parameter -> parameter.name }) + } + + @Test + fun `local function expression alias routes only through the export name`() { + val source = testResourcePath("/mapping/exports/CallableLocalFixture.ts") + val mapper = mapper(source) + + val aliasArtifact = mapper.map( + manifest(module = source.fileName.toString(), exportName = "aliasedPredicate"), + ) + val localNameArtifact = mapper.map( + manifest(module = source.fileName.toString(), exportName = "functionPredicate"), + ) + + assertEquals(EtsMappingStatus.EXACT, aliasArtifact.predicate.status) + val aliasMethod = aliasArtifact.predicate.targets.single().method + assertTrue(aliasMethod.name.startsWith("%AM")) + assertEquals(EtsMappingStatus.UNMAPPED, localNameArtifact.predicate.status) + assertEquals(emptyList(), localNameArtifact.predicate.targets) + } + + @Test + fun `non-callable exported local remains unmapped`() { + val source = testResourcePath("/mapping/exports/CallableLocalFixture.ts") + val mapper = mapper(source) + + val artifact = mapper.map(manifest(module = source.fileName.toString(), exportName = "nonCallable")) + + assertEquals(EtsMappingStatus.UNMAPPED, artifact.predicate.status) + assertEquals(emptyList(), artifact.predicate.targets) + } + + @Test + fun `multiple lifted assignments for one export remain ambiguous`() { + val source = testResourcePath("/mapping/exports/CallableLocalFixture.ts") + val mapper = mapper(source) + + val artifact = mapper.map(manifest(module = source.fileName.toString(), exportName = "reassignedPredicate")) + + assertEquals(EtsMappingStatus.AMBIGUOUS, artifact.predicate.status) + assertEquals(2, artifact.predicate.targets.size) + assertTrue(artifact.predicate.targets.all { target -> target.method.name.startsWith("%AM") }) + assertEquals("mapping.entry-point.ambiguous", artifact.predicate.diagnostics.single().code) + } + + @Test + fun `callable local followed by a non-callable assignment remains ambiguous`() { + val source = testResourcePath("/mapping/exports/CallableLocalFixture.ts") + val mapper = mapper(source) + + val artifact = mapper.map(manifest(module = source.fileName.toString(), exportName = "callableThenValue")) + val target = artifact.predicate.targets.single() + + assertEquals(EtsMappingStatus.AMBIGUOUS, artifact.predicate.status) + assertEquals(1, artifact.predicate.targets.size) + assertTrue(target.method.name.startsWith("%AM")) + assertEquals("mapping.entry-point.ambiguous", artifact.predicate.diagnostics.single().code) + } + + @Test + fun `aliased callable with repeated links to one lifted method remains ambiguous`() { + val source = testResourcePath("/mapping/exports/CallableLocalFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val defaultClass = file.classes.single { etsClass -> etsClass.name == DEFAULT_ARK_CLASS_NAME } + val defaultMethod = defaultClass.methods.single { method -> method.name == DEFAULT_ARK_METHOD_NAME } + val linkedSignatures = defaultMethod.cfg.stmts + .filterIsInstance() + .mapNotNull { assignment -> + val field = assignment.lhv as? EtsStaticFieldRef ?: return@mapNotNull null + if (field.field.name != "multiplyLinkedPredicate") return@mapNotNull null + + val local = assignment.rhv as? EtsLocal ?: return@mapNotNull null + val functionType = local.type as? EtsFunctionType ?: return@mapNotNull null + + functionType.signature + } + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val aliasArtifact = mapper.map( + manifest(module = source.fileName.toString(), exportName = "aliasedMultiplyLinkedPredicate"), + ) + val localNameArtifact = mapper.map( + manifest(module = source.fileName.toString(), exportName = "multiplyLinkedPredicate"), + ) + + assertEquals(2, linkedSignatures.size) + assertEquals(1, linkedSignatures.distinct().size) + assertEquals(EtsMappingStatus.AMBIGUOUS, aliasArtifact.predicate.status) + val target = aliasArtifact.predicate.targets.single() + assertEquals(linkedSignatures.distinct().single(), target.method.signature) + assertTrue(target.method.name.startsWith("%AM")) + val diagnostic = aliasArtifact.predicate.diagnostics.single() + assertEquals("mapping.entry-point.ambiguous", diagnostic.code) + assertEquals( + "Several EtsIR methods, source candidates, or export links match " + + "CallableLocalFixture.ts#aliasedMultiplyLinkedPredicate", + diagnostic.message, + ) + assertEquals(EtsMappingStatus.UNMAPPED, localNameArtifact.predicate.status) + assertEquals(emptyList(), localNameArtifact.predicate.targets) + } + + @Test + fun `bare star export does not forward the default export`() { + val entrySource = testResourcePath("/mapping/exports/StarDefaultEntry.ts") + val predicateSource = testResourcePath("/mapping/exports/DefaultPredicate.ts") + val mapper = mapper(entrySource, predicateSource) + + val artifact = mapper.map(manifest(module = entrySource.fileName.toString(), exportName = "default")) + + assertEquals(EtsMappingStatus.UNMAPPED, artifact.predicate.status) + assertEquals(emptyList(), artifact.predicate.targets) + } + + @Test + fun `duplicate re-export paths resolve one EtsIR method exactly`() { + val sourceDirectory = testResourcePath("/mapping/exports") + val sources = listOf("DiamondEntry.ts", "Left.ts", "Right.ts", "Predicate.ts") + .map(sourceDirectory::resolve) + val mapper = mapper(*sources.toTypedArray()) + + val artifact = mapper.map(manifest(module = "DiamondEntry.ts", exportName = "predicate")) + + assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) + val targetMethod = artifact.predicate.targets.single().method + assertEquals("corePredicate", targetMethod.name) + } + + @Test + fun `re-export with multiple module files stays ambiguous when only one exports the target`() { + val sourceDirectory = testResourcePath("/mapping/exports/ambiguous-reexport") + val sources = listOf("Entry.ts", "Foo.ts", "Foo/index.ts").map(sourceDirectory::resolve) + val mapper = mapper(*sources.toTypedArray()) + + val artifact = mapper.map(manifest(module = "Entry.ts", exportName = "predicate")) + val target = artifact.predicate.targets.single() + + assertEquals(EtsMappingStatus.AMBIGUOUS, artifact.predicate.status) + assertEquals(1, artifact.predicate.targets.size) + assertEquals("predicate", target.method.name) + assertEquals("mapping.entry-point.ambiguous", artifact.predicate.diagnostics.single().code) + } + + private fun mapper(vararg sources: Path): PropertyEtsMapper { + val sourceRoot = sources.first().parent + val files = sources.map { source -> + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + + EtsFile( + signature = file.signature.copy(fileName = sourceRoot.relativize(source).toString()), + classes = file.classes, + namespaces = file.namespaces, + importInfos = file.importInfos, + exportInfos = file.exportInfos, + ) + } + + return PropertyEtsMapper( + scene = EtsScene(files), + sourceRoots = listOf(sourceRoot), + ) + } + + private fun manifest(module: String, exportName: String): PropertyManifest = PropertyManifest( + propertyId = "mapping.export-resolution", + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint(module = module, exportName = exportName), + ) +} diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsMapperTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsMapperTest.kt new file mode 100644 index 000000000..57e9dc04c --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsMapperTest.kt @@ -0,0 +1,1095 @@ +package org.usvm.ts.pbt.mapping + +import org.jacodb.ets.model.EtsBlockCfg +import org.jacodb.ets.model.EtsIfStmt +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.junit.jupiter.api.Test +import org.usvm.ts.pbt.backend.BranchArmCoverage +import org.usvm.ts.pbt.backend.BranchCoverage +import org.usvm.ts.pbt.backend.CoverageCollectorIdentity +import org.usvm.ts.pbt.backend.CoverageProvenance +import org.usvm.ts.pbt.backend.PropertyCoverageArtifact +import org.usvm.ts.pbt.backend.PropertyCoverageRequest +import org.usvm.ts.pbt.backend.SourceFileCoverage +import org.usvm.ts.pbt.backend.SourcePosition +import org.usvm.ts.pbt.backend.SourceRange +import org.usvm.ts.pbt.backend.StatementCoverage +import org.usvm.ts.pbt.manifest.PropertyManifest +import org.usvm.ts.pbt.model.IntegerDomain +import org.usvm.ts.pbt.model.PropertyId +import org.usvm.ts.pbt.model.PropertyInput +import org.usvm.ts.pbt.model.TypeScriptEntryPoint +import org.usvm.ts.pbt.testResourcePath +import java.nio.file.Path +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PropertyEtsMapperTest { + @Test + fun `maps an exported predicate and its symbolic bindings`() { + val source = testResourcePath("/mapping/PropertyMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val manifest = PropertyManifest( + propertyId = "mapping.positive", + inputs = listOf( + PropertyInput( + name = "value", + domain = IntegerDomain(min = -10, max = 10), + ), + ), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "isPositive", + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest) + + assertEquals(PropertyId("mapping.positive"), artifact.propertyId) + assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) + val target = artifact.predicate.targets.single() + val inputBinding = target.bindings.inputs.single() + assertEquals("isPositive", target.method.name) + assertEquals(0, target.bindings.receiver.stackSlot) + assertEquals("value", inputBinding.propertyInputName) + assertEquals(0, inputBinding.parameter.index) + assertEquals(1, inputBinding.stackSlot) + assertEquals(target.method.returnType, target.bindings.result.type) + } + + @Test + fun `maps an optional precondition independently from the predicate`() { + val predicateSource = testResourcePath("/mapping/PropertyMappingFixture.ts") + val preconditionSource = testResourcePath("/mapping/PropertyPreconditionFixture.ts") + val files = listOf(predicateSource, preconditionSource).map { source -> + loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + } + val manifest = PropertyManifest( + propertyId = "mapping.precondition", + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "isPositive", + ), + precondition = TypeScriptEntryPoint( + module = "PropertyPreconditionFixture.ts", + exportName = "isNonZero", + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(files), + sourceRoots = listOf(predicateSource.parent), + ) + + val artifact = mapper.map(manifest) + + val precondition = assertNotNull(artifact.precondition) + assertEquals(EtsMappingStatus.EXACT, precondition.status) + assertEquals("isNonZero", precondition.targets.single().method.name) + val predicateTarget = artifact.predicate.targets.single() + assertEquals("isPositive", predicateTarget.method.name) + } + + @Test + fun `reports an unmapped predicate instead of guessing or throwing`() { + val source = testResourcePath("/mapping/PropertyMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val manifest = PropertyManifest( + propertyId = "mapping.missing", + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "missingPredicate", + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest) + + assertEquals(EtsMappingStatus.UNMAPPED, artifact.predicate.status) + assertEquals(emptyList(), artifact.predicate.targets) + assertEquals("mapping.entry-point.unmapped", artifact.predicate.diagnostics.single().code) + } + + @Test + fun `reports ambiguous predicate candidates across source roots`() { + val primarySource = testResourcePath("/mapping/PropertyMappingFixture.ts") + val duplicateSource = testResourcePath("/mapping/duplicate/PropertyMappingFixture.ts") + val files = listOf(primarySource, duplicateSource).map { source -> + loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + } + val manifest = PropertyManifest( + propertyId = "mapping.ambiguous", + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "isPositive", + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(files), + sourceRoots = listOf(primarySource.parent, duplicateSource.parent), + ) + + val artifact = mapper.map(manifest) + + assertEquals(EtsMappingStatus.AMBIGUOUS, artifact.predicate.status) + assertEquals(2, artifact.predicate.targets.size) + assertEquals("mapping.entry-point.ambiguous", artifact.predicate.diagnostics.single().code) + } + + @Test + fun `duplicate frontend signatures stay ambiguous when one source has no matching targets`() { + val sourceRoot = testResourcePath("/mapping/source-roots") + val primarySource = sourceRoot.resolve("a/Foo.ts") + val duplicateSource = sourceRoot.resolve("b/Foo.ts") + val primaryFile = loadEtsFileAutoConvert(primarySource, provider = EtsIrProvider.TS_FRONTEND) + val duplicateFile = loadEtsFileAutoConvert(duplicateSource, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.duplicate-source-provenance") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "Foo.ts", + exportName = "predicate", + ), + ) + val branchLocation = SourceRange( + start = SourcePosition(line = 2, column = 2), + end = SourcePosition(line = 6, column = 3), + ) + val coverage = coverageArtifact( + source = primarySource, + propertyId = propertyId, + statements = listOf( + StatementCoverage( + statementId = 0, + location = SourceRange( + start = SourcePosition(line = 3, column = 4), + end = SourcePosition(line = 3, column = 16), + ), + hits = 1, + ), + ), + branches = listOf( + BranchCoverage( + branchId = 0, + type = "if", + location = branchLocation, + arms = listOf( + BranchArmCoverage(location = branchLocation, hits = 1), + BranchArmCoverage(location = branchLocation, hits = 0), + ), + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(primaryFile, duplicateFile)), + sourceRoots = listOf(primarySource.parent, duplicateSource.parent), + ) + + val artifact = mapper.map(manifest, coverage) + val primaryMethods = primaryFile.classes.flatMap { etsClass -> etsClass.methods } + val duplicateMethods = duplicateFile.classes.flatMap { etsClass -> etsClass.methods } + + assertEquals(EtsMappingStatus.AMBIGUOUS, artifact.predicate.status) + assertEquals(1, artifact.predicate.targets.size) + val mapping = artifact.coverage.statements.single().mapping + assertEquals(EtsMappingStatus.AMBIGUOUS, mapping.status) + assertTrue(mapping.targets.isNotEmpty()) + assertTrue( + mapping.targets.all { target -> + primaryMethods.any { method -> target.statement.location.method === method } + }, + ) + assertTrue( + mapping.targets.none { target -> + duplicateMethods.any { method -> target.statement.location.method === method } + }, + ) + assertEquals("mapping.statement.ambiguous", mapping.diagnostics.single().code) + val branch = artifact.coverage.branches.single() + assertEquals(EtsMappingStatus.AMBIGUOUS, branch.mapping.status) + assertEquals("mapping.branch.ambiguous", branch.mapping.diagnostics.single().code) + assertEquals( + listOf(EtsMappingStatus.AMBIGUOUS, EtsMappingStatus.AMBIGUOUS), + branch.arms.map { arm -> arm.mapping.status }, + ) + branch.arms.forEach { arm -> + val targetMethods = arm.mapping.targets.map { target -> target.condition.location.method } + + assertTrue(targetMethods.isNotEmpty()) + assertTrue(targetMethods.all { target -> primaryMethods.any { method -> target === method } }) + assertTrue(targetMethods.none { target -> duplicateMethods.any { method -> target === method } }) + } + } + + @Test + fun `reports unsupported bindings when an ambiguous candidate has another arity`() { + val primarySource = testResourcePath("/mapping/PropertyMappingFixture.ts") + val mismatchedSource = testResourcePath("/mapping/mismatched/PropertyMappingFixture.ts") + val files = listOf(primarySource, mismatchedSource).map { source -> + loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + } + val manifest = PropertyManifest( + propertyId = "mapping.ambiguous-arity", + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "isPositive", + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(files), + sourceRoots = listOf(primarySource.parent, mismatchedSource.parent), + ) + + val artifact = mapper.map(manifest) + + assertEquals(EtsMappingStatus.UNSUPPORTED, artifact.predicate.status) + assertEquals(emptyList(), artifact.predicate.targets) + assertEquals("mapping.entry-point.bindings.unsupported", artifact.predicate.diagnostics.single().code) + } + + @Test + fun `reports unsupported bindings when property inputs do not match parameters`() { + val source = testResourcePath("/mapping/PropertyMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val manifest = PropertyManifest( + propertyId = "mapping.unsupported-bindings", + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "needsTwoInputs", + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest) + + assertEquals(EtsMappingStatus.UNSUPPORTED, artifact.predicate.status) + assertEquals(emptyList(), artifact.predicate.targets) + assertEquals("mapping.entry-point.bindings.unsupported", artifact.predicate.diagnostics.single().code) + } + + @Test + fun `produces an unsupported coverage mapping when the backend returned no coverage`() { + val source = testResourcePath("/mapping/PropertyMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val manifest = PropertyManifest( + propertyId = "mapping.no-coverage", + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "isPositive", + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest) + + assertEquals(EtsMappingStatus.UNSUPPORTED, artifact.coverage.status) + assertNull(artifact.coverage.backendProvenance) + assertEquals("mapping.coverage.unavailable", artifact.coverage.diagnostics.single().code) + } + + @Test + fun `does not map coverage produced for another property`() { + val source = testResourcePath("/mapping/PropertyMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val manifest = PropertyManifest( + propertyId = "mapping.expected-property", + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "isPositive", + ), + ) + val coverage = coverageArtifact( + source = source, + propertyId = PropertyId("mapping.other-property"), + statements = emptyList(), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + assertEquals(PropertyId("mapping.expected-property"), artifact.propertyId) + assertEquals(EtsMappingStatus.UNSUPPORTED, artifact.coverage.status) + assertEquals(coverage.provenance, artifact.coverage.backendProvenance) + assertEquals(emptyList(), artifact.coverage.statements) + assertEquals(emptyList(), artifact.coverage.branches) + assertEquals("mapping.coverage.property-id.mismatch", artifact.coverage.diagnostics.single().code) + } +} + +class PropertyEtsStatementMappingTest { + @Test + fun `normalizes a TypeScript statement location and maps it to its EtsIR origin`() { + val source = testResourcePath("/mapping/PropertyMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.statement") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "isPositive", + ), + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = listOf( + StatementCoverage( + statementId = 0, + location = SourceRange( + start = SourcePosition(line = 3, column = 2), + end = SourcePosition(line = 3, column = 19), + ), + hits = 1, + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + assertEquals(coverage.provenance, artifact.coverage.backendProvenance) + assertEquals( + listOf(source.parent.toAbsolutePath().normalize().toString()), + artifact.provenance.sourceRoots, + ) + assertEquals(EtsSourceCoordinateSystem.TYPESCRIPT_UTF16_ZERO_BASED_HALF_OPEN, artifact.provenance.coordinates) + assertEquals(EtsBranchSuccessorOrder.TRUE_FALSE, artifact.provenance.branchSuccessorOrder) + val statement = artifact.coverage.statements.single() + val location = assertNotNull(statement.location) + assertEquals(source.toAbsolutePath().normalize().toString(), location.path) + assertEquals(NormalizedSourcePosition(line = 2, column = 2, offset = 82), location.start) + assertEquals(NormalizedSourcePosition(line = 2, column = 19, offset = 99), location.end) + assertEquals(EtsMappingStatus.EXACT, statement.mapping.status) + assertTrue(statement.mapping.targets.size > 1, "Normalized EtsIR statements must retain their shared span") + statement.mapping.targets.forEach { target -> + val origin = assertNotNull(target.statement.location.origin) + assertEquals("ReturnStatement", origin.nodeKind) + assertEquals(82, origin.startOffset) + assertEquals(99, origin.endOffset) + } + } + + @Test + fun `reports a source range containing distinct EtsIR spans as ambiguous`() { + val source = testResourcePath("/mapping/BranchMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.ambiguous-statement") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "BranchMappingFixture.ts", + exportName = "classifiesPositive", + ), + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = listOf( + StatementCoverage( + statementId = 0, + location = SourceRange( + start = SourcePosition(line = 2, column = 1), + end = SourcePosition(line = 7, column = 0), + ), + hits = 1, + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val statement = artifact.coverage.statements.single() + assertEquals(EtsMappingStatus.AMBIGUOUS, statement.mapping.status) + assertTrue(statement.mapping.targets.isNotEmpty()) + assertEquals("mapping.statement.ambiguous", statement.mapping.diagnostics.single().code) + } + + @Test + fun `reports a valid source range without an EtsIR statement as unmapped`() { + val source = testResourcePath("/mapping/PropertyMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.unmapped-statement") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "isPositive", + ), + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = listOf( + StatementCoverage( + statementId = 0, + location = SourceRange( + start = SourcePosition(line = 5, column = 0), + end = SourcePosition(line = 5, column = 0), + ), + hits = 0, + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val statement = artifact.coverage.statements.single() + assertEquals(EtsMappingStatus.UNMAPPED, statement.mapping.status) + assertEquals(emptyList(), statement.mapping.targets) + assertEquals("mapping.statement.unmapped", statement.mapping.diagnostics.single().code) + } +} + +class PropertyEtsBranchMappingTest { + @Test + fun `maps an Istanbul if branch to ordered true and false EtsIR edges`() { + val source = testResourcePath("/mapping/BranchMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.branch") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "BranchMappingFixture.ts", + exportName = "classifiesPositive", + ), + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = emptyList(), + branches = listOf( + BranchCoverage( + branchId = 0, + type = "if", + location = SourceRange( + start = SourcePosition(line = 2, column = 2), + end = SourcePosition(line = 6, column = 3), + ), + arms = listOf( + BranchArmCoverage( + location = SourceRange( + start = SourcePosition(line = 2, column = 2), + end = SourcePosition(line = 4, column = 3), + ), + hits = 5, + ), + BranchArmCoverage( + location = SourceRange( + start = SourcePosition(line = 4, column = 4), + end = SourcePosition(line = 6, column = 3), + ), + hits = 2, + ), + ), + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val branch = artifact.coverage.branches.single() + assertEquals(EtsMappingStatus.EXACT, branch.mapping.status) + assertEquals(listOf(5L, 2L), branch.arms.map { arm -> arm.coverage.hits }) + assertEquals(listOf(true, false), branch.arms.map { arm -> arm.mapping.targets.single().outcome }) + val successorLines = branch.arms.map { arm -> + val target = arm.mapping.targets.single() + val origin = assertNotNull(target.successor.location.origin) + + origin.startLine + } + assertEquals(listOf(2, 4), successorLines) + } + + @Test + fun `reports a branch range without an EtsIR condition as unmapped`() { + val source = testResourcePath("/mapping/PropertyMappingFixture.ts") + val duplicateSource = testResourcePath("/mapping/duplicate/PropertyMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val duplicateFile = loadEtsFileAutoConvert(duplicateSource, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.unmapped-branch") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "isPositive", + ), + ) + val branchLocation = SourceRange( + start = SourcePosition(line = 3, column = 2), + end = SourcePosition(line = 3, column = 19), + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = emptyList(), + branches = listOf( + BranchCoverage( + branchId = 0, + type = "if", + location = branchLocation, + arms = listOf( + BranchArmCoverage(location = branchLocation, hits = 1), + BranchArmCoverage(location = branchLocation, hits = 0), + ), + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file, duplicateFile)), + sourceRoots = listOf(source.parent, duplicateSource.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val branch = artifact.coverage.branches.single() + assertEquals(EtsMappingStatus.UNMAPPED, branch.mapping.status) + assertEquals(emptyList(), branch.mapping.targets) + assertEquals("mapping.branch.unmapped", branch.mapping.diagnostics.single().code) + assertTrue(branch.arms.all { arm -> arm.mapping.status == EtsMappingStatus.UNMAPPED }) + } + + @Test + fun `reports a branch range containing distinct EtsIR conditions as ambiguous`() { + val source = testResourcePath("/mapping/AmbiguousBranchMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.ambiguous-branch") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "AmbiguousBranchMappingFixture.ts", + exportName = "classifiesLargePositive", + ), + ) + val branchLocation = SourceRange( + start = SourcePosition(line = 2, column = 2), + end = SourcePosition(line = 6, column = 3), + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = emptyList(), + branches = listOf( + BranchCoverage( + branchId = 0, + type = "if", + location = branchLocation, + arms = listOf( + BranchArmCoverage(location = branchLocation, hits = 1), + BranchArmCoverage(location = branchLocation, hits = 0), + ), + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val branch = artifact.coverage.branches.single() + assertEquals(EtsMappingStatus.AMBIGUOUS, branch.mapping.status) + assertEquals(2, branch.mapping.targets.size) + assertEquals("mapping.branch.ambiguous", branch.mapping.diagnostics.single().code) + assertTrue(branch.arms.all { arm -> arm.mapping.targets.size == 2 }) + } +} + +class PropertyEtsUnsupportedMappingTest { + @Test + fun `reports unsupported source mapping when covered source text is unavailable`() { + val source = testResourcePath("/mapping/PropertyMappingFixture.ts") + val missingSource = source.resolveSibling("MissingMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.missing-source") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "isPositive", + ), + ) + val coverage = coverageArtifact( + source = source, + coveragePath = missingSource, + propertyId = propertyId, + statements = listOf( + StatementCoverage( + statementId = 0, + location = SourceRange( + start = SourcePosition(line = 1, column = 0), + end = SourcePosition(line = 1, column = 1), + ), + hits = 0, + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val statement = artifact.coverage.statements.single() + assertNull(statement.location) + assertEquals(EtsMappingStatus.UNSUPPORTED, statement.mapping.status) + assertEquals("mapping.source.unavailable", statement.mapping.diagnostics.single().code) + } + + @Test + fun `reports unsupported mapping when the EtsIR frontend supplied no source origins`() { + val source = testResourcePath("/mapping/PropertyMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + file.allClasses + .flatMap { etsClass -> etsClass.methods } + .flatMap { method -> method.cfg.stmts } + .forEach { statement -> statement.location.origin = null } + val propertyId = PropertyId("mapping.no-origins") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "isPositive", + ), + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = listOf( + StatementCoverage( + statementId = 0, + location = SourceRange( + start = SourcePosition(line = 3, column = 2), + end = SourcePosition(line = 3, column = 19), + ), + hits = 1, + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val statement = artifact.coverage.statements.single() + assertEquals(EtsMappingStatus.UNSUPPORTED, statement.mapping.status) + assertEquals("mapping.source-origins.unsupported", statement.mapping.diagnostics.single().code) + } + + @Test + fun `reports unsupported branch mapping when the EtsIR frontend supplied no source origins`() { + val source = testResourcePath("/mapping/BranchMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + file.allClasses + .flatMap { etsClass -> etsClass.methods } + .flatMap { method -> method.cfg.stmts } + .forEach { statement -> statement.location.origin = null } + val propertyId = PropertyId("mapping.branch-no-origins") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "BranchMappingFixture.ts", + exportName = "classifiesPositive", + ), + ) + val branchLocation = SourceRange( + start = SourcePosition(line = 2, column = 2), + end = SourcePosition(line = 6, column = 3), + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = emptyList(), + branches = listOf( + BranchCoverage( + branchId = 0, + type = "if", + location = branchLocation, + arms = listOf( + BranchArmCoverage(location = branchLocation, hits = 1), + BranchArmCoverage(location = branchLocation, hits = 0), + ), + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val branch = artifact.coverage.branches.single() + assertEquals(EtsMappingStatus.UNSUPPORTED, branch.mapping.status) + assertEquals("mapping.source-origins.unsupported", branch.mapping.diagnostics.single().code) + assertTrue(branch.arms.all { arm -> arm.mapping.status == EtsMappingStatus.UNSUPPORTED }) + } + + @Test + fun `reports unsupported non-if branch types even when they have two arms`() { + val source = testResourcePath("/mapping/BranchMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.unsupported-branch") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "BranchMappingFixture.ts", + exportName = "classifiesPositive", + ), + ) + val branchLocation = SourceRange( + start = SourcePosition(line = 2, column = 2), + end = SourcePosition(line = 6, column = 3), + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = emptyList(), + branches = listOf( + BranchCoverage( + branchId = 0, + type = "switch", + location = branchLocation, + arms = listOf( + BranchArmCoverage(location = branchLocation, hits = 1), + BranchArmCoverage(location = branchLocation, hits = 1), + ), + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val branch = artifact.coverage.branches.single() + assertEquals(EtsMappingStatus.UNSUPPORTED, branch.mapping.status) + assertEquals(emptyList(), branch.mapping.targets) + assertEquals("mapping.branch.shape.unsupported", branch.mapping.diagnostics.single().code) + assertTrue(branch.arms.all { arm -> arm.mapping.status == EtsMappingStatus.UNSUPPORTED }) + } + + @Test + fun `reports unsupported mapping when an EtsIR condition has fewer than two successors`() { + val source = testResourcePath("/mapping/BranchMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val condition = file.allClasses + .flatMap { etsClass -> etsClass.methods } + .flatMap { method -> method.cfg.stmts } + .filterIsInstance() + .single() + val method = condition.location.method + val originalCfg = method.cfg + val conditionBlock = originalCfg.blocks.single { block -> condition in block.statements } + val conditionSuccessors = originalCfg.successors.getValue(conditionBlock.id) + method.body.cfg = EtsBlockCfg( + blocks = originalCfg.blocks, + successors = originalCfg.successors + (conditionBlock.id to conditionSuccessors.take(1)), + ) + val propertyId = PropertyId("mapping.unsupported-cfg-branch") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "BranchMappingFixture.ts", + exportName = "classifiesPositive", + ), + ) + val branchLocation = SourceRange( + start = SourcePosition(line = 2, column = 2), + end = SourcePosition(line = 6, column = 3), + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = emptyList(), + branches = listOf( + BranchCoverage( + branchId = 0, + type = "if", + location = branchLocation, + arms = listOf( + BranchArmCoverage(location = branchLocation, hits = 1), + BranchArmCoverage(location = branchLocation, hits = 0), + ), + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val branch = artifact.coverage.branches.single() + assertEquals(EtsMappingStatus.UNSUPPORTED, branch.mapping.status) + assertEquals("mapping.branch.cfg.unsupported", branch.mapping.diagnostics.single().code) + assertTrue(branch.arms.all { arm -> arm.mapping.status == EtsMappingStatus.UNSUPPORTED }) + } +} + +class PropertyEtsMappingEdgeCasesTest { + @Test + fun `resolves extensionless modules through a named TypeScript re-export`() { + val entrySource = testResourcePath("/mapping/reexports/Entry.ts") + val predicateSource = testResourcePath("/mapping/reexports/Predicate.ts") + val files = listOf(entrySource, predicateSource).map { source -> + loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + } + val manifest = PropertyManifest( + propertyId = "mapping.reexport", + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "Entry", + exportName = "predicate", + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(files), + sourceRoots = listOf(entrySource.parent), + ) + + val artifact = mapper.map(manifest) + + assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) + val target = artifact.predicate.targets.single() + val targetClass = target.method.signature.enclosingClass + val targetFileName = targetClass.file.fileName + assertEquals("corePredicate", target.method.name) + assertTrue(targetFileName.endsWith("Predicate.ts")) + } + + @Test + fun `reports unsupported coverage coordinates outside the UTF-16 source line`() { + val source = testResourcePath("/mapping/PropertyMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.invalid-location") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "PropertyMappingFixture.ts", + exportName = "isPositive", + ), + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = listOf( + StatementCoverage( + statementId = 0, + location = SourceRange( + start = SourcePosition(line = 3, column = 200), + end = SourcePosition(line = 3, column = 200), + ), + hits = 0, + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val statement = artifact.coverage.statements.single() + assertNull(statement.location) + assertEquals(EtsMappingStatus.UNSUPPORTED, statement.mapping.status) + assertEquals("mapping.source.location.unsupported", statement.mapping.diagnostics.single().code) + } + + @Test + fun `reports an invalid branch arm without discarding the mapped condition`() { + val source = testResourcePath("/mapping/BranchMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.invalid-branch-arm") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "BranchMappingFixture.ts", + exportName = "classifiesPositive", + ), + ) + val branchLocation = SourceRange( + start = SourcePosition(line = 2, column = 2), + end = SourcePosition(line = 6, column = 3), + ) + val coverage = coverageArtifact( + source = source, + propertyId = propertyId, + statements = emptyList(), + branches = listOf( + BranchCoverage( + branchId = 0, + type = "if", + location = branchLocation, + arms = listOf( + BranchArmCoverage(location = branchLocation, hits = 1), + BranchArmCoverage( + location = SourceRange( + start = SourcePosition(line = 4, column = 200), + end = SourcePosition(line = 4, column = 200), + ), + hits = 0, + ), + ), + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val branch = artifact.coverage.branches.single() + assertEquals(EtsMappingStatus.EXACT, branch.mapping.status) + assertEquals(EtsMappingStatus.EXACT, branch.arms.first().mapping.status) + val invalidArm = branch.arms.last() + assertNull(invalidArm.location) + assertEquals(EtsMappingStatus.UNSUPPORTED, invalidArm.mapping.status) + assertEquals("mapping.source.location.unsupported", invalidArm.mapping.diagnostics.single().code) + assertEquals(EtsMappingStatus.UNSUPPORTED, artifact.coverage.status) + } + + @Test + fun `reports unsupported branch mapping when covered source text is unavailable`() { + val source = testResourcePath("/mapping/BranchMappingFixture.ts") + val missingSource = source.resolveSibling("MissingBranchMappingFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.missing-branch-source") + val manifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint( + module = "BranchMappingFixture.ts", + exportName = "classifiesPositive", + ), + ) + val location = SourceRange( + start = SourcePosition(line = 1, column = 0), + end = SourcePosition(line = 1, column = 1), + ) + val coverage = coverageArtifact( + source = source, + coveragePath = missingSource, + propertyId = propertyId, + statements = emptyList(), + branches = listOf( + BranchCoverage( + branchId = 0, + type = "if", + location = location, + arms = listOf( + BranchArmCoverage(location = location, hits = 0), + BranchArmCoverage(location = location, hits = 0), + ), + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val artifact = mapper.map(manifest, coverage) + + val branch = artifact.coverage.branches.single() + assertNull(branch.location) + assertEquals(EtsMappingStatus.UNSUPPORTED, branch.mapping.status) + assertEquals("mapping.source.unavailable", branch.mapping.diagnostics.single().code) + assertTrue( + branch.arms.all { arm -> + arm.location == null && arm.mapping.status == EtsMappingStatus.UNSUPPORTED + }, + ) + } +} + +private fun coverageArtifact( + source: Path, + coveragePath: Path = source, + propertyId: PropertyId, + statements: List, + branches: List = emptyList(), +): PropertyCoverageArtifact = PropertyCoverageArtifact( + backendId = "fixture-backend", + backendVersion = "1.0", + propertyId = propertyId, + provenance = CoverageProvenance( + collector = CoverageCollectorIdentity(id = "fixture", version = "1.0"), + runtimeId = "node", + runtimeVersion = "22.0.0", + sourceRoots = listOf(source.parent.toString()), + request = PropertyCoverageRequest(), + ), + files = listOf( + SourceFileCoverage( + path = coveragePath.toString(), + statements = statements, + functions = emptyList(), + branches = branches, + ), + ), +) diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsSourceNormalizationTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsSourceNormalizationTest.kt new file mode 100644 index 000000000..0bc6d52e9 --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsSourceNormalizationTest.kt @@ -0,0 +1,158 @@ +package org.usvm.ts.pbt.mapping + +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.usvm.ts.pbt.backend.CoverageCollectorIdentity +import org.usvm.ts.pbt.backend.CoverageProvenance +import org.usvm.ts.pbt.backend.PropertyCoverageArtifact +import org.usvm.ts.pbt.backend.PropertyCoverageRequest +import org.usvm.ts.pbt.backend.SourceFileCoverage +import org.usvm.ts.pbt.backend.SourcePosition +import org.usvm.ts.pbt.backend.SourceRange +import org.usvm.ts.pbt.backend.StatementCoverage +import org.usvm.ts.pbt.manifest.PropertyManifest +import org.usvm.ts.pbt.model.IntegerDomain +import org.usvm.ts.pbt.model.PropertyId +import org.usvm.ts.pbt.model.PropertyInput +import org.usvm.ts.pbt.model.TypeScriptEntryPoint +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class PropertyEtsSourceNormalizationTest { + @TempDir + lateinit var tempDirectory: Path + + @Test + fun `missing source roots produce an unsupported entry-point diagnostic`() { + val missingRoot = tempDirectory.resolve("missing") + val propertyId = PropertyId("mapping.missing-root") + val mapper = PropertyEtsMapper( + scene = EtsScene(emptyList()), + sourceRoots = listOf(missingRoot), + ) + + val artifact = mapper.map(manifest(propertyId, module = "Predicate.ts")) + + assertEquals(EtsMappingStatus.UNSUPPORTED, artifact.predicate.status) + assertEquals("mapping.source-root.unsupported", artifact.predicate.diagnostics.single().code) + } + + @Test + fun `canonical source roots align symlinked EtsIR origins with backend coverage`() { + val realRoot = Files.createDirectory(tempDirectory.resolve("real")) + val symlinkRoot = Files.createSymbolicLink(tempDirectory.resolve("alias"), realRoot) + val realSource = realRoot.resolve("Predicate.ts") + Files.writeString( + realSource, + """ + export function predicate(value: number): boolean { + return value > 0; + } + """.trimIndent(), + ) + val symlinkSource = symlinkRoot.resolve(realSource.fileName) + val file = loadEtsFileAutoConvert(symlinkSource, provider = EtsIrProvider.TS_FRONTEND) + val propertyId = PropertyId("mapping.symlink-root") + val coverage = coverageArtifact( + sourceRoot = realRoot, + sourcePath = realSource.toRealPath(), + propertyId = propertyId, + statements = listOf( + StatementCoverage( + statementId = 0, + location = SourceRange( + start = SourcePosition(line = 2, column = 2), + end = SourcePosition(line = 2, column = 19), + ), + hits = 1, + ), + ), + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(symlinkRoot), + ) + + val artifact = mapper.map(manifest(propertyId, module = "Predicate.ts"), coverage) + + assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) + val statement = artifact.coverage.statements.single() + assertEquals(EtsMappingStatus.EXACT, statement.mapping.status) + assertEquals(realSource.toRealPath().toString(), statement.location?.path) + } + + @Test + fun `TypeScript line terminators produce UTF-16 source offsets`() { + val source = tempDirectory.resolve("LineTerminators.ts") + Files.writeString(source, "a\r\nb\rc\u2028d\u2029e") + val propertyId = PropertyId("mapping.line-terminators") + val statements = listOf( + statement(statementId = 0, line = 2), + statement(statementId = 1, line = 3), + statement(statementId = 2, line = 4), + statement(statementId = 3, line = 5), + ) + val coverage = coverageArtifact( + sourceRoot = tempDirectory, + sourcePath = source, + propertyId = propertyId, + statements = statements, + ) + val mapper = PropertyEtsMapper( + scene = EtsScene(emptyList()), + sourceRoots = listOf(tempDirectory), + ) + + val artifact = mapper.map(manifest(propertyId, module = source.fileName.toString()), coverage) + + val locations = artifact.coverage.statements.map { mapping -> assertNotNull(mapping.location) } + assertEquals(listOf(3, 5, 7, 9), locations.map { location -> location.start.offset }) + assertEquals(listOf(4, 6, 8, 10), locations.map { location -> location.end.offset }) + } + + private fun statement(statementId: Int, line: Int): StatementCoverage = StatementCoverage( + statementId = statementId, + location = SourceRange( + start = SourcePosition(line = line, column = 0), + end = SourcePosition(line = line, column = 1), + ), + hits = 0, + ) + + private fun manifest(propertyId: PropertyId, module: String): PropertyManifest = PropertyManifest( + propertyId = propertyId.value, + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain())), + predicate = TypeScriptEntryPoint(module = module, exportName = "predicate"), + ) + + private fun coverageArtifact( + sourceRoot: Path, + sourcePath: Path, + propertyId: PropertyId, + statements: List, + ): PropertyCoverageArtifact = PropertyCoverageArtifact( + backendId = "fixture-backend", + backendVersion = "1.0", + propertyId = propertyId, + provenance = CoverageProvenance( + collector = CoverageCollectorIdentity(id = "fixture", version = "1.0"), + runtimeId = "node", + runtimeVersion = "22.0.0", + sourceRoots = listOf(sourceRoot.toString()), + request = PropertyCoverageRequest(), + ), + files = listOf( + SourceFileCoverage( + path = sourcePath.toString(), + statements = statements, + functions = emptyList(), + branches = emptyList(), + ), + ), + ) +} diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/JsConcreteValueTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/JsConcreteValueTest.kt index e80f23a99..d858c0a71 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/JsConcreteValueTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/JsConcreteValueTest.kt @@ -1,9 +1,11 @@ package org.usvm.ts.pbt.model +import kotlinx.serialization.SerializationException import kotlinx.serialization.encodeToString import org.junit.jupiter.api.Test import org.usvm.ts.pbt.manifest.PropertyManifestJson import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class JsConcreteValueTest { @Test @@ -53,4 +55,75 @@ class JsConcreteValueTest { assertEquals(value, PropertyManifestJson.json.decodeFromString(encoded)) } + + @Test + fun `string tags require string values`() { + assertFailsWith { + PropertyManifestJson.json.decodeFromString("""{"kind":"string","value":123}""") + } + } + + @Test + fun `boolean tags require Boolean values`() { + assertFailsWith { + PropertyManifestJson.json.decodeFromString("""{"kind":"boolean","value":"true"}""") + } + } + + @Test + fun `array tags require array elements`() { + assertFailsWith { + PropertyManifestJson.json.decodeFromString("""{"kind":"array","elements":"[]"}""") + } + } + + @Test + fun `number tags require string bits`() { + assertFailsWith { + PropertyManifestJson.json.decodeFromString( + """{"kind":"number","value":"finite","bits":4607182418800017408}""", + ) + } + } + + @Test + fun `undefined tags reject unexpected fields`() { + assertFailsWith { + PropertyManifestJson.json.decodeFromString("""{"kind":"undefined","value":null}""") + } + } + + @Test + fun `every tagged value kind rejects unexpected fields`() { + val cases = listOf( + """{"kind":"null","extra":null}""", + """{"kind":"boolean","value":true,"extra":null}""", + """{"kind":"string","value":"value","extra":null}""", + """{"kind":"array","elements":[],"extra":null}""", + """{"kind":"number","value":"finite","bits":"3ff0000000000000","extra":null}""", + """{"kind":"number","value":"nan","extra":null}""", + """{"kind":"number","value":"positive-infinity","extra":null}""", + """{"kind":"number","value":"negative-infinity","extra":null}""", + ) + + cases.forEach { encoded -> + assertFailsWith { + PropertyManifestJson.json.decodeFromString(encoded) + } + } + } + + @Test + fun `finite number tags reject non-finite bit patterns`() { + listOf( + "7ff0000000000000", + "7ff8000000000000", + ).forEach { bits -> + assertFailsWith { + PropertyManifestJson.json.decodeFromString( + """{"kind":"number","value":"finite","bits":"$bits"}""", + ) + } + } + } } diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/validation/PropertyValidationTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/validation/PropertyValidationTest.kt index dde7a5bcc..a847e1cdb 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/validation/PropertyValidationTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/validation/PropertyValidationTest.kt @@ -91,6 +91,25 @@ class PropertyValidationTest { ) } + @Test + fun `finite tags for infinity and NaN are invalid`() { + listOf( + "7ff0000000000000", + "7ff8000000000000", + ).forEach { bits -> + val definition = validDefinition( + ConstantDomain( + JsConcreteValue.Number(JsNumber(JsNumberKind.FINITE, bits = bits)), + ), + ) + + assertEquals( + listOf("js-number.encoding.invalid"), + validatePropertyDefinition(definition).diagnostics.map { it.code }, + ) + } + } + @Test fun `valid definition has no diagnostics`() { assertTrue(validatePropertyDefinition(validDefinition(IntegerDomain(-5, 5))).isValid) diff --git a/usvm-ts-pbt/src/test/resources/mapping/AmbiguousBranchMappingFixture.ts b/usvm-ts-pbt/src/test/resources/mapping/AmbiguousBranchMappingFixture.ts new file mode 100644 index 000000000..60f55eb03 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/AmbiguousBranchMappingFixture.ts @@ -0,0 +1,8 @@ +export function classifiesLargePositive(value: number): boolean { + if (value > 0) { + if (value > 10) { + return true; + } + } + return false; +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/BranchMappingFixture.ts b/usvm-ts-pbt/src/test/resources/mapping/BranchMappingFixture.ts new file mode 100644 index 000000000..f9f412ba0 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/BranchMappingFixture.ts @@ -0,0 +1,7 @@ +export function classifiesPositive(value: number): boolean { + if (value > 0) { + return true; + } else { + return false; + } +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/PropertyMappingFixture.ts b/usvm-ts-pbt/src/test/resources/mapping/PropertyMappingFixture.ts new file mode 100644 index 000000000..fc09a7ef2 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/PropertyMappingFixture.ts @@ -0,0 +1,8 @@ +const astralMarker = "😀"; +export function isPositive(value: number): boolean { + return value > 0; +} + +export function needsTwoInputs(left: number, right: number): boolean { + return left !== right; +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/PropertyPreconditionFixture.ts b/usvm-ts-pbt/src/test/resources/mapping/PropertyPreconditionFixture.ts new file mode 100644 index 000000000..843d4ce0d --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/PropertyPreconditionFixture.ts @@ -0,0 +1,3 @@ +export function isNonZero(value: number): boolean { + return value !== 0; +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/duplicate/PropertyMappingFixture.ts b/usvm-ts-pbt/src/test/resources/mapping/duplicate/PropertyMappingFixture.ts new file mode 100644 index 000000000..f337bbde6 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/duplicate/PropertyMappingFixture.ts @@ -0,0 +1,3 @@ +export function isPositive(value: number): boolean { + return value >= 0; +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/CallableLocalFixture.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/CallableLocalFixture.ts new file mode 100644 index 000000000..77976b594 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/CallableLocalFixture.ts @@ -0,0 +1,18 @@ +export const arrowPredicate = (value: number): boolean => value > 0; + +const functionPredicate = function (value: number): boolean { + return value !== 0; +}; +export { functionPredicate as aliasedPredicate }; + +export const nonCallable = 42; + +export let reassignedPredicate = (value: number): boolean => value > 0; +reassignedPredicate = (value: number): boolean => value < 0; + +export let callableThenValue: any = (value: number): boolean => value > 0; +callableThenValue = 42; + +let multiplyLinkedPredicate: (value: number) => boolean; +multiplyLinkedPredicate = multiplyLinkedPredicate = (value: number): boolean => value === 0; +export { multiplyLinkedPredicate as aliasedMultiplyLinkedPredicate }; diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/DefaultPredicate.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/DefaultPredicate.ts new file mode 100644 index 000000000..870f7517d --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/DefaultPredicate.ts @@ -0,0 +1,5 @@ +function defaultPredicate(value: number): boolean { + return value > 0; +} + +export { defaultPredicate as default }; diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/DiamondEntry.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/DiamondEntry.ts new file mode 100644 index 000000000..412c1af4d --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/DiamondEntry.ts @@ -0,0 +1,2 @@ +export * from './Left'; +export * from './Right'; diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/DirectExportFixture.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/DirectExportFixture.ts new file mode 100644 index 000000000..d08d5e373 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/DirectExportFixture.ts @@ -0,0 +1,9 @@ +export function predicate(value: number): boolean { + return value > 0; +} + +export class PredicateContainer { + predicate(left: number, right: number): boolean { + return left > right; + } +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/ExplicitPrecedenceEntry.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/ExplicitPrecedenceEntry.ts new file mode 100644 index 000000000..3ea4396ff --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/ExplicitPrecedenceEntry.ts @@ -0,0 +1,2 @@ +export { corePredicate as predicate } from './Predicate'; +export * from './StarPredicate'; diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/Left.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/Left.ts new file mode 100644 index 000000000..4562f7d5d --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/Left.ts @@ -0,0 +1 @@ +export { corePredicate as predicate } from './Predicate'; diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/NamedDefaultDeclaration.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/NamedDefaultDeclaration.ts new file mode 100644 index 000000000..fc48aaa72 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/NamedDefaultDeclaration.ts @@ -0,0 +1,3 @@ +export default function namedDefault(value: number): boolean { + return value > 0; +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/NamespaceEntry.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/NamespaceEntry.ts new file mode 100644 index 000000000..30cc86670 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/NamespaceEntry.ts @@ -0,0 +1 @@ +export * as api from './Predicate'; diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/Predicate.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/Predicate.ts new file mode 100644 index 000000000..b94fc03b6 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/Predicate.ts @@ -0,0 +1,3 @@ +export function corePredicate(value: number): boolean { + return value > 0; +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/Right.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/Right.ts new file mode 100644 index 000000000..4562f7d5d --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/Right.ts @@ -0,0 +1 @@ +export { corePredicate as predicate } from './Predicate'; diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/StarDefaultEntry.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/StarDefaultEntry.ts new file mode 100644 index 000000000..ecc97cec4 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/StarDefaultEntry.ts @@ -0,0 +1 @@ +export * from './DefaultPredicate'; diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/StarPredicate.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/StarPredicate.ts new file mode 100644 index 000000000..0d2a5f370 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/StarPredicate.ts @@ -0,0 +1,3 @@ +export function predicate(value: number): boolean { + return value >= 0; +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/TypeOnlyPrecedenceEntry.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/TypeOnlyPrecedenceEntry.ts new file mode 100644 index 000000000..390abba96 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/TypeOnlyPrecedenceEntry.ts @@ -0,0 +1,2 @@ +export type { predicate } from './TypeOnlyPredicate'; +export * from './StarPredicate'; diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/TypeOnlyPredicate.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/TypeOnlyPredicate.ts new file mode 100644 index 000000000..70861f657 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/TypeOnlyPredicate.ts @@ -0,0 +1,3 @@ +export function predicate(value: number): boolean { + return value < 0; +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/TypeOnlyStarEntry.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/TypeOnlyStarEntry.ts new file mode 100644 index 000000000..312d32a9d --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/TypeOnlyStarEntry.ts @@ -0,0 +1,2 @@ +export type * from './TypeOnlyPredicate'; +export * from './StarPredicate'; diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/ambiguous-reexport/Entry.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/ambiguous-reexport/Entry.ts new file mode 100644 index 000000000..98869708b --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/ambiguous-reexport/Entry.ts @@ -0,0 +1 @@ +export { predicate } from './Foo'; diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/ambiguous-reexport/Foo.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/ambiguous-reexport/Foo.ts new file mode 100644 index 000000000..81398a0c1 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/ambiguous-reexport/Foo.ts @@ -0,0 +1,3 @@ +export function predicate(value: number): boolean { + return value > 0; +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/ambiguous-reexport/Foo/index.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/ambiguous-reexport/Foo/index.ts new file mode 100644 index 000000000..242912fe6 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/ambiguous-reexport/Foo/index.ts @@ -0,0 +1 @@ +export const unrelated = 0; diff --git a/usvm-ts-pbt/src/test/resources/mapping/mismatched/PropertyMappingFixture.ts b/usvm-ts-pbt/src/test/resources/mapping/mismatched/PropertyMappingFixture.ts new file mode 100644 index 000000000..f6f20d269 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/mismatched/PropertyMappingFixture.ts @@ -0,0 +1,3 @@ +export function isPositive(left: number, right: number): boolean { + return left > 0 && right > 0; +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/reexports/Entry.ts b/usvm-ts-pbt/src/test/resources/mapping/reexports/Entry.ts new file mode 100644 index 000000000..4562f7d5d --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/reexports/Entry.ts @@ -0,0 +1 @@ +export { corePredicate as predicate } from './Predicate'; diff --git a/usvm-ts-pbt/src/test/resources/mapping/reexports/Predicate.ts b/usvm-ts-pbt/src/test/resources/mapping/reexports/Predicate.ts new file mode 100644 index 000000000..b94fc03b6 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/reexports/Predicate.ts @@ -0,0 +1,3 @@ +export function corePredicate(value: number): boolean { + return value > 0; +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/source-roots/a/Foo.ts b/usvm-ts-pbt/src/test/resources/mapping/source-roots/a/Foo.ts new file mode 100644 index 000000000..606a033c5 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/source-roots/a/Foo.ts @@ -0,0 +1,7 @@ +export function predicate(value: number): boolean { + if (value > 0) { + return true; + } else { + return false; + } +} diff --git a/usvm-ts-pbt/src/test/resources/mapping/source-roots/b/Foo.ts b/usvm-ts-pbt/src/test/resources/mapping/source-roots/b/Foo.ts new file mode 100644 index 000000000..242912fe6 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/source-roots/b/Foo.ts @@ -0,0 +1 @@ +export const unrelated = 0; diff --git a/usvm-ts-pbt/src/test/resources/properties/coverage/invalid-map-entry.js b/usvm-ts-pbt/src/test/resources/properties/coverage/invalid-map-entry.js new file mode 100644 index 000000000..03d16f8e0 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/properties/coverage/invalid-map-entry.js @@ -0,0 +1,4 @@ +export function invalidMapPredicate(value) { + return value > 0; +} +//# sourceMappingURL=invalid-map-entry.js.map diff --git a/usvm-ts-pbt/src/test/resources/properties/coverage/invalid-map-entry.js.map b/usvm-ts-pbt/src/test/resources/properties/coverage/invalid-map-entry.js.map new file mode 100644 index 000000000..89d4d2557 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/properties/coverage/invalid-map-entry.js.map @@ -0,0 +1 @@ +{not-json diff --git a/usvm-ts-pbt/src/test/resources/properties/coverage/missing-map-entry.js b/usvm-ts-pbt/src/test/resources/properties/coverage/missing-map-entry.js new file mode 100644 index 000000000..9aca88783 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/properties/coverage/missing-map-entry.js @@ -0,0 +1,4 @@ +export function missingMapPredicate(value) { + return value > 0; +} +//# sourceMappingURL=missing-map-entry.js.map