From 8284894aff7c2c0ee6c50ad2cd9c305a4caa8ba6 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 19 Sep 2026 13:43:19 +0300 Subject: [PATCH 01/13] [TS Calls] Add four-profile source replay experiment --- usvm-ts-pbt/build.gradle.kts | 10 + .../fast-check-adapter/package-lock.json | 7 +- usvm-ts-pbt/fast-check-adapter/package.json | 6 +- .../fast-check-adapter/src/entry-point.ts | 45 +- .../src/source-target-replay-cli.ts | 277 +++++++++++++ .../src/source-target-replay-worker.ts | 65 +++ .../test/source-target-replay-cli.test.ts | 138 +++++++ .../test/source-target-replay-fixture.ts | 13 + .../org/usvm/ts/pbt/calls/CallsExperiment.kt | 387 ++++++++++++++++++ .../usvm/ts/pbt/calls/CallsExperimentCli.kt | 57 +++ .../usvm/ts/pbt/calls/CallsSourceReplay.kt | 203 +++++++++ .../pbt/calls/CurrentTsCallsSymbolicEngine.kt | 211 ++++++++++ .../usvm/ts/pbt/fastcheck/FastCheckRuntime.kt | 3 + .../usvm/ts/pbt/calls/CallsExperimentTest.kt | 203 +++++++++ .../resources/calls/CallsExperimentFixture.ts | 9 + .../resources/calls/fixture-manifest.json | 59 +++ 16 files changed, 1679 insertions(+), 14 deletions(-) create mode 100644 usvm-ts-pbt/fast-check-adapter/src/source-target-replay-cli.ts create mode 100644 usvm-ts-pbt/fast-check-adapter/src/source-target-replay-worker.ts create mode 100644 usvm-ts-pbt/fast-check-adapter/test/source-target-replay-cli.test.ts create mode 100644 usvm-ts-pbt/fast-check-adapter/test/source-target-replay-fixture.ts create mode 100644 usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperiment.kt create mode 100644 usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperimentCli.kt create mode 100644 usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsSourceReplay.kt create mode 100644 usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt create mode 100644 usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt create mode 100644 usvm-ts-pbt/src/test/resources/calls/CallsExperimentFixture.ts create mode 100644 usvm-ts-pbt/src/test/resources/calls/fixture-manifest.json diff --git a/usvm-ts-pbt/build.gradle.kts b/usvm-ts-pbt/build.gradle.kts index afc82d9f47..b00baf9cbf 100644 --- a/usvm-ts-pbt/build.gradle.kts +++ b/usvm-ts-pbt/build.gradle.kts @@ -7,6 +7,7 @@ plugins { } dependencies { + implementation(project(":usvm-core")) implementation(project(":usvm-ts")) implementation(Libs.jacodb_ets) implementation(Libs.clikt) @@ -152,6 +153,15 @@ tasks.named("run") { systemProperty(fastCheckRuntimeProperty, fastCheckAdapterDir.asFile.absolutePath) } +val runCalls by tasks.registering(JavaExec::class) { + group = "application" + description = "Runs the frozen four-profile TypeScript Calls experiment." + mainClass.set("org.usvm.ts.pbt.calls.CallsExperimentCliKt") + classpath = sourceSets.main.get().runtimeClasspath + systemProperty(fastCheckRuntimeProperty, fastCheckAdapterDir.asFile.absolutePath) + dependsOn(buildFastCheckAdapter) +} + distributions { main { contents { diff --git a/usvm-ts-pbt/fast-check-adapter/package-lock.json b/usvm-ts-pbt/fast-check-adapter/package-lock.json index 594dfb5981..1154505ca5 100644 --- a/usvm-ts-pbt/fast-check-adapter/package-lock.json +++ b/usvm-ts-pbt/fast-check-adapter/package-lock.json @@ -10,11 +10,11 @@ "dependencies": { "c8": "10.1.3", "fast-check": "4.9.0", - "tsx": "4.23.12" + "tsx": "4.23.12", + "typescript": "5.9.2" }, "devDependencies": { - "@types/node": "18.19.130", - "typescript": "5.9.2" + "@types/node": "18.19.130" }, "engines": { "node": ">=18.18.0" @@ -1304,7 +1304,6 @@ "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/usvm-ts-pbt/fast-check-adapter/package.json b/usvm-ts-pbt/fast-check-adapter/package.json index fdac679842..660ad8fc80 100644 --- a/usvm-ts-pbt/fast-check-adapter/package.json +++ b/usvm-ts-pbt/fast-check-adapter/package.json @@ -9,16 +9,16 @@ "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/process-group-shutdown.test.js dist/test/process-supervisor.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 dist/test/source-target-replay-cli.test.js" }, "dependencies": { "c8": "10.1.3", "fast-check": "4.9.0", + "typescript": "5.9.2", "tsx": "4.23.12" }, "devDependencies": { - "@types/node": "18.19.130", - "typescript": "5.9.2" + "@types/node": "18.19.130" }, "overrides": { "c8": { diff --git a/usvm-ts-pbt/fast-check-adapter/src/entry-point.ts b/usvm-ts-pbt/fast-check-adapter/src/entry-point.ts index 3cf32a460f..62973ff086 100644 --- a/usvm-ts-pbt/fast-check-adapter/src/entry-point.ts +++ b/usvm-ts-pbt/fast-check-adapter/src/entry-point.ts @@ -19,6 +19,11 @@ export interface LoadedEntryPoint { invoke(args: JsConcreteValue[]): boolean | Promise; } +export interface LoadedCallable { + executionKind: ExecutionKind; + invoke(args: JsConcreteValue[]): unknown | Promise; +} + type EntryPointFunction = (...args: JsConcreteValue[]) => unknown; export async function loadEntryPoint( @@ -26,6 +31,20 @@ export async function loadEntryPoint( sourceRoots: string[], referencePath: string, ): Promise { + const callable = await loadCallable(reference, sourceRoots, referencePath); + + return { + executionKind: callable.executionKind, + invoke: buildBooleanInvocation(callable, referencePath), + }; +} + +/** Loads an original TypeScript export without imposing property-result semantics. */ +export async function loadCallable( + reference: TypeScriptEntryPointReference, + sourceRoots: string[], + referencePath: string, +): Promise { const modulePath = await resolveModule(reference.module, sourceRoots, referencePath); const moduleNamespace = await importTypeScriptModule(modulePath, referencePath); @@ -50,7 +69,7 @@ export async function loadEntryPoint( return { executionKind: reference.executionKind, - invoke: buildInvocation(entryPoint, reference.executionKind, referencePath), + invoke: buildRawInvocation(entryPoint, reference.executionKind, referencePath), }; } @@ -176,13 +195,13 @@ async function importTypeScriptModule( } } -function buildInvocation( +function buildRawInvocation( entryPoint: EntryPointFunction, executionKind: ExecutionKind, referencePath: string, -): (args: JsConcreteValue[]) => boolean | Promise { +): (args: JsConcreteValue[]) => unknown | Promise { if (executionKind === 'sync') { - return (args: JsConcreteValue[]): boolean => { + return (args: JsConcreteValue[]): unknown => { const result = entryPoint(...args); if (isThenable(result)) { @@ -194,11 +213,11 @@ function buildInvocation( ); } - return requireBoolean(result, referencePath); + return result; }; } - return async (args: JsConcreteValue[]): Promise => { + return async (args: JsConcreteValue[]): Promise => { const result = entryPoint(...args); if (!isThenable(result)) { @@ -209,10 +228,22 @@ function buildInvocation( ); } - return requireBoolean(await result, referencePath); + return await result; }; } +function buildBooleanInvocation( + callable: LoadedCallable, + referencePath: string, +): (args: JsConcreteValue[]) => boolean | Promise { + if (callable.executionKind === 'sync') { + return (args: JsConcreteValue[]): boolean => requireBoolean(callable.invoke(args), referencePath); + } + + return async (args: JsConcreteValue[]): Promise => + requireBoolean(await callable.invoke(args), referencePath); +} + function requireBoolean(result: unknown, referencePath: string): boolean { if (typeof result !== 'boolean') { throw protocolError( diff --git a/usvm-ts-pbt/fast-check-adapter/src/source-target-replay-cli.ts b/usvm-ts-pbt/fast-check-adapter/src/source-target-replay-cli.ts new file mode 100644 index 0000000000..7c12622b6a --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/src/source-target-replay-cli.ts @@ -0,0 +1,277 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import type { TypeScriptEntryPointReference } from './entry-point.js'; +import type { TaggedJsValue } from './js-value.js'; + +interface SourcePosition { + line: number; + column: number; +} + +interface ReplayRequest { + sourceRoots: string[]; + entryPoint: TypeScriptEntryPointReference; + inputs: TaggedJsValue[]; + target: { + sourcePath: string; + sourceSha256: string; + startOffset: number; + endOffset: number; + start: SourcePosition; + end: SourcePosition; + }; + timeoutMillis: number; +} + +interface WorkerResult { + invocation: 'returned' | 'threw'; + targetHit: boolean; + errorName?: string; + errorMessage?: string; +} + +interface ProcessResult { + exitCode: number | null; + timedOut: boolean; + stderr: string; +} + +interface ResolvedTarget { + sourceRootIndex: number; + sourceRoot: string; + sourcePath: string; + absolutePath: string; + source: string; +} + +async function main(): Promise { + const request = validateRequest(JSON.parse(await readStdin()) as unknown); + const workspace = await mkdtemp(path.join(tmpdir(), 'usvm-ts-calls-replay-')); + + try { + const target = await resolveTarget(request); + const actualHash = createHash('sha256').update(target.source, 'utf8').digest('hex'); + if (actualHash !== request.target.sourceSha256) { + writeResponse({ status: 'ok', replayStatus: 'unmapped', reason: 'source-hash-mismatch', invocation: null }); + return; + } + + const sourceFile = ts.createSourceFile( + target.absolutePath, + target.source, + ts.ScriptTarget.Latest, + true, + scriptKind(target.absolutePath), + ); + const matches = collectStatements(sourceFile).filter((statement) => + statement.getStart(sourceFile, false) === request.target.startOffset + && statement.getEnd() === request.target.endOffset); + if (matches.length !== 1) { + writeResponse({ + status: 'ok', + replayStatus: matches.length === 0 ? 'unmapped' : 'ambiguous', + reason: matches.length === 0 ? 'statement-range-unmapped' : 'statement-range-ambiguous', + invocation: null, + }); + return; + } + + const statement = matches[0] as ts.Statement; + const start = sourceFile.getLineAndCharacterOfPosition(statement.getStart(sourceFile, false)); + const end = sourceFile.getLineAndCharacterOfPosition(statement.getEnd()); + if (!samePosition(start, request.target.start) || !samePosition(end, request.target.end)) { + writeResponse({ status: 'ok', replayStatus: 'unmapped', reason: 'statement-coordinate-mismatch', invocation: null }); + return; + } + if (!ts.isBlock(statement.parent) && !ts.isSourceFile(statement.parent)) { + writeResponse({ status: 'ok', replayStatus: 'unsupported', reason: 'statement-parent-unsupported', invocation: null }); + return; + } + + const hitKey = `__usvm_source_target_${randomUUID().replaceAll('-', '_')}`; + const marker = `;(globalThis as Record)[${JSON.stringify(hitKey)}] = true;\n`; + const instrumented = target.source.slice(0, request.target.startOffset) + + marker + + target.source.slice(request.target.startOffset); + const overlayRoot = path.join(workspace, 'source-overlay'); + await createOverlay(target.sourceRoot, overlayRoot, target.sourcePath, instrumented); + const workerRequestPath = path.join(workspace, 'request.json'); + const workerResultPath = path.join(workspace, 'result.json'); + const workerPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'source-target-replay-worker.js'); + await requireFile(workerPath, 'source-target replay worker'); + const sourceRoots = request.sourceRoots.map((root, index) => index === target.sourceRootIndex ? overlayRoot : root); + await writeFile(workerRequestPath, JSON.stringify({ + sourceRoots, + entryPoint: request.entryPoint, + inputs: request.inputs, + hitKey, + resultPath: workerResultPath, + }), 'utf8'); + + const execution = await runProcess(process.execPath, [workerPath, workerRequestPath], request.timeoutMillis); + if (execution.timedOut) { + writeResponse({ status: 'ok', replayStatus: 'timeout', invocation: null }); + return; + } + if (execution.exitCode !== 0) { + writeResponse({ + status: 'error', replayStatus: 'tool-error', message: execution.stderr.trim() || `worker exited with code ${execution.exitCode}`, + }); + return; + } + + const worker = JSON.parse(await readFile(workerResultPath, 'utf8')) as WorkerResult; + writeResponse({ status: 'ok', replayStatus: worker.targetHit ? 'confirmed' : 'rejected', invocation: worker }); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +} + +function validateRequest(value: unknown): ReplayRequest { + if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error('Request must be an object'); + const request = value as Partial; + if (!Array.isArray(request.sourceRoots) || request.sourceRoots.length === 0) throw new Error('sourceRoots are required'); + if (request.entryPoint === undefined || !Array.isArray(request.inputs)) throw new Error('entryPoint and inputs are required'); + if (request.target === undefined || typeof request.target.sourcePath !== 'string') throw new Error('target is required'); + if (!Number.isInteger(request.timeoutMillis) || (request.timeoutMillis as number) <= 0) throw new Error('timeoutMillis must be positive'); + + return request as ReplayRequest; +} + +async function resolveTarget(request: ReplayRequest): Promise { + if (path.isAbsolute(request.target.sourcePath)) throw new Error('target.sourcePath must be relative'); + const matches: ResolvedTarget[] = []; + + for (const [sourceRootIndex, sourceRootValue] of request.sourceRoots.entries()) { + const sourceRoot = await realpath(sourceRootValue); + const candidate = path.resolve(sourceRoot, request.target.sourcePath); + const relative = path.relative(sourceRoot, candidate); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) continue; + + try { + const absolutePath = await realpath(candidate); + const canonicalRelative = path.relative(sourceRoot, absolutePath); + if (canonicalRelative === '..' || canonicalRelative.startsWith(`..${path.sep}`) || path.isAbsolute(canonicalRelative)) continue; + if ((await stat(absolutePath)).isFile()) { + matches.push({ + sourceRootIndex, + sourceRoot, + sourcePath: request.target.sourcePath, + absolutePath, + source: await readFile(absolutePath, 'utf8'), + }); + } + } catch (error: unknown) { + if (!(error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT')) throw error; + } + } + + if (matches.length !== 1) throw new Error(`Target source path resolved to ${matches.length} files`); + return matches[0] as ResolvedTarget; +} + +function collectStatements(sourceFile: ts.SourceFile): ts.Statement[] { + const statements: ts.Statement[] = []; + const visit = (node: ts.Node): void => { + if (ts.isStatement(node)) statements.push(node); + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return statements; +} + +function scriptKind(filePath: string): ts.ScriptKind { + return filePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS; +} + +function samePosition(actual: ts.LineAndCharacter, expected: SourcePosition): boolean { + return actual.line === expected.line && actual.character === expected.column; +} + +async function createOverlay( + sourceRoot: string, + overlayRoot: string, + relativeTarget: string, + instrumentedSource: string, +): Promise { + const segments = relativeTarget.split('/'); + if (segments.length === 0 || segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..')) { + throw new Error('target.sourcePath must be normalized POSIX relative path'); + } + + let sourceDirectory = sourceRoot; + let overlayDirectory = overlayRoot; + await mkdir(overlayDirectory, { recursive: true }); + for (const [index, segment] of segments.entries()) { + const last = index === segments.length - 1; + const entries = await readdir(sourceDirectory); + for (const entry of entries) { + if (entry === segment) continue; + const original = path.join(sourceDirectory, entry); + const kind = (await lstat(original)).isDirectory() ? 'dir' : 'file'; + await symlink(original, path.join(overlayDirectory, entry), kind); + } + if (last) { + await writeFile(path.join(overlayDirectory, segment), instrumentedSource, 'utf8'); + } else { + sourceDirectory = path.join(sourceDirectory, segment); + overlayDirectory = path.join(overlayDirectory, segment); + await mkdir(overlayDirectory); + } + } +} + +async function runProcess(executable: string, args: string[], timeoutMillis: number): Promise { + const detached = process.platform !== 'win32'; + const child = spawn(executable, args, { detached, stdio: ['ignore', 'ignore', 'pipe'] }); + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk: string) => { stderr += chunk; }); + + return await new Promise((resolve, reject) => { + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + terminate(child.pid, detached, 'SIGTERM'); + setTimeout(() => terminate(child.pid, detached, 'SIGKILL'), 250).unref(); + }, timeoutMillis); + child.once('error', reject); + child.once('close', (exitCode) => { + clearTimeout(timer); + resolve({ exitCode, timedOut, stderr }); + }); + }); +} + +function terminate(pid: number | undefined, detached: boolean, signal: NodeJS.Signals): void { + if (pid === undefined) return; + try { + process.kill(detached ? -pid : pid, signal); + } catch (error: unknown) { + if (!(error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ESRCH')) throw error; + } +} + +async function requireFile(filePath: string, description: string): Promise { + if (!(await stat(filePath)).isFile()) throw new Error(`Missing ${description}: ${filePath}`); +} + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString('utf8'); +} + +function writeResponse(response: unknown): void { + process.stdout.write(`${JSON.stringify(response)}\n`); +} + +main().catch((error: unknown) => { + writeResponse({ status: 'error', replayStatus: 'tool-error', message: error instanceof Error ? error.message : String(error) }); + process.exitCode = 1; +}); diff --git a/usvm-ts-pbt/fast-check-adapter/src/source-target-replay-worker.ts b/usvm-ts-pbt/fast-check-adapter/src/source-target-replay-worker.ts new file mode 100644 index 0000000000..af9ab067f8 --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/src/source-target-replay-worker.ts @@ -0,0 +1,65 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { loadCallable, type TypeScriptEntryPointReference } from './entry-point.js'; +import { decodeJsValue, type TaggedJsValue } from './js-value.js'; + +interface ReplayWorkerRequest { + sourceRoots: string[]; + entryPoint: TypeScriptEntryPointReference; + inputs: TaggedJsValue[]; + hitKey: string; + resultPath: string; +} + +interface ReplayWorkerResult { + invocation: 'returned' | 'threw'; + targetHit: boolean; + errorName?: string; + errorMessage?: string; +} + +async function main(): Promise { + const requestPath = process.argv[2]; + if (requestPath === undefined) throw new Error('Expected a replay request path'); + + const request = JSON.parse(await readFile(requestPath, 'utf8')) as ReplayWorkerRequest; + Object.defineProperty(globalThis, request.hitKey, { + configurable: true, + enumerable: false, + value: false, + writable: true, + }); + const callable = await loadCallable(request.entryPoint, request.sourceRoots, 'entryPoint'); + const inputs = request.inputs.map((value, index) => decodeJsValue(value, `inputs[${index}]`)); + let result: ReplayWorkerResult; + + try { + await callable.invoke(inputs); + result = { + invocation: 'returned', + targetHit: globalThis[request.hitKey as keyof typeof globalThis] === true, + }; + } catch (error: unknown) { + result = { + invocation: 'threw', + targetHit: globalThis[request.hitKey as keyof typeof globalThis] === true, + errorName: error instanceof Error ? error.name : typeof error, + errorMessage: error instanceof Error ? error.message : String(error), + }; + } + + await writeFile(request.resultPath, `${JSON.stringify(result)}\n`, 'utf8'); +} + +main().catch(async (error: unknown) => { + const fallbackPath = process.argv[3]; + if (fallbackPath !== undefined) { + await writeFile(fallbackPath, `${JSON.stringify({ + invocation: 'threw', + targetHit: false, + errorName: error instanceof Error ? error.name : typeof error, + errorMessage: error instanceof Error ? error.message : String(error), + })}\n`, 'utf8'); + } + + throw error; +}); diff --git a/usvm-ts-pbt/fast-check-adapter/test/source-target-replay-cli.test.ts b/usvm-ts-pbt/fast-check-adapter/test/source-target-replay-cli.test.ts new file mode 100644 index 0000000000..c64aa0ccad --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/test/source-target-replay-cli.test.ts @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +interface ReplayResponse { + status: 'ok' | 'error'; + replayStatus: string; + reason?: string; + invocation?: { + invocation: 'returned' | 'threw'; + targetHit: boolean; + } | null; +} + +interface StatementTarget { + sourcePath: string; + sourceSha256: string; + startOffset: number; + endOffset: number; + start: { line: number; column: number }; + end: { line: number; column: number }; +} + +const adapterRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const fixturePath = path.join(adapterRoot, 'test', 'source-target-replay-fixture.ts'); +const cliPath = path.join(adapterRoot, 'dist', 'src', 'source-target-replay-cli.js'); + +test('confirms only the exact source statement reached by original TypeScript', async () => { + const taken = await statementTarget('inlineChoose', 'return 1;'); + const untaken = await statementTarget('inlineChoose', 'return 0;'); + const request = baseRequest('inlineChoose'); + + const takenResponse = await replay({ ...request, target: taken }); + const untakenResponse = await replay({ ...request, target: untaken }); + + assert.equal(takenResponse.replayStatus, 'confirmed'); + assert.equal(takenResponse.invocation?.targetHit, true); + assert.equal(untakenResponse.replayStatus, 'rejected'); + assert.equal(untakenResponse.invocation?.targetHit, false); +}); + +test('retains target confirmation when the original TypeScript invocation throws', async () => { + const target = await statementTarget('throwsAtTarget', "throw new Error('expected');"); + + const response = await replay({ ...baseRequest('throwsAtTarget', []), target }); + + assert.equal(response.replayStatus, 'confirmed'); + assert.equal(response.invocation?.invocation, 'threw'); + assert.equal(response.invocation?.targetHit, true); +}); + +test('rejects stale source identity before executing', async () => { + const target = await statementTarget('choose', 'return 1;'); + + const response = await replay({ + ...baseRequest('choose'), + target: { ...target, sourceSha256: '0'.repeat(64) }, + }); + + assert.equal(response.replayStatus, 'unmapped'); + assert.equal(response.reason, 'source-hash-mismatch'); + assert.equal(response.invocation, null); +}); + +function baseRequest(exportName: string, inputs = [numberValue(1)]): Record { + return { + sourceRoots: [path.dirname(fixturePath)], + entryPoint: { + module: path.basename(fixturePath), + exportName, + executionKind: 'sync', + }, + inputs, + timeoutMillis: 10_000, + }; +} + +async function statementTarget(functionName: string, text: string): Promise { + const source = await readFile(fixturePath, 'utf8'); + const sourceFile = ts.createSourceFile(fixturePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const functions = sourceFile.statements.filter(ts.isFunctionDeclaration); + const declaration = functions.find((candidate) => candidate.name?.text === functionName); + assert.ok(declaration, `missing function ${functionName}`); + let match: ts.Statement | undefined; + const visit = (node: ts.Node): void => { + if (ts.isStatement(node) && node.getText(sourceFile) === text) match = node; + ts.forEachChild(node, visit); + }; + visit(declaration); + assert.ok(match, `missing statement ${text}`); + const startOffset = match.getStart(sourceFile, false); + const endOffset = match.getEnd(); + const start = sourceFile.getLineAndCharacterOfPosition(startOffset); + const end = sourceFile.getLineAndCharacterOfPosition(endOffset); + + return { + sourcePath: path.basename(fixturePath), + sourceSha256: createHash('sha256').update(source, 'utf8').digest('hex'), + startOffset, + endOffset, + start: { line: start.line, column: start.character }, + end: { line: end.line, column: end.character }, + }; +} + +function replay(request: Record): Promise { + const child = spawn(process.execPath, [cliPath], { stdio: ['pipe', 'pipe', 'pipe'] }); + child.stdin.end(JSON.stringify(request)); + + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { stdout += chunk; }); + child.stderr.on('data', (chunk: string) => { stderr += chunk; }); + child.once('error', reject); + child.once('close', (exitCode) => { + if (exitCode !== 0) { + reject(new Error(`replay CLI failed with ${exitCode}: ${stderr}\n${stdout}`)); + return; + } + resolve(JSON.parse(stdout) as ReplayResponse); + }); + }); +} + +function numberValue(value: number): Record { + const buffer = Buffer.allocUnsafe(8); + buffer.writeDoubleBE(value); + + return { kind: 'number', value: 'finite', bits: buffer.toString('hex') }; +} diff --git a/usvm-ts-pbt/fast-check-adapter/test/source-target-replay-fixture.ts b/usvm-ts-pbt/fast-check-adapter/test/source-target-replay-fixture.ts new file mode 100644 index 0000000000..4f8296c176 --- /dev/null +++ b/usvm-ts-pbt/fast-check-adapter/test/source-target-replay-fixture.ts @@ -0,0 +1,13 @@ +export function choose(value: number): number { + if (value > 0) { + return 1; + } + + return 0; +} + +export function inlineChoose(value: number): number { if (value > 0) { return 1; } return 0; } + +export function throwsAtTarget(): never { + throw new Error('expected'); +} diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperiment.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperiment.kt new file mode 100644 index 0000000000..2cd573c176 --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperiment.kt @@ -0,0 +1,387 @@ +package org.usvm.ts.pbt.calls + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.usvm.machine.call.TsResidualCallPolicy +import org.usvm.ts.pbt.model.JsConcreteValue +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 java.nio.file.StandardOpenOption +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +@Serializable +internal enum class CallsExperimentProfile( + val usesFrozenModels: Boolean, + val fallback: TsResidualCallPolicy, +) { + EMPTY_STOP(usesFrozenModels = false, fallback = TsResidualCallPolicy.STOP_PATH), + EMPTY_FRESH(usesFrozenModels = false, fallback = TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN), + FROZEN_STOP(usesFrozenModels = true, fallback = TsResidualCallPolicy.STOP_PATH), + FROZEN_FRESH(usesFrozenModels = true, fallback = TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN), +} + +@Serializable +internal data class CallsModelSetIdentity( + val ids: Set, + val catalogFingerprint: String, + val sourceHash: String, + val etsIrHash: String, + val toolRevision: String, +) + +@Serializable +internal data class CallsFunctionCase( + val functionId: String, + val module: String, + val entryPoint: TypeScriptEntryPoint, + val inputs: List, + val targets: List, +) + +@Serializable +internal data class CallsProjectCase( + val projectId: String, + val revision: String, + val sourceRoot: String, + val development: Boolean, + val functions: List, +) + +@Serializable +internal data class CallsExperimentManifest( + val schemaVersion: Int, + val experimentId: String, + val toolRevision: String, + val nativeFrontendRevision: String, + val solver: String, + val searchPolicy: String, + val modelSet: CallsModelSetIdentity, + val seeds: List, + val perTargetBudgetMillis: Long, + val projects: List, +) { + init { + require(schemaVersion == SCHEMA_VERSION) { "Unsupported calls experiment schema: $schemaVersion" } + require(experimentId.isNotBlank()) { "Experiment ID must not be blank" } + require(seeds.isNotEmpty() && seeds.distinct().size == seeds.size) { "Seeds must be non-empty and unique" } + require(perTargetBudgetMillis > 0) { "Per-target budget must be positive" } + require(projects.isNotEmpty()) { "At least one project is required" } + val functions = projects.flatMap(CallsProjectCase::functions) + require(functions.map(CallsFunctionCase::functionId).distinct().size == functions.size) { + "Function IDs must be unique" + } + val targets = functions.flatMap(CallsFunctionCase::targets) + require(targets.map(CallsSourceTarget::targetId).distinct().size == targets.size) { + "Target IDs must be unique" + } + } + + companion object { + const val SCHEMA_VERSION = 1 + } +} + +@Serializable +internal enum class CallsSymbolicStatus { + REACHED, + UNREACHED, + UNREPRESENTABLE, + UNSUPPORTED, + TIMEOUT, + TOOL_ERROR, + UNMAPPED, + AMBIGUOUS, +} + +internal data class CallsSymbolicSearchRequest( + val sourceRoot: Path, + val project: CallsProjectCase, + val function: CallsFunctionCase, + val target: CallsSourceTarget, + val profile: CallsExperimentProfile, + val frozenModelIds: Set, + val seed: Long, + val budget: Duration, +) + +internal data class CallsSymbolicSearchResult( + val status: CallsSymbolicStatus, + val inputs: List? = null, + val catalogFingerprint: String? = null, + val elapsedMillis: Long, + val diagnostic: String? = null, +) { + init { + require(status == CallsSymbolicStatus.REACHED || inputs == null) { + "Only a reached source target may carry extracted inputs" + } + } +} + +internal fun interface CallsSymbolicEngine { + fun search(request: CallsSymbolicSearchRequest): CallsSymbolicSearchResult +} + +@Serializable +internal sealed interface CallsRawRecord + +@Serializable +@SerialName("run-metadata") +internal data class CallsRunMetadata( + val experimentId: String, + val toolRevision: String, + val nativeFrontendRevision: String, + val modelSet: CallsModelSetIdentity, + val profiles: List, + val seeds: List, + val commonEligibleTargets: Int, +) : CallsRawRecord + +@Serializable +@SerialName("target-result") +internal data class CallsTargetResult( + val experimentId: String, + val projectId: String, + val revision: String, + val development: Boolean, + val functionId: String, + val targetId: String, + val siteId: String, + val profile: CallsExperimentProfile, + val seed: Long, + val symbolicStatus: CallsSymbolicStatus, + val solverReached: Boolean, + val inputExtracted: Boolean, + val replayStatus: CallsReplayStatus?, + val catalogFingerprint: String?, + val symbolicElapsedMillis: Long, + val diagnostic: String? = null, +) : CallsRawRecord + +@Serializable +internal data class CallsExperimentSummary( + val experimentId: String, + val commonEligibleTargets: Int, + val resultRows: Int, + val byProfile: Map, +) + +@Serializable +internal data class CallsProfileSummary( + val runs: Int, + val solverReached: Int, + val inputExtracted: Int, + val replayConfirmed: Int, + val replayRejected: Int, + val unsupported: Int, + val timeouts: Int, + val toolErrors: Int, +) + +internal object CallsExperimentJson { + val json = Json { + classDiscriminator = "kind" + encodeDefaults = true + explicitNulls = false + ignoreUnknownKeys = false + useAlternativeNames = false + prettyPrint = false + } + + fun decodeManifest(value: String): CallsExperimentManifest = json.decodeFromString(value) + + fun encodeManifest(manifest: CallsExperimentManifest): String = json.encodeToString(manifest) +} + +internal class CallsExperimentRunner( + private val symbolicEngine: CallsSymbolicEngine, + private val targetReplayer: CallsTargetReplayer, +) { + fun run( + manifest: CallsExperimentManifest, + manifestDirectory: Path, + rawOutput: Path, + ) { + Files.createDirectories(requireNotNull(rawOutput.parent) { "Raw output must have a parent directory" }) + Files.deleteIfExists(rawOutput) + val commonEligibleTargets = manifest.projects.sumOf { project -> + project.functions.sumOf { function -> function.targets.size } + } + val metadata = CallsRunMetadata( + experimentId = manifest.experimentId, + toolRevision = manifest.toolRevision, + nativeFrontendRevision = manifest.nativeFrontendRevision, + modelSet = manifest.modelSet, + profiles = CallsExperimentProfile.entries, + seeds = manifest.seeds, + commonEligibleTargets = commonEligibleTargets, + ) + + append(rawOutput, metadata) + + manifest.projects.forEach { project -> + runProject( + manifest = manifest, + manifestDirectory = manifestDirectory, + rawOutput = rawOutput, + project = project, + ) + } + } + + private fun runProject( + manifest: CallsExperimentManifest, + manifestDirectory: Path, + rawOutput: Path, + project: CallsProjectCase, + ) { + val sourceRoot = manifestDirectory.resolve(project.sourceRoot).normalize().toRealPath() + + project.functions.forEach { function -> + runFunction( + manifest = manifest, + rawOutput = rawOutput, + sourceRoot = sourceRoot, + project = project, + function = function, + ) + } + } + + private fun runFunction( + manifest: CallsExperimentManifest, + rawOutput: Path, + sourceRoot: Path, + project: CallsProjectCase, + function: CallsFunctionCase, + ) { + function.targets.forEach { target -> + manifest.seeds.forEach { seed -> + rotatedProfiles(seed).forEach { profile -> + val result = runTarget( + manifest = manifest, + sourceRoot = sourceRoot, + project = project, + function = function, + target = target, + seed = seed, + profile = profile, + ) + + append(rawOutput, result) + } + } + } + } + + private fun runTarget( + manifest: CallsExperimentManifest, + sourceRoot: Path, + project: CallsProjectCase, + function: CallsFunctionCase, + target: CallsSourceTarget, + seed: Long, + profile: CallsExperimentProfile, + ): CallsTargetResult { + val symbolic = symbolicEngine.search( + CallsSymbolicSearchRequest( + sourceRoot = sourceRoot, + project = project, + function = function, + target = target, + profile = profile, + frozenModelIds = manifest.modelSet.ids, + seed = seed, + budget = manifest.perTargetBudgetMillis.milliseconds, + ), + ) + val replay = symbolic.inputs?.let { inputs -> + targetReplayer.replay( + sourceRoots = listOf(sourceRoot), + entryPoint = function.entryPoint, + inputs = inputs, + target = target, + timeoutMillis = manifest.perTargetBudgetMillis, + ) + } + + return CallsTargetResult( + experimentId = manifest.experimentId, + projectId = project.projectId, + revision = project.revision, + development = project.development, + functionId = function.functionId, + targetId = target.targetId, + siteId = target.siteId, + profile = profile, + seed = seed, + symbolicStatus = symbolic.status, + solverReached = symbolic.status == CallsSymbolicStatus.REACHED, + inputExtracted = symbolic.inputs != null, + replayStatus = replay?.status, + catalogFingerprint = symbolic.catalogFingerprint, + symbolicElapsedMillis = symbolic.elapsedMillis, + diagnostic = replay?.message ?: replay?.reason ?: symbolic.diagnostic, + ) + } + + private fun rotatedProfiles(seed: Long): List { + val profiles = CallsExperimentProfile.entries + val offset = Math.floorMod(seed, profiles.size.toLong()).toInt() + + return profiles.drop(offset) + profiles.take(offset) + } + + private fun append(path: Path, record: CallsRawRecord) { + Files.writeString( + path, + CallsExperimentJson.json.encodeToString(record) + "\n", + StandardOpenOption.CREATE, + StandardOpenOption.APPEND, + ) + } +} + +internal object CallsExperimentAggregator { + fun summarize(rawInput: Path): CallsExperimentSummary { + val records = Files.readAllLines(rawInput).filter(String::isNotBlank).map { line -> + CallsExperimentJson.json.decodeFromString(line) + } + val metadata = records.filterIsInstance().single() + val results = records.filterIsInstance() + val byProfile = CallsExperimentProfile.entries.associateWith { profile -> + val rows = results.filter { result -> result.profile == profile } + CallsProfileSummary( + runs = rows.size, + solverReached = rows.count(CallsTargetResult::solverReached), + inputExtracted = rows.count(CallsTargetResult::inputExtracted), + replayConfirmed = rows.count { result -> result.replayStatus == CallsReplayStatus.CONFIRMED }, + replayRejected = rows.count { result -> result.replayStatus == CallsReplayStatus.REJECTED }, + unsupported = rows.count { result -> + result.symbolicStatus == CallsSymbolicStatus.UNSUPPORTED + }, + timeouts = rows.count { result -> + result.symbolicStatus == CallsSymbolicStatus.TIMEOUT || + result.replayStatus == CallsReplayStatus.TIMEOUT + }, + toolErrors = rows.count { result -> + result.symbolicStatus == CallsSymbolicStatus.TOOL_ERROR || + result.replayStatus == CallsReplayStatus.TOOL_ERROR + }, + ) + } + + return CallsExperimentSummary( + experimentId = metadata.experimentId, + commonEligibleTargets = metadata.commonEligibleTargets, + resultRows = results.size, + byProfile = byProfile, + ) + } +} diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperimentCli.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperimentCli.kt new file mode 100644 index 0000000000..53c426d84a --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperimentCli.kt @@ -0,0 +1,57 @@ +package org.usvm.ts.pbt.calls + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.nio.file.Files +import java.nio.file.Path + +fun main(args: Array) { + require(args.isNotEmpty()) { usage() } + + when (args.first()) { + "run" -> runExperiment(args.drop(1)) + "summarize" -> summarize(args.drop(1)) + else -> error(usage()) + } +} + +private fun runExperiment(args: List) { + require(args.size == 2) { usage() } + val manifestPath = Path.of(args[0]).toAbsolutePath().normalize() + val rawDirectory = Path.of(args[1]).toAbsolutePath().normalize() + val manifest = CallsExperimentJson.decodeManifest(Files.readString(manifestPath)) + Files.createDirectories(rawDirectory) + Files.writeString( + rawDirectory.resolve("manifest.json"), + CallsExperimentJson.encodeManifest(manifest) + "\n", + ) + + CallsExperimentRunner( + symbolicEngine = CurrentTsCallsSymbolicEngine(), + targetReplayer = OriginalTypeScriptTargetReplayer(), + ).run( + manifest = manifest, + manifestDirectory = requireNotNull(manifestPath.parent), + rawOutput = rawDirectory.resolve("results.jsonl"), + ) +} + +private fun summarize(args: List) { + require(args.size == 2) { usage() } + val rawInput = Path.of(args[0]).toAbsolutePath().normalize() + val output = Path.of(args[1]).toAbsolutePath().normalize() + val summary = CallsExperimentAggregator.summarize(rawInput) + val json = Json { + encodeDefaults = true + explicitNulls = false + prettyPrint = true + } + output.parent?.let(Files::createDirectories) + Files.writeString(output, json.encodeToString(summary) + "\n") +} + +private fun usage(): String = """ + Usage: + calls run + calls summarize +""".trimIndent() diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsSourceReplay.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsSourceReplay.kt new file mode 100644 index 0000000000..e9dae0e732 --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsSourceReplay.kt @@ -0,0 +1,203 @@ +package org.usvm.ts.pbt.calls + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import org.usvm.ts.pbt.fastcheck.FastCheckProcessTransport +import org.usvm.ts.pbt.fastcheck.FastCheckRuntime +import org.usvm.ts.pbt.fastcheck.FastCheckTransportException +import org.usvm.ts.pbt.manifest.PropertyManifestJson +import org.usvm.ts.pbt.model.ExecutionKind +import org.usvm.ts.pbt.model.JsConcreteValue +import org.usvm.ts.pbt.model.TypeScriptEntryPoint +import java.nio.file.Path + +@Serializable +internal enum class CallsReplayStatus { + @SerialName("confirmed") + CONFIRMED, + + @SerialName("rejected") + REJECTED, + + @SerialName("unmapped") + UNMAPPED, + + @SerialName("ambiguous") + AMBIGUOUS, + + @SerialName("unsupported") + UNSUPPORTED, + + @SerialName("timeout") + TIMEOUT, + + @SerialName("tool-error") + TOOL_ERROR, +} + +@Serializable +internal data class CallsSourcePosition( + val line: Int, + val column: Int, +) + +@Serializable +internal data class CallsSourceTarget( + val targetId: String, + val siteId: String, + val sourcePath: String, + val sourceSha256: String, + val startOffset: Int, + val endOffset: Int, + val start: CallsSourcePosition, + val end: CallsSourcePosition, +) + +@Serializable +internal data class CallsSourceReplayResult( + val status: CallsReplayStatus, + val invocation: CallsInvocationResult? = null, + val reason: String? = null, + val message: String? = null, +) + +@Serializable +internal data class CallsInvocationResult( + val invocation: String, + val targetHit: Boolean, + val errorName: String? = null, + val errorMessage: String? = null, +) + +@Serializable +private data class CallsSourceReplayRequest( + val sourceRoots: List, + val entryPoint: TypeScriptEntryPoint, + val inputs: List, + val target: CallsSourceTargetWire, + val timeoutMillis: Long, +) + +@Serializable +private data class CallsSourceTargetWire( + val sourcePath: String, + val sourceSha256: String, + val startOffset: Int, + val endOffset: Int, + val start: CallsSourcePosition, + val end: CallsSourcePosition, +) + +@Serializable +private data class CallsSourceReplayResponse( + val status: String, + val replayStatus: CallsReplayStatus, + val invocation: CallsInvocationResult? = null, + val reason: String? = null, + val message: String? = null, +) + +internal fun interface CallsTargetReplayer { + fun replay( + sourceRoots: List, + entryPoint: TypeScriptEntryPoint, + inputs: List, + target: CallsSourceTarget, + timeoutMillis: Long, + ): CallsSourceReplayResult +} + +internal class OriginalTypeScriptTargetReplayer( + private val nodeExecutable: String = "node", +) : CallsTargetReplayer { + private val transport = FastCheckProcessTransport( + nodeExecutable = nodeExecutable, + maxRequestBytes = MAX_REQUEST_BYTES, + maxStdoutBytes = MAX_STDOUT_BYTES, + maxStderrBytes = MAX_STDERR_BYTES, + shutdownGraceMillis = SHUTDOWN_GRACE_MILLIS, + ) + + override fun replay( + sourceRoots: List, + entryPoint: TypeScriptEntryPoint, + inputs: List, + target: CallsSourceTarget, + timeoutMillis: Long, + ): CallsSourceReplayResult { + require(entryPoint.executionKind == ExecutionKind.SYNC) { + "Source-target replay currently supports synchronous callables only" + } + val request = CallsSourceReplayRequest( + sourceRoots = sourceRoots.map { root -> root.toRealPath().toString() }, + entryPoint = entryPoint, + inputs = inputs, + target = CallsSourceTargetWire( + sourcePath = target.sourcePath, + sourceSha256 = target.sourceSha256, + startOffset = target.startOffset, + endOffset = target.endOffset, + start = target.start, + end = target.end, + ), + timeoutMillis = timeoutMillis, + ) + val encoded = PropertyManifestJson.json.encodeToString(request) + val replayEntryPoint = FastCheckRuntime.sourceTargetReplayEntryPoint().toString() + + val output = try { + transport.invoke( + command = listOf(nodeExecutable, replayEntryPoint), + request = encoded, + timeoutMillis = timeoutMillis + TRANSPORT_GRACE_MILLIS, + reportedTimeoutMillis = timeoutMillis, + description = "original TypeScript source-target replay", + ) + } catch (error: FastCheckTransportException) { + val status = if (error.code.endsWith("timeout")) { + CallsReplayStatus.TIMEOUT + } else { + CallsReplayStatus.TOOL_ERROR + } + + return CallsSourceReplayResult( + status = status, + message = error.message, + ) + } + + val stdout = output.stdout + val response = runCatching { + PropertyManifestJson.json.decodeFromString(stdout) + }.getOrElse { error -> + return CallsSourceReplayResult( + status = CallsReplayStatus.TOOL_ERROR, + message = "Invalid source-target replay response: ${error.message}", + ) + } + if (output.exitCode != 0 || response.status != "ok") { + return CallsSourceReplayResult( + status = CallsReplayStatus.TOOL_ERROR, + reason = response.reason, + message = response.message ?: output.stderr.trim().ifEmpty { "Source-target replay failed" }, + ) + } + + return CallsSourceReplayResult( + status = response.replayStatus, + invocation = response.invocation, + reason = response.reason, + message = response.message, + ) + } + + private companion object { + const val MAX_REQUEST_BYTES = 4 * 1024 * 1024 + const val MAX_STDOUT_BYTES = 256 * 1024 + const val MAX_STDERR_BYTES = 64 * 1024 + const val SHUTDOWN_GRACE_MILLIS = 250L + const val TRANSPORT_GRACE_MILLIS = 2_000L + } +} diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt new file mode 100644 index 0000000000..28b51c7879 --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt @@ -0,0 +1,211 @@ +package org.usvm.ts.pbt.calls + +import io.ksmt.utils.asExpr +import org.jacodb.ets.model.EtsBooleanType +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.model.EtsStmt +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.usvm.PathSelectionStrategy +import org.usvm.SolverType +import org.usvm.StateCollectionStrategy +import org.usvm.UMachineOptions +import org.usvm.api.targets.ReachabilityObserver +import org.usvm.api.targets.TsReachabilityTarget +import org.usvm.machine.TsMachine +import org.usvm.machine.TsOptions +import org.usvm.machine.call.TsUnknownCallModelSelection +import org.usvm.machine.expr.extractDouble +import org.usvm.machine.expr.toConcreteBoolValue +import org.usvm.machine.state.TsState +import org.usvm.ts.pbt.manifest.PropertyManifest +import org.usvm.ts.pbt.mapping.EtsMappingStatus +import org.usvm.ts.pbt.mapping.PropertyEtsMapper +import org.usvm.ts.pbt.model.BooleanDomain +import org.usvm.ts.pbt.model.JsConcreteValue +import org.usvm.ts.pbt.model.NumberDomain +import org.usvm.ts.pbt.model.contains +import org.usvm.util.mkRegisterStackLValue +import kotlin.time.TimeSource + +internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { + override fun search(request: CallsSymbolicSearchRequest): CallsSymbolicSearchResult { + val startedAt = TimeSource.Monotonic.markNow() + val unsupportedInput = request.function.inputs.firstOrNull { input -> + input.domain != BooleanDomain && input.domain !is NumberDomain + } + if (unsupportedInput != null) { + return result( + status = CallsSymbolicStatus.UNSUPPORTED, + startedAt = startedAt, + diagnostic = "Only boolean and number input domains are supported; found ${unsupportedInput.domain}", + ) + } + + return runCatching { + searchSupported(request = request, startedAt = startedAt) + }.getOrElse { error -> + result( + status = CallsSymbolicStatus.TOOL_ERROR, + startedAt = startedAt, + diagnostic = error.message ?: error::class.java.name, + ) + } + } + + private fun searchSupported( + request: CallsSymbolicSearchRequest, + startedAt: TimeSource.Monotonic.ValueTimeMark, + ): CallsSymbolicSearchResult { + val source = request.sourceRoot.resolve(request.function.module).normalize() + val sourceFile = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val scene = EtsScene(projectFiles = listOf(sourceFile)) + val propertyManifest = PropertyManifest( + propertyId = "calls.mapping", + inputs = request.function.inputs, + predicate = request.function.entryPoint, + ) + val mapping = PropertyEtsMapper(scene = scene, sourceRoots = listOf(request.sourceRoot)).map(propertyManifest) + if (mapping.predicate.status != EtsMappingStatus.EXACT) { + return result( + status = mapping.predicate.status.toSymbolicStatus(), + startedAt = startedAt, + diagnostic = mapping.predicate.diagnostics.joinToString { diagnostic -> diagnostic.message }, + ) + } + + val method = mapping.predicate.targets.single().method + val targetCandidates = exactTargetCandidates(method, request.target) + if (targetCandidates.isEmpty()) { + return result( + status = CallsSymbolicStatus.UNMAPPED, + startedAt = startedAt, + diagnostic = "No EtsIR statement has the exact frozen source range", + ) + } + + val modelSelection = if (request.profile.usesFrozenModels) { + TsUnknownCallModelSelection.Only(request.frozenModelIds) + } else { + TsUnknownCallModelSelection.Only(emptySet()) + } + val machineOptions = UMachineOptions( + pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), + stateCollectionStrategy = StateCollectionStrategy.REACHED_TARGET, + randomSeed = request.seed, + timeout = request.budget, + solverType = SolverType.Z3, + stopOnTargetsReached = false, + ) + val tsOptions = TsOptions( + unknownCallModelSelection = modelSelection, + unknownCallFallback = request.profile.fallback, + ) + // One source statement may lower to consecutive EtsIR instructions with the same exact source span. + // Reaching the first instruction is the stable entry point for that statement. + val target = TsReachabilityTarget.FinalPoint(targetCandidates.first()) + val analysis = TsMachine( + scene = scene, + options = machineOptions, + tsOptions = tsOptions, + machineObserver = ReachabilityObserver(), + ).use { machine -> + machine.analyze(methods = listOf(method), targets = listOf(target)) to + machine.unknownCallModelCatalogFingerprint + } + val states = analysis.first + val fingerprint = analysis.second + if (states.isEmpty()) { + val status = if (startedAt.elapsedNow() >= request.budget) { + CallsSymbolicStatus.TIMEOUT + } else { + CallsSymbolicStatus.UNREACHED + } + + return result( + status = status, + startedAt = startedAt, + catalogFingerprint = fingerprint, + ) + } + + val inputs = states.asSequence() + .map { state -> resolveScalarInputs(state, method) } + .firstOrNull { candidate -> + candidate.zip(request.function.inputs).all { (value, input) -> value in input.domain } + } + if (inputs == null) { + return result( + status = CallsSymbolicStatus.UNREPRESENTABLE, + startedAt = startedAt, + catalogFingerprint = fingerprint, + diagnostic = "No reached state has inputs inside every frozen domain", + ) + } + + return result( + status = CallsSymbolicStatus.REACHED, + inputs = inputs, + startedAt = startedAt, + catalogFingerprint = fingerprint, + diagnostic = "exact-source-lowering-size=${targetCandidates.size}", + ) + } + + private fun exactTargetCandidates(method: EtsMethod, target: CallsSourceTarget): List = + method.cfg.stmts.filter { statement -> + val origin = statement.location.origin ?: return@filter false + origin.startOffset == target.startOffset && + origin.endOffset == target.endOffset && + origin.startLine == target.start.line && + origin.startColumn == target.start.column && + origin.endLine == target.end.line && + origin.endColumn == target.end.column + } + + private fun resolveScalarInputs(state: TsState, method: EtsMethod): List = with(state.ctx) { + val model = state.models.single() + + method.parameters.mapIndexed { index, parameter -> + val stackIndex = index + 1 + when (parameter.type) { + EtsNumberType -> { + val lValue = mkRegisterStackLValue(fp64Sort, stackIndex) + val value = model.eval(state.memory.read(lValue).asExpr(fp64Sort)).extractDouble() + JsConcreteValue.number(value) + } + + EtsBooleanType -> { + val lValue = mkRegisterStackLValue(boolSort, stackIndex) + val value = model.eval(state.memory.read(lValue).asExpr(boolSort)).toConcreteBoolValue() + JsConcreteValue.Boolean(value) + } + + else -> error("Unsupported scalar parameter type: ${parameter.type}") + } + } + } + + private fun result( + status: CallsSymbolicStatus, + startedAt: TimeSource.Monotonic.ValueTimeMark, + inputs: List? = null, + catalogFingerprint: String? = null, + diagnostic: String? = null, + ) = CallsSymbolicSearchResult( + status = status, + inputs = inputs, + catalogFingerprint = catalogFingerprint, + elapsedMillis = startedAt.elapsedNow().inWholeMilliseconds, + diagnostic = diagnostic, + ) +} + +private fun EtsMappingStatus.toSymbolicStatus(): CallsSymbolicStatus = when (this) { + EtsMappingStatus.EXACT -> error("Exact mapping has no failure status") + EtsMappingStatus.AMBIGUOUS -> CallsSymbolicStatus.AMBIGUOUS + EtsMappingStatus.UNMAPPED -> CallsSymbolicStatus.UNMAPPED + EtsMappingStatus.UNSUPPORTED -> CallsSymbolicStatus.UNSUPPORTED +} 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 860a04f579..8ff105505f 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 @@ -12,6 +12,8 @@ internal object FastCheckRuntime { fun processSupervisorEntryPoint(): Path = locateEntryPoint(PROCESS_SUPERVISOR) + fun sourceTargetReplayEntryPoint(): Path = locateEntryPoint(SOURCE_TARGET_REPLAY_CLI) + private fun locateEntryPoint(fileName: String): Path { val candidates = runtimeDirectories().map { runtimeDirectory -> runtimeDirectory.resolve(ENTRY_POINT_DIRECTORY).resolve(fileName) @@ -49,5 +51,6 @@ internal object FastCheckRuntime { 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 SOURCE_TARGET_REPLAY_CLI = "source-target-replay-cli.js" private const val INSTALLED_RUNTIME_DIRECTORY = "fast-check-adapter" } diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt new file mode 100644 index 0000000000..3b19394b6b --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt @@ -0,0 +1,203 @@ +package org.usvm.ts.pbt.calls + +import kotlinx.serialization.decodeFromString +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.usvm.ts.pbt.model.BooleanDomain +import org.usvm.ts.pbt.model.JsConcreteValue +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.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CallsExperimentTest { + @Test + fun `runner rotates profiles and records symbolic and replay outcomes separately`(@TempDir directory: Path) { + val requests = mutableListOf() + val engine = CallsSymbolicEngine { request -> + requests += request + + when (request.profile) { + CallsExperimentProfile.EMPTY_STOP -> result(status = CallsSymbolicStatus.UNREACHED) + CallsExperimentProfile.EMPTY_FRESH -> result( + status = CallsSymbolicStatus.REACHED, + inputs = listOf(JsConcreteValue.Boolean(false)), + ) + + CallsExperimentProfile.FROZEN_STOP -> result( + status = CallsSymbolicStatus.REACHED, + inputs = listOf(JsConcreteValue.Boolean(true)), + ) + + CallsExperimentProfile.FROZEN_FRESH -> result(status = CallsSymbolicStatus.UNSUPPORTED) + } + } + val replayer = CallsTargetReplayer { _, _, inputs, _, _ -> + val input = inputs.single() as JsConcreteValue.Boolean + + CallsSourceReplayResult( + status = if (input.value) CallsReplayStatus.CONFIRMED else CallsReplayStatus.REJECTED, + ) + } + val rawOutput = directory.resolve("raw/results.jsonl") + + CallsExperimentRunner(symbolicEngine = engine, targetReplayer = replayer).run( + manifest = manifest(sourceRoot = ".", seeds = listOf(1L)), + manifestDirectory = directory, + rawOutput = rawOutput, + ) + + val records = readRecords(rawOutput) + val metadata = records.filterIsInstance().single() + val results = records.filterIsInstance() + + assertEquals(1, metadata.commonEligibleTargets) + assertEquals( + listOf( + CallsExperimentProfile.EMPTY_FRESH, + CallsExperimentProfile.FROZEN_STOP, + CallsExperimentProfile.FROZEN_FRESH, + CallsExperimentProfile.EMPTY_STOP, + ), + requests.map(CallsSymbolicSearchRequest::profile), + ) + assertEquals(requests.map(CallsSymbolicSearchRequest::profile), results.map(CallsTargetResult::profile)) + + val emptyFresh = results.single { result -> result.profile == CallsExperimentProfile.EMPTY_FRESH } + assertTrue(emptyFresh.solverReached) + assertTrue(emptyFresh.inputExtracted) + assertEquals(CallsReplayStatus.REJECTED, emptyFresh.replayStatus) + + val frozenStop = results.single { result -> result.profile == CallsExperimentProfile.FROZEN_STOP } + assertTrue(frozenStop.solverReached) + assertTrue(frozenStop.inputExtracted) + assertEquals(CallsReplayStatus.CONFIRMED, frozenStop.replayStatus) + + val emptyStop = results.single { result -> result.profile == CallsExperimentProfile.EMPTY_STOP } + assertFalse(emptyStop.solverReached) + assertFalse(emptyStop.inputExtracted) + assertNull(emptyStop.replayStatus) + } + + @Test + fun `aggregator counts each profile from raw rows without collapsing outcome stages`(@TempDir directory: Path) { + val rawOutput = directory.resolve("results.jsonl") + val statuses = mapOf( + CallsExperimentProfile.EMPTY_STOP to CallsSymbolicStatus.TIMEOUT, + CallsExperimentProfile.EMPTY_FRESH to CallsSymbolicStatus.REACHED, + CallsExperimentProfile.FROZEN_STOP to CallsSymbolicStatus.REACHED, + CallsExperimentProfile.FROZEN_FRESH to CallsSymbolicStatus.TOOL_ERROR, + ) + val engine = CallsSymbolicEngine { request -> + val status = statuses.getValue(request.profile) + result( + status = status, + inputs = if (status == CallsSymbolicStatus.REACHED) { + listOf(JsConcreteValue.Boolean(request.profile.usesFrozenModels)) + } else { + null + }, + ) + } + val replayer = CallsTargetReplayer { _, _, inputs, _, _ -> + val input = inputs.single() as JsConcreteValue.Boolean + CallsSourceReplayResult( + status = if (input.value) CallsReplayStatus.CONFIRMED else CallsReplayStatus.REJECTED, + ) + } + + CallsExperimentRunner(symbolicEngine = engine, targetReplayer = replayer).run( + manifest = manifest(sourceRoot = ".", seeds = listOf(0L, 3L)), + manifestDirectory = directory, + rawOutput = rawOutput, + ) + val summary = CallsExperimentAggregator.summarize(rawOutput) + + assertEquals("fixture", summary.experimentId) + assertEquals(1, summary.commonEligibleTargets) + assertEquals(8, summary.resultRows) + assertEquals( + CallsProfileSummary( + runs = 2, + solverReached = 0, + inputExtracted = 0, + replayConfirmed = 0, + replayRejected = 0, + unsupported = 0, + timeouts = 2, + toolErrors = 0, + ), + summary.byProfile.getValue(CallsExperimentProfile.EMPTY_STOP), + ) + assertEquals(2, summary.byProfile.getValue(CallsExperimentProfile.EMPTY_FRESH).replayRejected) + assertEquals(2, summary.byProfile.getValue(CallsExperimentProfile.FROZEN_STOP).replayConfirmed) + assertEquals(2, summary.byProfile.getValue(CallsExperimentProfile.FROZEN_FRESH).toolErrors) + } + + private fun manifest(sourceRoot: String, seeds: List) = CallsExperimentManifest( + schemaVersion = CallsExperimentManifest.SCHEMA_VERSION, + experimentId = "fixture", + toolRevision = "tool-revision", + nativeFrontendRevision = "frontend-revision", + solver = "Z3", + searchPolicy = "BFS", + modelSet = CallsModelSetIdentity( + ids = setOf("ts.array.pop", "ts.array.shift"), + catalogFingerprint = "frozen-fingerprint", + sourceHash = "source-hash", + etsIrHash = "ets-ir-hash", + toolRevision = "tool-revision", + ), + seeds = seeds, + perTargetBudgetMillis = 1_000L, + projects = listOf( + CallsProjectCase( + projectId = "fixture/project", + revision = "project-revision", + sourceRoot = sourceRoot, + development = true, + functions = listOf( + CallsFunctionCase( + functionId = "fixture.ts::predicate/1", + module = "fixture.ts", + entryPoint = TypeScriptEntryPoint( + module = "fixture.ts", + exportName = "predicate", + ), + inputs = listOf(PropertyInput(name = "value", domain = BooleanDomain)), + targets = listOf( + CallsSourceTarget( + targetId = "fixture.ts::predicate/1#return", + siteId = "fixture.ts:1:1-1:12::predicate/1", + sourcePath = "fixture.ts", + sourceSha256 = "source-hash", + startOffset = 0, + endOffset = 11, + start = CallsSourcePosition(line = 0, column = 0), + end = CallsSourcePosition(line = 0, column = 11), + ), + ), + ), + ), + ), + ), + ) + + private fun result( + status: CallsSymbolicStatus, + inputs: List? = null, + ) = CallsSymbolicSearchResult( + status = status, + inputs = inputs, + catalogFingerprint = "runtime-fingerprint", + elapsedMillis = 7L, + ) + + private fun readRecords(path: Path): List = Files.readAllLines(path) + .filter(String::isNotBlank) + .map { line -> CallsExperimentJson.json.decodeFromString(line) } +} diff --git a/usvm-ts-pbt/src/test/resources/calls/CallsExperimentFixture.ts b/usvm-ts-pbt/src/test/resources/calls/CallsExperimentFixture.ts new file mode 100644 index 0000000000..07e315aa0c --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/calls/CallsExperimentFixture.ts @@ -0,0 +1,9 @@ +export function shiftTarget(value: number): number { + const values = [value]; + const removed = values.shift(); + if (removed === 42) { + return 1; + } + + return 0; +} diff --git a/usvm-ts-pbt/src/test/resources/calls/fixture-manifest.json b/usvm-ts-pbt/src/test/resources/calls/fixture-manifest.json new file mode 100644 index 0000000000..9bdf377ca5 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/calls/fixture-manifest.json @@ -0,0 +1,59 @@ +{ + "schemaVersion": 1, + "experimentId": "calls-fixture-shift", + "toolRevision": "41961f7b66c30c8a2a7507c67a79396f495f4520", + "nativeFrontendRevision": "local-fixture", + "solver": "Z3", + "searchPolicy": "BFS", + "modelSet": { + "ids": ["ts.array.pop", "ts.array.shift"], + "catalogFingerprint": "09687a921363212c7736237f19e3978665655a45e0165da4f24f689c48439e31", + "sourceHash": "9f40d3abce58e3412a0206eabd9fdb0547e12c2ebd832ce48b260b3339518e26", + "etsIrHash": "0000000000000000000000000000000000000000000000000000000000000000", + "toolRevision": "41961f7b66c30c8a2a7507c67a79396f495f4520" + }, + "seeds": [42], + "perTargetBudgetMillis": 5000, + "projects": [ + { + "projectId": "synthetic/calls-fixture", + "revision": "94edca29194e2cf4cf1c6a1cbb9848c8bda014a21c1422c12f039fcd6b95440b", + "sourceRoot": ".", + "development": true, + "functions": [ + { + "functionId": "CallsExperimentFixture.ts::shiftTarget/1", + "module": "CallsExperimentFixture.ts", + "entryPoint": { + "module": "CallsExperimentFixture.ts", + "exportName": "shiftTarget", + "executionKind": "sync" + }, + "inputs": [ + { + "name": "value", + "domain": { + "kind": "number", + "min": {"value": "negative-infinity"}, + "max": {"value": "positive-infinity"}, + "allowNaN": true + } + } + ], + "targets": [ + { + "targetId": "CallsExperimentFixture.ts::shiftTarget/1#return-one", + "siteId": "CallsExperimentFixture.ts:5:5-5:14::shiftTarget/1", + "sourcePath": "CallsExperimentFixture.ts", + "sourceSha256": "94edca29194e2cf4cf1c6a1cbb9848c8bda014a21c1422c12f039fcd6b95440b", + "startOffset": 141, + "endOffset": 150, + "start": {"line": 4, "column": 4}, + "end": {"line": 4, "column": 13} + } + ] + } + ] + } + ] +} From e521df2664dce9e55f18b3fb2c11d481dae0b42e Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 19 Sep 2026 13:53:42 +0300 Subject: [PATCH 02/13] [TS Calls] Validate frozen experiment identities --- .../org/usvm/ts/pbt/calls/CallsExperiment.kt | 26 ++++++++++++++++++- .../pbt/calls/CurrentTsCallsSymbolicEngine.kt | 10 ++++++- .../usvm/ts/pbt/calls/CallsExperimentTest.kt | 2 +- .../resources/calls/fixture-manifest.json | 2 +- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperiment.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperiment.kt index 2cd573c176..c91d6ac9e8 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperiment.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperiment.kt @@ -38,7 +38,7 @@ internal data class CallsModelSetIdentity( @Serializable internal data class CallsFunctionCase( val functionId: String, - val module: String, + val sourceFile: String, val entryPoint: TypeScriptEntryPoint, val inputs: List, val targets: List, @@ -72,14 +72,27 @@ internal data class CallsExperimentManifest( require(seeds.isNotEmpty() && seeds.distinct().size == seeds.size) { "Seeds must be non-empty and unique" } require(perTargetBudgetMillis > 0) { "Per-target budget must be positive" } require(projects.isNotEmpty()) { "At least one project is required" } + require(solver == "Z3") { "The frozen calls experiment requires the Z3 solver" } + require(searchPolicy == "BFS") { "The frozen calls experiment requires BFS search" } + require(toolRevision == modelSet.toolRevision) { "Tool and model-set revisions must match" } val functions = projects.flatMap(CallsProjectCase::functions) require(functions.map(CallsFunctionCase::functionId).distinct().size == functions.size) { "Function IDs must be unique" } + require(functions.all { function -> function.sourceFile == function.entryPoint.module }) { + "Function source files must match their replay entry-point modules" + } val targets = functions.flatMap(CallsFunctionCase::targets) require(targets.map(CallsSourceTarget::targetId).distinct().size == targets.size) { "Target IDs must be unique" } + require( + functions.all { function -> + function.targets.all { target -> target.sourcePath == function.sourceFile } + }, + ) { + "Every target must belong to its function source file" + } } companion object { @@ -106,6 +119,7 @@ internal data class CallsSymbolicSearchRequest( val target: CallsSourceTarget, val profile: CallsExperimentProfile, val frozenModelIds: Set, + val expectedCatalogFingerprint: String, val seed: Long, val budget: Duration, ) @@ -297,6 +311,11 @@ internal class CallsExperimentRunner( target = target, profile = profile, frozenModelIds = manifest.modelSet.ids, + expectedCatalogFingerprint = if (profile.usesFrozenModels) { + manifest.modelSet.catalogFingerprint + } else { + EMPTY_CATALOG_FINGERPRINT + }, seed = seed, budget = manifest.perTargetBudgetMillis.milliseconds, ), @@ -346,6 +365,11 @@ internal class CallsExperimentRunner( StandardOpenOption.APPEND, ) } + + private companion object { + const val EMPTY_CATALOG_FINGERPRINT = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } } internal object CallsExperimentAggregator { diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt index 28b51c7879..16c7d9dd3c 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt @@ -59,7 +59,7 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { request: CallsSymbolicSearchRequest, startedAt: TimeSource.Monotonic.ValueTimeMark, ): CallsSymbolicSearchResult { - val source = request.sourceRoot.resolve(request.function.module).normalize() + val source = request.sourceRoot.resolve(request.function.sourceFile).normalize() val sourceFile = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) val scene = EtsScene(projectFiles = listOf(sourceFile)) val propertyManifest = PropertyManifest( @@ -117,6 +117,14 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { } val states = analysis.first val fingerprint = analysis.second + if (fingerprint != request.expectedCatalogFingerprint) { + return result( + status = CallsSymbolicStatus.TOOL_ERROR, + startedAt = startedAt, + catalogFingerprint = fingerprint, + diagnostic = "Runtime model fingerprint $fingerprint does not match the frozen manifest", + ) + } if (states.isEmpty()) { val status = if (startedAt.elapsedNow() >= request.budget) { CallsSymbolicStatus.TIMEOUT diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt index 3b19394b6b..cc8931e761 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt @@ -163,7 +163,7 @@ class CallsExperimentTest { functions = listOf( CallsFunctionCase( functionId = "fixture.ts::predicate/1", - module = "fixture.ts", + sourceFile = "fixture.ts", entryPoint = TypeScriptEntryPoint( module = "fixture.ts", exportName = "predicate", diff --git a/usvm-ts-pbt/src/test/resources/calls/fixture-manifest.json b/usvm-ts-pbt/src/test/resources/calls/fixture-manifest.json index 9bdf377ca5..a68d817d23 100644 --- a/usvm-ts-pbt/src/test/resources/calls/fixture-manifest.json +++ b/usvm-ts-pbt/src/test/resources/calls/fixture-manifest.json @@ -23,7 +23,7 @@ "functions": [ { "functionId": "CallsExperimentFixture.ts::shiftTarget/1", - "module": "CallsExperimentFixture.ts", + "sourceFile": "CallsExperimentFixture.ts", "entryPoint": { "module": "CallsExperimentFixture.ts", "exportName": "shiftTarget", From 1101333e02203afabb0eb45518260372a6df48fb Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 19 Sep 2026 14:00:31 +0300 Subject: [PATCH 03/13] [TS Calls] Resolve frontend basenames for source mapping --- .../org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt index 16c7d9dd3c..6cfc2c53cc 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt @@ -62,10 +62,13 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { val source = request.sourceRoot.resolve(request.function.sourceFile).normalize() val sourceFile = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) val scene = EtsScene(projectFiles = listOf(sourceFile)) + val frontendEntryPoint = request.function.entryPoint.copy( + module = requireNotNull(source.fileName).toString(), + ) val propertyManifest = PropertyManifest( propertyId = "calls.mapping", inputs = request.function.inputs, - predicate = request.function.entryPoint, + predicate = frontendEntryPoint, ) val mapping = PropertyEtsMapper(scene = scene, sourceRoots = listOf(request.sourceRoot)).map(propertyManifest) if (mapping.predicate.status != EtsMappingStatus.EXACT) { From ca618ecf1b72fb894237826dcc24362adce16ece Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 19 Sep 2026 14:21:43 +0300 Subject: [PATCH 04/13] Harden four-profile source replay artifacts --- usvm-ts-pbt/build.gradle.kts | 30 +++ .../src/source-target-replay-cli.ts | 67 +++++-- .../src/source-target-replay-worker.ts | 15 +- .../org/usvm/ts/pbt/calls/CallsExperiment.kt | 187 +++++++++++++++++- .../usvm/ts/pbt/calls/CallsExperimentCli.kt | 9 +- .../pbt/calls/CurrentTsCallsSymbolicEngine.kt | 174 ++++++++++++++-- .../usvm/ts/pbt/calls/CallsExperimentTest.kt | 64 +++++- .../resources/calls/CallsExperimentFixture.ts | 9 - .../resources/calls/fixture-manifest.json | 59 ------ 9 files changed, 483 insertions(+), 131 deletions(-) delete mode 100644 usvm-ts-pbt/src/test/resources/calls/CallsExperimentFixture.ts delete mode 100644 usvm-ts-pbt/src/test/resources/calls/fixture-manifest.json diff --git a/usvm-ts-pbt/build.gradle.kts b/usvm-ts-pbt/build.gradle.kts index b00baf9cbf..2c28460760 100644 --- a/usvm-ts-pbt/build.gradle.kts +++ b/usvm-ts-pbt/build.gradle.kts @@ -23,6 +23,9 @@ val fastCheckRuntimeProperty = "org.usvm.ts.pbt.fastcheck.runtime" val generatedFastCheckRuntimeMetadataDirectory = layout.buildDirectory.dir( "generated/resources/fastCheckRuntimeMetadata", ) +val generatedCallsBuildMetadataDirectory = layout.buildDirectory.dir( + "generated/resources/callsBuildMetadata", +) val hostOperatingSystem = System.getProperty("os.name").lowercase() val hostPlatform = when { hostOperatingSystem.contains("mac") -> "darwin" @@ -71,12 +74,39 @@ val generateFastCheckRuntimeMetadata = tasks.register("generateFastCheckRuntimeM } } +val callsToolRevision = providers.exec { + workingDir(rootProject.projectDir) + commandLine("git", "rev-parse", "HEAD") +}.standardOutput.asText.map(String::trim) +val callsToolStatus = providers.exec { + workingDir(rootProject.projectDir) + commandLine("git", "status", "--porcelain", "--untracked-files=all") +}.standardOutput.asText.map(String::trim) + +val generateCallsBuildMetadata = tasks.register("generateCallsBuildMetadata") { + inputs.property("toolRevision", callsToolRevision) + inputs.property("toolStatus", callsToolStatus) + outputs.dir(generatedCallsBuildMetadataDirectory) + + doLast { + val revision = callsToolRevision.get() + val buildIdentity = if (callsToolStatus.get().isBlank()) revision else "$revision-dirty" + val metadataFile = generatedCallsBuildMetadataDirectory.get() + .file("org/usvm/ts/pbt/calls/build.properties") + .asFile + metadataFile.parentFile.mkdirs() + metadataFile.writeText("tool.revision=$buildIdentity\n", Charsets.UTF_8) + } +} + sourceSets.main { resources.srcDir(generatedFastCheckRuntimeMetadataDirectory) + resources.srcDir(generatedCallsBuildMetadataDirectory) } tasks.processResources { dependsOn(generateFastCheckRuntimeMetadata) + dependsOn(generateCallsBuildMetadata) } val installFastCheckAdapter = tasks.register("installFastCheckAdapter") { diff --git a/usvm-ts-pbt/fast-check-adapter/src/source-target-replay-cli.ts b/usvm-ts-pbt/fast-check-adapter/src/source-target-replay-cli.ts index 7c12622b6a..1ee828a283 100644 --- a/usvm-ts-pbt/fast-check-adapter/src/source-target-replay-cli.ts +++ b/usvm-ts-pbt/fast-check-adapter/src/source-target-replay-cli.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID } from 'node:crypto'; import { spawn } from 'node:child_process'; -import { lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { copyFile, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -38,6 +38,7 @@ interface WorkerResult { interface ProcessResult { exitCode: number | null; timedOut: boolean; + stderrOverflow: boolean; stderr: string; } @@ -118,6 +119,10 @@ async function main(): Promise { writeResponse({ status: 'ok', replayStatus: 'timeout', invocation: null }); return; } + if (execution.stderrOverflow) { + writeResponse({ status: 'error', replayStatus: 'tool-error', message: 'worker stderr exceeded 65536 bytes' }); + return; + } if (execution.exitCode !== 0) { writeResponse({ status: 'error', replayStatus: 'tool-error', message: execution.stderr.trim() || `worker exited with code ${execution.exitCode}`, @@ -213,8 +218,7 @@ async function createOverlay( for (const entry of entries) { if (entry === segment) continue; const original = path.join(sourceDirectory, entry); - const kind = (await lstat(original)).isDirectory() ? 'dir' : 'file'; - await symlink(original, path.join(overlayDirectory, entry), kind); + await mirrorOverlayEntry(original, path.join(overlayDirectory, entry)); } if (last) { await writeFile(path.join(overlayDirectory, segment), instrumentedSource, 'utf8'); @@ -226,37 +230,57 @@ async function createOverlay( } } +async function mirrorOverlayEntry(original: string, overlay: string): Promise { + const kind = (await stat(original)).isDirectory() ? 'dir' : 'file'; + if (process.platform === 'win32') { + if (kind === 'dir') { + await symlink(original, overlay, 'junction'); + } else { + await copyFile(original, overlay); + } + return; + } + + await symlink(original, overlay, kind); +} + async function runProcess(executable: string, args: string[], timeoutMillis: number): Promise { - const detached = process.platform !== 'win32'; - const child = spawn(executable, args, { detached, stdio: ['ignore', 'ignore', 'pipe'] }); - let stderr = ''; - child.stderr.setEncoding('utf8'); - child.stderr.on('data', (chunk: string) => { stderr += chunk; }); + const child = spawn(executable, args, { stdio: ['ignore', 'ignore', 'pipe'] }); + const stderrChunks: Buffer[] = []; + let stderrBytes = 0; + let stderrOverflow = false; + child.stderr.on('data', (chunk: Buffer) => { + if (stderrOverflow) return; + + stderrBytes += chunk.length; + if (stderrBytes > MAX_WORKER_STDERR_BYTES) { + stderrOverflow = true; + child.kill('SIGTERM'); + return; + } + + stderrChunks.push(chunk); + }); return await new Promise((resolve, reject) => { let timedOut = false; + let forceKillTimer: NodeJS.Timeout | undefined; const timer = setTimeout(() => { timedOut = true; - terminate(child.pid, detached, 'SIGTERM'); - setTimeout(() => terminate(child.pid, detached, 'SIGKILL'), 250).unref(); + child.kill('SIGTERM'); + forceKillTimer = setTimeout(() => child.kill('SIGKILL'), WORKER_SHUTDOWN_GRACE_MILLIS); + forceKillTimer.unref(); }, timeoutMillis); child.once('error', reject); child.once('close', (exitCode) => { clearTimeout(timer); - resolve({ exitCode, timedOut, stderr }); + if (forceKillTimer !== undefined) clearTimeout(forceKillTimer); + const stderr = Buffer.concat(stderrChunks).toString('utf8'); + resolve({ exitCode, timedOut, stderrOverflow, stderr }); }); }); } -function terminate(pid: number | undefined, detached: boolean, signal: NodeJS.Signals): void { - if (pid === undefined) return; - try { - process.kill(detached ? -pid : pid, signal); - } catch (error: unknown) { - if (!(error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ESRCH')) throw error; - } -} - async function requireFile(filePath: string, description: string): Promise { if (!(await stat(filePath)).isFile()) throw new Error(`Missing ${description}: ${filePath}`); } @@ -275,3 +299,6 @@ main().catch((error: unknown) => { writeResponse({ status: 'error', replayStatus: 'tool-error', message: error instanceof Error ? error.message : String(error) }); process.exitCode = 1; }); + +const MAX_WORKER_STDERR_BYTES = 64 * 1024; +const WORKER_SHUTDOWN_GRACE_MILLIS = 250; diff --git a/usvm-ts-pbt/fast-check-adapter/src/source-target-replay-worker.ts b/usvm-ts-pbt/fast-check-adapter/src/source-target-replay-worker.ts index af9ab067f8..5ef5867c44 100644 --- a/usvm-ts-pbt/fast-check-adapter/src/source-target-replay-worker.ts +++ b/usvm-ts-pbt/fast-check-adapter/src/source-target-replay-worker.ts @@ -50,16 +50,7 @@ async function main(): Promise { await writeFile(request.resultPath, `${JSON.stringify(result)}\n`, 'utf8'); } -main().catch(async (error: unknown) => { - const fallbackPath = process.argv[3]; - if (fallbackPath !== undefined) { - await writeFile(fallbackPath, `${JSON.stringify({ - invocation: 'threw', - targetHit: false, - errorName: error instanceof Error ? error.name : typeof error, - errorMessage: error instanceof Error ? error.message : String(error), - })}\n`, 'utf8'); - } - - throw error; +main().catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); + process.exitCode = 1; }); diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperiment.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperiment.kt index c91d6ac9e8..6c3085b197 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperiment.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperiment.kt @@ -11,7 +11,9 @@ 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 java.nio.file.StandardCopyOption import java.nio.file.StandardOpenOption +import java.util.Properties import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -59,6 +61,7 @@ internal data class CallsExperimentManifest( val experimentId: String, val toolRevision: String, val nativeFrontendRevision: String, + val nativeFrontendSha256: String, val solver: String, val searchPolicy: String, val modelSet: CallsModelSetIdentity, @@ -74,6 +77,10 @@ internal data class CallsExperimentManifest( require(projects.isNotEmpty()) { "At least one project is required" } require(solver == "Z3") { "The frozen calls experiment requires the Z3 solver" } require(searchPolicy == "BFS") { "The frozen calls experiment requires BFS search" } + val cleanGitRevision = Regex("[0-9a-f]{40}") + require(toolRevision.matches(cleanGitRevision)) { + "Tool revision must identify a clean Git commit" + } require(toolRevision == modelSet.toolRevision) { "Tool and model-set revisions must match" } val functions = projects.flatMap(CallsProjectCase::functions) require(functions.map(CallsFunctionCase::functionId).distinct().size == functions.size) { @@ -120,20 +127,25 @@ internal data class CallsSymbolicSearchRequest( val profile: CallsExperimentProfile, val frozenModelIds: Set, val expectedCatalogFingerprint: String, + val expectedModelSourceHash: String, + val expectedModelEtsIrHash: String, + val expectedNativeFrontendRevision: String, + val expectedNativeFrontendSha256: String, val seed: Long, val budget: Duration, ) internal data class CallsSymbolicSearchResult( val status: CallsSymbolicStatus, + val solverReached: Boolean = status == CallsSymbolicStatus.REACHED, val inputs: List? = null, val catalogFingerprint: String? = null, val elapsedMillis: Long, val diagnostic: String? = null, ) { init { - require(status == CallsSymbolicStatus.REACHED || inputs == null) { - "Only a reached source target may carry extracted inputs" + require(solverReached || inputs == null) { + "Only a solver-reached source target may carry extracted inputs" } } } @@ -151,12 +163,24 @@ internal data class CallsRunMetadata( val experimentId: String, val toolRevision: String, val nativeFrontendRevision: String, + val nativeFrontendSha256: String? = null, val modelSet: CallsModelSetIdentity, val profiles: List, val seeds: List, val commonEligibleTargets: Int, + val targets: List, ) : CallsRawRecord +@Serializable +internal data class CallsRunTargetIdentity( + val projectId: String, + val revision: String, + val development: Boolean, + val functionId: String, + val targetId: String, + val siteId: String, +) + @Serializable @SerialName("target-result") internal data class CallsTargetResult( @@ -178,6 +202,13 @@ internal data class CallsTargetResult( val diagnostic: String? = null, ) : CallsRawRecord +@Serializable +@SerialName("run-completion") +internal data class CallsRunCompletion( + val experimentId: String, + val resultRows: Int, +) : CallsRawRecord + @Serializable internal data class CallsExperimentSummary( val experimentId: String, @@ -196,6 +227,9 @@ internal data class CallsProfileSummary( val unsupported: Int, val timeouts: Int, val toolErrors: Int, + val symbolicStatuses: Map, + val replayStatuses: Map, + val replayNotRun: Int, ) internal object CallsExperimentJson { @@ -213,17 +247,39 @@ internal object CallsExperimentJson { fun encodeManifest(manifest: CallsExperimentManifest): String = json.encodeToString(manifest) } +internal object CallsBuildIdentity { + val toolRevision: String by lazy { + val properties = Properties() + val resource = checkNotNull(javaClass.getResourceAsStream("/org/usvm/ts/pbt/calls/build.properties")) { + "Missing calls build identity" + } + resource.use(properties::load) + + checkNotNull(properties.getProperty("tool.revision")).takeIf(String::isNotBlank) + ?: error("Missing tool revision in calls build identity") + } +} + internal class CallsExperimentRunner( private val symbolicEngine: CallsSymbolicEngine, private val targetReplayer: CallsTargetReplayer, + private val runtimeToolRevision: String = CallsBuildIdentity.toolRevision, ) { fun run( manifest: CallsExperimentManifest, manifestDirectory: Path, rawOutput: Path, ) { - Files.createDirectories(requireNotNull(rawOutput.parent) { "Raw output must have a parent directory" }) - Files.deleteIfExists(rawOutput) + require(manifest.toolRevision == runtimeToolRevision) { + "Manifest tool revision ${manifest.toolRevision} does not match running build $runtimeToolRevision" + } + require(System.getenv("ETS_FRONTEND_SCRIPT") == null) { + "ETS_FRONTEND_SCRIPT must be unset so the frozen native frontend runtime is used" + } + val outputDirectory = requireNotNull(rawOutput.parent) { "Raw output must have a parent directory" } + val partialOutput = outputDirectory.resolve("${rawOutput.fileName}.partial") + Files.createDirectories(outputDirectory) + Files.deleteIfExists(partialOutput) val commonEligibleTargets = manifest.projects.sumOf { project -> project.functions.sumOf { function -> function.targets.size } } @@ -231,22 +287,55 @@ internal class CallsExperimentRunner( experimentId = manifest.experimentId, toolRevision = manifest.toolRevision, nativeFrontendRevision = manifest.nativeFrontendRevision, + nativeFrontendSha256 = manifest.nativeFrontendSha256, modelSet = manifest.modelSet, profiles = CallsExperimentProfile.entries, seeds = manifest.seeds, commonEligibleTargets = commonEligibleTargets, + targets = manifest.projects.flatMap { project -> + project.functions.flatMap { function -> + function.targets.map { target -> + CallsRunTargetIdentity( + projectId = project.projectId, + revision = project.revision, + development = project.development, + functionId = function.functionId, + targetId = target.targetId, + siteId = target.siteId, + ) + } + } + }, ) - append(rawOutput, metadata) + append(partialOutput, metadata) manifest.projects.forEach { project -> runProject( manifest = manifest, manifestDirectory = manifestDirectory, - rawOutput = rawOutput, + rawOutput = partialOutput, project = project, ) } + + val resultRows = Math.multiplyExact( + Math.multiplyExact(commonEligibleTargets, manifest.seeds.size), + CallsExperimentProfile.entries.size, + ) + append( + partialOutput, + CallsRunCompletion( + experimentId = manifest.experimentId, + resultRows = resultRows, + ), + ) + Files.move( + partialOutput, + rawOutput, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) } private fun runProject( @@ -316,6 +405,10 @@ internal class CallsExperimentRunner( } else { EMPTY_CATALOG_FINGERPRINT }, + expectedModelSourceHash = manifest.modelSet.sourceHash, + expectedModelEtsIrHash = manifest.modelSet.etsIrHash, + expectedNativeFrontendRevision = manifest.nativeFrontendRevision, + expectedNativeFrontendSha256 = manifest.nativeFrontendSha256, seed = seed, budget = manifest.perTargetBudgetMillis.milliseconds, ), @@ -341,7 +434,7 @@ internal class CallsExperimentRunner( profile = profile, seed = seed, symbolicStatus = symbolic.status, - solverReached = symbolic.status == CallsSymbolicStatus.REACHED, + solverReached = symbolic.solverReached, inputExtracted = symbolic.inputs != null, replayStatus = replay?.status, catalogFingerprint = symbolic.catalogFingerprint, @@ -373,14 +466,81 @@ internal class CallsExperimentRunner( } internal object CallsExperimentAggregator { + @Suppress("LongMethod") fun summarize(rawInput: Path): CallsExperimentSummary { val records = Files.readAllLines(rawInput).filter(String::isNotBlank).map { line -> CallsExperimentJson.json.decodeFromString(line) } - val metadata = records.filterIsInstance().single() + val metadataRows = records.filterIsInstance() + require(metadataRows.size == 1) { "Raw results must contain exactly one metadata record" } + val metadata = metadataRows.single() val results = records.filterIsInstance() + val completionRows = records.filterIsInstance() + require(completionRows.size == 1) { "Raw results must contain exactly one completion record" } + val completion = completionRows.single() + val expectedRows = Math.multiplyExact( + Math.multiplyExact(metadata.commonEligibleTargets, metadata.seeds.size), + metadata.profiles.size, + ) + require(completion.experimentId == metadata.experimentId) { "Completion experiment ID does not match metadata" } + require(completion.resultRows == expectedRows) { "Completion row count does not match metadata" } + require(results.size == expectedRows) { "Raw result row count does not match metadata" } + require(metadata.targets.size == metadata.commonEligibleTargets) { + "Metadata target count does not match common eligible target count" + } + val targetIdentities = metadata.targets.associateBy { target -> + Triple(target.projectId, target.functionId, target.targetId) + } + require(targetIdentities.size == metadata.targets.size) { "Metadata contains duplicate targets" } + require(results.all { result -> result.experimentId == metadata.experimentId }) { + "Result experiment ID does not match metadata" + } + require( + results.all { result -> + val identity = targetIdentities[Triple(result.projectId, result.functionId, result.targetId)] + identity != null && + result.revision == identity.revision && + result.development == identity.development && + result.siteId == identity.siteId + }, + ) { "Result target identity does not match metadata" } + val resultKeys = results.map { result -> + ResultKey( + projectId = result.projectId, + functionId = result.functionId, + targetId = result.targetId, + profile = result.profile, + seed = result.seed, + ) + } + require(resultKeys.distinct().size == resultKeys.size) { "Raw results contain duplicate target runs" } + val expectedKeys = metadata.targets.flatMap { target -> + metadata.seeds.flatMap { seed -> + metadata.profiles.map { profile -> + ResultKey( + projectId = target.projectId, + functionId = target.functionId, + targetId = target.targetId, + profile = profile, + seed = seed, + ) + } + } + } + require(resultKeys.toSet() == expectedKeys.toSet()) { "Raw results do not match the frozen target matrix" } val byProfile = CallsExperimentProfile.entries.associateWith { profile -> val rows = results.filter { result -> result.profile == profile } + val symbolicStatuses = CallsSymbolicStatus.entries.associateWith { status -> + rows.count { result -> result.symbolicStatus == status } + } + val replayStatuses = CallsReplayStatus.entries.associateWith { status -> + rows.count { result -> result.replayStatus == status } + } + val replayNotRun = rows.count { result -> result.replayStatus == null } + check(symbolicStatuses.values.sum() == rows.size) { "Symbolic statuses do not reconcile with profile runs" } + check(replayStatuses.values.sum() + replayNotRun == rows.size) { + "Replay statuses do not reconcile with profile runs" + } CallsProfileSummary( runs = rows.size, solverReached = rows.count(CallsTargetResult::solverReached), @@ -398,6 +558,9 @@ internal object CallsExperimentAggregator { result.symbolicStatus == CallsSymbolicStatus.TOOL_ERROR || result.replayStatus == CallsReplayStatus.TOOL_ERROR }, + symbolicStatuses = symbolicStatuses, + replayStatuses = replayStatuses, + replayNotRun = replayNotRun, ) } @@ -408,4 +571,12 @@ internal object CallsExperimentAggregator { byProfile = byProfile, ) } + + private data class ResultKey( + val projectId: String, + val functionId: String, + val targetId: String, + val profile: CallsExperimentProfile, + val seed: Long, + ) } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperimentCli.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperimentCli.kt index 53c426d84a..c080f9705c 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperimentCli.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperimentCli.kt @@ -20,11 +20,6 @@ private fun runExperiment(args: List) { val manifestPath = Path.of(args[0]).toAbsolutePath().normalize() val rawDirectory = Path.of(args[1]).toAbsolutePath().normalize() val manifest = CallsExperimentJson.decodeManifest(Files.readString(manifestPath)) - Files.createDirectories(rawDirectory) - Files.writeString( - rawDirectory.resolve("manifest.json"), - CallsExperimentJson.encodeManifest(manifest) + "\n", - ) CallsExperimentRunner( symbolicEngine = CurrentTsCallsSymbolicEngine(), @@ -34,6 +29,10 @@ private fun runExperiment(args: List) { manifestDirectory = requireNotNull(manifestPath.parent), rawOutput = rawDirectory.resolve("results.jsonl"), ) + Files.writeString( + rawDirectory.resolve("manifest.json"), + CallsExperimentJson.encodeManifest(manifest) + "\n", + ) } private fun summarize(args: List) { diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt index 6cfc2c53cc..4b6cdfab06 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt @@ -12,14 +12,14 @@ import org.usvm.PathSelectionStrategy import org.usvm.SolverType import org.usvm.StateCollectionStrategy import org.usvm.UMachineOptions -import org.usvm.api.targets.ReachabilityObserver -import org.usvm.api.targets.TsReachabilityTarget +import org.usvm.machine.TsAnalysisStopReason import org.usvm.machine.TsMachine import org.usvm.machine.TsOptions import org.usvm.machine.call.TsUnknownCallModelSelection import org.usvm.machine.expr.extractDouble import org.usvm.machine.expr.toConcreteBoolValue import org.usvm.machine.state.TsState +import org.usvm.statistics.UMachineObserver import org.usvm.ts.pbt.manifest.PropertyManifest import org.usvm.ts.pbt.mapping.EtsMappingStatus import org.usvm.ts.pbt.mapping.PropertyEtsMapper @@ -28,9 +28,17 @@ import org.usvm.ts.pbt.model.JsConcreteValue import org.usvm.ts.pbt.model.NumberDomain import org.usvm.ts.pbt.model.contains import org.usvm.util.mkRegisterStackLValue +import java.nio.file.Files +import java.nio.file.Path +import java.security.MessageDigest import kotlin.time.TimeSource +private const val BYTE_MASK = 0xff + internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { + private val verifiedProjects = mutableMapOf() + private var verifiedNativeFrontend: Pair? = null + override fun search(request: CallsSymbolicSearchRequest): CallsSymbolicSearchResult { val startedAt = TimeSource.Monotonic.markNow() val unsupportedInput = request.function.inputs.firstOrNull { input -> @@ -55,12 +63,40 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { } } + @Suppress("LongMethod") private fun searchSupported( request: CallsSymbolicSearchRequest, startedAt: TimeSource.Monotonic.ValueTimeMark, ): CallsSymbolicSearchResult { + verifyGitCheckoutOnce( + checkout = request.sourceRoot, + expectedRevision = request.project.revision, + cache = verifiedProjects, + ) + verifyNativeFrontendOnce( + expectedRevision = request.expectedNativeFrontendRevision, + expectedSha256 = request.expectedNativeFrontendSha256, + ) + val source = request.sourceRoot.resolve(request.function.sourceFile).normalize() + require(source.startsWith(request.sourceRoot)) { "Function source escapes its frozen source root" } + val actualSourceHash = Files.readAllBytes(source).sha256() + if (actualSourceHash != request.target.sourceSha256) { + return result( + status = CallsSymbolicStatus.TOOL_ERROR, + startedAt = startedAt, + diagnostic = "Source hash $actualSourceHash does not match frozen hash ${request.target.sourceSha256}", + ) + } + val sourceFile = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + if (sourceFile.importInfos.isNotEmpty()) { + return result( + status = CallsSymbolicStatus.UNSUPPORTED, + startedAt = startedAt, + diagnostic = "Single-file symbolic replay does not support imported project callees", + ) + } val scene = EtsScene(projectFiles = listOf(sourceFile)) val frontendEntryPoint = request.function.entryPoint.copy( module = requireNotNull(source.fileName).toString(), @@ -100,26 +136,36 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { randomSeed = request.seed, timeout = request.budget, solverType = SolverType.Z3, + stopOnCoverage = 0, stopOnTargetsReached = false, + throwExceptionOnStepFailure = true, ) val tsOptions = TsOptions( unknownCallModelSelection = modelSelection, unknownCallFallback = request.profile.fallback, ) // One source statement may lower to consecutive EtsIR instructions with the same exact source span. - // Reaching the first instruction is the stable entry point for that statement. - val target = TsReachabilityTarget.FinalPoint(targetCandidates.first()) + // Observe the first instruction before it executes, matching the source replay marker + // inserted before the statement. + val entryObserver = SourceStatementEntryObserver(targetCandidates.first()) val analysis = TsMachine( scene = scene, options = machineOptions, tsOptions = tsOptions, - machineObserver = ReachabilityObserver(), + machineObserver = entryObserver, ).use { machine -> - machine.analyze(methods = listOf(method), targets = listOf(target)) to - machine.unknownCallModelCatalogFingerprint + val outcome = machine.analyzeWithOutcome(methods = listOf(method)) + MachineResult( + states = entryObserver.reachedStates, + stopReason = outcome.stopReason, + catalogFingerprint = machine.unknownCallModelCatalogFingerprint, + artifactIdentities = machine.unknownCallModelArtifactIdentities.mapValues { (_, identity) -> + identity.sourceHash to identity.etsIrHash + }, + ) } - val states = analysis.first - val fingerprint = analysis.second + val states = analysis.states + val fingerprint = analysis.catalogFingerprint if (fingerprint != request.expectedCatalogFingerprint) { return result( status = CallsSymbolicStatus.TOOL_ERROR, @@ -128,17 +174,35 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { diagnostic = "Runtime model fingerprint $fingerprint does not match the frozen manifest", ) } + if (request.profile.usesFrozenModels) { + val artifactIdentities = analysis.artifactIdentities.values.toSet() + val expectedIdentity = request.expectedModelSourceHash to request.expectedModelEtsIrHash + if (artifactIdentities != setOf(expectedIdentity)) { + return result( + status = CallsSymbolicStatus.TOOL_ERROR, + startedAt = startedAt, + catalogFingerprint = fingerprint, + diagnostic = "Runtime EtsIR model artifacts $artifactIdentities " + + "do not match frozen artifact $expectedIdentity", + ) + } + } if (states.isEmpty()) { - val status = if (startedAt.elapsedNow() >= request.budget) { - CallsSymbolicStatus.TIMEOUT - } else { - CallsSymbolicStatus.UNREACHED + val status = when (analysis.stopReason) { + TsAnalysisStopReason.EXHAUSTED -> CallsSymbolicStatus.UNREACHED + TsAnalysisStopReason.TIMEOUT -> CallsSymbolicStatus.TIMEOUT + TsAnalysisStopReason.OTHER_LIMIT -> CallsSymbolicStatus.TOOL_ERROR } return result( status = status, startedAt = startedAt, catalogFingerprint = fingerprint, + diagnostic = if (analysis.stopReason == TsAnalysisStopReason.OTHER_LIMIT) { + "Symbolic execution stopped for an unexpected non-timeout limit" + } else { + null + }, ) } @@ -150,6 +214,7 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { if (inputs == null) { return result( status = CallsSymbolicStatus.UNREPRESENTABLE, + solverReached = true, startedAt = startedAt, catalogFingerprint = fingerprint, diagnostic = "No reached state has inputs inside every frozen domain", @@ -202,18 +267,101 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { private fun result( status: CallsSymbolicStatus, startedAt: TimeSource.Monotonic.ValueTimeMark, + solverReached: Boolean = status == CallsSymbolicStatus.REACHED, inputs: List? = null, catalogFingerprint: String? = null, diagnostic: String? = null, ) = CallsSymbolicSearchResult( status = status, + solverReached = solverReached, inputs = inputs, catalogFingerprint = catalogFingerprint, elapsedMillis = startedAt.elapsedNow().inWholeMilliseconds, diagnostic = diagnostic, ) + + private fun verifyNativeFrontendOnce(expectedRevision: String, expectedSha256: String) { + require(System.getenv("ETS_FRONTEND_SCRIPT") == null) { + "ETS_FRONTEND_SCRIPT must be unset so the frozen native frontend runtime is used" + } + val configuredFrontend = requireNotNull(System.getenv("ETS_FRONTEND_DIR")) { + "ETS_FRONTEND_DIR is required to verify the frozen native frontend revision" + } + val frontendDirectory = Path.of(configuredFrontend).toRealPath() + val cached = verifiedNativeFrontend + val expectedIdentity = "$expectedRevision:$expectedSha256" + if (cached == Pair(frontendDirectory, expectedIdentity)) { + return + } + + verifyGitCheckout(frontendDirectory, expectedRevision) + val runtimeScript = frontendDirectory.resolve("dist/index.js") + val actualSha256 = Files.readAllBytes(runtimeScript).sha256() + require(actualSha256 == expectedSha256) { + "Native frontend runtime hash $actualSha256 does not match frozen hash $expectedSha256" + } + verifiedNativeFrontend = frontendDirectory to expectedIdentity + } + + private fun verifyGitCheckoutOnce( + checkout: Path, + expectedRevision: String, + cache: MutableMap, + ) { + if (cache[checkout] == expectedRevision) { + return + } + + verifyGitCheckout(checkout, expectedRevision) + cache[checkout] = expectedRevision + } + + private fun verifyGitCheckout(checkout: Path, expectedRevision: String) { + val actualRevision = runGit(checkout, "rev-parse", "HEAD").trim() + require(actualRevision == expectedRevision) { + "Checkout $checkout is at $actualRevision, expected frozen revision $expectedRevision" + } + runGit(checkout, "diff", "--quiet", "HEAD", "--") + } + + private fun runGit(checkout: Path, vararg arguments: String): String { + val process = ProcessBuilder(listOf("git", "-C", checkout.toString()) + arguments) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().use { reader -> reader.readText() } + val exitCode = process.waitFor() + require(exitCode == 0) { + val command = arguments.joinToString(separator = " ") + "Git $command failed for $checkout with exit $exitCode: ${output.trim()}" + } + + return output + } + + private class SourceStatementEntryObserver( + private val target: EtsStmt, + ) : UMachineObserver { + val reachedStates = mutableListOf() + + override fun onStatePeeked(state: TsState) { + if (state.currentStatement == target) { + reachedStates += state.clone() + } + } + } + + private data class MachineResult( + val states: List, + val stopReason: TsAnalysisStopReason, + val catalogFingerprint: String?, + val artifactIdentities: Map>, + ) } +private fun ByteArray.sha256(): String = MessageDigest.getInstance("SHA-256") + .digest(this) + .joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and BYTE_MASK) } + private fun EtsMappingStatus.toSymbolicStatus(): CallsSymbolicStatus = when (this) { EtsMappingStatus.EXACT -> error("Exact mapping has no failure status") EtsMappingStatus.AMBIGUOUS -> CallsSymbolicStatus.AMBIGUOUS diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt index cc8931e761..90d99d38dc 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt @@ -10,6 +10,7 @@ import org.usvm.ts.pbt.model.TypeScriptEntryPoint import java.nio.file.Files import java.nio.file.Path import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -33,7 +34,10 @@ class CallsExperimentTest { inputs = listOf(JsConcreteValue.Boolean(true)), ) - CallsExperimentProfile.FROZEN_FRESH -> result(status = CallsSymbolicStatus.UNSUPPORTED) + CallsExperimentProfile.FROZEN_FRESH -> result( + status = CallsSymbolicStatus.UNREPRESENTABLE, + solverReached = true, + ) } } val replayer = CallsTargetReplayer { _, _, inputs, _, _ -> @@ -45,7 +49,11 @@ class CallsExperimentTest { } val rawOutput = directory.resolve("raw/results.jsonl") - CallsExperimentRunner(symbolicEngine = engine, targetReplayer = replayer).run( + CallsExperimentRunner( + symbolicEngine = engine, + targetReplayer = replayer, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + ).run( manifest = manifest(sourceRoot = ".", seeds = listOf(1L)), manifestDirectory = directory, rawOutput = rawOutput, @@ -54,8 +62,11 @@ class CallsExperimentTest { val records = readRecords(rawOutput) val metadata = records.filterIsInstance().single() val results = records.filterIsInstance() + val completion = records.filterIsInstance().single() assertEquals(1, metadata.commonEligibleTargets) + assertEquals(4, completion.resultRows) + assertFalse(Files.exists(rawOutput.resolveSibling("results.jsonl.partial"))) assertEquals( listOf( CallsExperimentProfile.EMPTY_FRESH, @@ -81,6 +92,33 @@ class CallsExperimentTest { assertFalse(emptyStop.solverReached) assertFalse(emptyStop.inputExtracted) assertNull(emptyStop.replayStatus) + + val frozenFresh = results.single { result -> result.profile == CallsExperimentProfile.FROZEN_FRESH } + assertTrue(frozenFresh.solverReached) + assertFalse(frozenFresh.inputExtracted) + assertNull(frozenFresh.replayStatus) + } + + @Test + fun `aggregator rejects an interrupted raw prefix without completion`(@TempDir directory: Path) { + val rawOutput = directory.resolve("results.jsonl") + val engine = CallsSymbolicEngine { result(status = CallsSymbolicStatus.UNREACHED) } + + CallsExperimentRunner( + symbolicEngine = engine, + targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> error("Replay must not run") }, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + ).run( + manifest = manifest(sourceRoot = ".", seeds = listOf(0L)), + manifestDirectory = directory, + rawOutput = rawOutput, + ) + val interrupted = directory.resolve("interrupted.jsonl") + Files.write(interrupted, Files.readAllLines(rawOutput).dropLast(1)) + + assertFailsWith { + CallsExperimentAggregator.summarize(interrupted) + } } @Test @@ -110,7 +148,11 @@ class CallsExperimentTest { ) } - CallsExperimentRunner(symbolicEngine = engine, targetReplayer = replayer).run( + CallsExperimentRunner( + symbolicEngine = engine, + targetReplayer = replayer, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + ).run( manifest = manifest(sourceRoot = ".", seeds = listOf(0L, 3L)), manifestDirectory = directory, rawOutput = rawOutput, @@ -130,6 +172,11 @@ class CallsExperimentTest { unsupported = 0, timeouts = 2, toolErrors = 0, + symbolicStatuses = CallsSymbolicStatus.entries.associateWith { status -> + if (status == CallsSymbolicStatus.TIMEOUT) 2 else 0 + }, + replayStatuses = CallsReplayStatus.entries.associateWith { 0 }, + replayNotRun = 2, ), summary.byProfile.getValue(CallsExperimentProfile.EMPTY_STOP), ) @@ -141,8 +188,9 @@ class CallsExperimentTest { private fun manifest(sourceRoot: String, seeds: List) = CallsExperimentManifest( schemaVersion = CallsExperimentManifest.SCHEMA_VERSION, experimentId = "fixture", - toolRevision = "tool-revision", + toolRevision = FIXTURE_TOOL_REVISION, nativeFrontendRevision = "frontend-revision", + nativeFrontendSha256 = "frontend-sha256", solver = "Z3", searchPolicy = "BFS", modelSet = CallsModelSetIdentity( @@ -150,7 +198,7 @@ class CallsExperimentTest { catalogFingerprint = "frozen-fingerprint", sourceHash = "source-hash", etsIrHash = "ets-ir-hash", - toolRevision = "tool-revision", + toolRevision = FIXTURE_TOOL_REVISION, ), seeds = seeds, perTargetBudgetMillis = 1_000L, @@ -189,9 +237,11 @@ class CallsExperimentTest { private fun result( status: CallsSymbolicStatus, + solverReached: Boolean = status == CallsSymbolicStatus.REACHED, inputs: List? = null, ) = CallsSymbolicSearchResult( status = status, + solverReached = solverReached, inputs = inputs, catalogFingerprint = "runtime-fingerprint", elapsedMillis = 7L, @@ -200,4 +250,8 @@ class CallsExperimentTest { private fun readRecords(path: Path): List = Files.readAllLines(path) .filter(String::isNotBlank) .map { line -> CallsExperimentJson.json.decodeFromString(line) } + + private companion object { + const val FIXTURE_TOOL_REVISION = "0000000000000000000000000000000000000001" + } } diff --git a/usvm-ts-pbt/src/test/resources/calls/CallsExperimentFixture.ts b/usvm-ts-pbt/src/test/resources/calls/CallsExperimentFixture.ts deleted file mode 100644 index 07e315aa0c..0000000000 --- a/usvm-ts-pbt/src/test/resources/calls/CallsExperimentFixture.ts +++ /dev/null @@ -1,9 +0,0 @@ -export function shiftTarget(value: number): number { - const values = [value]; - const removed = values.shift(); - if (removed === 42) { - return 1; - } - - return 0; -} diff --git a/usvm-ts-pbt/src/test/resources/calls/fixture-manifest.json b/usvm-ts-pbt/src/test/resources/calls/fixture-manifest.json deleted file mode 100644 index a68d817d23..0000000000 --- a/usvm-ts-pbt/src/test/resources/calls/fixture-manifest.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "schemaVersion": 1, - "experimentId": "calls-fixture-shift", - "toolRevision": "41961f7b66c30c8a2a7507c67a79396f495f4520", - "nativeFrontendRevision": "local-fixture", - "solver": "Z3", - "searchPolicy": "BFS", - "modelSet": { - "ids": ["ts.array.pop", "ts.array.shift"], - "catalogFingerprint": "09687a921363212c7736237f19e3978665655a45e0165da4f24f689c48439e31", - "sourceHash": "9f40d3abce58e3412a0206eabd9fdb0547e12c2ebd832ce48b260b3339518e26", - "etsIrHash": "0000000000000000000000000000000000000000000000000000000000000000", - "toolRevision": "41961f7b66c30c8a2a7507c67a79396f495f4520" - }, - "seeds": [42], - "perTargetBudgetMillis": 5000, - "projects": [ - { - "projectId": "synthetic/calls-fixture", - "revision": "94edca29194e2cf4cf1c6a1cbb9848c8bda014a21c1422c12f039fcd6b95440b", - "sourceRoot": ".", - "development": true, - "functions": [ - { - "functionId": "CallsExperimentFixture.ts::shiftTarget/1", - "sourceFile": "CallsExperimentFixture.ts", - "entryPoint": { - "module": "CallsExperimentFixture.ts", - "exportName": "shiftTarget", - "executionKind": "sync" - }, - "inputs": [ - { - "name": "value", - "domain": { - "kind": "number", - "min": {"value": "negative-infinity"}, - "max": {"value": "positive-infinity"}, - "allowNaN": true - } - } - ], - "targets": [ - { - "targetId": "CallsExperimentFixture.ts::shiftTarget/1#return-one", - "siteId": "CallsExperimentFixture.ts:5:5-5:14::shiftTarget/1", - "sourcePath": "CallsExperimentFixture.ts", - "sourceSha256": "94edca29194e2cf4cf1c6a1cbb9848c8bda014a21c1422c12f039fcd6b95440b", - "startOffset": 141, - "endOffset": 150, - "start": {"line": 4, "column": 4}, - "end": {"line": 4, "column": 13} - } - ] - } - ] - } - ] -} From 44ef33e592f3a1bc23ac9df661a1682652626533 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 19 Sep 2026 16:20:53 +0300 Subject: [PATCH 05/13] [TS Calls] Persist and replay experiment witnesses --- .../src/source-target-replay-worker.ts | 1 + .../test/source-target-replay-cli.test.ts | 12 ++ .../test/source-target-replay-fixture.ts | 10 + .../org/usvm/ts/pbt/calls/CallsExperiment.kt | 35 +++- .../usvm/ts/pbt/calls/CallsExperimentCli.kt | 38 ++++ .../usvm/ts/pbt/calls/CallsWitnessReplay.kt | 147 ++++++++++++++ .../pbt/calls/CurrentTsCallsSymbolicEngine.kt | 48 ++--- .../usvm/ts/pbt/calls/CallsExperimentTest.kt | 188 ++++++++++++++++++ 8 files changed, 445 insertions(+), 34 deletions(-) create mode 100644 usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsWitnessReplay.kt diff --git a/usvm-ts-pbt/fast-check-adapter/src/source-target-replay-worker.ts b/usvm-ts-pbt/fast-check-adapter/src/source-target-replay-worker.ts index 5ef5867c44..9be8210261 100644 --- a/usvm-ts-pbt/fast-check-adapter/src/source-target-replay-worker.ts +++ b/usvm-ts-pbt/fast-check-adapter/src/source-target-replay-worker.ts @@ -30,6 +30,7 @@ async function main(): Promise { }); const callable = await loadCallable(request.entryPoint, request.sourceRoots, 'entryPoint'); const inputs = request.inputs.map((value, index) => decodeJsValue(value, `inputs[${index}]`)); + (globalThis as Record)[request.hitKey] = false; let result: ReplayWorkerResult; try { diff --git a/usvm-ts-pbt/fast-check-adapter/test/source-target-replay-cli.test.ts b/usvm-ts-pbt/fast-check-adapter/test/source-target-replay-cli.test.ts index c64aa0ccad..6476be327a 100644 --- a/usvm-ts-pbt/fast-check-adapter/test/source-target-replay-cli.test.ts +++ b/usvm-ts-pbt/fast-check-adapter/test/source-target-replay-cli.test.ts @@ -54,6 +54,18 @@ test('retains target confirmation when the original TypeScript invocation throws assert.equal(response.invocation?.targetHit, true); }); +test('counts target hits from the selected invocation rather than module import', async () => { + const target = await statementTarget('importOnlyTarget', 'return 7;'); + + const importOnly = await replay({ ...baseRequest('skipsImportOnlyTarget', []), target }); + const invoked = await replay({ ...baseRequest('importOnlyTarget', []), target }); + + assert.equal(importOnly.replayStatus, 'rejected'); + assert.equal(importOnly.invocation?.targetHit, false); + assert.equal(invoked.replayStatus, 'confirmed'); + assert.equal(invoked.invocation?.targetHit, true); +}); + test('rejects stale source identity before executing', async () => { const target = await statementTarget('choose', 'return 1;'); diff --git a/usvm-ts-pbt/fast-check-adapter/test/source-target-replay-fixture.ts b/usvm-ts-pbt/fast-check-adapter/test/source-target-replay-fixture.ts index 4f8296c176..e76df39389 100644 --- a/usvm-ts-pbt/fast-check-adapter/test/source-target-replay-fixture.ts +++ b/usvm-ts-pbt/fast-check-adapter/test/source-target-replay-fixture.ts @@ -11,3 +11,13 @@ export function inlineChoose(value: number): number { if (value > 0) { return 1; export function throwsAtTarget(): never { throw new Error('expected'); } + +export function importOnlyTarget(): number { + return 7; +} + +const importedTargetValue = importOnlyTarget(); + +export function skipsImportOnlyTarget(): number { + return importedTargetValue; +} diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperiment.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperiment.kt index 6c3085b197..ced262c43c 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperiment.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperiment.kt @@ -196,6 +196,7 @@ internal data class CallsTargetResult( val symbolicStatus: CallsSymbolicStatus, val solverReached: Boolean, val inputExtracted: Boolean, + val inputs: List? = null, val replayStatus: CallsReplayStatus?, val catalogFingerprint: String?, val symbolicElapsedMillis: Long, @@ -436,6 +437,7 @@ internal class CallsExperimentRunner( symbolicStatus = symbolic.status, solverReached = symbolic.solverReached, inputExtracted = symbolic.inputs != null, + inputs = symbolic.inputs, replayStatus = replay?.status, catalogFingerprint = symbolic.catalogFingerprint, symbolicElapsedMillis = symbolic.elapsedMillis, @@ -465,9 +467,14 @@ internal class CallsExperimentRunner( } } -internal object CallsExperimentAggregator { +internal data class CallsValidatedRawResults( + val metadata: CallsRunMetadata, + val results: List, +) + +internal object CallsRawResultsReader { @Suppress("LongMethod") - fun summarize(rawInput: Path): CallsExperimentSummary { + fun read(rawInput: Path): CallsValidatedRawResults { val records = Files.readAllLines(rawInput).filter(String::isNotBlank).map { line -> CallsExperimentJson.json.decodeFromString(line) } @@ -528,6 +535,22 @@ internal object CallsExperimentAggregator { } } require(resultKeys.toSet() == expectedKeys.toSet()) { "Raw results do not match the frozen target matrix" } + + return CallsValidatedRawResults(metadata = metadata, results = results) + } + + private data class ResultKey( + val projectId: String, + val functionId: String, + val targetId: String, + val profile: CallsExperimentProfile, + val seed: Long, + ) +} + +internal object CallsExperimentAggregator { + fun summarize(rawInput: Path): CallsExperimentSummary { + val (metadata, results) = CallsRawResultsReader.read(rawInput) val byProfile = CallsExperimentProfile.entries.associateWith { profile -> val rows = results.filter { result -> result.profile == profile } val symbolicStatuses = CallsSymbolicStatus.entries.associateWith { status -> @@ -571,12 +594,4 @@ internal object CallsExperimentAggregator { byProfile = byProfile, ) } - - private data class ResultKey( - val projectId: String, - val functionId: String, - val targetId: String, - val profile: CallsExperimentProfile, - val seed: Long, - ) } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperimentCli.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperimentCli.kt index c080f9705c..24e5ffb22d 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperimentCli.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperimentCli.kt @@ -10,11 +10,46 @@ fun main(args: Array) { when (args.first()) { "run" -> runExperiment(args.drop(1)) + "replay-witness" -> replayWitness(args.drop(1)) "summarize" -> summarize(args.drop(1)) else -> error(usage()) } } +internal fun replayWitness(args: List) { + require(args.size == REPLAY_WITNESS_ARGUMENT_COUNT) { usage() } + val arguments = args.iterator() + val manifestArgument = arguments.next() + val rawArgument = arguments.next() + val projectId = arguments.next() + val functionId = arguments.next() + val targetId = arguments.next() + val profileName = arguments.next() + val seedText = arguments.next() + val manifestPath = Path.of(manifestArgument).toAbsolutePath().normalize() + val rawInput = Path.of(rawArgument).toAbsolutePath().normalize() + val selector = CallsWitnessSelector( + projectId = projectId, + functionId = functionId, + targetId = targetId, + profile = CallsExperimentProfile.valueOf(profileName), + seed = seedText.toLong(), + ) + preflightCallsWitness(rawInput = rawInput, selector = selector) + + val manifest = CallsExperimentJson.decodeManifest(Files.readString(manifestPath)) + val result = CallsWitnessReplayer( + targetReplayer = OriginalTypeScriptTargetReplayer(), + ).replay( + manifest = manifest, + manifestDirectory = requireNotNull(manifestPath.parent), + rawInput = rawInput, + selector = selector, + ) + + println(CallsExperimentJson.json.encodeToString(result)) +} + private fun runExperiment(args: List) { require(args.size == 2) { usage() } val manifestPath = Path.of(args[0]).toAbsolutePath().normalize() @@ -52,5 +87,8 @@ private fun summarize(args: List) { private fun usage(): String = """ Usage: calls run + calls replay-witness calls summarize """.trimIndent() + +private const val REPLAY_WITNESS_ARGUMENT_COUNT = 7 diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsWitnessReplay.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsWitnessReplay.kt new file mode 100644 index 0000000000..f238d67b53 --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsWitnessReplay.kt @@ -0,0 +1,147 @@ +package org.usvm.ts.pbt.calls + +import org.usvm.ts.pbt.model.JsConcreteValue +import org.usvm.ts.pbt.model.contains +import java.nio.file.Path + +internal data class CallsWitnessSelector( + val projectId: String, + val functionId: String, + val targetId: String, + val profile: CallsExperimentProfile, + val seed: Long, +) + +internal fun preflightCallsWitness(rawInput: Path, selector: CallsWitnessSelector) { + loadCallsWitness(rawInput = rawInput, selector = selector) +} + +private data class LoadedCallsWitness( + val raw: CallsValidatedRawResults, + val row: CallsTargetResult, + val inputs: List, +) + +private fun loadCallsWitness(rawInput: Path, selector: CallsWitnessSelector): LoadedCallsWitness { + val raw = CallsRawResultsReader.read(rawInput) + val row = raw.results.singleOrNull { result -> result.matches(selector) } + ?: error("Raw results must contain exactly one row for $selector") + val inputs = row.inputs + if (inputs == null && row.inputExtracted) { + error( + "The selected row is from a historical raw artifact that did not store its extracted witness; " + + "single-witness replay is unavailable", + ) + } + + return LoadedCallsWitness( + raw = raw, + row = row, + inputs = requireNotNull(inputs) { + "The selected row has no extracted witness; single-witness replay is unavailable" + }, + ) +} + +internal class CallsWitnessReplayer( + private val targetReplayer: CallsTargetReplayer, + private val runtimeToolRevision: String = CallsBuildIdentity.toolRevision, + private val verifyProjectCheckout: (Path, String) -> Unit = ::verifyCallsGitCheckout, +) { + fun replay( + manifest: CallsExperimentManifest, + manifestDirectory: Path, + rawInput: Path, + selector: CallsWitnessSelector, + ): CallsSourceReplayResult { + val loaded = loadCallsWitness(rawInput = rawInput, selector = selector) + val row = loaded.row + val inputs = loaded.inputs + + verifyExperimentIdentity(manifest = manifest, metadata = loaded.raw.metadata) + require(runtimeToolRevision == manifest.toolRevision) { + "Manifest tool revision ${manifest.toolRevision} does not match running build $runtimeToolRevision; " + + "single-witness replay requires the same clean tool revision" + } + + val project = manifest.projects.single { project -> project.projectId == selector.projectId } + require(row.revision == project.revision && row.development == project.development) { + "Selected raw row project identity does not match the frozen manifest" + } + val function = project.functions.single { function -> function.functionId == selector.functionId } + val target = function.targets.single { target -> target.targetId == selector.targetId } + require(row.siteId == target.siteId) { + "Selected raw row target identity does not match the frozen manifest" + } + require(inputs.size == function.inputs.size) { + "Stored witness has ${inputs.size} values, expected ${function.inputs.size}" + } + inputs.zip(function.inputs).forEach { (value, input) -> + require(value in input.domain) { + "Stored witness value for ${input.name} is outside the frozen input domain" + } + } + + val sourceRoot = manifestDirectory.resolve(project.sourceRoot).normalize().toRealPath() + verifyProjectCheckout(sourceRoot, project.revision) + + return targetReplayer.replay( + sourceRoots = listOf(sourceRoot), + entryPoint = function.entryPoint, + inputs = inputs, + target = target, + timeoutMillis = manifest.perTargetBudgetMillis, + ) + } + + private fun verifyExperimentIdentity( + manifest: CallsExperimentManifest, + metadata: CallsRunMetadata, + ) { + require(metadata.experimentId == manifest.experimentId) { + "Raw experiment ID does not match the frozen manifest" + } + require(metadata.toolRevision == manifest.toolRevision) { + "Raw tool revision does not match the frozen manifest" + } + require(metadata.nativeFrontendRevision == manifest.nativeFrontendRevision) { + "Raw native frontend revision does not match the frozen manifest" + } + require(metadata.nativeFrontendSha256 == manifest.nativeFrontendSha256) { + "Raw native frontend runtime hash does not match the frozen manifest" + } + require(metadata.modelSet == manifest.modelSet) { + "Raw model-set identity does not match the frozen manifest" + } + require(metadata.profiles == CallsExperimentProfile.entries) { + "Raw profiles do not match the frozen experiment contract" + } + require(metadata.seeds == manifest.seeds) { + "Raw seeds do not match the frozen manifest" + } + val manifestTargets = manifest.projects.flatMap { project -> + project.functions.flatMap { function -> + function.targets.map { target -> + CallsRunTargetIdentity( + projectId = project.projectId, + revision = project.revision, + development = project.development, + functionId = function.functionId, + targetId = target.targetId, + siteId = target.siteId, + ) + } + } + } + require(metadata.commonEligibleTargets == manifestTargets.size && metadata.targets == manifestTargets) { + "Raw target matrix does not match the frozen manifest" + } + } +} + +private fun CallsTargetResult.matches(selector: CallsWitnessSelector): Boolean = + projectId == selector.projectId && + functionId == selector.functionId && + targetId == selector.targetId && + profile == selector.profile && + seed == selector.seed diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt index 4b6cdfab06..b61996fa4b 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt @@ -294,7 +294,7 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { return } - verifyGitCheckout(frontendDirectory, expectedRevision) + verifyCallsGitCheckout(frontendDirectory, expectedRevision) val runtimeScript = frontendDirectory.resolve("dist/index.js") val actualSha256 = Files.readAllBytes(runtimeScript).sha256() require(actualSha256 == expectedSha256) { @@ -312,32 +312,10 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { return } - verifyGitCheckout(checkout, expectedRevision) + verifyCallsGitCheckout(checkout, expectedRevision) cache[checkout] = expectedRevision } - private fun verifyGitCheckout(checkout: Path, expectedRevision: String) { - val actualRevision = runGit(checkout, "rev-parse", "HEAD").trim() - require(actualRevision == expectedRevision) { - "Checkout $checkout is at $actualRevision, expected frozen revision $expectedRevision" - } - runGit(checkout, "diff", "--quiet", "HEAD", "--") - } - - private fun runGit(checkout: Path, vararg arguments: String): String { - val process = ProcessBuilder(listOf("git", "-C", checkout.toString()) + arguments) - .redirectErrorStream(true) - .start() - val output = process.inputStream.bufferedReader().use { reader -> reader.readText() } - val exitCode = process.waitFor() - require(exitCode == 0) { - val command = arguments.joinToString(separator = " ") - "Git $command failed for $checkout with exit $exitCode: ${output.trim()}" - } - - return output - } - private class SourceStatementEntryObserver( private val target: EtsStmt, ) : UMachineObserver { @@ -358,6 +336,28 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { ) } +internal fun verifyCallsGitCheckout(checkout: Path, expectedRevision: String) { + val actualRevision = runCallsGit(checkout, "rev-parse", "HEAD").trim() + require(actualRevision == expectedRevision) { + "Checkout $checkout is at $actualRevision, expected frozen revision $expectedRevision" + } + runCallsGit(checkout, "diff", "--quiet", "HEAD", "--") +} + +private fun runCallsGit(checkout: Path, vararg arguments: String): String { + val process = ProcessBuilder(listOf("git", "-C", checkout.toString()) + arguments) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().use { reader -> reader.readText() } + val exitCode = process.waitFor() + require(exitCode == 0) { + val command = arguments.joinToString(separator = " ") + "Git $command failed for $checkout with exit $exitCode: ${output.trim()}" + } + + return output +} + private fun ByteArray.sha256(): String = MessageDigest.getInstance("SHA-256") .digest(this) .joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and BYTE_MASK) } diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt index 90d99d38dc..9e1087ac3e 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt @@ -1,6 +1,9 @@ package org.usvm.ts.pbt.calls import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import org.usvm.ts.pbt.model.BooleanDomain @@ -81,24 +84,173 @@ class CallsExperimentTest { val emptyFresh = results.single { result -> result.profile == CallsExperimentProfile.EMPTY_FRESH } assertTrue(emptyFresh.solverReached) assertTrue(emptyFresh.inputExtracted) + assertEquals(listOf(JsConcreteValue.Boolean(false)), emptyFresh.inputs) assertEquals(CallsReplayStatus.REJECTED, emptyFresh.replayStatus) val frozenStop = results.single { result -> result.profile == CallsExperimentProfile.FROZEN_STOP } assertTrue(frozenStop.solverReached) assertTrue(frozenStop.inputExtracted) + assertEquals(listOf(JsConcreteValue.Boolean(true)), frozenStop.inputs) assertEquals(CallsReplayStatus.CONFIRMED, frozenStop.replayStatus) val emptyStop = results.single { result -> result.profile == CallsExperimentProfile.EMPTY_STOP } assertFalse(emptyStop.solverReached) assertFalse(emptyStop.inputExtracted) + assertNull(emptyStop.inputs) assertNull(emptyStop.replayStatus) val frozenFresh = results.single { result -> result.profile == CallsExperimentProfile.FROZEN_FRESH } assertTrue(frozenFresh.solverReached) assertFalse(frozenFresh.inputExtracted) + assertNull(frozenFresh.inputs) assertNull(frozenFresh.replayStatus) } + @Test + fun `target result witness uses lossless concrete value serialization`() { + val witness = listOf( + JsConcreteValue.number(-0.0), + JsConcreteValue.number(Double.NaN), + JsConcreteValue.Array( + elements = listOf(JsConcreteValue.Undefined, JsConcreteValue.Null, JsConcreteValue.String("value")), + ), + ) + val result = targetResult(inputs = witness) + + val encoded = CallsExperimentJson.json.encodeToString(result) + val decoded = CallsExperimentJson.json.decodeFromString(encoded) as CallsTargetResult + + assertEquals(result, decoded) + assertEquals(witness, decoded.inputs) + } + + @Test + fun `single witness replay uses selected stored inputs without symbolic search`(@TempDir directory: Path) { + val rawOutput = directory.resolve("results.jsonl") + val frozenManifest = manifest(sourceRoot = ".", seeds = listOf(11L)) + CallsExperimentRunner( + symbolicEngine = CallsSymbolicEngine { + result( + status = CallsSymbolicStatus.REACHED, + inputs = listOf(JsConcreteValue.Boolean(true)), + ) + }, + targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> + CallsSourceReplayResult(status = CallsReplayStatus.CONFIRMED) + }, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + ).run( + manifest = frozenManifest, + manifestDirectory = directory, + rawOutput = rawOutput, + ) + var replayedInputs: List? = null + var replayedTarget: CallsSourceTarget? = null + var replayedTimeout: Long? = null + var verifiedCheckout: Path? = null + val witnessReplayer = CallsWitnessReplayer( + targetReplayer = CallsTargetReplayer { _, _, inputs, target, timeoutMillis -> + replayedInputs = inputs + replayedTarget = target + replayedTimeout = timeoutMillis + + CallsSourceReplayResult(status = CallsReplayStatus.REJECTED) + }, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + verifyProjectCheckout = { checkout, expectedRevision -> + assertEquals("project-revision", expectedRevision) + verifiedCheckout = checkout + }, + ) + + val replay = witnessReplayer.replay( + manifest = frozenManifest, + manifestDirectory = directory, + rawInput = rawOutput, + selector = selector(seed = 11L, profile = CallsExperimentProfile.FROZEN_STOP), + ) + + assertEquals(CallsReplayStatus.REJECTED, replay.status) + assertEquals(listOf(JsConcreteValue.Boolean(true)), replayedInputs) + assertEquals(frozenManifest.projects.single().functions.single().targets.single(), replayedTarget) + assertEquals(1_000L, replayedTimeout) + assertEquals(directory.toRealPath(), verifiedCheckout) + } + + @Test + fun `historical extracted row without stored witness reports replay unavailable first`(@TempDir directory: Path) { + val rawOutput = directory.resolve("results.jsonl") + val frozenManifest = manifest(sourceRoot = ".", seeds = listOf(5L)) + CallsExperimentRunner( + symbolicEngine = CallsSymbolicEngine { + result( + status = CallsSymbolicStatus.REACHED, + inputs = listOf(JsConcreteValue.Boolean(true)), + ) + }, + targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> + CallsSourceReplayResult(status = CallsReplayStatus.CONFIRMED) + }, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + ).run( + manifest = frozenManifest, + manifestDirectory = directory, + rawOutput = rawOutput, + ) + val historicalRecords = readRecords(rawOutput).map { record -> + when { + record is CallsRunMetadata -> record.copy(nativeFrontendSha256 = null) + record is CallsTargetResult && record.profile == CallsExperimentProfile.EMPTY_FRESH -> { + record.copy(inputs = null) + } + + else -> record + } + } + writeRecords(rawOutput, historicalRecords) + val witnessReplayer = CallsWitnessReplayer( + targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> error("Replay must not run") }, + runtimeToolRevision = "different-runtime-revision", + verifyProjectCheckout = { _, _ -> error("Checkout verification must not run") }, + ) + + val error = assertFailsWith { + witnessReplayer.replay( + manifest = frozenManifest, + manifestDirectory = directory, + rawInput = rawOutput, + selector = selector(seed = 5L, profile = CallsExperimentProfile.EMPTY_FRESH), + ) + } + + assertTrue(error.message.orEmpty().contains("historical raw artifact")) + assertTrue(error.message.orEmpty().contains("single-witness replay is unavailable")) + + val manifestPath = directory.resolve("historical-manifest.json") + val manifestJson = CallsExperimentJson.json.encodeToString(frozenManifest) + val historicalManifest = JsonObject( + CallsExperimentJson.json.parseToJsonElement(manifestJson).jsonObject - "nativeFrontendSha256", + ) + Files.writeString(manifestPath, historicalManifest.toString()) + + val cliError = assertFailsWith { + replayWitness( + listOf( + manifestPath.toString(), + rawOutput.toString(), + "fixture/project", + "fixture.ts::predicate/1", + "fixture.ts::predicate/1#return", + CallsExperimentProfile.EMPTY_FRESH.name, + "5", + ), + ) + } + + assertTrue(cliError.message.orEmpty().contains("historical raw artifact")) + assertTrue(cliError.message.orEmpty().contains("single-witness replay is unavailable")) + } + @Test fun `aggregator rejects an interrupted raw prefix without completion`(@TempDir directory: Path) { val rawOutput = directory.resolve("results.jsonl") @@ -251,6 +403,42 @@ class CallsExperimentTest { .filter(String::isNotBlank) .map { line -> CallsExperimentJson.json.decodeFromString(line) } + private fun writeRecords(path: Path, records: List) { + Files.writeString( + path, + records.joinToString(separator = "\n", postfix = "\n") { record -> + CallsExperimentJson.json.encodeToString(record) + }, + ) + } + + private fun selector(seed: Long, profile: CallsExperimentProfile) = CallsWitnessSelector( + projectId = "fixture/project", + functionId = "fixture.ts::predicate/1", + targetId = "fixture.ts::predicate/1#return", + profile = profile, + seed = seed, + ) + + private fun targetResult(inputs: List) = CallsTargetResult( + experimentId = "fixture", + projectId = "fixture/project", + revision = "project-revision", + development = true, + functionId = "fixture.ts::predicate/1", + targetId = "fixture.ts::predicate/1#return", + siteId = "fixture.ts:1:1-1:12::predicate/1", + profile = CallsExperimentProfile.FROZEN_STOP, + seed = 1L, + symbolicStatus = CallsSymbolicStatus.REACHED, + solverReached = true, + inputExtracted = true, + inputs = inputs, + replayStatus = CallsReplayStatus.CONFIRMED, + catalogFingerprint = "runtime-fingerprint", + symbolicElapsedMillis = 7L, + ) + private companion object { const val FIXTURE_TOOL_REVISION = "0000000000000000000000000000000000000001" } From b2bc1f08331fe9eca15af9afff1fc93c3f4e2fde Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 19 Sep 2026 16:32:22 +0300 Subject: [PATCH 06/13] [TS Calls] Use configurable witness replay output --- .../kotlin/org/usvm/ts/pbt/calls/CallsExperimentCli.kt | 3 ++- .../kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperimentCli.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperimentCli.kt index 24e5ffb22d..40a852bbd0 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperimentCli.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperimentCli.kt @@ -47,7 +47,8 @@ internal fun replayWitness(args: List) { selector = selector, ) - println(CallsExperimentJson.json.encodeToString(result)) + val encoded = CallsExperimentJson.json.encodeToString(result) + System.out.appendLine(encoded) } private fun runExperiment(args: List) { diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt index 9e1087ac3e..b23de30cb0 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt @@ -170,9 +170,15 @@ class CallsExperimentTest { selector = selector(seed = 11L, profile = CallsExperimentProfile.FROZEN_STOP), ) + val expectedTarget = frozenManifest.projects + .single() + .functions + .single() + .targets + .single() assertEquals(CallsReplayStatus.REJECTED, replay.status) assertEquals(listOf(JsConcreteValue.Boolean(true)), replayedInputs) - assertEquals(frozenManifest.projects.single().functions.single().targets.single(), replayedTarget) + assertEquals(expectedTarget, replayedTarget) assertEquals(1_000L, replayedTimeout) assertEquals(directory.toRealPath(), verifiedCheckout) } From 88afee3a26d713c1cef6583c5a9b0c7b4bcc2b3a Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 00:01:26 +0300 Subject: [PATCH 07/13] [TS Calls] Adapt experiment identities after model merge --- .../org/usvm/ts/pbt/calls/CallsExperiment.kt | 29 ++++------- .../pbt/calls/CurrentTsCallsSymbolicEngine.kt | 33 ------------- .../usvm/ts/pbt/calls/CallsExperimentTest.kt | 48 ++++++++++++++++--- .../main/kotlin/org/usvm/machine/TsMachine.kt | 30 +++++++++++- 4 files changed, 77 insertions(+), 63 deletions(-) diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperiment.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperiment.kt index ced262c43c..ff47d829bf 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperiment.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperiment.kt @@ -31,10 +31,13 @@ internal enum class CallsExperimentProfile( @Serializable internal data class CallsModelSetIdentity( val ids: Set, - val catalogFingerprint: String, - val sourceHash: String, - val etsIrHash: String, val toolRevision: String, + @SerialName("catalogFingerprint") + val legacyCatalogFingerprint: String? = null, + @SerialName("sourceHash") + val legacyModelSourceHash: String? = null, + @SerialName("etsIrHash") + val legacyModelEtsIrHash: String? = null, ) @Serializable @@ -126,9 +129,6 @@ internal data class CallsSymbolicSearchRequest( val target: CallsSourceTarget, val profile: CallsExperimentProfile, val frozenModelIds: Set, - val expectedCatalogFingerprint: String, - val expectedModelSourceHash: String, - val expectedModelEtsIrHash: String, val expectedNativeFrontendRevision: String, val expectedNativeFrontendSha256: String, val seed: Long, @@ -139,7 +139,6 @@ internal data class CallsSymbolicSearchResult( val status: CallsSymbolicStatus, val solverReached: Boolean = status == CallsSymbolicStatus.REACHED, val inputs: List? = null, - val catalogFingerprint: String? = null, val elapsedMillis: Long, val diagnostic: String? = null, ) { @@ -198,7 +197,8 @@ internal data class CallsTargetResult( val inputExtracted: Boolean, val inputs: List? = null, val replayStatus: CallsReplayStatus?, - val catalogFingerprint: String?, + @SerialName("catalogFingerprint") + val legacyCatalogFingerprint: String? = null, val symbolicElapsedMillis: Long, val diagnostic: String? = null, ) : CallsRawRecord @@ -401,13 +401,6 @@ internal class CallsExperimentRunner( target = target, profile = profile, frozenModelIds = manifest.modelSet.ids, - expectedCatalogFingerprint = if (profile.usesFrozenModels) { - manifest.modelSet.catalogFingerprint - } else { - EMPTY_CATALOG_FINGERPRINT - }, - expectedModelSourceHash = manifest.modelSet.sourceHash, - expectedModelEtsIrHash = manifest.modelSet.etsIrHash, expectedNativeFrontendRevision = manifest.nativeFrontendRevision, expectedNativeFrontendSha256 = manifest.nativeFrontendSha256, seed = seed, @@ -439,7 +432,6 @@ internal class CallsExperimentRunner( inputExtracted = symbolic.inputs != null, inputs = symbolic.inputs, replayStatus = replay?.status, - catalogFingerprint = symbolic.catalogFingerprint, symbolicElapsedMillis = symbolic.elapsedMillis, diagnostic = replay?.message ?: replay?.reason ?: symbolic.diagnostic, ) @@ -460,11 +452,6 @@ internal class CallsExperimentRunner( StandardOpenOption.APPEND, ) } - - private companion object { - const val EMPTY_CATALOG_FINGERPRINT = - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - } } internal data class CallsValidatedRawResults( diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt index b61996fa4b..e49ca55991 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt @@ -158,35 +158,9 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { MachineResult( states = entryObserver.reachedStates, stopReason = outcome.stopReason, - catalogFingerprint = machine.unknownCallModelCatalogFingerprint, - artifactIdentities = machine.unknownCallModelArtifactIdentities.mapValues { (_, identity) -> - identity.sourceHash to identity.etsIrHash - }, ) } val states = analysis.states - val fingerprint = analysis.catalogFingerprint - if (fingerprint != request.expectedCatalogFingerprint) { - return result( - status = CallsSymbolicStatus.TOOL_ERROR, - startedAt = startedAt, - catalogFingerprint = fingerprint, - diagnostic = "Runtime model fingerprint $fingerprint does not match the frozen manifest", - ) - } - if (request.profile.usesFrozenModels) { - val artifactIdentities = analysis.artifactIdentities.values.toSet() - val expectedIdentity = request.expectedModelSourceHash to request.expectedModelEtsIrHash - if (artifactIdentities != setOf(expectedIdentity)) { - return result( - status = CallsSymbolicStatus.TOOL_ERROR, - startedAt = startedAt, - catalogFingerprint = fingerprint, - diagnostic = "Runtime EtsIR model artifacts $artifactIdentities " + - "do not match frozen artifact $expectedIdentity", - ) - } - } if (states.isEmpty()) { val status = when (analysis.stopReason) { TsAnalysisStopReason.EXHAUSTED -> CallsSymbolicStatus.UNREACHED @@ -197,7 +171,6 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { return result( status = status, startedAt = startedAt, - catalogFingerprint = fingerprint, diagnostic = if (analysis.stopReason == TsAnalysisStopReason.OTHER_LIMIT) { "Symbolic execution stopped for an unexpected non-timeout limit" } else { @@ -216,7 +189,6 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { status = CallsSymbolicStatus.UNREPRESENTABLE, solverReached = true, startedAt = startedAt, - catalogFingerprint = fingerprint, diagnostic = "No reached state has inputs inside every frozen domain", ) } @@ -225,7 +197,6 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { status = CallsSymbolicStatus.REACHED, inputs = inputs, startedAt = startedAt, - catalogFingerprint = fingerprint, diagnostic = "exact-source-lowering-size=${targetCandidates.size}", ) } @@ -269,13 +240,11 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { startedAt: TimeSource.Monotonic.ValueTimeMark, solverReached: Boolean = status == CallsSymbolicStatus.REACHED, inputs: List? = null, - catalogFingerprint: String? = null, diagnostic: String? = null, ) = CallsSymbolicSearchResult( status = status, solverReached = solverReached, inputs = inputs, - catalogFingerprint = catalogFingerprint, elapsedMillis = startedAt.elapsedNow().inWholeMilliseconds, diagnostic = diagnostic, ) @@ -331,8 +300,6 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { private data class MachineResult( val states: List, val stopReason: TsAnalysisStopReason, - val catalogFingerprint: String?, - val artifactIdentities: Map>, ) } diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt index b23de30cb0..edda493bf6 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt @@ -85,6 +85,7 @@ class CallsExperimentTest { assertTrue(emptyFresh.solverReached) assertTrue(emptyFresh.inputExtracted) assertEquals(listOf(JsConcreteValue.Boolean(false)), emptyFresh.inputs) + assertNull(emptyFresh.legacyCatalogFingerprint) assertEquals(CallsReplayStatus.REJECTED, emptyFresh.replayStatus) val frozenStop = results.single { result -> result.profile == CallsExperimentProfile.FROZEN_STOP } @@ -115,13 +116,47 @@ class CallsExperimentTest { elements = listOf(JsConcreteValue.Undefined, JsConcreteValue.Null, JsConcreteValue.String("value")), ), ) - val result = targetResult(inputs = witness) + val result = targetResult( + inputs = witness, + legacyCatalogFingerprint = "historical-runtime-fingerprint", + ) val encoded = CallsExperimentJson.json.encodeToString(result) val decoded = CallsExperimentJson.json.decodeFromString(encoded) as CallsTargetResult assertEquals(result, decoded) assertEquals(witness, decoded.inputs) + assertEquals("historical-runtime-fingerprint", decoded.legacyCatalogFingerprint) + } + + @Test + fun `historical semantic model identities decode but current manifests do not invent them`() { + val currentManifest = manifest(sourceRoot = ".", seeds = listOf(1L)) + val encodedCurrent = CallsExperimentJson.encodeManifest(currentManifest) + assertFalse(encodedCurrent.contains("catalogFingerprint")) + assertFalse(encodedCurrent.contains("sourceHash")) + assertFalse(encodedCurrent.contains("etsIrHash")) + + val historicalManifest = currentManifest.copy( + modelSet = currentManifest.modelSet.copy( + legacyCatalogFingerprint = "historical-catalog-fingerprint", + legacyModelSourceHash = "historical-model-source-hash", + legacyModelEtsIrHash = "historical-model-ets-ir-hash", + ), + ) + val decoded = CallsExperimentJson.decodeManifest(CallsExperimentJson.encodeManifest(historicalManifest)) + val sourceReplayHash = decoded.projects + .single() + .functions + .single() + .targets + .single() + .sourceSha256 + + assertEquals("historical-catalog-fingerprint", decoded.modelSet.legacyCatalogFingerprint) + assertEquals("historical-model-source-hash", decoded.modelSet.legacyModelSourceHash) + assertEquals("historical-model-ets-ir-hash", decoded.modelSet.legacyModelEtsIrHash) + assertEquals("source-hash", sourceReplayHash) } @Test @@ -353,9 +388,6 @@ class CallsExperimentTest { searchPolicy = "BFS", modelSet = CallsModelSetIdentity( ids = setOf("ts.array.pop", "ts.array.shift"), - catalogFingerprint = "frozen-fingerprint", - sourceHash = "source-hash", - etsIrHash = "ets-ir-hash", toolRevision = FIXTURE_TOOL_REVISION, ), seeds = seeds, @@ -401,7 +433,6 @@ class CallsExperimentTest { status = status, solverReached = solverReached, inputs = inputs, - catalogFingerprint = "runtime-fingerprint", elapsedMillis = 7L, ) @@ -426,7 +457,10 @@ class CallsExperimentTest { seed = seed, ) - private fun targetResult(inputs: List) = CallsTargetResult( + private fun targetResult( + inputs: List, + legacyCatalogFingerprint: String? = null, + ) = CallsTargetResult( experimentId = "fixture", projectId = "fixture/project", revision = "project-revision", @@ -441,7 +475,7 @@ class CallsExperimentTest { inputExtracted = true, inputs = inputs, replayStatus = CallsReplayStatus.CONFIRMED, - catalogFingerprint = "runtime-fingerprint", + legacyCatalogFingerprint = legacyCatalogFingerprint, symbolicElapsedMillis = 7L, ) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt index 637b91d3d2..22b97e70c9 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt @@ -39,6 +39,19 @@ import kotlin.time.Duration.Companion.seconds private val logger = KotlinLogging.logger {} +/** Reason the symbolic machine stopped its most recent analysis. */ +enum class TsAnalysisStopReason { + EXHAUSTED, + TIMEOUT, + OTHER_LIMIT, +} + +/** Collected states together with the exact machine stop reason. */ +data class TsAnalysisResult( + val states: List, + val stopReason: TsAnalysisStopReason, +) + class TsMachine( scene: EtsScene, override val options: UMachineOptions, @@ -92,7 +105,12 @@ class TsMachine( fun analyze( methods: List, targets: List = emptyList(), - ): List { + ): List = analyzeWithOutcome(methods = methods, targets = targets).states + + fun analyzeWithOutcome( + methods: List, + targets: List = emptyList(), + ): TsAnalysisResult { val initialStates = mutableMapOf() methods.forEach { initialStates[it] = interpreter.getInitialState(it, targets) } @@ -194,7 +212,15 @@ class TsMachine( stopStrategy = stopStrategy ) - return statesCollector.collectedStates + val stopReason = when { + pathSelector.isEmpty() -> TsAnalysisStopReason.EXHAUSTED + options.timeout < kotlin.time.Duration.INFINITE && timeStatistics.runningTime > options.timeout -> { + TsAnalysisStopReason.TIMEOUT + } + else -> TsAnalysisStopReason.OTHER_LIMIT + } + + return TsAnalysisResult(states = statesCollector.collectedStates, stopReason = stopReason) } override fun close() { From 3a76e5a808070dd6f8e55d870d30c9cd99c36b63 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 00:54:35 +0300 Subject: [PATCH 08/13] [TS Calls] Observe source statements before nested lowering --- .../pbt/calls/CurrentTsCallsSymbolicEngine.kt | 75 ++++++++- .../ts/pbt/calls/SourceStatementEntryTest.kt | 154 ++++++++++++++++++ .../calls/SourceStatementEntryFixture.ts | 15 ++ 3 files changed, 237 insertions(+), 7 deletions(-) create mode 100644 usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/SourceStatementEntryTest.kt create mode 100644 usvm-ts-pbt/src/test/resources/calls/SourceStatementEntryFixture.ts diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt index e49ca55991..809b1995a8 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt @@ -116,14 +116,22 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { } val method = mapping.predicate.targets.single().method - val targetCandidates = exactTargetCandidates(method, request.target) - if (targetCandidates.isEmpty()) { + val exactTargetCandidates = exactTargetCandidates(method, request.target) + if (exactTargetCandidates.isEmpty()) { return result( status = CallsSymbolicStatus.UNMAPPED, startedAt = startedAt, diagnostic = "No EtsIR statement has the exact frozen source range", ) } + val statementEntry = sourceStatementEntry(method, request.target) + if (statementEntry == null) { + return result( + status = CallsSymbolicStatus.UNSUPPORTED, + startedAt = startedAt, + diagnostic = "EtsIR origins do not prove entry before evaluation of the frozen source statement", + ) + } val modelSelection = if (request.profile.usesFrozenModels) { TsUnknownCallModelSelection.Only(request.frozenModelIds) @@ -144,10 +152,9 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { unknownCallModelSelection = modelSelection, unknownCallFallback = request.profile.fallback, ) - // One source statement may lower to consecutive EtsIR instructions with the same exact source span. - // Observe the first instruction before it executes, matching the source replay marker - // inserted before the statement. - val entryObserver = SourceStatementEntryObserver(targetCandidates.first()) + // Observe the unique CFG entry into the source statement's origin-contained lowering region. + // This matches the replay marker before statement evaluation, including nested constructor and call lowering. + val entryObserver = SourceStatementEntryObserver(statementEntry.statement) val analysis = TsMachine( scene = scene, options = machineOptions, @@ -197,7 +204,8 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { status = CallsSymbolicStatus.REACHED, inputs = inputs, startedAt = startedAt, - diagnostic = "exact-source-lowering-size=${targetCandidates.size}", + diagnostic = "source-statement-lowering-size=${statementEntry.loweringSize};" + + "exact-source-lowering-size=${exactTargetCandidates.size}", ) } @@ -303,6 +311,59 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { ) } +internal data class SourceStatementEntry( + val statement: EtsStmt, + val loweringSize: Int, +) + +internal fun sourceStatementEntry(method: EtsMethod, target: CallsSourceTarget): SourceStatementEntry? { + val loweringRegion = method.cfg.stmts.filter { statement -> + val origin = statement.location.origin ?: return@filter false + origin.startOffset >= target.startOffset && origin.endOffset <= target.endOffset + } + val loweringRegionSet = loweringRegion.toSet() + if (loweringRegionSet.isEmpty()) { + return null + } + + val boundaryStatements = loweringRegion.filter { statement -> + val predecessors = method.cfg.predecessors(statement) + predecessors.isEmpty() || predecessors.any { predecessor -> predecessor !in loweringRegionSet } + } + val entry = boundaryStatements.singleOrNull() ?: return null + val outsidePredecessors = method.cfg.predecessors(entry).filter { predecessor -> + predecessor !in loweringRegionSet + } + val outsideOriginsAreBeforeTarget = outsidePredecessors.all { predecessor -> + val origin = predecessor.location.origin ?: return@all false + origin.endOffset <= target.startOffset + } + if (!outsideOriginsAreBeforeTarget) { + return null + } + + val reachable = mutableSetOf() + val pending = ArrayDeque() + pending += entry + while (pending.isNotEmpty()) { + val statement = pending.removeFirst() + if (!reachable.add(statement)) { + continue + } + + method.cfg.successors(statement) + .filterTo(pending) { successor -> successor in loweringRegionSet } + } + if (reachable.size != loweringRegionSet.size) { + return null + } + + return SourceStatementEntry( + statement = entry, + loweringSize = loweringRegionSet.size, + ) +} + internal fun verifyCallsGitCheckout(checkout: Path, expectedRevision: String) { val actualRevision = runCallsGit(checkout, "rev-parse", "HEAD").trim() require(actualRevision == expectedRevision) { diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/SourceStatementEntryTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/SourceStatementEntryTest.kt new file mode 100644 index 0000000000..3d4a6a3145 --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/SourceStatementEntryTest.kt @@ -0,0 +1,154 @@ +package org.usvm.ts.pbt.calls + +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsReturnStmt +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.model.EtsStmt +import org.jacodb.ets.model.EtsThrowStmt +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.junit.jupiter.api.Test +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SourceStatementEntryTest { + @Test + fun `throw entry precedes constructor lowering and exact throw instruction`() { + val fixture = loadFixture() + val sourceStatement = "throw new Error('negative');" + val target = fixture.target(sourceStatement) + val exactThrow = fixture.method("nestedLowering").exactStatement(target) + + val entry = assertNotNull(sourceStatementEntry(fixture.method("nestedLowering"), target)) + + assertTrue(entry.loweringSize > 1, "Throw statement must contain nested constructor lowering") + assertNotEquals(exactThrow, entry.statement, "Entry must precede the exact throw instruction") + assertTrue( + fixture.method("nestedLowering").reachesWithinTarget(entry.statement, exactThrow, target), + "Exact throw instruction must be reachable from the selected entry inside the statement", + ) + } + + @Test + fun `return entry precedes nested call lowering and exact return instruction`() { + val fixture = loadFixture() + val sourceStatement = "return Math.round(value) / 2;" + val target = fixture.target(sourceStatement) + val exactReturn = fixture.method("nestedLowering").exactStatement(target) + + val entry = assertNotNull(sourceStatementEntry(fixture.method("nestedLowering"), target)) + + assertTrue(entry.loweringSize > 1, "Return statement must contain nested call lowering") + assertNotEquals(exactReturn, entry.statement, "Entry must precede the exact return instruction") + assertTrue( + fixture.method("nestedLowering").reachesWithinTarget(entry.statement, exactReturn, target), + "Exact return instruction must be reachable from the selected entry inside the statement", + ) + } + + @Test + fun `multiple CFG entries into a candidate source range are rejected`() { + val fixture = loadFixture() + val source = fixture.source + val firstBranch = source.indexOf("return -value;") + val secondBranchEnd = source.indexOf("return value;") + "return value;".length + val target = fixture.target(startOffset = firstBranch, endOffset = secondBranchEnd) + + val entry = sourceStatementEntry(fixture.method("separateBranches"), target) + + assertNull(entry) + } + + private fun loadFixture(): Fixture { + val path = resourcePath("/calls/SourceStatementEntryFixture.ts") + val source = Files.readString(path) + val file = loadEtsFileAutoConvert(path, provider = EtsIrProvider.TS_FRONTEND) + val methods = EtsScene(projectFiles = listOf(file)).projectClasses + .flatMap { projectClass -> projectClass.methods } + .associateBy { method -> method.name } + + return Fixture(source = source, methods = methods) + } + + private fun resourcePath(name: String): Path { + val resource = assertNotNull(javaClass.getResource(name), "Missing test resource $name") + return Paths.get(resource.toURI()) + } + + private data class Fixture( + val source: String, + val methods: Map, + ) { + fun method(name: String): EtsMethod = assertNotNull(methods[name], "Missing method $name") + + fun target(sourceStatement: String): CallsSourceTarget { + val startOffset = source.indexOf(sourceStatement) + assertTrue(startOffset >= 0, "Missing source statement: $sourceStatement") + + return target(startOffset = startOffset, endOffset = startOffset + sourceStatement.length) + } + + fun target(startOffset: Int, endOffset: Int): CallsSourceTarget = CallsSourceTarget( + targetId = "test-target", + siteId = "test-site", + sourcePath = "SourceStatementEntryFixture.ts", + sourceSha256 = "unused", + startOffset = startOffset, + endOffset = endOffset, + start = source.positionAt(startOffset), + end = source.positionAt(endOffset), + ) + } +} + +private inline fun EtsMethod.exactStatement(target: CallsSourceTarget): T { + val matches = cfg.stmts.filterIsInstance().filter { statement -> + val origin = statement.location.origin ?: return@filter false + origin.startOffset == target.startOffset && origin.endOffset == target.endOffset + } + + return assertEquals(1, matches.size, "Expected one exact ${T::class.simpleName} statement").let { + matches.single() + } +} + +private fun EtsMethod.reachesWithinTarget( + start: EtsStmt, + targetStatement: EtsStmt, + target: CallsSourceTarget, +): Boolean { + val pending = ArrayDeque() + val visited = mutableSetOf() + pending += start + while (pending.isNotEmpty()) { + val statement = pending.removeFirst() + if (!visited.add(statement)) { + continue + } + if (statement == targetStatement) { + return true + } + + cfg.successors(statement).filterTo(pending) { successor -> + val origin = successor.location.origin ?: return@filterTo false + origin.startOffset >= target.startOffset && origin.endOffset <= target.endOffset + } + } + + return false +} + +private fun String.positionAt(offset: Int): CallsSourcePosition { + val prefix = substring(startIndex = 0, endIndex = offset) + val line = prefix.count { character -> character == '\n' } + val lastLineBreak = prefix.lastIndexOf('\n') + val column = offset - lastLineBreak - 1 + + return CallsSourcePosition(line = line, column = column) +} diff --git a/usvm-ts-pbt/src/test/resources/calls/SourceStatementEntryFixture.ts b/usvm-ts-pbt/src/test/resources/calls/SourceStatementEntryFixture.ts new file mode 100644 index 0000000000..aa00452346 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/calls/SourceStatementEntryFixture.ts @@ -0,0 +1,15 @@ +export function nestedLowering(value: number): number { + if (value < 0) { + throw new Error('negative'); + } + + return Math.round(value) / 2; +} + +export function separateBranches(value: number): number { + if (value < 0) { + return -value; + } else { + return value; + } +} From 3bd8a8ac72507667ceb72195d3c3864f3b75be85 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 13:05:18 +0300 Subject: [PATCH 09/13] [TS Calls] Distinguish exhaustion from configured stops --- .../pbt/calls/CurrentTsCallsSymbolicEngine.kt | 9 +---- .../main/kotlin/org/usvm/machine/TsMachine.kt | 18 ++++----- .../usvm/machine/TsMachineCompletionTest.kt | 40 +++++++++++++++++++ 3 files changed, 49 insertions(+), 18 deletions(-) create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/TsMachineCompletionTest.kt diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt index 809b1995a8..ee769f96b9 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt @@ -171,18 +171,13 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { if (states.isEmpty()) { val status = when (analysis.stopReason) { TsAnalysisStopReason.EXHAUSTED -> CallsSymbolicStatus.UNREACHED - TsAnalysisStopReason.TIMEOUT -> CallsSymbolicStatus.TIMEOUT - TsAnalysisStopReason.OTHER_LIMIT -> CallsSymbolicStatus.TOOL_ERROR + // The machine options above disable every stop condition except the per-target timeout. + TsAnalysisStopReason.STOPPED -> CallsSymbolicStatus.TIMEOUT } return result( status = status, startedAt = startedAt, - diagnostic = if (analysis.stopReason == TsAnalysisStopReason.OTHER_LIMIT) { - "Symbolic execution stopped for an unexpected non-timeout limit" - } else { - null - }, ) } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt index 22b97e70c9..5c5d344b3b 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt @@ -39,14 +39,13 @@ import kotlin.time.Duration.Companion.seconds private val logger = KotlinLogging.logger {} -/** Reason the symbolic machine stopped its most recent analysis. */ +/** Whether symbolic analysis exhausted its paths or a configured strategy stopped it. */ enum class TsAnalysisStopReason { EXHAUSTED, - TIMEOUT, - OTHER_LIMIT, + STOPPED, } -/** Collected states together with the exact machine stop reason. */ +/** Collected states together with the machine completion kind. */ data class TsAnalysisResult( val states: List, val stopReason: TsAnalysisStopReason, @@ -165,7 +164,6 @@ class TsMachine( } val stepsStatistics = StepsStatistics() - val stopStrategy = object : StopStrategy { val strategy = createStopStrategy( options, @@ -212,12 +210,10 @@ class TsMachine( stopStrategy = stopStrategy ) - val stopReason = when { - pathSelector.isEmpty() -> TsAnalysisStopReason.EXHAUSTED - options.timeout < kotlin.time.Duration.INFINITE && timeStatistics.runningTime > options.timeout -> { - TsAnalysisStopReason.TIMEOUT - } - else -> TsAnalysisStopReason.OTHER_LIMIT + val stopReason = if (pathSelector.isEmpty()) { + TsAnalysisStopReason.EXHAUSTED + } else { + TsAnalysisStopReason.STOPPED } return TsAnalysisResult(states = statesCollector.collectedStates, stopReason = stopReason) diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/TsMachineCompletionTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/TsMachineCompletionTest.kt new file mode 100644 index 0000000000..416e1910ae --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/TsMachineCompletionTest.kt @@ -0,0 +1,40 @@ +package org.usvm.machine + +import org.jacodb.ets.model.EtsScene +import org.junit.jupiter.api.Test +import org.usvm.UMachineOptions +import org.usvm.util.TsMethodTestRunner +import kotlin.test.assertEquals +import kotlin.time.Duration + +class TsMachineCompletionTest : TsMethodTestRunner() { + override val scene: EtsScene = loadScene("/samples/lang/StaticOverloads.ts") + + @Test + fun `analysis distinguishes path exhaustion from strategy stop`() { + val method = getMethod(methodName = "callOverloaded", className = "StaticOverloads") + val exhaustedOptions = UMachineOptions( + stopOnCoverage = 0, + timeout = Duration.INFINITE, + ) + val stoppedOptions = exhaustedOptions.copy(stepLimit = 1uL) + + val exhausted = TsMachine( + scene = scene, + options = exhaustedOptions, + tsOptions = TsOptions(), + ).use { machine -> + machine.analyzeWithOutcome(methods = listOf(method)) + } + val stopped = TsMachine( + scene = scene, + options = stoppedOptions, + tsOptions = TsOptions(), + ).use { machine -> + machine.analyzeWithOutcome(methods = listOf(method)) + } + + assertEquals(TsAnalysisStopReason.EXHAUSTED, exhausted.stopReason) + assertEquals(TsAnalysisStopReason.STOPPED, stopped.stopReason) + } +} From 1cd3a9074f6e3b83f6141c4392259ba259517e8a Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 13:39:44 +0300 Subject: [PATCH 10/13] [TS Calls] Isolate experiment tooling from PBT --- build.gradle.kts | 1 + settings.gradle.kts | 1 + usvm-ts-calls/build.gradle.kts | 128 +++++ .../source-replay-adapter/.gitignore | 2 + .../source-replay-adapter/package-lock.json | 542 ++++++++++++++++++ .../source-replay-adapter/package.json | 24 + .../src/calls-entry-point.ts | 95 +++ .../src/calls-js-value.ts | 44 ++ .../src/process-group-shutdown.ts | 77 +++ .../src/process-supervisor.ts | 197 +++++++ .../src/source-target-replay-cli.ts | 13 +- .../src/source-target-replay-worker.ts | 4 +- .../test/source-target-replay-cli.test.ts | 16 - .../test/source-target-replay-fixture.ts | 0 .../source-replay-adapter/tsconfig.json | 20 + .../org/usvm/ts}/calls/CallsExperiment.kt | 17 +- .../org/usvm/ts}/calls/CallsExperimentCli.kt | 2 +- .../usvm/ts/calls/CallsProcessTransport.kt | 437 ++++++++++++++ .../org/usvm/ts/calls/CallsReplayRuntime.kt | 44 ++ .../org/usvm/ts}/calls/CallsSourceReplay.kt | 14 +- .../org/usvm/ts}/calls/CallsWitnessReplay.kt | 5 +- .../ts}/calls/CurrentTsCallsSymbolicEngine.kt | 36 +- .../org/usvm/ts}/calls/CallsExperimentTest.kt | 60 +- .../ts}/calls/SourceStatementEntryTest.kt | 3 +- .../calls/SourceStatementEntryFixture.ts | 0 usvm-ts-pbt/build.gradle.kts | 40 -- .../fast-check-adapter/package-lock.json | 7 +- usvm-ts-pbt/fast-check-adapter/package.json | 6 +- .../fast-check-adapter/src/entry-point.ts | 45 +- .../usvm/ts/pbt/fastcheck/FastCheckRuntime.kt | 3 - 30 files changed, 1654 insertions(+), 229 deletions(-) create mode 100644 usvm-ts-calls/build.gradle.kts create mode 100644 usvm-ts-calls/source-replay-adapter/.gitignore create mode 100644 usvm-ts-calls/source-replay-adapter/package-lock.json create mode 100644 usvm-ts-calls/source-replay-adapter/package.json create mode 100644 usvm-ts-calls/source-replay-adapter/src/calls-entry-point.ts create mode 100644 usvm-ts-calls/source-replay-adapter/src/calls-js-value.ts create mode 100644 usvm-ts-calls/source-replay-adapter/src/process-group-shutdown.ts create mode 100644 usvm-ts-calls/source-replay-adapter/src/process-supervisor.ts rename {usvm-ts-pbt/fast-check-adapter => usvm-ts-calls/source-replay-adapter}/src/source-target-replay-cli.ts (95%) rename {usvm-ts-pbt/fast-check-adapter => usvm-ts-calls/source-replay-adapter}/src/source-target-replay-worker.ts (94%) rename {usvm-ts-pbt/fast-check-adapter => usvm-ts-calls/source-replay-adapter}/test/source-target-replay-cli.test.ts (90%) rename {usvm-ts-pbt/fast-check-adapter => usvm-ts-calls/source-replay-adapter}/test/source-target-replay-fixture.ts (100%) create mode 100644 usvm-ts-calls/source-replay-adapter/tsconfig.json rename {usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt => usvm-ts-calls/src/main/kotlin/org/usvm/ts}/calls/CallsExperiment.kt (96%) rename {usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt => usvm-ts-calls/src/main/kotlin/org/usvm/ts}/calls/CallsExperimentCli.kt (99%) create mode 100644 usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsProcessTransport.kt create mode 100644 usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsReplayRuntime.kt rename {usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt => usvm-ts-calls/src/main/kotlin/org/usvm/ts}/calls/CallsSourceReplay.kt (91%) rename {usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt => usvm-ts-calls/src/main/kotlin/org/usvm/ts}/calls/CallsWitnessReplay.kt (96%) rename {usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt => usvm-ts-calls/src/main/kotlin/org/usvm/ts}/calls/CurrentTsCallsSymbolicEngine.kt (91%) rename {usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt => usvm-ts-calls/src/test/kotlin/org/usvm/ts}/calls/CallsExperimentTest.kt (87%) rename {usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt => usvm-ts-calls/src/test/kotlin/org/usvm/ts}/calls/SourceStatementEntryTest.kt (98%) rename {usvm-ts-pbt => usvm-ts-calls}/src/test/resources/calls/SourceStatementEntryFixture.ts (100%) diff --git a/build.gradle.kts b/build.gradle.kts index 0c4158164e..a97eb8838e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,6 +22,7 @@ tasks.register("validateProjectList") { project(":usvm-jvm-instrumentation"), project(":usvm-python"), project(":usvm-ts"), + project(":usvm-ts-calls"), project(":usvm-ts-pbt"), project(":usvm-ts-dataflow"), ) diff --git a/settings.gradle.kts b/settings.gradle.kts index 6e81861905..ba4587723c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,6 +34,7 @@ include("usvm-jvm:usvm-jvm-api") include("usvm-jvm:usvm-jvm-test-api") include("usvm-jvm:usvm-jvm-util") include("usvm-ts") +include("usvm-ts-calls") include("usvm-ts-pbt") include("usvm-util") include("usvm-jvm-instrumentation") diff --git a/usvm-ts-calls/build.gradle.kts b/usvm-ts-calls/build.gradle.kts new file mode 100644 index 0000000000..7e12ba8129 --- /dev/null +++ b/usvm-ts-calls/build.gradle.kts @@ -0,0 +1,128 @@ +plugins { + id("usvm.kotlin-conventions") + kotlin("plugin.serialization") version Versions.kotlin + application +} + +dependencies { + implementation(project(":usvm-core")) + implementation(project(":usvm-ts")) + implementation(project(":usvm-ts-pbt")) + implementation(Libs.jacodb_ets) + implementation(Libs.kotlinx_serialization_json) + + testImplementation(Libs.logback) +} + +val replayAdapterDir = layout.projectDirectory.dir("source-replay-adapter") +val replayAdapterPackageJson = replayAdapterDir.file("package.json") +val replayAdapterPackageLock = replayAdapterDir.file("package-lock.json") +val replayRuntimeProperty = "org.usvm.ts.calls.replay.runtime" +val generatedBuildMetadataDirectory = layout.buildDirectory.dir("generated/resources/callsBuildMetadata") +val hostOperatingSystem = System.getProperty("os.name").lowercase() +val hostPlatform = when { + hostOperatingSystem.contains("mac") -> "darwin" + hostOperatingSystem.contains("linux") -> "linux" + hostOperatingSystem.contains("windows") -> "win32" + else -> error("Unsupported source replay operating system: $hostOperatingSystem") +} +val npmExecutable = if (hostPlatform == "win32") "npm.cmd" else "npm" + +val toolRevision = providers.exec { + workingDir(rootProject.projectDir) + commandLine("git", "rev-parse", "HEAD") +}.standardOutput.asText.map(String::trim) +val toolStatus = providers.exec { + workingDir(rootProject.projectDir) + commandLine("git", "status", "--porcelain", "--untracked-files=all") +}.standardOutput.asText.map(String::trim) + +val generateBuildMetadata = tasks.register("generateBuildMetadata") { + inputs.property("toolRevision", toolRevision) + inputs.property("toolStatus", toolStatus) + outputs.dir(generatedBuildMetadataDirectory) + + doLast { + val revision = toolRevision.get() + val buildIdentity = if (toolStatus.get().isBlank()) revision else "$revision-dirty" + val metadataFile = generatedBuildMetadataDirectory.get() + .file("org/usvm/ts/calls/build.properties") + .asFile + metadataFile.parentFile.mkdirs() + metadataFile.writeText("tool.revision=$buildIdentity\n", Charsets.UTF_8) + } +} + +sourceSets.main { + resources.srcDir(generatedBuildMetadataDirectory) +} + +tasks.processResources { + dependsOn(generateBuildMetadata) +} + +val installReplayAdapter = tasks.register("installReplayAdapter") { + workingDir(replayAdapterDir) + commandLine(npmExecutable, "ci", "--ignore-scripts") + inputs.files(replayAdapterPackageJson, replayAdapterPackageLock) + outputs.dir(replayAdapterDir.dir("node_modules")) +} + +val buildReplayAdapter = tasks.register("buildReplayAdapter") { + dependsOn(installReplayAdapter) + workingDir(replayAdapterDir) + commandLine(npmExecutable, "run", "build") + inputs.files(replayAdapterPackageJson, replayAdapterPackageLock, replayAdapterDir.file("tsconfig.json")) + inputs.dir(replayAdapterDir.dir("src")) + inputs.dir(replayAdapterDir.dir("test")) + outputs.dir(replayAdapterDir.dir("dist")) +} + +val testReplayAdapter = tasks.register("testReplayAdapter") { + dependsOn(buildReplayAdapter) + workingDir(replayAdapterDir) + commandLine(npmExecutable, "run", "test:compiled") + inputs.dir(replayAdapterDir.dir("dist")) +} + +tasks.test { + dependsOn(buildReplayAdapter) + systemProperty(replayRuntimeProperty, replayAdapterDir.asFile.absolutePath) +} + +tasks.check { + dependsOn(testReplayAdapter) +} + +tasks.clean { + delete(replayAdapterDir.dir("dist")) +} + +application { + mainClass = "org.usvm.ts.calls.CallsExperimentCliKt" + applicationDefaultJvmArgs = listOf("-Dfile.encoding=UTF-8", "-Dsun.stdout.encoding=UTF-8") +} + +tasks.named("run") { + systemProperty(replayRuntimeProperty, replayAdapterDir.asFile.absolutePath) + dependsOn(buildReplayAdapter) +} + +distributions { + main { + contents { + into("lib/source-replay-adapter") { + from(replayAdapterDir) + include("dist/src/**") + include("node_modules/**") + include("package.json") + } + } + } +} + +listOf("startScripts", "installDist", "distZip", "distTar").forEach { taskName -> + tasks.named(taskName) { + dependsOn(buildReplayAdapter) + } +} diff --git a/usvm-ts-calls/source-replay-adapter/.gitignore b/usvm-ts-calls/source-replay-adapter/.gitignore new file mode 100644 index 0000000000..1eae0cf670 --- /dev/null +++ b/usvm-ts-calls/source-replay-adapter/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/usvm-ts-calls/source-replay-adapter/package-lock.json b/usvm-ts-calls/source-replay-adapter/package-lock.json new file mode 100644 index 0000000000..5e48264aaf --- /dev/null +++ b/usvm-ts-calls/source-replay-adapter/package-lock.json @@ -0,0 +1,542 @@ +{ + "name": "@usvm/ts-calls-source-replay-adapter", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@usvm/ts-calls-source-replay-adapter", + "version": "0.1.0", + "dependencies": { + "typescript": "5.9.2", + "tsx": "4.23.12" + }, + "devDependencies": { + "@types/node": "18.19.130" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/usvm-ts-calls/source-replay-adapter/package.json b/usvm-ts-calls/source-replay-adapter/package.json new file mode 100644 index 0000000000..893ff16310 --- /dev/null +++ b/usvm-ts-calls/source-replay-adapter/package.json @@ -0,0 +1,24 @@ +{ + "name": "@usvm/ts-calls-source-replay-adapter", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "clean": "node --input-type=module --eval \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\"", + "prebuild": "npm run clean", + "build": "tsc --project tsconfig.json", + "pretest": "npm run build", + "test": "npm run test:compiled", + "test:compiled": "node --test dist/test/source-target-replay-cli.test.js" + }, + "dependencies": { + "typescript": "5.9.2", + "tsx": "4.23.12" + }, + "devDependencies": { + "@types/node": "18.19.130" + }, + "engines": { + "node": ">=18.18.0" + } +} diff --git a/usvm-ts-calls/source-replay-adapter/src/calls-entry-point.ts b/usvm-ts-calls/source-replay-adapter/src/calls-entry-point.ts new file mode 100644 index 0000000000..dc5679839d --- /dev/null +++ b/usvm-ts-calls/source-replay-adapter/src/calls-entry-point.ts @@ -0,0 +1,95 @@ +import { realpath, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { tsImport } from 'tsx/esm/api'; + +export interface TypeScriptEntryPointReference { + module: string; + exportName: string; + executionKind: 'sync' | 'async'; +} + +interface LoadedCallable { + invoke(args: unknown[]): unknown | Promise; +} + +type EntryPointFunction = (...args: unknown[]) => unknown; + +export async function loadCallable( + reference: TypeScriptEntryPointReference, + sourceRoots: string[], + referencePath: string, +): Promise { + const modulePath = await resolveModule(reference.module, sourceRoots, referencePath); + const moduleNamespace = await tsImport(pathToFileURL(modulePath).href, import.meta.url) as Record; + const exportedValue = moduleNamespace[reference.exportName]; + if (typeof exportedValue !== 'function') { + throw new Error(`${referencePath}.exportName must identify a function export`); + } + + return { invoke: buildInvocation(exportedValue as EntryPointFunction, reference, referencePath) }; +} + +async function resolveModule(module: string, sourceRoots: string[], referencePath: string): Promise { + const matches: string[] = []; + for (const sourceRootValue of sourceRoots) { + if (!path.isAbsolute(sourceRootValue)) throw new Error('sourceRoots must contain absolute paths'); + + const sourceRoot = await realpath(sourceRootValue); + const candidate = path.resolve(sourceRoot, module); + if (!isWithin(candidate, sourceRoot)) throw new Error(`${referencePath}.module escapes its source root`); + + try { + const resolved = await realpath(candidate); + if (!isWithin(resolved, sourceRoot)) throw new Error(`${referencePath}.module resolves outside its source root`); + if ((await stat(resolved)).isFile()) matches.push(resolved); + } catch (error: unknown) { + if (!isMissingPath(error)) throw error; + } + } + + if (matches.length !== 1) throw new Error(`${referencePath}.module resolved to ${matches.length} files`); + + return matches[0] as string; +} + +function buildInvocation( + entryPoint: EntryPointFunction, + reference: TypeScriptEntryPointReference, + referencePath: string, +): (args: unknown[]) => unknown | Promise { + if (reference.executionKind === 'sync') { + return (args: unknown[]): unknown => { + const result = entryPoint(...args); + if (isThenable(result)) throw new Error(`${referencePath}.executionKind expected a synchronous result`); + + return result; + }; + } + + return async (args: unknown[]): Promise => { + const result = entryPoint(...args); + if (!isThenable(result)) throw new Error(`${referencePath}.executionKind expected an asynchronous result`); + + return await result; + }; +} + +function isWithin(candidate: string, root: string): boolean { + const relative = path.relative(root, candidate); + + return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); +} + +function isThenable(value: unknown): value is PromiseLike { + if (value === null) return false; + if (typeof value !== 'object' && typeof value !== 'function') return false; + + return typeof (value as { then?: unknown }).then === 'function'; +} + +function isMissingPath(error: unknown): boolean { + return error instanceof Error + && 'code' in error + && (error.code === 'ENOENT' || error.code === 'ENOTDIR'); +} diff --git a/usvm-ts-calls/source-replay-adapter/src/calls-js-value.ts b/usvm-ts-calls/source-replay-adapter/src/calls-js-value.ts new file mode 100644 index 0000000000..efa04bb02a --- /dev/null +++ b/usvm-ts-calls/source-replay-adapter/src/calls-js-value.ts @@ -0,0 +1,44 @@ +export type TaggedJsValue = + | { kind: 'undefined' } + | { kind: 'null' } + | { kind: 'boolean'; value: boolean } + | { kind: 'string'; value: string } + | { kind: 'number'; value: 'finite'; bits: string } + | { kind: 'number'; value: 'nan' | 'positive-infinity' | 'negative-infinity' } + | { kind: 'array'; elements: TaggedJsValue[] }; + +export function decodeJsValue(value: TaggedJsValue, path = 'value'): unknown { + switch (value.kind) { + case 'undefined': + return undefined; + case 'null': + return null; + case 'boolean': + case 'string': + return value.value; + case 'number': + return decodeNumber(value, path); + case 'array': + return value.elements.map((element, index) => decodeJsValue(element, `${path}.elements[${index}]`)); + } +} + +function decodeNumber(value: Extract, path: string): number { + switch (value.value) { + case 'nan': + return Number.NaN; + case 'positive-infinity': + return Number.POSITIVE_INFINITY; + case 'negative-infinity': + return Number.NEGATIVE_INFINITY; + case 'finite': { + if (!/^[0-9a-f]{16}$/.test(value.bits)) throw new Error(`${path} has an invalid finite number encoding`); + + const buffer = new ArrayBuffer(8); + const view = new DataView(buffer); + view.setBigUint64(0, BigInt(`0x${value.bits}`), false); + + return view.getFloat64(0, false); + } + } +} diff --git a/usvm-ts-calls/source-replay-adapter/src/process-group-shutdown.ts b/usvm-ts-calls/source-replay-adapter/src/process-group-shutdown.ts new file mode 100644 index 0000000000..5ff8d6039d --- /dev/null +++ b/usvm-ts-calls/source-replay-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-calls/source-replay-adapter/src/process-supervisor.ts b/usvm-ts-calls/source-replay-adapter/src/process-supervisor.ts new file mode 100644 index 0000000000..71771e9631 --- /dev/null +++ b/usvm-ts-calls/source-replay-adapter/src/process-supervisor.ts @@ -0,0 +1,197 @@ +import { spawn } from 'node:child_process'; +import { unlinkSync, writeFileSync } from 'node:fs'; +import { + ProcessGroupShutdown, + terminateOwnProcessGroup, + terminateOwnedProcessGroup, +} from './process-group-shutdown.js'; + +interface CommandExitMessage { + type: 'command-exit'; + code: number; +} + +type Command = [string, ...string[]]; + +/** + * Process tree: + * Kotlin client -> supervisor -> detached group owner -> command -> any descendants. + * The supervisor stays outside the owned group so it can escalate shutdown. The group owner stays alive over IPC + * until the command reports its exit, then the supervisor removes every remaining descendant at once. + */ + +const commandModeFlag = '--command'; +const groupOwnerFlag = '--group-owner'; +const MAX_TIMER_DELAY_MILLIS = 2 ** 31 - 1; + +runProcess(process.argv.slice(2)); + +function runProcess(arguments_: string[]): void { + const mode = requireArgument(arguments_[0], 'supervisor mode'); + + if (mode === groupOwnerFlag) { + runGroupOwner(requireCommand(arguments_.slice(1))); + return; + } + + if (mode !== commandModeFlag) fail(`Unknown supervisor mode: ${mode}`); + + const forceKillDelayMillis = requireTimerDelay(arguments_[1], 'force-kill delay'); + const processGroupFile = requireArgument(arguments_[2], 'process-group file'); + const command = requireCommand(arguments_.slice(3)); + + runSupervisor(command, forceKillDelayMillis, processGroupFile); +} + +function runSupervisor( + command: Command, + forceKillDelayMillis: number, + processGroupFile: string, +): void { + const shutdown = new ProcessGroupShutdown(forceKillDelayMillis, terminateOwnedProcessGroup); + installSupervisorSignalHandlers(shutdown); + + const supervisorEntryPoint = requireArgument(process.argv[1], 'supervisor entry point'); + const groupOwner = spawn( + process.execPath, + [supervisorEntryPoint, groupOwnerFlag, ...command], + { + detached: true, + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + }, + ); + const groupOwnerPid = requirePid(groupOwner.pid, 'group owner'); + const groupOwnerStdin = requireStream(groupOwner.stdin, 'group owner stdin'); + const groupOwnerStdout = requireStream(groupOwner.stdout, 'group owner stdout'); + const groupOwnerStderr = requireStream(groupOwner.stderr, 'group owner stderr'); + let reportedExitCode: number | undefined; + + shutdown.attach(groupOwnerPid); + writeFileSync(processGroupFile, String(groupOwnerPid)); + + process.stdin.pipe(groupOwnerStdin); + groupOwnerStdout.pipe(process.stdout); + groupOwnerStderr.pipe(process.stderr); + + groupOwner.on('message', (message: unknown) => { + if (!isCommandExitMessage(message)) return; + + reportedExitCode = message.code; + terminateOwnedProcessGroup(groupOwnerPid, 'forceful'); + }); + groupOwner.on('error', (error: Error) => { + process.stderr.write(`Failed to start process-group owner: ${error.message}\n`); + reportedExitCode = 1; + }); + groupOwner.on('close', (code: number | null) => { + shutdown.cancel(); + removeProcessGroupFile(processGroupFile); + + process.exitCode = reportedExitCode ?? code ?? 1; + }); +} + +function installSupervisorSignalHandlers(shutdown: ProcessGroupShutdown): void { + process.on('SIGINT', () => shutdown.request()); + process.on('SIGTERM', () => shutdown.request()); +} + +function runGroupOwner(command: Command): void { + installProcessGroupOwnerHandlers(); + + const reportExit = createCommandExitReporter(); + const child = spawn(command[0], command.slice(1), { + // Direct inheritance avoids a user-space forwarding buffer that could be truncated when the group is removed. + stdio: 'inherit', + }); + + child.on('error', (error: Error) => { + process.stderr.write(`Failed to start supervised command: ${error.message}\n`); + reportExit(1); + }); + child.on('exit', (code: number | null) => reportExit(code ?? 1)); +} + +function installProcessGroupOwnerHandlers(): void { + // Keep the process-group identity stable while shutdown propagates through the group. If the supervisor disappears, + // the IPC disconnect is the last reliable opportunity to remove the entire owned group. + process.on('SIGINT', () => undefined); + process.on('SIGTERM', () => undefined); + process.on('disconnect', terminateOwnProcessGroup); +} + +function createCommandExitReporter(): (code: number) => void { + let reported = false; + + return (code: number): void => { + if (reported) return; + + reported = true; + const message: CommandExitMessage = { type: 'command-exit', code }; + process.send?.(message); + }; +} + +function isCommandExitMessage(value: unknown): value is CommandExitMessage { + if (value === null || typeof value !== 'object') return false; + + const record = value as Record; + + return record.type === 'command-exit' + && typeof record.code === 'number' + && Number.isInteger(record.code); +} + +function removeProcessGroupFile(processGroupFile: string): void { + try { + unlinkSync(processGroupFile); + } catch (error: unknown) { + if (!isMissingFile(error)) throw error; + } +} + +function isMissingFile(error: unknown): boolean { + if (!(error instanceof Error) || !('code' in error)) return false; + + return error.code === 'ENOENT'; +} + +function requireArgument(value: string | undefined, name: string): string { + if (value === undefined || value.length === 0) fail(`Missing ${name}`); + + return value; +} + +function requireCommand(command: string[]): Command { + const executable = requireArgument(command[0], 'command executable'); + + return [executable, ...command.slice(1)]; +} + +function requireTimerDelay(value: string | undefined, name: string): number { + const parsed = value === undefined ? Number.NaN : Number(value); + const isInteger = Number.isInteger(parsed); + const isPositive = parsed > 0; + const fitsNodeTimer = parsed <= MAX_TIMER_DELAY_MILLIS; + const valid = isInteger && isPositive && fitsNodeTimer; + if (!valid) fail(`Invalid ${name}: ${value ?? ''}`); + + return parsed; +} + +function requirePid(value: number | undefined, name: string): number { + if (value === undefined) fail(`Missing ${name} PID`); + + return value; +} + +function requireStream(value: T | null, name: string): T { + if (value === null) fail(`Missing ${name}`); + + return value; +} + +function fail(message: string): never { + process.stderr.write(`${message}\n`); + process.exit(1); +} diff --git a/usvm-ts-pbt/fast-check-adapter/src/source-target-replay-cli.ts b/usvm-ts-calls/source-replay-adapter/src/source-target-replay-cli.ts similarity index 95% rename from usvm-ts-pbt/fast-check-adapter/src/source-target-replay-cli.ts rename to usvm-ts-calls/source-replay-adapter/src/source-target-replay-cli.ts index 1ee828a283..adf2ca004f 100644 --- a/usvm-ts-pbt/fast-check-adapter/src/source-target-replay-cli.ts +++ b/usvm-ts-calls/source-replay-adapter/src/source-target-replay-cli.ts @@ -1,12 +1,12 @@ -import { createHash, randomUUID } from 'node:crypto'; +import { randomUUID } from 'node:crypto'; import { spawn } from 'node:child_process'; import { copyFile, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; -import type { TypeScriptEntryPointReference } from './entry-point.js'; -import type { TaggedJsValue } from './js-value.js'; +import type { TypeScriptEntryPointReference } from './calls-entry-point.js'; +import type { TaggedJsValue } from './calls-js-value.js'; interface SourcePosition { line: number; @@ -19,7 +19,6 @@ interface ReplayRequest { inputs: TaggedJsValue[]; target: { sourcePath: string; - sourceSha256: string; startOffset: number; endOffset: number; start: SourcePosition; @@ -56,12 +55,6 @@ async function main(): Promise { try { const target = await resolveTarget(request); - const actualHash = createHash('sha256').update(target.source, 'utf8').digest('hex'); - if (actualHash !== request.target.sourceSha256) { - writeResponse({ status: 'ok', replayStatus: 'unmapped', reason: 'source-hash-mismatch', invocation: null }); - return; - } - const sourceFile = ts.createSourceFile( target.absolutePath, target.source, diff --git a/usvm-ts-pbt/fast-check-adapter/src/source-target-replay-worker.ts b/usvm-ts-calls/source-replay-adapter/src/source-target-replay-worker.ts similarity index 94% rename from usvm-ts-pbt/fast-check-adapter/src/source-target-replay-worker.ts rename to usvm-ts-calls/source-replay-adapter/src/source-target-replay-worker.ts index 9be8210261..1ec2e85b8d 100644 --- a/usvm-ts-pbt/fast-check-adapter/src/source-target-replay-worker.ts +++ b/usvm-ts-calls/source-replay-adapter/src/source-target-replay-worker.ts @@ -1,6 +1,6 @@ import { readFile, writeFile } from 'node:fs/promises'; -import { loadCallable, type TypeScriptEntryPointReference } from './entry-point.js'; -import { decodeJsValue, type TaggedJsValue } from './js-value.js'; +import { loadCallable, type TypeScriptEntryPointReference } from './calls-entry-point.js'; +import { decodeJsValue, type TaggedJsValue } from './calls-js-value.js'; interface ReplayWorkerRequest { sourceRoots: string[]; diff --git a/usvm-ts-pbt/fast-check-adapter/test/source-target-replay-cli.test.ts b/usvm-ts-calls/source-replay-adapter/test/source-target-replay-cli.test.ts similarity index 90% rename from usvm-ts-pbt/fast-check-adapter/test/source-target-replay-cli.test.ts rename to usvm-ts-calls/source-replay-adapter/test/source-target-replay-cli.test.ts index 6476be327a..b3fc711989 100644 --- a/usvm-ts-pbt/fast-check-adapter/test/source-target-replay-cli.test.ts +++ b/usvm-ts-calls/source-replay-adapter/test/source-target-replay-cli.test.ts @@ -1,5 +1,4 @@ import assert from 'node:assert/strict'; -import { createHash } from 'node:crypto'; import { spawn } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; @@ -19,7 +18,6 @@ interface ReplayResponse { interface StatementTarget { sourcePath: string; - sourceSha256: string; startOffset: number; endOffset: number; start: { line: number; column: number }; @@ -66,19 +64,6 @@ test('counts target hits from the selected invocation rather than module import' assert.equal(invoked.invocation?.targetHit, true); }); -test('rejects stale source identity before executing', async () => { - const target = await statementTarget('choose', 'return 1;'); - - const response = await replay({ - ...baseRequest('choose'), - target: { ...target, sourceSha256: '0'.repeat(64) }, - }); - - assert.equal(response.replayStatus, 'unmapped'); - assert.equal(response.reason, 'source-hash-mismatch'); - assert.equal(response.invocation, null); -}); - function baseRequest(exportName: string, inputs = [numberValue(1)]): Record { return { sourceRoots: [path.dirname(fixturePath)], @@ -112,7 +97,6 @@ async function statementTarget(functionName: string, text: string): Promise, val toolRevision: String, - @SerialName("catalogFingerprint") - val legacyCatalogFingerprint: String? = null, - @SerialName("sourceHash") - val legacyModelSourceHash: String? = null, - @SerialName("etsIrHash") - val legacyModelEtsIrHash: String? = null, ) @Serializable @@ -64,7 +58,6 @@ internal data class CallsExperimentManifest( val experimentId: String, val toolRevision: String, val nativeFrontendRevision: String, - val nativeFrontendSha256: String, val solver: String, val searchPolicy: String, val modelSet: CallsModelSetIdentity, @@ -130,7 +123,6 @@ internal data class CallsSymbolicSearchRequest( val profile: CallsExperimentProfile, val frozenModelIds: Set, val expectedNativeFrontendRevision: String, - val expectedNativeFrontendSha256: String, val seed: Long, val budget: Duration, ) @@ -162,7 +154,6 @@ internal data class CallsRunMetadata( val experimentId: String, val toolRevision: String, val nativeFrontendRevision: String, - val nativeFrontendSha256: String? = null, val modelSet: CallsModelSetIdentity, val profiles: List, val seeds: List, @@ -197,8 +188,6 @@ internal data class CallsTargetResult( val inputExtracted: Boolean, val inputs: List? = null, val replayStatus: CallsReplayStatus?, - @SerialName("catalogFingerprint") - val legacyCatalogFingerprint: String? = null, val symbolicElapsedMillis: Long, val diagnostic: String? = null, ) : CallsRawRecord @@ -251,7 +240,7 @@ internal object CallsExperimentJson { internal object CallsBuildIdentity { val toolRevision: String by lazy { val properties = Properties() - val resource = checkNotNull(javaClass.getResourceAsStream("/org/usvm/ts/pbt/calls/build.properties")) { + val resource = checkNotNull(javaClass.getResourceAsStream("/org/usvm/ts/calls/build.properties")) { "Missing calls build identity" } resource.use(properties::load) @@ -288,7 +277,6 @@ internal class CallsExperimentRunner( experimentId = manifest.experimentId, toolRevision = manifest.toolRevision, nativeFrontendRevision = manifest.nativeFrontendRevision, - nativeFrontendSha256 = manifest.nativeFrontendSha256, modelSet = manifest.modelSet, profiles = CallsExperimentProfile.entries, seeds = manifest.seeds, @@ -402,7 +390,6 @@ internal class CallsExperimentRunner( profile = profile, frozenModelIds = manifest.modelSet.ids, expectedNativeFrontendRevision = manifest.nativeFrontendRevision, - expectedNativeFrontendSha256 = manifest.nativeFrontendSha256, seed = seed, budget = manifest.perTargetBudgetMillis.milliseconds, ), diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperimentCli.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperimentCli.kt similarity index 99% rename from usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperimentCli.kt rename to usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperimentCli.kt index 40a852bbd0..fda9021f1c 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsExperimentCli.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperimentCli.kt @@ -1,4 +1,4 @@ -package org.usvm.ts.pbt.calls +package org.usvm.ts.calls import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsProcessTransport.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsProcessTransport.kt new file mode 100644 index 0000000000..32ffb7479d --- /dev/null +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsProcessTransport.kt @@ -0,0 +1,437 @@ +package org.usvm.ts.calls + +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.ExecutionException +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +private object CallsTransportCode { + const val BACKEND_REQUEST_TOO_LARGE = "calls.replay.request-too-large" + const val BACKEND_PROCESS_READ_FAILED = "calls.replay.process-read-failed" + const val BACKEND_PROCESS_WRITE_FAILED = "calls.replay.process-write-failed" + const val BACKEND_PROCESS_INTERRUPTED = "calls.replay.process-interrupted" + const val BACKEND_PROCESS_START_FAILED = "calls.replay.process-start-failed" + const val BACKEND_PROCESS_TIMEOUT = "calls.replay.process-timeout" + const val BACKEND_RESPONSE_TOO_LARGE = "calls.replay.response-too-large" +} + +/** Completed output of one supervised request-response process. */ +internal data class CallsProcessOutput( + val exitCode: Int, + val stdout: String, + val stderr: String, +) + +/** Transport failure before a response can be interpreted by a protocol client. */ +internal class CallsTransportException( + val code: String, + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) + +/** + * Runs one bounded request-response exchange through the shared Node process supervisor. + * + * Three I/O tasks are intentional: draining stdout and stderr concurrently prevents pipe deadlocks, while writing + * stdin separately lets the same wall-clock deadline cover a child that never reads its request. + */ +internal class CallsProcessTransport( + private val nodeExecutable: String, + private val maxRequestBytes: Int, + private val maxStdoutBytes: Int, + private val maxStderrBytes: Int, + private val shutdownGraceMillis: Long, +) { + init { + require(maxRequestBytes > 0) { "Maximum request size must be positive" } + require(maxStdoutBytes > 0) { "Maximum stdout size must be positive" } + require(maxStderrBytes > 0) { "Maximum stderr size must be positive" } + require(shutdownGraceMillis in 1..Int.MAX_VALUE.toLong()) { + "Shutdown grace period must fit the positive delay range supported by Node timers" + } + } + + fun invoke( + command: List, + request: String, + timeoutMillis: Long, + reportedTimeoutMillis: Long, + description: String, + ): CallsProcessOutput { + require(timeoutMillis > 0) { "Process timeout must be positive" } + require(command.isNotEmpty()) { "Supervised command must not be empty" } + requireRequestWithinLimit(request, description) + + val deadlineNanos = deadlineAfter(timeoutMillis) + val managedProcess = startProcess(command, description) + val executor = Executors.newFixedThreadPool(IO_TASK_COUNT) + val tasks = startIoTasks(managedProcess.process, request, description, executor) + + try { + awaitProcess( + process = managedProcess.process, + tasks = tasks.all, + deadlineNanos = deadlineNanos, + reportedTimeoutMillis = reportedTimeoutMillis, + description = description, + ) + awaitIo( + tasks = tasks, + deadlineNanos = deadlineNanos, + reportedTimeoutMillis = reportedTimeoutMillis, + description = description, + ) + val stdout = tasks.stdout.completedValue(description) + val stderr = tasks.stderr.completedValue(description) + + return CallsProcessOutput( + exitCode = managedProcess.process.exitValue(), + stdout = stdout, + stderr = stderr, + ) + } finally { + tasks.all.forEach { task -> task.cancel() } + terminate(managedProcess, deadlineNanos) + closeStreams(managedProcess.process) + runCatching { Files.deleteIfExists(managedProcess.processGroupFile) } + executor.shutdownNow() + } + } + + private fun requireRequestWithinLimit(request: String, description: String) { + if (request.toByteArray(Charsets.UTF_8).size > maxRequestBytes) { + fail( + code = CallsTransportCode.BACKEND_REQUEST_TOO_LARGE, + message = "$description request exceeds $maxRequestBytes bytes", + ) + } + } + + private fun startIoTasks( + process: Process, + request: String, + description: String, + executor: ExecutorService, + ): ProcessIoTasks { + val stdout = ProcessIoTask( + future = executor.submit { + process.inputStream.readBounded(maxStdoutBytes, stream = "stdout") + }, + operation = "reading $description stdout", + failureCode = CallsTransportCode.BACKEND_PROCESS_READ_FAILED, + ) + val stderr = ProcessIoTask( + future = executor.submit { + process.errorStream.readBounded(maxStderrBytes, stream = "stderr") + }, + operation = "reading $description stderr", + failureCode = CallsTransportCode.BACKEND_PROCESS_READ_FAILED, + ) + val writer = ProcessIoTask( + future = executor.submit { + process.outputStream.bufferedWriter(Charsets.UTF_8).use { output -> + output.write(request) + } + }, + operation = "writing the $description request", + failureCode = CallsTransportCode.BACKEND_PROCESS_WRITE_FAILED, + ) + + return ProcessIoTasks(stdout = stdout, stderr = stderr, writer = writer) + } + + private fun awaitProcess( + process: Process, + tasks: List>, + deadlineNanos: Long, + reportedTimeoutMillis: Long, + description: String, + ) { + while (true) { + tasks.forEach { task -> task.throwIfFailed(description) } + + val remainingMillis = remainingMillis(deadlineNanos) + if (remainingMillis <= FORCED_TERMINATION_RESERVE_MILLIS) { + timeout(description, reportedTimeoutMillis) + } + + val completed = try { + process.waitFor(minOf(remainingMillis, PROCESS_POLL_MILLIS), TimeUnit.MILLISECONDS) + } catch (error: InterruptedException) { + Thread.currentThread().interrupt() + fail( + code = CallsTransportCode.BACKEND_PROCESS_INTERRUPTED, + message = "Interrupted while waiting for the $description", + cause = error, + ) + } + if (completed) return + } + } + + private fun awaitIo( + tasks: ProcessIoTasks, + deadlineNanos: Long, + reportedTimeoutMillis: Long, + description: String, + ) { + while (true) { + tasks.all.forEach { task -> task.throwIfFailed(description) } + + val pendingTask = tasks.all.firstOrNull { task -> !task.isDone } ?: break + + val remainingMillis = remainingMillis(deadlineNanos) + if (remainingMillis <= FORCED_TERMINATION_RESERVE_MILLIS) { + timeout(description, reportedTimeoutMillis) + } + + pendingTask.await(minOf(remainingMillis, IO_POLL_MILLIS), description) + } + + tasks.all.forEach { task -> task.completedValue(description) } + } + + private fun startProcess( + supervisedCommand: List, + description: String, + ): SupervisedProcessHandle { + val processGroupFile = try { + Files.createTempFile(PROCESS_GROUP_FILE_PREFIX, ".pid") + } catch (error: IOException) { + processStartFailure(description, error) + } + var processStarted = false + + try { + val command = buildList { + add(nodeExecutable) + add(CallsReplayRuntime.processSupervisorEntryPoint().toString()) + add(PROCESS_SUPERVISOR_COMMAND) + add(shutdownGraceMillis.toString()) + add(processGroupFile.toString()) + addAll(supervisedCommand) + } + val process = ProcessBuilder(command).start() + processStarted = true + + return SupervisedProcessHandle(process = process, processGroupFile = processGroupFile) + } catch (error: IOException) { + processStartFailure(description, error) + } finally { + if (!processStarted) runCatching { Files.deleteIfExists(processGroupFile) } + } + } + + private fun processStartFailure(description: String, error: IOException): Nothing = fail( + code = CallsTransportCode.BACKEND_PROCESS_START_FAILED, + message = "Failed to start $description: ${error.message}", + cause = error, + ) + + private fun terminate(managedProcess: SupervisedProcessHandle, deadlineNanos: Long) { + val process = managedProcess.process + if (!process.isAlive) { + forceTerminateOwnedProcessGroup(managedProcess.processGroupFile, deadlineNanos) + return + } + + process.destroy() + val gracefulDeadlineNanos = minOf( + deadlineBefore( + deadlineNanos = deadlineNanos, + durationMillis = FORCED_TERMINATION_RESERVE_MILLIS, + ), + deadlineAfter(shutdownGraceMillis), + ) + if (awaitProcessExit(process, gracefulDeadlineNanos)) return + + forceTerminateOwnedProcessGroup(managedProcess.processGroupFile, deadlineNanos) + process.destroyForcibly() + awaitProcessExit(process, deadlineNanos) + } + + private fun forceTerminateOwnedProcessGroup(processGroupFile: Path, deadlineNanos: Long) { + val processGroupText = runCatching { Files.readString(processGroupFile) }.getOrNull() ?: return + val processGroupId = processGroupText.trim().toLongOrNull() ?: return + val command = if (IS_WINDOWS) { + listOf("taskkill", "/PID", processGroupId.toString(), "/T", "/F") + } else { + listOf("/bin/kill", "-KILL", "--", "-$processGroupId") + } + val killer = runCatching { + ProcessBuilder(command) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start() + }.getOrNull() ?: return + val waitMillis = minOf(remainingMillis(deadlineNanos), PROCESS_GROUP_KILL_WAIT_MILLIS) + if (waitMillis == 0L) return + + try { + if (!killer.waitFor(waitMillis, TimeUnit.MILLISECONDS)) killer.destroyForcibly() + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + killer.destroyForcibly() + } + } + + private fun awaitProcessExit(process: Process, deadlineNanos: Long): Boolean { + while (process.isAlive) { + val waitMillis = minOf(remainingMillis(deadlineNanos), PROCESS_POLL_MILLIS) + if (waitMillis == 0L) return false + + try { + if (process.waitFor(waitMillis, TimeUnit.MILLISECONDS)) return true + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + return false + } + } + + return true + } + + private fun closeStreams(process: Process) { + runCatching { process.outputStream.close() } + runCatching { process.inputStream.close() } + runCatching { process.errorStream.close() } + } + + private fun timeout(description: String, reportedTimeoutMillis: Long): Nothing = fail( + code = CallsTransportCode.BACKEND_PROCESS_TIMEOUT, + message = "$description exceeded the $reportedTimeoutMillis ms timeout", + ) + + private fun fail(code: String, message: String, cause: Throwable? = null): Nothing = + throw CallsTransportException(code = code, message = message, cause = cause) + + private companion object { + const val IO_TASK_COUNT = 3 + const val PROCESS_SUPERVISOR_COMMAND = "--command" + const val PROCESS_GROUP_FILE_PREFIX = "usvm-ts-calls-process-group-" + const val PROCESS_POLL_MILLIS = 10L + const val IO_POLL_MILLIS = 10L + const val FORCED_TERMINATION_RESERVE_MILLIS = 25L + const val PROCESS_GROUP_KILL_WAIT_MILLIS = 10L + + val IS_WINDOWS = System.getProperty("os.name").lowercase().contains("windows") + } +} + +private data class SupervisedProcessHandle( + val process: Process, + val processGroupFile: Path, +) + +private data class ProcessIoTasks( + val stdout: ProcessIoTask, + val stderr: ProcessIoTask, + val writer: ProcessIoTask, +) { + val all: List> = listOf(stdout, stderr, writer) +} + +private data class ProcessIoTask( + val future: Future, + val operation: String, + val failureCode: String, +) { + val isDone: Boolean + get() = future.isDone + + fun cancel() { + future.cancel(true) + } + + fun throwIfFailed(description: String) { + if (isDone) completedValue(description) + } + + fun completedValue(description: String): T = requireNotNull(await(waitMillis = 0, description)) + + fun await(waitMillis: Long, description: String): T? = try { + future.get(waitMillis, TimeUnit.MILLISECONDS) + } catch (_: TimeoutException) { + null + } catch (error: InterruptedException) { + Thread.currentThread().interrupt() + throw CallsTransportException( + code = CallsTransportCode.BACKEND_PROCESS_INTERRUPTED, + message = "Interrupted while $operation", + cause = error, + ) + } catch (error: ExecutionException) { + val cause = error.cause ?: error + if (cause is ProcessOutputLimitExceeded) { + throw CallsTransportException( + code = CallsTransportCode.BACKEND_RESPONSE_TOO_LARGE, + message = "$description ${cause.stream} exceeds ${cause.limit} bytes", + cause = cause, + ) + } + + throw CallsTransportException( + code = failureCode, + message = "Failed while $operation: ${cause.message}", + cause = cause, + ) + } +} + +private class ProcessOutputLimitExceeded( + val stream: String, + val limit: Int, +) : IOException("$stream exceeds $limit bytes") + +private fun InputStream.readBounded(limit: Int, stream: String): String { + val output = ByteArrayOutputStream(minOf(limit, DEFAULT_BUFFER_SIZE)) + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + + while (true) { + val read = read(buffer) + if (read < 0) break + + val remaining = limit - output.size() + if (remaining > 0) output.write(buffer, 0, minOf(read, remaining)) + if (read > remaining) throw ProcessOutputLimitExceeded(stream, limit) + } + + return output.toString(Charsets.UTF_8) +} + +private fun deadlineAfter(timeoutMillis: Long): Long { + val timeoutNanos = TimeUnit.MILLISECONDS.toNanos(timeoutMillis) + val now = System.nanoTime() + + return if (now > Long.MAX_VALUE - timeoutNanos) Long.MAX_VALUE else now + timeoutNanos +} + +private fun deadlineBefore(deadlineNanos: Long, durationMillis: Long): Long { + if (deadlineNanos == Long.MAX_VALUE) return Long.MAX_VALUE + + val durationNanos = TimeUnit.MILLISECONDS.toNanos(durationMillis) + + return if (deadlineNanos < Long.MIN_VALUE + durationNanos) Long.MIN_VALUE else deadlineNanos - durationNanos +} + +private fun remainingMillis(deadlineNanos: Long): Long { + if (deadlineNanos == Long.MAX_VALUE) return Long.MAX_VALUE + + val remainingNanos = deadlineNanos - System.nanoTime() + if (remainingNanos <= 0) return 0 + + return TimeUnit.NANOSECONDS.toMillis(remainingNanos) +} + +internal fun saturatedAdd(left: Long, right: Long): Long = if (left > Long.MAX_VALUE - right) { + Long.MAX_VALUE +} else { + left + right +} diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsReplayRuntime.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsReplayRuntime.kt new file mode 100644 index 0000000000..b422cdc34b --- /dev/null +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsReplayRuntime.kt @@ -0,0 +1,44 @@ +package org.usvm.ts.calls + +import java.nio.file.Files +import java.nio.file.Path + +internal object CallsReplayRuntime { + fun sourceTargetReplayEntryPoint(): Path = locateEntryPoint(SOURCE_TARGET_REPLAY_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) + } + + return candidates.firstOrNull(Files::isRegularFile) + ?: error("Cannot locate built TS Calls source replay adapter; checked $candidates") + } + + private fun runtimeDirectories(): List = listOfNotNull( + configuredRuntimeDirectory(), + installedRuntimeDirectory(), + ).distinct() + + private fun configuredRuntimeDirectory(): Path? = System.getProperty(RUNTIME_DIRECTORY_PROPERTY) + ?.takeIf(String::isNotBlank) + ?.let(Path::of) + ?.toAbsolutePath() + ?.normalize() + + private fun installedRuntimeDirectory(): Path? { + val location = CallsReplayRuntime::class.java.protectionDomain.codeSource?.location ?: return null + val codePath = runCatching { Path.of(location.toURI()) }.getOrNull() ?: return null + val libraryDirectory = if (Files.isDirectory(codePath)) codePath else codePath.parent ?: return null + + return libraryDirectory.resolve(INSTALLED_RUNTIME_DIRECTORY) + } + + private const val RUNTIME_DIRECTORY_PROPERTY = "org.usvm.ts.calls.replay.runtime" + private const val ENTRY_POINT_DIRECTORY = "dist/src" + private const val SOURCE_TARGET_REPLAY_CLI = "source-target-replay-cli.js" + private const val PROCESS_SUPERVISOR = "process-supervisor.js" + private const val INSTALLED_RUNTIME_DIRECTORY = "source-replay-adapter" +} diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsSourceReplay.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceReplay.kt similarity index 91% rename from usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsSourceReplay.kt rename to usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceReplay.kt index e9dae0e732..6dd7c3dd13 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsSourceReplay.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceReplay.kt @@ -1,12 +1,9 @@ -package org.usvm.ts.pbt.calls +package org.usvm.ts.calls import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString -import org.usvm.ts.pbt.fastcheck.FastCheckProcessTransport -import org.usvm.ts.pbt.fastcheck.FastCheckRuntime -import org.usvm.ts.pbt.fastcheck.FastCheckTransportException import org.usvm.ts.pbt.manifest.PropertyManifestJson import org.usvm.ts.pbt.model.ExecutionKind import org.usvm.ts.pbt.model.JsConcreteValue @@ -48,7 +45,6 @@ internal data class CallsSourceTarget( val targetId: String, val siteId: String, val sourcePath: String, - val sourceSha256: String, val startOffset: Int, val endOffset: Int, val start: CallsSourcePosition, @@ -83,7 +79,6 @@ private data class CallsSourceReplayRequest( @Serializable private data class CallsSourceTargetWire( val sourcePath: String, - val sourceSha256: String, val startOffset: Int, val endOffset: Int, val start: CallsSourcePosition, @@ -112,7 +107,7 @@ internal fun interface CallsTargetReplayer { internal class OriginalTypeScriptTargetReplayer( private val nodeExecutable: String = "node", ) : CallsTargetReplayer { - private val transport = FastCheckProcessTransport( + private val transport = CallsProcessTransport( nodeExecutable = nodeExecutable, maxRequestBytes = MAX_REQUEST_BYTES, maxStdoutBytes = MAX_STDOUT_BYTES, @@ -136,7 +131,6 @@ internal class OriginalTypeScriptTargetReplayer( inputs = inputs, target = CallsSourceTargetWire( sourcePath = target.sourcePath, - sourceSha256 = target.sourceSha256, startOffset = target.startOffset, endOffset = target.endOffset, start = target.start, @@ -145,7 +139,7 @@ internal class OriginalTypeScriptTargetReplayer( timeoutMillis = timeoutMillis, ) val encoded = PropertyManifestJson.json.encodeToString(request) - val replayEntryPoint = FastCheckRuntime.sourceTargetReplayEntryPoint().toString() + val replayEntryPoint = CallsReplayRuntime.sourceTargetReplayEntryPoint().toString() val output = try { transport.invoke( @@ -155,7 +149,7 @@ internal class OriginalTypeScriptTargetReplayer( reportedTimeoutMillis = timeoutMillis, description = "original TypeScript source-target replay", ) - } catch (error: FastCheckTransportException) { + } catch (error: CallsTransportException) { val status = if (error.code.endsWith("timeout")) { CallsReplayStatus.TIMEOUT } else { diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsWitnessReplay.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsWitnessReplay.kt similarity index 96% rename from usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsWitnessReplay.kt rename to usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsWitnessReplay.kt index f238d67b53..631ef895b3 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CallsWitnessReplay.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsWitnessReplay.kt @@ -1,4 +1,4 @@ -package org.usvm.ts.pbt.calls +package org.usvm.ts.calls import org.usvm.ts.pbt.model.JsConcreteValue import org.usvm.ts.pbt.model.contains @@ -107,9 +107,6 @@ internal class CallsWitnessReplayer( require(metadata.nativeFrontendRevision == manifest.nativeFrontendRevision) { "Raw native frontend revision does not match the frozen manifest" } - require(metadata.nativeFrontendSha256 == manifest.nativeFrontendSha256) { - "Raw native frontend runtime hash does not match the frozen manifest" - } require(metadata.modelSet == manifest.modelSet) { "Raw model-set identity does not match the frozen manifest" } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt similarity index 91% rename from usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt rename to usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt index ee769f96b9..0232eacecf 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/calls/CurrentTsCallsSymbolicEngine.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt @@ -1,4 +1,4 @@ -package org.usvm.ts.pbt.calls +package org.usvm.ts.calls import io.ksmt.utils.asExpr import org.jacodb.ets.model.EtsBooleanType @@ -28,13 +28,9 @@ import org.usvm.ts.pbt.model.JsConcreteValue import org.usvm.ts.pbt.model.NumberDomain import org.usvm.ts.pbt.model.contains import org.usvm.util.mkRegisterStackLValue -import java.nio.file.Files import java.nio.file.Path -import java.security.MessageDigest import kotlin.time.TimeSource -private const val BYTE_MASK = 0xff - internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { private val verifiedProjects = mutableMapOf() private var verifiedNativeFrontend: Pair? = null @@ -73,21 +69,10 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { expectedRevision = request.project.revision, cache = verifiedProjects, ) - verifyNativeFrontendOnce( - expectedRevision = request.expectedNativeFrontendRevision, - expectedSha256 = request.expectedNativeFrontendSha256, - ) + verifyNativeFrontendOnce(expectedRevision = request.expectedNativeFrontendRevision) val source = request.sourceRoot.resolve(request.function.sourceFile).normalize() require(source.startsWith(request.sourceRoot)) { "Function source escapes its frozen source root" } - val actualSourceHash = Files.readAllBytes(source).sha256() - if (actualSourceHash != request.target.sourceSha256) { - return result( - status = CallsSymbolicStatus.TOOL_ERROR, - startedAt = startedAt, - diagnostic = "Source hash $actualSourceHash does not match frozen hash ${request.target.sourceSha256}", - ) - } val sourceFile = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) if (sourceFile.importInfos.isNotEmpty()) { @@ -233,7 +218,9 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { JsConcreteValue.Boolean(value) } - else -> error("Unsupported scalar parameter type: ${parameter.type}") + else -> { + error("Unsupported scalar parameter type: ${parameter.type}") + } } } } @@ -252,7 +239,7 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { diagnostic = diagnostic, ) - private fun verifyNativeFrontendOnce(expectedRevision: String, expectedSha256: String) { + private fun verifyNativeFrontendOnce(expectedRevision: String) { require(System.getenv("ETS_FRONTEND_SCRIPT") == null) { "ETS_FRONTEND_SCRIPT must be unset so the frozen native frontend runtime is used" } @@ -261,17 +248,12 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { } val frontendDirectory = Path.of(configuredFrontend).toRealPath() val cached = verifiedNativeFrontend - val expectedIdentity = "$expectedRevision:$expectedSha256" + val expectedIdentity = expectedRevision if (cached == Pair(frontendDirectory, expectedIdentity)) { return } verifyCallsGitCheckout(frontendDirectory, expectedRevision) - val runtimeScript = frontendDirectory.resolve("dist/index.js") - val actualSha256 = Files.readAllBytes(runtimeScript).sha256() - require(actualSha256 == expectedSha256) { - "Native frontend runtime hash $actualSha256 does not match frozen hash $expectedSha256" - } verifiedNativeFrontend = frontendDirectory to expectedIdentity } @@ -381,10 +363,6 @@ private fun runCallsGit(checkout: Path, vararg arguments: String): String { return output } -private fun ByteArray.sha256(): String = MessageDigest.getInstance("SHA-256") - .digest(this) - .joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and BYTE_MASK) } - private fun EtsMappingStatus.toSymbolicStatus(): CallsSymbolicStatus = when (this) { EtsMappingStatus.EXACT -> error("Exact mapping has no failure status") EtsMappingStatus.AMBIGUOUS -> CallsSymbolicStatus.AMBIGUOUS diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt similarity index 87% rename from usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt rename to usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt index edda493bf6..2ecc895cbf 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/CallsExperimentTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt @@ -1,9 +1,7 @@ -package org.usvm.ts.pbt.calls +package org.usvm.ts.calls import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonObject import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import org.usvm.ts.pbt.model.BooleanDomain @@ -85,7 +83,6 @@ class CallsExperimentTest { assertTrue(emptyFresh.solverReached) assertTrue(emptyFresh.inputExtracted) assertEquals(listOf(JsConcreteValue.Boolean(false)), emptyFresh.inputs) - assertNull(emptyFresh.legacyCatalogFingerprint) assertEquals(CallsReplayStatus.REJECTED, emptyFresh.replayStatus) val frozenStop = results.single { result -> result.profile == CallsExperimentProfile.FROZEN_STOP } @@ -116,47 +113,13 @@ class CallsExperimentTest { elements = listOf(JsConcreteValue.Undefined, JsConcreteValue.Null, JsConcreteValue.String("value")), ), ) - val result = targetResult( - inputs = witness, - legacyCatalogFingerprint = "historical-runtime-fingerprint", - ) + val result = targetResult(inputs = witness) val encoded = CallsExperimentJson.json.encodeToString(result) val decoded = CallsExperimentJson.json.decodeFromString(encoded) as CallsTargetResult assertEquals(result, decoded) assertEquals(witness, decoded.inputs) - assertEquals("historical-runtime-fingerprint", decoded.legacyCatalogFingerprint) - } - - @Test - fun `historical semantic model identities decode but current manifests do not invent them`() { - val currentManifest = manifest(sourceRoot = ".", seeds = listOf(1L)) - val encodedCurrent = CallsExperimentJson.encodeManifest(currentManifest) - assertFalse(encodedCurrent.contains("catalogFingerprint")) - assertFalse(encodedCurrent.contains("sourceHash")) - assertFalse(encodedCurrent.contains("etsIrHash")) - - val historicalManifest = currentManifest.copy( - modelSet = currentManifest.modelSet.copy( - legacyCatalogFingerprint = "historical-catalog-fingerprint", - legacyModelSourceHash = "historical-model-source-hash", - legacyModelEtsIrHash = "historical-model-ets-ir-hash", - ), - ) - val decoded = CallsExperimentJson.decodeManifest(CallsExperimentJson.encodeManifest(historicalManifest)) - val sourceReplayHash = decoded.projects - .single() - .functions - .single() - .targets - .single() - .sourceSha256 - - assertEquals("historical-catalog-fingerprint", decoded.modelSet.legacyCatalogFingerprint) - assertEquals("historical-model-source-hash", decoded.modelSet.legacyModelSourceHash) - assertEquals("historical-model-ets-ir-hash", decoded.modelSet.legacyModelEtsIrHash) - assertEquals("source-hash", sourceReplayHash) } @Test @@ -240,12 +203,13 @@ class CallsExperimentTest { ) val historicalRecords = readRecords(rawOutput).map { record -> when { - record is CallsRunMetadata -> record.copy(nativeFrontendSha256 = null) record is CallsTargetResult && record.profile == CallsExperimentProfile.EMPTY_FRESH -> { record.copy(inputs = null) } - else -> record + else -> { + record + } } } writeRecords(rawOutput, historicalRecords) @@ -268,11 +232,7 @@ class CallsExperimentTest { assertTrue(error.message.orEmpty().contains("single-witness replay is unavailable")) val manifestPath = directory.resolve("historical-manifest.json") - val manifestJson = CallsExperimentJson.json.encodeToString(frozenManifest) - val historicalManifest = JsonObject( - CallsExperimentJson.json.parseToJsonElement(manifestJson).jsonObject - "nativeFrontendSha256", - ) - Files.writeString(manifestPath, historicalManifest.toString()) + Files.writeString(manifestPath, CallsExperimentJson.encodeManifest(frozenManifest)) val cliError = assertFailsWith { replayWitness( @@ -383,7 +343,6 @@ class CallsExperimentTest { experimentId = "fixture", toolRevision = FIXTURE_TOOL_REVISION, nativeFrontendRevision = "frontend-revision", - nativeFrontendSha256 = "frontend-sha256", solver = "Z3", searchPolicy = "BFS", modelSet = CallsModelSetIdentity( @@ -412,7 +371,6 @@ class CallsExperimentTest { targetId = "fixture.ts::predicate/1#return", siteId = "fixture.ts:1:1-1:12::predicate/1", sourcePath = "fixture.ts", - sourceSha256 = "source-hash", startOffset = 0, endOffset = 11, start = CallsSourcePosition(line = 0, column = 0), @@ -457,10 +415,7 @@ class CallsExperimentTest { seed = seed, ) - private fun targetResult( - inputs: List, - legacyCatalogFingerprint: String? = null, - ) = CallsTargetResult( + private fun targetResult(inputs: List) = CallsTargetResult( experimentId = "fixture", projectId = "fixture/project", revision = "project-revision", @@ -475,7 +430,6 @@ class CallsExperimentTest { inputExtracted = true, inputs = inputs, replayStatus = CallsReplayStatus.CONFIRMED, - legacyCatalogFingerprint = legacyCatalogFingerprint, symbolicElapsedMillis = 7L, ) diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/SourceStatementEntryTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/SourceStatementEntryTest.kt similarity index 98% rename from usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/SourceStatementEntryTest.kt rename to usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/SourceStatementEntryTest.kt index 3d4a6a3145..b1949af26c 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/calls/SourceStatementEntryTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/SourceStatementEntryTest.kt @@ -1,4 +1,4 @@ -package org.usvm.ts.pbt.calls +package org.usvm.ts.calls import org.jacodb.ets.model.EtsMethod import org.jacodb.ets.model.EtsReturnStmt @@ -98,7 +98,6 @@ class SourceStatementEntryTest { targetId = "test-target", siteId = "test-site", sourcePath = "SourceStatementEntryFixture.ts", - sourceSha256 = "unused", startOffset = startOffset, endOffset = endOffset, start = source.positionAt(startOffset), diff --git a/usvm-ts-pbt/src/test/resources/calls/SourceStatementEntryFixture.ts b/usvm-ts-calls/src/test/resources/calls/SourceStatementEntryFixture.ts similarity index 100% rename from usvm-ts-pbt/src/test/resources/calls/SourceStatementEntryFixture.ts rename to usvm-ts-calls/src/test/resources/calls/SourceStatementEntryFixture.ts diff --git a/usvm-ts-pbt/build.gradle.kts b/usvm-ts-pbt/build.gradle.kts index 2c28460760..afc82d9f47 100644 --- a/usvm-ts-pbt/build.gradle.kts +++ b/usvm-ts-pbt/build.gradle.kts @@ -7,7 +7,6 @@ plugins { } dependencies { - implementation(project(":usvm-core")) implementation(project(":usvm-ts")) implementation(Libs.jacodb_ets) implementation(Libs.clikt) @@ -23,9 +22,6 @@ val fastCheckRuntimeProperty = "org.usvm.ts.pbt.fastcheck.runtime" val generatedFastCheckRuntimeMetadataDirectory = layout.buildDirectory.dir( "generated/resources/fastCheckRuntimeMetadata", ) -val generatedCallsBuildMetadataDirectory = layout.buildDirectory.dir( - "generated/resources/callsBuildMetadata", -) val hostOperatingSystem = System.getProperty("os.name").lowercase() val hostPlatform = when { hostOperatingSystem.contains("mac") -> "darwin" @@ -74,39 +70,12 @@ val generateFastCheckRuntimeMetadata = tasks.register("generateFastCheckRuntimeM } } -val callsToolRevision = providers.exec { - workingDir(rootProject.projectDir) - commandLine("git", "rev-parse", "HEAD") -}.standardOutput.asText.map(String::trim) -val callsToolStatus = providers.exec { - workingDir(rootProject.projectDir) - commandLine("git", "status", "--porcelain", "--untracked-files=all") -}.standardOutput.asText.map(String::trim) - -val generateCallsBuildMetadata = tasks.register("generateCallsBuildMetadata") { - inputs.property("toolRevision", callsToolRevision) - inputs.property("toolStatus", callsToolStatus) - outputs.dir(generatedCallsBuildMetadataDirectory) - - doLast { - val revision = callsToolRevision.get() - val buildIdentity = if (callsToolStatus.get().isBlank()) revision else "$revision-dirty" - val metadataFile = generatedCallsBuildMetadataDirectory.get() - .file("org/usvm/ts/pbt/calls/build.properties") - .asFile - metadataFile.parentFile.mkdirs() - metadataFile.writeText("tool.revision=$buildIdentity\n", Charsets.UTF_8) - } -} - sourceSets.main { resources.srcDir(generatedFastCheckRuntimeMetadataDirectory) - resources.srcDir(generatedCallsBuildMetadataDirectory) } tasks.processResources { dependsOn(generateFastCheckRuntimeMetadata) - dependsOn(generateCallsBuildMetadata) } val installFastCheckAdapter = tasks.register("installFastCheckAdapter") { @@ -183,15 +152,6 @@ tasks.named("run") { systemProperty(fastCheckRuntimeProperty, fastCheckAdapterDir.asFile.absolutePath) } -val runCalls by tasks.registering(JavaExec::class) { - group = "application" - description = "Runs the frozen four-profile TypeScript Calls experiment." - mainClass.set("org.usvm.ts.pbt.calls.CallsExperimentCliKt") - classpath = sourceSets.main.get().runtimeClasspath - systemProperty(fastCheckRuntimeProperty, fastCheckAdapterDir.asFile.absolutePath) - dependsOn(buildFastCheckAdapter) -} - distributions { main { contents { diff --git a/usvm-ts-pbt/fast-check-adapter/package-lock.json b/usvm-ts-pbt/fast-check-adapter/package-lock.json index 1154505ca5..594dfb5981 100644 --- a/usvm-ts-pbt/fast-check-adapter/package-lock.json +++ b/usvm-ts-pbt/fast-check-adapter/package-lock.json @@ -10,11 +10,11 @@ "dependencies": { "c8": "10.1.3", "fast-check": "4.9.0", - "tsx": "4.23.12", - "typescript": "5.9.2" + "tsx": "4.23.12" }, "devDependencies": { - "@types/node": "18.19.130" + "@types/node": "18.19.130", + "typescript": "5.9.2" }, "engines": { "node": ">=18.18.0" @@ -1304,6 +1304,7 @@ "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/usvm-ts-pbt/fast-check-adapter/package.json b/usvm-ts-pbt/fast-check-adapter/package.json index 660ad8fc80..fdac679842 100644 --- a/usvm-ts-pbt/fast-check-adapter/package.json +++ b/usvm-ts-pbt/fast-check-adapter/package.json @@ -9,16 +9,16 @@ "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/process-group-shutdown.test.js dist/test/process-supervisor.test.js dist/test/project-domain.test.js dist/test/projection-cli.test.js dist/test/source-target-replay-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", "fast-check": "4.9.0", - "typescript": "5.9.2", "tsx": "4.23.12" }, "devDependencies": { - "@types/node": "18.19.130" + "@types/node": "18.19.130", + "typescript": "5.9.2" }, "overrides": { "c8": { diff --git a/usvm-ts-pbt/fast-check-adapter/src/entry-point.ts b/usvm-ts-pbt/fast-check-adapter/src/entry-point.ts index 62973ff086..3cf32a460f 100644 --- a/usvm-ts-pbt/fast-check-adapter/src/entry-point.ts +++ b/usvm-ts-pbt/fast-check-adapter/src/entry-point.ts @@ -19,11 +19,6 @@ export interface LoadedEntryPoint { invoke(args: JsConcreteValue[]): boolean | Promise; } -export interface LoadedCallable { - executionKind: ExecutionKind; - invoke(args: JsConcreteValue[]): unknown | Promise; -} - type EntryPointFunction = (...args: JsConcreteValue[]) => unknown; export async function loadEntryPoint( @@ -31,20 +26,6 @@ export async function loadEntryPoint( sourceRoots: string[], referencePath: string, ): Promise { - const callable = await loadCallable(reference, sourceRoots, referencePath); - - return { - executionKind: callable.executionKind, - invoke: buildBooleanInvocation(callable, referencePath), - }; -} - -/** Loads an original TypeScript export without imposing property-result semantics. */ -export async function loadCallable( - reference: TypeScriptEntryPointReference, - sourceRoots: string[], - referencePath: string, -): Promise { const modulePath = await resolveModule(reference.module, sourceRoots, referencePath); const moduleNamespace = await importTypeScriptModule(modulePath, referencePath); @@ -69,7 +50,7 @@ export async function loadCallable( return { executionKind: reference.executionKind, - invoke: buildRawInvocation(entryPoint, reference.executionKind, referencePath), + invoke: buildInvocation(entryPoint, reference.executionKind, referencePath), }; } @@ -195,13 +176,13 @@ async function importTypeScriptModule( } } -function buildRawInvocation( +function buildInvocation( entryPoint: EntryPointFunction, executionKind: ExecutionKind, referencePath: string, -): (args: JsConcreteValue[]) => unknown | Promise { +): (args: JsConcreteValue[]) => boolean | Promise { if (executionKind === 'sync') { - return (args: JsConcreteValue[]): unknown => { + return (args: JsConcreteValue[]): boolean => { const result = entryPoint(...args); if (isThenable(result)) { @@ -213,11 +194,11 @@ function buildRawInvocation( ); } - return result; + return requireBoolean(result, referencePath); }; } - return async (args: JsConcreteValue[]): Promise => { + return async (args: JsConcreteValue[]): Promise => { const result = entryPoint(...args); if (!isThenable(result)) { @@ -228,22 +209,10 @@ function buildRawInvocation( ); } - return await result; + return requireBoolean(await result, referencePath); }; } -function buildBooleanInvocation( - callable: LoadedCallable, - referencePath: string, -): (args: JsConcreteValue[]) => boolean | Promise { - if (callable.executionKind === 'sync') { - return (args: JsConcreteValue[]): boolean => requireBoolean(callable.invoke(args), referencePath); - } - - return async (args: JsConcreteValue[]): Promise => - requireBoolean(await callable.invoke(args), referencePath); -} - function requireBoolean(result: unknown, referencePath: string): boolean { if (typeof result !== 'boolean') { throw protocolError( 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 8ff105505f..860a04f579 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 @@ -12,8 +12,6 @@ internal object FastCheckRuntime { fun processSupervisorEntryPoint(): Path = locateEntryPoint(PROCESS_SUPERVISOR) - fun sourceTargetReplayEntryPoint(): Path = locateEntryPoint(SOURCE_TARGET_REPLAY_CLI) - private fun locateEntryPoint(fileName: String): Path { val candidates = runtimeDirectories().map { runtimeDirectory -> runtimeDirectory.resolve(ENTRY_POINT_DIRECTORY).resolve(fileName) @@ -51,6 +49,5 @@ internal object FastCheckRuntime { 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 SOURCE_TARGET_REPLAY_CLI = "source-target-replay-cli.js" private const val INSTALLED_RUNTIME_DIRECTORY = "fast-check-adapter" } From 27dd961f6d5dbf7db0ff61c802645d747ba26b97 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 14:57:53 +0300 Subject: [PATCH 11/13] [TS Calls] Reuse the property backend for source replay --- usvm-ts-calls/build.gradle.kts | 61 +- .../source-replay-adapter/.gitignore | 2 - .../source-replay-adapter/package-lock.json | 542 ------------------ .../source-replay-adapter/package.json | 24 - .../src/calls-entry-point.ts | 95 --- .../src/calls-js-value.ts | 44 -- .../src/process-group-shutdown.ts | 77 --- .../src/process-supervisor.ts | 197 ------- .../src/source-target-replay-cli.ts | 297 ---------- .../src/source-target-replay-worker.ts | 57 -- .../test/source-target-replay-cli.test.ts | 134 ----- .../source-replay-adapter/tsconfig.json | 20 - .../usvm/ts/calls/CallsProcessTransport.kt | 437 -------------- .../org/usvm/ts/calls/CallsReplayRuntime.kt | 44 -- .../org/usvm/ts/calls/CallsSourceReplay.kt | 367 +++++++++--- .../usvm/ts/calls/CallsSourceReplayTest.kt | 108 ++++ .../calls/SourceTargetReplayFixture.ts} | 8 - 17 files changed, 389 insertions(+), 2125 deletions(-) delete mode 100644 usvm-ts-calls/source-replay-adapter/.gitignore delete mode 100644 usvm-ts-calls/source-replay-adapter/package-lock.json delete mode 100644 usvm-ts-calls/source-replay-adapter/package.json delete mode 100644 usvm-ts-calls/source-replay-adapter/src/calls-entry-point.ts delete mode 100644 usvm-ts-calls/source-replay-adapter/src/calls-js-value.ts delete mode 100644 usvm-ts-calls/source-replay-adapter/src/process-group-shutdown.ts delete mode 100644 usvm-ts-calls/source-replay-adapter/src/process-supervisor.ts delete mode 100644 usvm-ts-calls/source-replay-adapter/src/source-target-replay-cli.ts delete mode 100644 usvm-ts-calls/source-replay-adapter/src/source-target-replay-worker.ts delete mode 100644 usvm-ts-calls/source-replay-adapter/test/source-target-replay-cli.test.ts delete mode 100644 usvm-ts-calls/source-replay-adapter/tsconfig.json delete mode 100644 usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsProcessTransport.kt delete mode 100644 usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsReplayRuntime.kt create mode 100644 usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceReplayTest.kt rename usvm-ts-calls/{source-replay-adapter/test/source-target-replay-fixture.ts => src/test/resources/calls/SourceTargetReplayFixture.ts} (78%) diff --git a/usvm-ts-calls/build.gradle.kts b/usvm-ts-calls/build.gradle.kts index 7e12ba8129..0d8362e592 100644 --- a/usvm-ts-calls/build.gradle.kts +++ b/usvm-ts-calls/build.gradle.kts @@ -14,20 +14,9 @@ dependencies { testImplementation(Libs.logback) } -val replayAdapterDir = layout.projectDirectory.dir("source-replay-adapter") -val replayAdapterPackageJson = replayAdapterDir.file("package.json") -val replayAdapterPackageLock = replayAdapterDir.file("package-lock.json") -val replayRuntimeProperty = "org.usvm.ts.calls.replay.runtime" +val fastCheckAdapterDir = project(":usvm-ts-pbt").layout.projectDirectory.dir("fast-check-adapter") +val fastCheckRuntimeProperty = "org.usvm.ts.pbt.fastcheck.runtime" val generatedBuildMetadataDirectory = layout.buildDirectory.dir("generated/resources/callsBuildMetadata") -val hostOperatingSystem = System.getProperty("os.name").lowercase() -val hostPlatform = when { - hostOperatingSystem.contains("mac") -> "darwin" - hostOperatingSystem.contains("linux") -> "linux" - hostOperatingSystem.contains("windows") -> "win32" - else -> error("Unsupported source replay operating system: $hostOperatingSystem") -} -val npmExecutable = if (hostPlatform == "win32") "npm.cmd" else "npm" - val toolRevision = providers.exec { workingDir(rootProject.projectDir) commandLine("git", "rev-parse", "HEAD") @@ -61,41 +50,9 @@ tasks.processResources { dependsOn(generateBuildMetadata) } -val installReplayAdapter = tasks.register("installReplayAdapter") { - workingDir(replayAdapterDir) - commandLine(npmExecutable, "ci", "--ignore-scripts") - inputs.files(replayAdapterPackageJson, replayAdapterPackageLock) - outputs.dir(replayAdapterDir.dir("node_modules")) -} - -val buildReplayAdapter = tasks.register("buildReplayAdapter") { - dependsOn(installReplayAdapter) - workingDir(replayAdapterDir) - commandLine(npmExecutable, "run", "build") - inputs.files(replayAdapterPackageJson, replayAdapterPackageLock, replayAdapterDir.file("tsconfig.json")) - inputs.dir(replayAdapterDir.dir("src")) - inputs.dir(replayAdapterDir.dir("test")) - outputs.dir(replayAdapterDir.dir("dist")) -} - -val testReplayAdapter = tasks.register("testReplayAdapter") { - dependsOn(buildReplayAdapter) - workingDir(replayAdapterDir) - commandLine(npmExecutable, "run", "test:compiled") - inputs.dir(replayAdapterDir.dir("dist")) -} - tasks.test { - dependsOn(buildReplayAdapter) - systemProperty(replayRuntimeProperty, replayAdapterDir.asFile.absolutePath) -} - -tasks.check { - dependsOn(testReplayAdapter) -} - -tasks.clean { - delete(replayAdapterDir.dir("dist")) + dependsOn(":usvm-ts-pbt:buildFastCheckAdapter") + systemProperty(fastCheckRuntimeProperty, fastCheckAdapterDir.asFile.absolutePath) } application { @@ -104,15 +61,15 @@ application { } tasks.named("run") { - systemProperty(replayRuntimeProperty, replayAdapterDir.asFile.absolutePath) - dependsOn(buildReplayAdapter) + dependsOn(":usvm-ts-pbt:buildFastCheckAdapter") + systemProperty(fastCheckRuntimeProperty, fastCheckAdapterDir.asFile.absolutePath) } distributions { main { contents { - into("lib/source-replay-adapter") { - from(replayAdapterDir) + into("lib/fast-check-adapter") { + from(fastCheckAdapterDir) include("dist/src/**") include("node_modules/**") include("package.json") @@ -123,6 +80,6 @@ distributions { listOf("startScripts", "installDist", "distZip", "distTar").forEach { taskName -> tasks.named(taskName) { - dependsOn(buildReplayAdapter) + dependsOn(":usvm-ts-pbt:buildFastCheckAdapter") } } diff --git a/usvm-ts-calls/source-replay-adapter/.gitignore b/usvm-ts-calls/source-replay-adapter/.gitignore deleted file mode 100644 index 1eae0cf670..0000000000 --- a/usvm-ts-calls/source-replay-adapter/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -dist/ -node_modules/ diff --git a/usvm-ts-calls/source-replay-adapter/package-lock.json b/usvm-ts-calls/source-replay-adapter/package-lock.json deleted file mode 100644 index 5e48264aaf..0000000000 --- a/usvm-ts-calls/source-replay-adapter/package-lock.json +++ /dev/null @@ -1,542 +0,0 @@ -{ - "name": "@usvm/ts-calls-source-replay-adapter", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@usvm/ts-calls-source-replay-adapter", - "version": "0.1.0", - "dependencies": { - "typescript": "5.9.2", - "tsx": "4.23.12" - }, - "devDependencies": { - "@types/node": "18.19.130" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", - "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", - "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", - "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", - "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", - "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", - "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", - "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", - "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", - "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", - "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", - "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", - "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", - "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", - "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", - "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", - "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", - "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", - "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", - "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", - "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", - "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", - "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", - "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", - "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", - "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", - "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/tsx": { - "version": "4.23.12", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", - "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", - "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/usvm-ts-calls/source-replay-adapter/package.json b/usvm-ts-calls/source-replay-adapter/package.json deleted file mode 100644 index 893ff16310..0000000000 --- a/usvm-ts-calls/source-replay-adapter/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@usvm/ts-calls-source-replay-adapter", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "clean": "node --input-type=module --eval \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\"", - "prebuild": "npm run clean", - "build": "tsc --project tsconfig.json", - "pretest": "npm run build", - "test": "npm run test:compiled", - "test:compiled": "node --test dist/test/source-target-replay-cli.test.js" - }, - "dependencies": { - "typescript": "5.9.2", - "tsx": "4.23.12" - }, - "devDependencies": { - "@types/node": "18.19.130" - }, - "engines": { - "node": ">=18.18.0" - } -} diff --git a/usvm-ts-calls/source-replay-adapter/src/calls-entry-point.ts b/usvm-ts-calls/source-replay-adapter/src/calls-entry-point.ts deleted file mode 100644 index dc5679839d..0000000000 --- a/usvm-ts-calls/source-replay-adapter/src/calls-entry-point.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { realpath, stat } from 'node:fs/promises'; -import path from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { tsImport } from 'tsx/esm/api'; - -export interface TypeScriptEntryPointReference { - module: string; - exportName: string; - executionKind: 'sync' | 'async'; -} - -interface LoadedCallable { - invoke(args: unknown[]): unknown | Promise; -} - -type EntryPointFunction = (...args: unknown[]) => unknown; - -export async function loadCallable( - reference: TypeScriptEntryPointReference, - sourceRoots: string[], - referencePath: string, -): Promise { - const modulePath = await resolveModule(reference.module, sourceRoots, referencePath); - const moduleNamespace = await tsImport(pathToFileURL(modulePath).href, import.meta.url) as Record; - const exportedValue = moduleNamespace[reference.exportName]; - if (typeof exportedValue !== 'function') { - throw new Error(`${referencePath}.exportName must identify a function export`); - } - - return { invoke: buildInvocation(exportedValue as EntryPointFunction, reference, referencePath) }; -} - -async function resolveModule(module: string, sourceRoots: string[], referencePath: string): Promise { - const matches: string[] = []; - for (const sourceRootValue of sourceRoots) { - if (!path.isAbsolute(sourceRootValue)) throw new Error('sourceRoots must contain absolute paths'); - - const sourceRoot = await realpath(sourceRootValue); - const candidate = path.resolve(sourceRoot, module); - if (!isWithin(candidate, sourceRoot)) throw new Error(`${referencePath}.module escapes its source root`); - - try { - const resolved = await realpath(candidate); - if (!isWithin(resolved, sourceRoot)) throw new Error(`${referencePath}.module resolves outside its source root`); - if ((await stat(resolved)).isFile()) matches.push(resolved); - } catch (error: unknown) { - if (!isMissingPath(error)) throw error; - } - } - - if (matches.length !== 1) throw new Error(`${referencePath}.module resolved to ${matches.length} files`); - - return matches[0] as string; -} - -function buildInvocation( - entryPoint: EntryPointFunction, - reference: TypeScriptEntryPointReference, - referencePath: string, -): (args: unknown[]) => unknown | Promise { - if (reference.executionKind === 'sync') { - return (args: unknown[]): unknown => { - const result = entryPoint(...args); - if (isThenable(result)) throw new Error(`${referencePath}.executionKind expected a synchronous result`); - - return result; - }; - } - - return async (args: unknown[]): Promise => { - const result = entryPoint(...args); - if (!isThenable(result)) throw new Error(`${referencePath}.executionKind expected an asynchronous result`); - - return await result; - }; -} - -function isWithin(candidate: string, root: string): boolean { - const relative = path.relative(root, candidate); - - return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); -} - -function isThenable(value: unknown): value is PromiseLike { - if (value === null) return false; - if (typeof value !== 'object' && typeof value !== 'function') return false; - - return typeof (value as { then?: unknown }).then === 'function'; -} - -function isMissingPath(error: unknown): boolean { - return error instanceof Error - && 'code' in error - && (error.code === 'ENOENT' || error.code === 'ENOTDIR'); -} diff --git a/usvm-ts-calls/source-replay-adapter/src/calls-js-value.ts b/usvm-ts-calls/source-replay-adapter/src/calls-js-value.ts deleted file mode 100644 index efa04bb02a..0000000000 --- a/usvm-ts-calls/source-replay-adapter/src/calls-js-value.ts +++ /dev/null @@ -1,44 +0,0 @@ -export type TaggedJsValue = - | { kind: 'undefined' } - | { kind: 'null' } - | { kind: 'boolean'; value: boolean } - | { kind: 'string'; value: string } - | { kind: 'number'; value: 'finite'; bits: string } - | { kind: 'number'; value: 'nan' | 'positive-infinity' | 'negative-infinity' } - | { kind: 'array'; elements: TaggedJsValue[] }; - -export function decodeJsValue(value: TaggedJsValue, path = 'value'): unknown { - switch (value.kind) { - case 'undefined': - return undefined; - case 'null': - return null; - case 'boolean': - case 'string': - return value.value; - case 'number': - return decodeNumber(value, path); - case 'array': - return value.elements.map((element, index) => decodeJsValue(element, `${path}.elements[${index}]`)); - } -} - -function decodeNumber(value: Extract, path: string): number { - switch (value.value) { - case 'nan': - return Number.NaN; - case 'positive-infinity': - return Number.POSITIVE_INFINITY; - case 'negative-infinity': - return Number.NEGATIVE_INFINITY; - case 'finite': { - if (!/^[0-9a-f]{16}$/.test(value.bits)) throw new Error(`${path} has an invalid finite number encoding`); - - const buffer = new ArrayBuffer(8); - const view = new DataView(buffer); - view.setBigUint64(0, BigInt(`0x${value.bits}`), false); - - return view.getFloat64(0, false); - } - } -} diff --git a/usvm-ts-calls/source-replay-adapter/src/process-group-shutdown.ts b/usvm-ts-calls/source-replay-adapter/src/process-group-shutdown.ts deleted file mode 100644 index 5ff8d6039d..0000000000 --- a/usvm-ts-calls/source-replay-adapter/src/process-group-shutdown.ts +++ /dev/null @@ -1,77 +0,0 @@ -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-calls/source-replay-adapter/src/process-supervisor.ts b/usvm-ts-calls/source-replay-adapter/src/process-supervisor.ts deleted file mode 100644 index 71771e9631..0000000000 --- a/usvm-ts-calls/source-replay-adapter/src/process-supervisor.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { spawn } from 'node:child_process'; -import { unlinkSync, writeFileSync } from 'node:fs'; -import { - ProcessGroupShutdown, - terminateOwnProcessGroup, - terminateOwnedProcessGroup, -} from './process-group-shutdown.js'; - -interface CommandExitMessage { - type: 'command-exit'; - code: number; -} - -type Command = [string, ...string[]]; - -/** - * Process tree: - * Kotlin client -> supervisor -> detached group owner -> command -> any descendants. - * The supervisor stays outside the owned group so it can escalate shutdown. The group owner stays alive over IPC - * until the command reports its exit, then the supervisor removes every remaining descendant at once. - */ - -const commandModeFlag = '--command'; -const groupOwnerFlag = '--group-owner'; -const MAX_TIMER_DELAY_MILLIS = 2 ** 31 - 1; - -runProcess(process.argv.slice(2)); - -function runProcess(arguments_: string[]): void { - const mode = requireArgument(arguments_[0], 'supervisor mode'); - - if (mode === groupOwnerFlag) { - runGroupOwner(requireCommand(arguments_.slice(1))); - return; - } - - if (mode !== commandModeFlag) fail(`Unknown supervisor mode: ${mode}`); - - const forceKillDelayMillis = requireTimerDelay(arguments_[1], 'force-kill delay'); - const processGroupFile = requireArgument(arguments_[2], 'process-group file'); - const command = requireCommand(arguments_.slice(3)); - - runSupervisor(command, forceKillDelayMillis, processGroupFile); -} - -function runSupervisor( - command: Command, - forceKillDelayMillis: number, - processGroupFile: string, -): void { - const shutdown = new ProcessGroupShutdown(forceKillDelayMillis, terminateOwnedProcessGroup); - installSupervisorSignalHandlers(shutdown); - - const supervisorEntryPoint = requireArgument(process.argv[1], 'supervisor entry point'); - const groupOwner = spawn( - process.execPath, - [supervisorEntryPoint, groupOwnerFlag, ...command], - { - detached: true, - stdio: ['pipe', 'pipe', 'pipe', 'ipc'], - }, - ); - const groupOwnerPid = requirePid(groupOwner.pid, 'group owner'); - const groupOwnerStdin = requireStream(groupOwner.stdin, 'group owner stdin'); - const groupOwnerStdout = requireStream(groupOwner.stdout, 'group owner stdout'); - const groupOwnerStderr = requireStream(groupOwner.stderr, 'group owner stderr'); - let reportedExitCode: number | undefined; - - shutdown.attach(groupOwnerPid); - writeFileSync(processGroupFile, String(groupOwnerPid)); - - process.stdin.pipe(groupOwnerStdin); - groupOwnerStdout.pipe(process.stdout); - groupOwnerStderr.pipe(process.stderr); - - groupOwner.on('message', (message: unknown) => { - if (!isCommandExitMessage(message)) return; - - reportedExitCode = message.code; - terminateOwnedProcessGroup(groupOwnerPid, 'forceful'); - }); - groupOwner.on('error', (error: Error) => { - process.stderr.write(`Failed to start process-group owner: ${error.message}\n`); - reportedExitCode = 1; - }); - groupOwner.on('close', (code: number | null) => { - shutdown.cancel(); - removeProcessGroupFile(processGroupFile); - - process.exitCode = reportedExitCode ?? code ?? 1; - }); -} - -function installSupervisorSignalHandlers(shutdown: ProcessGroupShutdown): void { - process.on('SIGINT', () => shutdown.request()); - process.on('SIGTERM', () => shutdown.request()); -} - -function runGroupOwner(command: Command): void { - installProcessGroupOwnerHandlers(); - - const reportExit = createCommandExitReporter(); - const child = spawn(command[0], command.slice(1), { - // Direct inheritance avoids a user-space forwarding buffer that could be truncated when the group is removed. - stdio: 'inherit', - }); - - child.on('error', (error: Error) => { - process.stderr.write(`Failed to start supervised command: ${error.message}\n`); - reportExit(1); - }); - child.on('exit', (code: number | null) => reportExit(code ?? 1)); -} - -function installProcessGroupOwnerHandlers(): void { - // Keep the process-group identity stable while shutdown propagates through the group. If the supervisor disappears, - // the IPC disconnect is the last reliable opportunity to remove the entire owned group. - process.on('SIGINT', () => undefined); - process.on('SIGTERM', () => undefined); - process.on('disconnect', terminateOwnProcessGroup); -} - -function createCommandExitReporter(): (code: number) => void { - let reported = false; - - return (code: number): void => { - if (reported) return; - - reported = true; - const message: CommandExitMessage = { type: 'command-exit', code }; - process.send?.(message); - }; -} - -function isCommandExitMessage(value: unknown): value is CommandExitMessage { - if (value === null || typeof value !== 'object') return false; - - const record = value as Record; - - return record.type === 'command-exit' - && typeof record.code === 'number' - && Number.isInteger(record.code); -} - -function removeProcessGroupFile(processGroupFile: string): void { - try { - unlinkSync(processGroupFile); - } catch (error: unknown) { - if (!isMissingFile(error)) throw error; - } -} - -function isMissingFile(error: unknown): boolean { - if (!(error instanceof Error) || !('code' in error)) return false; - - return error.code === 'ENOENT'; -} - -function requireArgument(value: string | undefined, name: string): string { - if (value === undefined || value.length === 0) fail(`Missing ${name}`); - - return value; -} - -function requireCommand(command: string[]): Command { - const executable = requireArgument(command[0], 'command executable'); - - return [executable, ...command.slice(1)]; -} - -function requireTimerDelay(value: string | undefined, name: string): number { - const parsed = value === undefined ? Number.NaN : Number(value); - const isInteger = Number.isInteger(parsed); - const isPositive = parsed > 0; - const fitsNodeTimer = parsed <= MAX_TIMER_DELAY_MILLIS; - const valid = isInteger && isPositive && fitsNodeTimer; - if (!valid) fail(`Invalid ${name}: ${value ?? ''}`); - - return parsed; -} - -function requirePid(value: number | undefined, name: string): number { - if (value === undefined) fail(`Missing ${name} PID`); - - return value; -} - -function requireStream(value: T | null, name: string): T { - if (value === null) fail(`Missing ${name}`); - - return value; -} - -function fail(message: string): never { - process.stderr.write(`${message}\n`); - process.exit(1); -} diff --git a/usvm-ts-calls/source-replay-adapter/src/source-target-replay-cli.ts b/usvm-ts-calls/source-replay-adapter/src/source-target-replay-cli.ts deleted file mode 100644 index adf2ca004f..0000000000 --- a/usvm-ts-calls/source-replay-adapter/src/source-target-replay-cli.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { spawn } from 'node:child_process'; -import { copyFile, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import ts from 'typescript'; -import type { TypeScriptEntryPointReference } from './calls-entry-point.js'; -import type { TaggedJsValue } from './calls-js-value.js'; - -interface SourcePosition { - line: number; - column: number; -} - -interface ReplayRequest { - sourceRoots: string[]; - entryPoint: TypeScriptEntryPointReference; - inputs: TaggedJsValue[]; - target: { - sourcePath: string; - startOffset: number; - endOffset: number; - start: SourcePosition; - end: SourcePosition; - }; - timeoutMillis: number; -} - -interface WorkerResult { - invocation: 'returned' | 'threw'; - targetHit: boolean; - errorName?: string; - errorMessage?: string; -} - -interface ProcessResult { - exitCode: number | null; - timedOut: boolean; - stderrOverflow: boolean; - stderr: string; -} - -interface ResolvedTarget { - sourceRootIndex: number; - sourceRoot: string; - sourcePath: string; - absolutePath: string; - source: string; -} - -async function main(): Promise { - const request = validateRequest(JSON.parse(await readStdin()) as unknown); - const workspace = await mkdtemp(path.join(tmpdir(), 'usvm-ts-calls-replay-')); - - try { - const target = await resolveTarget(request); - const sourceFile = ts.createSourceFile( - target.absolutePath, - target.source, - ts.ScriptTarget.Latest, - true, - scriptKind(target.absolutePath), - ); - const matches = collectStatements(sourceFile).filter((statement) => - statement.getStart(sourceFile, false) === request.target.startOffset - && statement.getEnd() === request.target.endOffset); - if (matches.length !== 1) { - writeResponse({ - status: 'ok', - replayStatus: matches.length === 0 ? 'unmapped' : 'ambiguous', - reason: matches.length === 0 ? 'statement-range-unmapped' : 'statement-range-ambiguous', - invocation: null, - }); - return; - } - - const statement = matches[0] as ts.Statement; - const start = sourceFile.getLineAndCharacterOfPosition(statement.getStart(sourceFile, false)); - const end = sourceFile.getLineAndCharacterOfPosition(statement.getEnd()); - if (!samePosition(start, request.target.start) || !samePosition(end, request.target.end)) { - writeResponse({ status: 'ok', replayStatus: 'unmapped', reason: 'statement-coordinate-mismatch', invocation: null }); - return; - } - if (!ts.isBlock(statement.parent) && !ts.isSourceFile(statement.parent)) { - writeResponse({ status: 'ok', replayStatus: 'unsupported', reason: 'statement-parent-unsupported', invocation: null }); - return; - } - - const hitKey = `__usvm_source_target_${randomUUID().replaceAll('-', '_')}`; - const marker = `;(globalThis as Record)[${JSON.stringify(hitKey)}] = true;\n`; - const instrumented = target.source.slice(0, request.target.startOffset) - + marker - + target.source.slice(request.target.startOffset); - const overlayRoot = path.join(workspace, 'source-overlay'); - await createOverlay(target.sourceRoot, overlayRoot, target.sourcePath, instrumented); - const workerRequestPath = path.join(workspace, 'request.json'); - const workerResultPath = path.join(workspace, 'result.json'); - const workerPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'source-target-replay-worker.js'); - await requireFile(workerPath, 'source-target replay worker'); - const sourceRoots = request.sourceRoots.map((root, index) => index === target.sourceRootIndex ? overlayRoot : root); - await writeFile(workerRequestPath, JSON.stringify({ - sourceRoots, - entryPoint: request.entryPoint, - inputs: request.inputs, - hitKey, - resultPath: workerResultPath, - }), 'utf8'); - - const execution = await runProcess(process.execPath, [workerPath, workerRequestPath], request.timeoutMillis); - if (execution.timedOut) { - writeResponse({ status: 'ok', replayStatus: 'timeout', invocation: null }); - return; - } - if (execution.stderrOverflow) { - writeResponse({ status: 'error', replayStatus: 'tool-error', message: 'worker stderr exceeded 65536 bytes' }); - return; - } - if (execution.exitCode !== 0) { - writeResponse({ - status: 'error', replayStatus: 'tool-error', message: execution.stderr.trim() || `worker exited with code ${execution.exitCode}`, - }); - return; - } - - const worker = JSON.parse(await readFile(workerResultPath, 'utf8')) as WorkerResult; - writeResponse({ status: 'ok', replayStatus: worker.targetHit ? 'confirmed' : 'rejected', invocation: worker }); - } finally { - await rm(workspace, { recursive: true, force: true }); - } -} - -function validateRequest(value: unknown): ReplayRequest { - if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error('Request must be an object'); - const request = value as Partial; - if (!Array.isArray(request.sourceRoots) || request.sourceRoots.length === 0) throw new Error('sourceRoots are required'); - if (request.entryPoint === undefined || !Array.isArray(request.inputs)) throw new Error('entryPoint and inputs are required'); - if (request.target === undefined || typeof request.target.sourcePath !== 'string') throw new Error('target is required'); - if (!Number.isInteger(request.timeoutMillis) || (request.timeoutMillis as number) <= 0) throw new Error('timeoutMillis must be positive'); - - return request as ReplayRequest; -} - -async function resolveTarget(request: ReplayRequest): Promise { - if (path.isAbsolute(request.target.sourcePath)) throw new Error('target.sourcePath must be relative'); - const matches: ResolvedTarget[] = []; - - for (const [sourceRootIndex, sourceRootValue] of request.sourceRoots.entries()) { - const sourceRoot = await realpath(sourceRootValue); - const candidate = path.resolve(sourceRoot, request.target.sourcePath); - const relative = path.relative(sourceRoot, candidate); - if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) continue; - - try { - const absolutePath = await realpath(candidate); - const canonicalRelative = path.relative(sourceRoot, absolutePath); - if (canonicalRelative === '..' || canonicalRelative.startsWith(`..${path.sep}`) || path.isAbsolute(canonicalRelative)) continue; - if ((await stat(absolutePath)).isFile()) { - matches.push({ - sourceRootIndex, - sourceRoot, - sourcePath: request.target.sourcePath, - absolutePath, - source: await readFile(absolutePath, 'utf8'), - }); - } - } catch (error: unknown) { - if (!(error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT')) throw error; - } - } - - if (matches.length !== 1) throw new Error(`Target source path resolved to ${matches.length} files`); - return matches[0] as ResolvedTarget; -} - -function collectStatements(sourceFile: ts.SourceFile): ts.Statement[] { - const statements: ts.Statement[] = []; - const visit = (node: ts.Node): void => { - if (ts.isStatement(node)) statements.push(node); - ts.forEachChild(node, visit); - }; - visit(sourceFile); - return statements; -} - -function scriptKind(filePath: string): ts.ScriptKind { - return filePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS; -} - -function samePosition(actual: ts.LineAndCharacter, expected: SourcePosition): boolean { - return actual.line === expected.line && actual.character === expected.column; -} - -async function createOverlay( - sourceRoot: string, - overlayRoot: string, - relativeTarget: string, - instrumentedSource: string, -): Promise { - const segments = relativeTarget.split('/'); - if (segments.length === 0 || segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..')) { - throw new Error('target.sourcePath must be normalized POSIX relative path'); - } - - let sourceDirectory = sourceRoot; - let overlayDirectory = overlayRoot; - await mkdir(overlayDirectory, { recursive: true }); - for (const [index, segment] of segments.entries()) { - const last = index === segments.length - 1; - const entries = await readdir(sourceDirectory); - for (const entry of entries) { - if (entry === segment) continue; - const original = path.join(sourceDirectory, entry); - await mirrorOverlayEntry(original, path.join(overlayDirectory, entry)); - } - if (last) { - await writeFile(path.join(overlayDirectory, segment), instrumentedSource, 'utf8'); - } else { - sourceDirectory = path.join(sourceDirectory, segment); - overlayDirectory = path.join(overlayDirectory, segment); - await mkdir(overlayDirectory); - } - } -} - -async function mirrorOverlayEntry(original: string, overlay: string): Promise { - const kind = (await stat(original)).isDirectory() ? 'dir' : 'file'; - if (process.platform === 'win32') { - if (kind === 'dir') { - await symlink(original, overlay, 'junction'); - } else { - await copyFile(original, overlay); - } - return; - } - - await symlink(original, overlay, kind); -} - -async function runProcess(executable: string, args: string[], timeoutMillis: number): Promise { - const child = spawn(executable, args, { stdio: ['ignore', 'ignore', 'pipe'] }); - const stderrChunks: Buffer[] = []; - let stderrBytes = 0; - let stderrOverflow = false; - child.stderr.on('data', (chunk: Buffer) => { - if (stderrOverflow) return; - - stderrBytes += chunk.length; - if (stderrBytes > MAX_WORKER_STDERR_BYTES) { - stderrOverflow = true; - child.kill('SIGTERM'); - return; - } - - stderrChunks.push(chunk); - }); - - return await new Promise((resolve, reject) => { - let timedOut = false; - let forceKillTimer: NodeJS.Timeout | undefined; - const timer = setTimeout(() => { - timedOut = true; - child.kill('SIGTERM'); - forceKillTimer = setTimeout(() => child.kill('SIGKILL'), WORKER_SHUTDOWN_GRACE_MILLIS); - forceKillTimer.unref(); - }, timeoutMillis); - child.once('error', reject); - child.once('close', (exitCode) => { - clearTimeout(timer); - if (forceKillTimer !== undefined) clearTimeout(forceKillTimer); - const stderr = Buffer.concat(stderrChunks).toString('utf8'); - resolve({ exitCode, timedOut, stderrOverflow, stderr }); - }); - }); -} - -async function requireFile(filePath: string, description: string): Promise { - if (!(await stat(filePath)).isFile()) throw new Error(`Missing ${description}: ${filePath}`); -} - -async function readStdin(): Promise { - const chunks: Buffer[] = []; - for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk)); - return Buffer.concat(chunks).toString('utf8'); -} - -function writeResponse(response: unknown): void { - process.stdout.write(`${JSON.stringify(response)}\n`); -} - -main().catch((error: unknown) => { - writeResponse({ status: 'error', replayStatus: 'tool-error', message: error instanceof Error ? error.message : String(error) }); - process.exitCode = 1; -}); - -const MAX_WORKER_STDERR_BYTES = 64 * 1024; -const WORKER_SHUTDOWN_GRACE_MILLIS = 250; diff --git a/usvm-ts-calls/source-replay-adapter/src/source-target-replay-worker.ts b/usvm-ts-calls/source-replay-adapter/src/source-target-replay-worker.ts deleted file mode 100644 index 1ec2e85b8d..0000000000 --- a/usvm-ts-calls/source-replay-adapter/src/source-target-replay-worker.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { readFile, writeFile } from 'node:fs/promises'; -import { loadCallable, type TypeScriptEntryPointReference } from './calls-entry-point.js'; -import { decodeJsValue, type TaggedJsValue } from './calls-js-value.js'; - -interface ReplayWorkerRequest { - sourceRoots: string[]; - entryPoint: TypeScriptEntryPointReference; - inputs: TaggedJsValue[]; - hitKey: string; - resultPath: string; -} - -interface ReplayWorkerResult { - invocation: 'returned' | 'threw'; - targetHit: boolean; - errorName?: string; - errorMessage?: string; -} - -async function main(): Promise { - const requestPath = process.argv[2]; - if (requestPath === undefined) throw new Error('Expected a replay request path'); - - const request = JSON.parse(await readFile(requestPath, 'utf8')) as ReplayWorkerRequest; - Object.defineProperty(globalThis, request.hitKey, { - configurable: true, - enumerable: false, - value: false, - writable: true, - }); - const callable = await loadCallable(request.entryPoint, request.sourceRoots, 'entryPoint'); - const inputs = request.inputs.map((value, index) => decodeJsValue(value, `inputs[${index}]`)); - (globalThis as Record)[request.hitKey] = false; - let result: ReplayWorkerResult; - - try { - await callable.invoke(inputs); - result = { - invocation: 'returned', - targetHit: globalThis[request.hitKey as keyof typeof globalThis] === true, - }; - } catch (error: unknown) { - result = { - invocation: 'threw', - targetHit: globalThis[request.hitKey as keyof typeof globalThis] === true, - errorName: error instanceof Error ? error.name : typeof error, - errorMessage: error instanceof Error ? error.message : String(error), - }; - } - - await writeFile(request.resultPath, `${JSON.stringify(result)}\n`, 'utf8'); -} - -main().catch((error: unknown) => { - process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); - process.exitCode = 1; -}); diff --git a/usvm-ts-calls/source-replay-adapter/test/source-target-replay-cli.test.ts b/usvm-ts-calls/source-replay-adapter/test/source-target-replay-cli.test.ts deleted file mode 100644 index b3fc711989..0000000000 --- a/usvm-ts-calls/source-replay-adapter/test/source-target-replay-cli.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import { readFile } from 'node:fs/promises'; -import path from 'node:path'; -import test from 'node:test'; -import { fileURLToPath } from 'node:url'; -import ts from 'typescript'; - -interface ReplayResponse { - status: 'ok' | 'error'; - replayStatus: string; - reason?: string; - invocation?: { - invocation: 'returned' | 'threw'; - targetHit: boolean; - } | null; -} - -interface StatementTarget { - sourcePath: string; - startOffset: number; - endOffset: number; - start: { line: number; column: number }; - end: { line: number; column: number }; -} - -const adapterRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); -const fixturePath = path.join(adapterRoot, 'test', 'source-target-replay-fixture.ts'); -const cliPath = path.join(adapterRoot, 'dist', 'src', 'source-target-replay-cli.js'); - -test('confirms only the exact source statement reached by original TypeScript', async () => { - const taken = await statementTarget('inlineChoose', 'return 1;'); - const untaken = await statementTarget('inlineChoose', 'return 0;'); - const request = baseRequest('inlineChoose'); - - const takenResponse = await replay({ ...request, target: taken }); - const untakenResponse = await replay({ ...request, target: untaken }); - - assert.equal(takenResponse.replayStatus, 'confirmed'); - assert.equal(takenResponse.invocation?.targetHit, true); - assert.equal(untakenResponse.replayStatus, 'rejected'); - assert.equal(untakenResponse.invocation?.targetHit, false); -}); - -test('retains target confirmation when the original TypeScript invocation throws', async () => { - const target = await statementTarget('throwsAtTarget', "throw new Error('expected');"); - - const response = await replay({ ...baseRequest('throwsAtTarget', []), target }); - - assert.equal(response.replayStatus, 'confirmed'); - assert.equal(response.invocation?.invocation, 'threw'); - assert.equal(response.invocation?.targetHit, true); -}); - -test('counts target hits from the selected invocation rather than module import', async () => { - const target = await statementTarget('importOnlyTarget', 'return 7;'); - - const importOnly = await replay({ ...baseRequest('skipsImportOnlyTarget', []), target }); - const invoked = await replay({ ...baseRequest('importOnlyTarget', []), target }); - - assert.equal(importOnly.replayStatus, 'rejected'); - assert.equal(importOnly.invocation?.targetHit, false); - assert.equal(invoked.replayStatus, 'confirmed'); - assert.equal(invoked.invocation?.targetHit, true); -}); - -function baseRequest(exportName: string, inputs = [numberValue(1)]): Record { - return { - sourceRoots: [path.dirname(fixturePath)], - entryPoint: { - module: path.basename(fixturePath), - exportName, - executionKind: 'sync', - }, - inputs, - timeoutMillis: 10_000, - }; -} - -async function statementTarget(functionName: string, text: string): Promise { - const source = await readFile(fixturePath, 'utf8'); - const sourceFile = ts.createSourceFile(fixturePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); - const functions = sourceFile.statements.filter(ts.isFunctionDeclaration); - const declaration = functions.find((candidate) => candidate.name?.text === functionName); - assert.ok(declaration, `missing function ${functionName}`); - let match: ts.Statement | undefined; - const visit = (node: ts.Node): void => { - if (ts.isStatement(node) && node.getText(sourceFile) === text) match = node; - ts.forEachChild(node, visit); - }; - visit(declaration); - assert.ok(match, `missing statement ${text}`); - const startOffset = match.getStart(sourceFile, false); - const endOffset = match.getEnd(); - const start = sourceFile.getLineAndCharacterOfPosition(startOffset); - const end = sourceFile.getLineAndCharacterOfPosition(endOffset); - - return { - sourcePath: path.basename(fixturePath), - startOffset, - endOffset, - start: { line: start.line, column: start.character }, - end: { line: end.line, column: end.character }, - }; -} - -function replay(request: Record): Promise { - const child = spawn(process.execPath, [cliPath], { stdio: ['pipe', 'pipe', 'pipe'] }); - child.stdin.end(JSON.stringify(request)); - - return new Promise((resolve, reject) => { - let stdout = ''; - let stderr = ''; - child.stdout.setEncoding('utf8'); - child.stderr.setEncoding('utf8'); - child.stdout.on('data', (chunk: string) => { stdout += chunk; }); - child.stderr.on('data', (chunk: string) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (exitCode) => { - if (exitCode !== 0) { - reject(new Error(`replay CLI failed with ${exitCode}: ${stderr}\n${stdout}`)); - return; - } - resolve(JSON.parse(stdout) as ReplayResponse); - }); - }); -} - -function numberValue(value: number): Record { - const buffer = Buffer.allocUnsafe(8); - buffer.writeDoubleBE(value); - - return { kind: 'number', value: 'finite', bits: buffer.toString('hex') }; -} diff --git a/usvm-ts-calls/source-replay-adapter/tsconfig.json b/usvm-ts-calls/source-replay-adapter/tsconfig.json deleted file mode 100644 index 706906f1c2..0000000000 --- a/usvm-ts-calls/source-replay-adapter/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "rootDir": ".", - "outDir": "dist", - "strict": true, - "noUncheckedIndexedAccess": true, - "exactOptionalPropertyTypes": true, - "noImplicitOverride": true, - "noFallthroughCasesInSwitch": true, - "forceConsistentCasingInFileNames": true, - "verbatimModuleSyntax": true, - "noEmitOnError": true, - "skipLibCheck": true, - "types": ["node"] - }, - "include": ["src/**/*.ts", "test/**/*.ts"] -} diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsProcessTransport.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsProcessTransport.kt deleted file mode 100644 index 32ffb7479d..0000000000 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsProcessTransport.kt +++ /dev/null @@ -1,437 +0,0 @@ -package org.usvm.ts.calls - -import java.io.ByteArrayOutputStream -import java.io.IOException -import java.io.InputStream -import java.nio.file.Files -import java.nio.file.Path -import java.util.concurrent.ExecutionException -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors -import java.util.concurrent.Future -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeoutException - -private object CallsTransportCode { - const val BACKEND_REQUEST_TOO_LARGE = "calls.replay.request-too-large" - const val BACKEND_PROCESS_READ_FAILED = "calls.replay.process-read-failed" - const val BACKEND_PROCESS_WRITE_FAILED = "calls.replay.process-write-failed" - const val BACKEND_PROCESS_INTERRUPTED = "calls.replay.process-interrupted" - const val BACKEND_PROCESS_START_FAILED = "calls.replay.process-start-failed" - const val BACKEND_PROCESS_TIMEOUT = "calls.replay.process-timeout" - const val BACKEND_RESPONSE_TOO_LARGE = "calls.replay.response-too-large" -} - -/** Completed output of one supervised request-response process. */ -internal data class CallsProcessOutput( - val exitCode: Int, - val stdout: String, - val stderr: String, -) - -/** Transport failure before a response can be interpreted by a protocol client. */ -internal class CallsTransportException( - val code: String, - message: String, - cause: Throwable? = null, -) : RuntimeException(message, cause) - -/** - * Runs one bounded request-response exchange through the shared Node process supervisor. - * - * Three I/O tasks are intentional: draining stdout and stderr concurrently prevents pipe deadlocks, while writing - * stdin separately lets the same wall-clock deadline cover a child that never reads its request. - */ -internal class CallsProcessTransport( - private val nodeExecutable: String, - private val maxRequestBytes: Int, - private val maxStdoutBytes: Int, - private val maxStderrBytes: Int, - private val shutdownGraceMillis: Long, -) { - init { - require(maxRequestBytes > 0) { "Maximum request size must be positive" } - require(maxStdoutBytes > 0) { "Maximum stdout size must be positive" } - require(maxStderrBytes > 0) { "Maximum stderr size must be positive" } - require(shutdownGraceMillis in 1..Int.MAX_VALUE.toLong()) { - "Shutdown grace period must fit the positive delay range supported by Node timers" - } - } - - fun invoke( - command: List, - request: String, - timeoutMillis: Long, - reportedTimeoutMillis: Long, - description: String, - ): CallsProcessOutput { - require(timeoutMillis > 0) { "Process timeout must be positive" } - require(command.isNotEmpty()) { "Supervised command must not be empty" } - requireRequestWithinLimit(request, description) - - val deadlineNanos = deadlineAfter(timeoutMillis) - val managedProcess = startProcess(command, description) - val executor = Executors.newFixedThreadPool(IO_TASK_COUNT) - val tasks = startIoTasks(managedProcess.process, request, description, executor) - - try { - awaitProcess( - process = managedProcess.process, - tasks = tasks.all, - deadlineNanos = deadlineNanos, - reportedTimeoutMillis = reportedTimeoutMillis, - description = description, - ) - awaitIo( - tasks = tasks, - deadlineNanos = deadlineNanos, - reportedTimeoutMillis = reportedTimeoutMillis, - description = description, - ) - val stdout = tasks.stdout.completedValue(description) - val stderr = tasks.stderr.completedValue(description) - - return CallsProcessOutput( - exitCode = managedProcess.process.exitValue(), - stdout = stdout, - stderr = stderr, - ) - } finally { - tasks.all.forEach { task -> task.cancel() } - terminate(managedProcess, deadlineNanos) - closeStreams(managedProcess.process) - runCatching { Files.deleteIfExists(managedProcess.processGroupFile) } - executor.shutdownNow() - } - } - - private fun requireRequestWithinLimit(request: String, description: String) { - if (request.toByteArray(Charsets.UTF_8).size > maxRequestBytes) { - fail( - code = CallsTransportCode.BACKEND_REQUEST_TOO_LARGE, - message = "$description request exceeds $maxRequestBytes bytes", - ) - } - } - - private fun startIoTasks( - process: Process, - request: String, - description: String, - executor: ExecutorService, - ): ProcessIoTasks { - val stdout = ProcessIoTask( - future = executor.submit { - process.inputStream.readBounded(maxStdoutBytes, stream = "stdout") - }, - operation = "reading $description stdout", - failureCode = CallsTransportCode.BACKEND_PROCESS_READ_FAILED, - ) - val stderr = ProcessIoTask( - future = executor.submit { - process.errorStream.readBounded(maxStderrBytes, stream = "stderr") - }, - operation = "reading $description stderr", - failureCode = CallsTransportCode.BACKEND_PROCESS_READ_FAILED, - ) - val writer = ProcessIoTask( - future = executor.submit { - process.outputStream.bufferedWriter(Charsets.UTF_8).use { output -> - output.write(request) - } - }, - operation = "writing the $description request", - failureCode = CallsTransportCode.BACKEND_PROCESS_WRITE_FAILED, - ) - - return ProcessIoTasks(stdout = stdout, stderr = stderr, writer = writer) - } - - private fun awaitProcess( - process: Process, - tasks: List>, - deadlineNanos: Long, - reportedTimeoutMillis: Long, - description: String, - ) { - while (true) { - tasks.forEach { task -> task.throwIfFailed(description) } - - val remainingMillis = remainingMillis(deadlineNanos) - if (remainingMillis <= FORCED_TERMINATION_RESERVE_MILLIS) { - timeout(description, reportedTimeoutMillis) - } - - val completed = try { - process.waitFor(minOf(remainingMillis, PROCESS_POLL_MILLIS), TimeUnit.MILLISECONDS) - } catch (error: InterruptedException) { - Thread.currentThread().interrupt() - fail( - code = CallsTransportCode.BACKEND_PROCESS_INTERRUPTED, - message = "Interrupted while waiting for the $description", - cause = error, - ) - } - if (completed) return - } - } - - private fun awaitIo( - tasks: ProcessIoTasks, - deadlineNanos: Long, - reportedTimeoutMillis: Long, - description: String, - ) { - while (true) { - tasks.all.forEach { task -> task.throwIfFailed(description) } - - val pendingTask = tasks.all.firstOrNull { task -> !task.isDone } ?: break - - val remainingMillis = remainingMillis(deadlineNanos) - if (remainingMillis <= FORCED_TERMINATION_RESERVE_MILLIS) { - timeout(description, reportedTimeoutMillis) - } - - pendingTask.await(minOf(remainingMillis, IO_POLL_MILLIS), description) - } - - tasks.all.forEach { task -> task.completedValue(description) } - } - - private fun startProcess( - supervisedCommand: List, - description: String, - ): SupervisedProcessHandle { - val processGroupFile = try { - Files.createTempFile(PROCESS_GROUP_FILE_PREFIX, ".pid") - } catch (error: IOException) { - processStartFailure(description, error) - } - var processStarted = false - - try { - val command = buildList { - add(nodeExecutable) - add(CallsReplayRuntime.processSupervisorEntryPoint().toString()) - add(PROCESS_SUPERVISOR_COMMAND) - add(shutdownGraceMillis.toString()) - add(processGroupFile.toString()) - addAll(supervisedCommand) - } - val process = ProcessBuilder(command).start() - processStarted = true - - return SupervisedProcessHandle(process = process, processGroupFile = processGroupFile) - } catch (error: IOException) { - processStartFailure(description, error) - } finally { - if (!processStarted) runCatching { Files.deleteIfExists(processGroupFile) } - } - } - - private fun processStartFailure(description: String, error: IOException): Nothing = fail( - code = CallsTransportCode.BACKEND_PROCESS_START_FAILED, - message = "Failed to start $description: ${error.message}", - cause = error, - ) - - private fun terminate(managedProcess: SupervisedProcessHandle, deadlineNanos: Long) { - val process = managedProcess.process - if (!process.isAlive) { - forceTerminateOwnedProcessGroup(managedProcess.processGroupFile, deadlineNanos) - return - } - - process.destroy() - val gracefulDeadlineNanos = minOf( - deadlineBefore( - deadlineNanos = deadlineNanos, - durationMillis = FORCED_TERMINATION_RESERVE_MILLIS, - ), - deadlineAfter(shutdownGraceMillis), - ) - if (awaitProcessExit(process, gracefulDeadlineNanos)) return - - forceTerminateOwnedProcessGroup(managedProcess.processGroupFile, deadlineNanos) - process.destroyForcibly() - awaitProcessExit(process, deadlineNanos) - } - - private fun forceTerminateOwnedProcessGroup(processGroupFile: Path, deadlineNanos: Long) { - val processGroupText = runCatching { Files.readString(processGroupFile) }.getOrNull() ?: return - val processGroupId = processGroupText.trim().toLongOrNull() ?: return - val command = if (IS_WINDOWS) { - listOf("taskkill", "/PID", processGroupId.toString(), "/T", "/F") - } else { - listOf("/bin/kill", "-KILL", "--", "-$processGroupId") - } - val killer = runCatching { - ProcessBuilder(command) - .redirectOutput(ProcessBuilder.Redirect.DISCARD) - .redirectError(ProcessBuilder.Redirect.DISCARD) - .start() - }.getOrNull() ?: return - val waitMillis = minOf(remainingMillis(deadlineNanos), PROCESS_GROUP_KILL_WAIT_MILLIS) - if (waitMillis == 0L) return - - try { - if (!killer.waitFor(waitMillis, TimeUnit.MILLISECONDS)) killer.destroyForcibly() - } catch (_: InterruptedException) { - Thread.currentThread().interrupt() - killer.destroyForcibly() - } - } - - private fun awaitProcessExit(process: Process, deadlineNanos: Long): Boolean { - while (process.isAlive) { - val waitMillis = minOf(remainingMillis(deadlineNanos), PROCESS_POLL_MILLIS) - if (waitMillis == 0L) return false - - try { - if (process.waitFor(waitMillis, TimeUnit.MILLISECONDS)) return true - } catch (_: InterruptedException) { - Thread.currentThread().interrupt() - return false - } - } - - return true - } - - private fun closeStreams(process: Process) { - runCatching { process.outputStream.close() } - runCatching { process.inputStream.close() } - runCatching { process.errorStream.close() } - } - - private fun timeout(description: String, reportedTimeoutMillis: Long): Nothing = fail( - code = CallsTransportCode.BACKEND_PROCESS_TIMEOUT, - message = "$description exceeded the $reportedTimeoutMillis ms timeout", - ) - - private fun fail(code: String, message: String, cause: Throwable? = null): Nothing = - throw CallsTransportException(code = code, message = message, cause = cause) - - private companion object { - const val IO_TASK_COUNT = 3 - const val PROCESS_SUPERVISOR_COMMAND = "--command" - const val PROCESS_GROUP_FILE_PREFIX = "usvm-ts-calls-process-group-" - const val PROCESS_POLL_MILLIS = 10L - const val IO_POLL_MILLIS = 10L - const val FORCED_TERMINATION_RESERVE_MILLIS = 25L - const val PROCESS_GROUP_KILL_WAIT_MILLIS = 10L - - val IS_WINDOWS = System.getProperty("os.name").lowercase().contains("windows") - } -} - -private data class SupervisedProcessHandle( - val process: Process, - val processGroupFile: Path, -) - -private data class ProcessIoTasks( - val stdout: ProcessIoTask, - val stderr: ProcessIoTask, - val writer: ProcessIoTask, -) { - val all: List> = listOf(stdout, stderr, writer) -} - -private data class ProcessIoTask( - val future: Future, - val operation: String, - val failureCode: String, -) { - val isDone: Boolean - get() = future.isDone - - fun cancel() { - future.cancel(true) - } - - fun throwIfFailed(description: String) { - if (isDone) completedValue(description) - } - - fun completedValue(description: String): T = requireNotNull(await(waitMillis = 0, description)) - - fun await(waitMillis: Long, description: String): T? = try { - future.get(waitMillis, TimeUnit.MILLISECONDS) - } catch (_: TimeoutException) { - null - } catch (error: InterruptedException) { - Thread.currentThread().interrupt() - throw CallsTransportException( - code = CallsTransportCode.BACKEND_PROCESS_INTERRUPTED, - message = "Interrupted while $operation", - cause = error, - ) - } catch (error: ExecutionException) { - val cause = error.cause ?: error - if (cause is ProcessOutputLimitExceeded) { - throw CallsTransportException( - code = CallsTransportCode.BACKEND_RESPONSE_TOO_LARGE, - message = "$description ${cause.stream} exceeds ${cause.limit} bytes", - cause = cause, - ) - } - - throw CallsTransportException( - code = failureCode, - message = "Failed while $operation: ${cause.message}", - cause = cause, - ) - } -} - -private class ProcessOutputLimitExceeded( - val stream: String, - val limit: Int, -) : IOException("$stream exceeds $limit bytes") - -private fun InputStream.readBounded(limit: Int, stream: String): String { - val output = ByteArrayOutputStream(minOf(limit, DEFAULT_BUFFER_SIZE)) - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - - while (true) { - val read = read(buffer) - if (read < 0) break - - val remaining = limit - output.size() - if (remaining > 0) output.write(buffer, 0, minOf(read, remaining)) - if (read > remaining) throw ProcessOutputLimitExceeded(stream, limit) - } - - return output.toString(Charsets.UTF_8) -} - -private fun deadlineAfter(timeoutMillis: Long): Long { - val timeoutNanos = TimeUnit.MILLISECONDS.toNanos(timeoutMillis) - val now = System.nanoTime() - - return if (now > Long.MAX_VALUE - timeoutNanos) Long.MAX_VALUE else now + timeoutNanos -} - -private fun deadlineBefore(deadlineNanos: Long, durationMillis: Long): Long { - if (deadlineNanos == Long.MAX_VALUE) return Long.MAX_VALUE - - val durationNanos = TimeUnit.MILLISECONDS.toNanos(durationMillis) - - return if (deadlineNanos < Long.MIN_VALUE + durationNanos) Long.MIN_VALUE else deadlineNanos - durationNanos -} - -private fun remainingMillis(deadlineNanos: Long): Long { - if (deadlineNanos == Long.MAX_VALUE) return Long.MAX_VALUE - - val remainingNanos = deadlineNanos - System.nanoTime() - if (remainingNanos <= 0) return 0 - - return TimeUnit.NANOSECONDS.toMillis(remainingNanos) -} - -internal fun saturatedAdd(left: Long, right: Long): Long = if (left > Long.MAX_VALUE - right) { - Long.MAX_VALUE -} else { - left + right -} diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsReplayRuntime.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsReplayRuntime.kt deleted file mode 100644 index b422cdc34b..0000000000 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsReplayRuntime.kt +++ /dev/null @@ -1,44 +0,0 @@ -package org.usvm.ts.calls - -import java.nio.file.Files -import java.nio.file.Path - -internal object CallsReplayRuntime { - fun sourceTargetReplayEntryPoint(): Path = locateEntryPoint(SOURCE_TARGET_REPLAY_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) - } - - return candidates.firstOrNull(Files::isRegularFile) - ?: error("Cannot locate built TS Calls source replay adapter; checked $candidates") - } - - private fun runtimeDirectories(): List = listOfNotNull( - configuredRuntimeDirectory(), - installedRuntimeDirectory(), - ).distinct() - - private fun configuredRuntimeDirectory(): Path? = System.getProperty(RUNTIME_DIRECTORY_PROPERTY) - ?.takeIf(String::isNotBlank) - ?.let(Path::of) - ?.toAbsolutePath() - ?.normalize() - - private fun installedRuntimeDirectory(): Path? { - val location = CallsReplayRuntime::class.java.protectionDomain.codeSource?.location ?: return null - val codePath = runCatching { Path.of(location.toURI()) }.getOrNull() ?: return null - val libraryDirectory = if (Files.isDirectory(codePath)) codePath else codePath.parent ?: return null - - return libraryDirectory.resolve(INSTALLED_RUNTIME_DIRECTORY) - } - - private const val RUNTIME_DIRECTORY_PROPERTY = "org.usvm.ts.calls.replay.runtime" - private const val ENTRY_POINT_DIRECTORY = "dist/src" - private const val SOURCE_TARGET_REPLAY_CLI = "source-target-replay-cli.js" - private const val PROCESS_SUPERVISOR = "process-supervisor.js" - private const val INSTALLED_RUNTIME_DIRECTORY = "source-replay-adapter" -} diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceReplay.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceReplay.kt index 6dd7c3dd13..4118cc6ade 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceReplay.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceReplay.kt @@ -4,11 +4,23 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString -import org.usvm.ts.pbt.manifest.PropertyManifestJson +import org.usvm.ts.pbt.backend.PropertyFailureKind +import org.usvm.ts.pbt.backend.PropertyRunConfiguration +import org.usvm.ts.pbt.backend.PropertyRunStatus +import org.usvm.ts.pbt.fastcheck.FastCheckBackend +import org.usvm.ts.pbt.fastcheck.PbtBackendException +import org.usvm.ts.pbt.model.ConstantDomain import org.usvm.ts.pbt.model.ExecutionKind import org.usvm.ts.pbt.model.JsConcreteValue +import org.usvm.ts.pbt.model.PropertyDefinition +import org.usvm.ts.pbt.model.PropertyId +import org.usvm.ts.pbt.model.PropertyInput import org.usvm.ts.pbt.model.TypeScriptEntryPoint +import java.nio.file.Files +import java.nio.file.LinkOption import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.util.UUID @Serializable internal enum class CallsReplayStatus { @@ -67,33 +79,6 @@ internal data class CallsInvocationResult( val errorMessage: String? = null, ) -@Serializable -private data class CallsSourceReplayRequest( - val sourceRoots: List, - val entryPoint: TypeScriptEntryPoint, - val inputs: List, - val target: CallsSourceTargetWire, - val timeoutMillis: Long, -) - -@Serializable -private data class CallsSourceTargetWire( - val sourcePath: String, - val startOffset: Int, - val endOffset: Int, - val start: CallsSourcePosition, - val end: CallsSourcePosition, -) - -@Serializable -private data class CallsSourceReplayResponse( - val status: String, - val replayStatus: CallsReplayStatus, - val invocation: CallsInvocationResult? = null, - val reason: String? = null, - val message: String? = null, -) - internal fun interface CallsTargetReplayer { fun replay( sourceRoots: List, @@ -104,17 +89,7 @@ internal fun interface CallsTargetReplayer { ): CallsSourceReplayResult } -internal class OriginalTypeScriptTargetReplayer( - private val nodeExecutable: String = "node", -) : CallsTargetReplayer { - private val transport = CallsProcessTransport( - nodeExecutable = nodeExecutable, - maxRequestBytes = MAX_REQUEST_BYTES, - maxStdoutBytes = MAX_STDOUT_BYTES, - maxStderrBytes = MAX_STDERR_BYTES, - shutdownGraceMillis = SHUTDOWN_GRACE_MILLIS, - ) - +internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { override fun replay( sourceRoots: List, entryPoint: TypeScriptEntryPoint, @@ -125,73 +100,275 @@ internal class OriginalTypeScriptTargetReplayer( require(entryPoint.executionKind == ExecutionKind.SYNC) { "Source-target replay currently supports synchronous callables only" } - val request = CallsSourceReplayRequest( - sourceRoots = sourceRoots.map { root -> root.toRealPath().toString() }, - entryPoint = entryPoint, - inputs = inputs, - target = CallsSourceTargetWire( - sourcePath = target.sourcePath, - startOffset = target.startOffset, - endOffset = target.endOffset, - start = target.start, - end = target.end, - ), - timeoutMillis = timeoutMillis, - ) - val encoded = PropertyManifestJson.json.encodeToString(request) - val replayEntryPoint = CallsReplayRuntime.sourceTargetReplayEntryPoint().toString() - - val output = try { - transport.invoke( - command = listOf(nodeExecutable, replayEntryPoint), - request = encoded, - timeoutMillis = timeoutMillis + TRANSPORT_GRACE_MILLIS, - reportedTimeoutMillis = timeoutMillis, - description = "original TypeScript source-target replay", - ) - } catch (error: CallsTransportException) { - val status = if (error.code.endsWith("timeout")) { - CallsReplayStatus.TIMEOUT - } else { - CallsReplayStatus.TOOL_ERROR - } - return CallsSourceReplayResult( - status = status, + return runCatching { + replaySupported( + sourceRoots = sourceRoots, + entryPoint = entryPoint, + inputs = inputs, + target = target, + timeoutMillis = timeoutMillis, + ) + }.getOrElse { error -> + CallsSourceReplayResult( + status = if (error is PbtBackendException && error.code.endsWith("timeout")) { + CallsReplayStatus.TIMEOUT + } else { + CallsReplayStatus.TOOL_ERROR + }, message = error.message, ) } + } - val stdout = output.stdout - val response = runCatching { - PropertyManifestJson.json.decodeFromString(stdout) - }.getOrElse { error -> - return CallsSourceReplayResult( - status = CallsReplayStatus.TOOL_ERROR, - message = "Invalid source-target replay response: ${error.message}", + private fun replaySupported( + sourceRoots: List, + entryPoint: TypeScriptEntryPoint, + inputs: List, + target: CallsSourceTarget, + timeoutMillis: Long, + ): CallsSourceReplayResult { + val resolved = resolveTarget(sourceRoots = sourceRoots, sourcePath = target.sourcePath) + val source = Files.readString(resolved.source) + requireTargetCoordinates(source = source, target = target) + val workspace = Files.createTempDirectory("usvm-ts-calls-replay-") + + return try { + val overlayRoot = workspace.resolve("source-overlay") + val marker = "__usvm_source_target_${UUID.randomUUID().toString().replace('-', '_')}" + val markerStatement = ";(globalThis as Record)[${jsString(marker)}] = true;\n" + val instrumented = source.substring(0, target.startOffset) + markerStatement + + source.substring(target.startOffset) + createOverlay( + sourceRoot = resolved.sourceRoot, + overlayRoot = overlayRoot, + relativeTarget = resolved.relativeTarget, + instrumentedSource = instrumented, ) - } - if (output.exitCode != 0 || response.status != "ok") { - return CallsSourceReplayResult( - status = CallsReplayStatus.TOOL_ERROR, - reason = response.reason, - message = response.message ?: output.stderr.trim().ifEmpty { "Source-target replay failed" }, + val resultPath = workspace.resolve("invocation.json") + val wrapperName = ".usvm-source-replay-${UUID.randomUUID()}.ts" + Files.writeString( + overlayRoot.resolve(wrapperName), + replayWrapper( + sourcePath = target.sourcePath, + exportName = entryPoint.exportName, + marker = marker, + resultPath = resultPath, + ), ) + val replayRoots = sourceRoots.mapIndexed { index, root -> + if (index == resolved.sourceRootIndex) overlayRoot else root + } + val replayInputs = inputs.ifEmpty { listOf(JsConcreteValue.Boolean(true)) } + val property = PropertyDefinition( + id = PropertyId("calls.source-target-replay"), + inputs = replayInputs.mapIndexed { index, value -> + PropertyInput(name = "input$index", domain = ConstantDomain(value)) + }, + predicate = TypeScriptEntryPoint(module = wrapperName, exportName = REPLAY_EXPORT), + ) + val result = FastCheckBackend(sourceRoots = replayRoots).run( + property = property, + configuration = PropertyRunConfiguration( + seed = 0, + numRuns = 1, + timeoutMillis = timeoutMillis, + ), + ) + if (result.failure?.kind == PropertyFailureKind.TIMEOUT) { + return CallsSourceReplayResult(status = CallsReplayStatus.TIMEOUT) + } + val invocation = if (Files.isRegularFile(resultPath)) { + CallsExperimentJson.json.decodeFromString(Files.readString(resultPath)) + } else { + return CallsSourceReplayResult( + status = CallsReplayStatus.TOOL_ERROR, + message = result.failure?.message ?: "Source replay produced no invocation result", + ) + } + val status = when (result.status) { + PropertyRunStatus.SUCCESS -> CallsReplayStatus.CONFIRMED + PropertyRunStatus.FAILURE -> CallsReplayStatus.REJECTED + } + check(invocation.targetHit == (status == CallsReplayStatus.CONFIRMED)) { + "Source replay result does not match the concrete property outcome" + } + + CallsSourceReplayResult(status = status, invocation = invocation) + } finally { + deleteTree(workspace) } + } - return CallsSourceReplayResult( - status = response.replayStatus, - invocation = response.invocation, - reason = response.reason, - message = response.message, - ) + private fun resolveTarget(sourceRoots: List, sourcePath: String): ResolvedTarget { + val relativeTarget = Path.of(sourcePath) + require(!relativeTarget.isAbsolute && relativeTarget.normalize() == relativeTarget) { + "Target source path must be normalized and relative" + } + val matches = sourceRoots.mapIndexedNotNull { index, root -> + val sourceRoot = root.toRealPath() + val candidate = sourceRoot.resolve(relativeTarget).normalize() + if (!candidate.startsWith(sourceRoot) || !Files.isRegularFile(candidate)) { + null + } else { + ResolvedTarget( + sourceRootIndex = index, + sourceRoot = sourceRoot, + relativeTarget = relativeTarget, + source = candidate.toRealPath(), + ) + } + } + require(matches.size == 1) { "Target source path resolved to ${matches.size} files" } + + return matches.single() + } + + private fun requireTargetCoordinates(source: String, target: CallsSourceTarget) { + require(target.startOffset in 0..source.length && target.endOffset in target.startOffset..source.length) { + "Target offsets are outside the source file" + } + require(sourcePositionAt(source = source, offset = target.startOffset) == target.start) { + "Target start coordinate does not match its source offset" + } + require(sourcePositionAt(source = source, offset = target.endOffset) == target.end) { + "Target end coordinate does not match its source offset" + } + } + + private fun createOverlay( + sourceRoot: Path, + overlayRoot: Path, + relativeTarget: Path, + instrumentedSource: String, + ) { + var sourceDirectory = sourceRoot + var overlayDirectory = overlayRoot + Files.createDirectories(overlayDirectory) + relativeTarget.forEachIndexed { index, segment -> + Files.newDirectoryStream(sourceDirectory).use { entries -> + entries.filter { entry -> entry.fileName != segment }.forEach { entry -> + mirrorEntry(source = entry, target = overlayDirectory.resolve(entry.fileName)) + } + } + val last = index == relativeTarget.nameCount - 1 + if (last) { + Files.writeString(overlayDirectory.resolve(segment), instrumentedSource) + } else { + sourceDirectory = sourceDirectory.resolve(segment) + overlayDirectory = overlayDirectory.resolve(segment) + Files.createDirectory(overlayDirectory) + } + } + } + + private fun mirrorEntry(source: Path, target: Path) { + val linked = runCatching { Files.createSymbolicLink(target, source.toAbsolutePath()) }.isSuccess + if (!linked) { + copyTree(source = source, target = target) + } + } + + private fun copyTree(source: Path, target: Path) { + if (Files.isDirectory(source, LinkOption.NOFOLLOW_LINKS)) { + Files.createDirectory(target) + Files.newDirectoryStream(source).use { entries -> + entries.forEach { entry -> copyTree(source = entry, target = target.resolve(entry.fileName)) } + } + } else { + Files.copy(source, target, LinkOption.NOFOLLOW_LINKS, StandardCopyOption.COPY_ATTRIBUTES) + } } + private fun deleteTree(root: Path) { + Files.walk(root).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach(Files::deleteIfExists) + } + } + + private fun replayWrapper( + sourcePath: String, + exportName: String, + marker: String, + resultPath: Path, + ): String = """ + import { writeFileSync } from 'node:fs'; + import * as targetModule from ${jsString("./$sourcePath")}; + + const callable = targetModule[${jsString(exportName)}]; + if (typeof callable !== 'function') throw new Error('Replay export is not a function'); + + export function $REPLAY_EXPORT(...args: unknown[]): boolean { + Object.defineProperty(globalThis, ${jsString(marker)}, { + configurable: true, + enumerable: false, + value: false, + writable: true, + }); + let invocation: 'returned' | 'threw' = 'returned'; + let caught: unknown; + try { + const result = callable(...args); + if (result !== null && (typeof result === 'object' || typeof result === 'function') + && typeof (result as { then?: unknown }).then === 'function') { + void Promise.resolve(result).catch(() => undefined); + throw new Error('Synchronous replay export returned an awaitable value'); + } + } catch (error: unknown) { + invocation = 'threw'; + caught = error; + } + const targetHit = (globalThis as Record)[${jsString(marker)}] === true; + const output: Record = { invocation, targetHit }; + if (invocation === 'threw') { + output.errorName = caught instanceof Error ? caught.name : typeof caught; + output.errorMessage = caught instanceof Error ? caught.message : String(caught); + } + writeFileSync(${jsString(resultPath.toString())}, `${'$'}{JSON.stringify(output)}\n`, 'utf8'); + + return targetHit; + } + """.trimIndent() + "\n" + + private fun jsString(value: String): String = CallsExperimentJson.json.encodeToString(value) + + private data class ResolvedTarget( + val sourceRootIndex: Int, + val sourceRoot: Path, + val relativeTarget: Path, + val source: Path, + ) + private companion object { - const val MAX_REQUEST_BYTES = 4 * 1024 * 1024 - const val MAX_STDOUT_BYTES = 256 * 1024 - const val MAX_STDERR_BYTES = 64 * 1024 - const val SHUTDOWN_GRACE_MILLIS = 250L - const val TRANSPORT_GRACE_MILLIS = 2_000L + const val REPLAY_EXPORT = "replaySourceTarget" } } + +internal fun sourcePositionAt(source: String, offset: Int): CallsSourcePosition { + var line = 0 + var column = 0 + var index = 0 + while (index < offset) { + when (source[index]) { + '\r' -> { + line += 1 + column = 0 + if (index + 1 < offset && source[index + 1] == '\n') { + index += 1 + } + } + + '\n', '\u2028', '\u2029' -> { + line += 1 + column = 0 + } + + else -> { + column += 1 + } + } + index += 1 + } + + return CallsSourcePosition(line = line, column = column) +} diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceReplayTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceReplayTest.kt new file mode 100644 index 0000000000..7f764c3a7c --- /dev/null +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceReplayTest.kt @@ -0,0 +1,108 @@ +package org.usvm.ts.calls + +import org.junit.jupiter.api.Test +import org.usvm.ts.pbt.model.JsConcreteValue +import org.usvm.ts.pbt.model.TypeScriptEntryPoint +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class CallsSourceReplayTest { + @Test + fun `confirms only the exact source statement reached by original TypeScript`() { + val fixture = fixture() + val taken = fixture.target(functionName = "inlineChoose", statement = "return 1;") + val untaken = fixture.target(functionName = "inlineChoose", statement = "return 0;") + + val takenReplay = fixture.replay(exportName = "inlineChoose", inputs = listOf(number(1.0)), target = taken) + val untakenReplay = fixture.replay(exportName = "inlineChoose", inputs = listOf(number(1.0)), target = untaken) + + assertEquals(CallsReplayStatus.CONFIRMED, takenReplay.status) + assertEquals(true, takenReplay.invocation?.targetHit) + assertEquals(CallsReplayStatus.REJECTED, untakenReplay.status) + assertEquals(false, untakenReplay.invocation?.targetHit) + } + + @Test + fun `retains target confirmation when the original invocation throws`() { + val fixture = fixture() + val target = fixture.target(functionName = "throwsAtTarget", statement = "throw new Error('expected');") + + val replay = fixture.replay(exportName = "throwsAtTarget", inputs = emptyList(), target = target) + + assertEquals(CallsReplayStatus.CONFIRMED, replay.status, replay.toString()) + assertEquals("threw", replay.invocation?.invocation) + assertEquals(true, replay.invocation?.targetHit) + assertEquals("Error", replay.invocation?.errorName) + assertEquals("expected", replay.invocation?.errorMessage) + } + + @Test + fun `counts target hits from the selected invocation rather than module import`() { + val fixture = fixture() + val target = fixture.target(functionName = "importOnlyTarget", statement = "return 7;") + + val importOnly = fixture.replay(exportName = "skipsImportOnlyTarget", inputs = emptyList(), target = target) + val invoked = fixture.replay(exportName = "importOnlyTarget", inputs = emptyList(), target = target) + + assertEquals(CallsReplayStatus.REJECTED, importOnly.status, importOnly.toString()) + assertEquals(false, importOnly.invocation?.targetHit) + assertEquals(CallsReplayStatus.CONFIRMED, invoked.status) + assertEquals(true, invoked.invocation?.targetHit) + } + + private fun fixture(): Fixture { + val sourcePath = resourcePath("/calls/SourceTargetReplayFixture.ts") + + return Fixture( + sourceRoot = assertNotNull(sourcePath.parent?.parent), + sourcePath = sourcePath, + source = Files.readString(sourcePath), + ) + } + + private fun resourcePath(name: String): Path { + val resource = assertNotNull(javaClass.getResource(name), "Missing test resource $name") + + return Paths.get(resource.toURI()) + } + + private fun number(value: Double): JsConcreteValue = JsConcreteValue.number(value) + + private data class Fixture( + val sourceRoot: Path, + val sourcePath: Path, + val source: String, + ) { + fun target(functionName: String, statement: String): CallsSourceTarget { + val functionStart = source.indexOf("function $functionName") + val startOffset = source.indexOf(statement, startIndex = functionStart) + check(functionStart >= 0 && startOffset >= 0) { "Missing $statement in $functionName" } + val endOffset = startOffset + statement.length + + return CallsSourceTarget( + targetId = "$functionName#$statement", + siteId = "$functionName:$startOffset:$endOffset", + sourcePath = sourceRoot.relativize(sourcePath).joinToString(separator = "/"), + startOffset = startOffset, + endOffset = endOffset, + start = sourcePositionAt(source = source, offset = startOffset), + end = sourcePositionAt(source = source, offset = endOffset), + ) + } + + fun replay( + exportName: String, + inputs: List, + target: CallsSourceTarget, + ): CallsSourceReplayResult = OriginalTypeScriptTargetReplayer().replay( + sourceRoots = listOf(sourceRoot), + entryPoint = TypeScriptEntryPoint(module = target.sourcePath, exportName = exportName), + inputs = inputs, + target = target, + timeoutMillis = 10_000L, + ) + } +} diff --git a/usvm-ts-calls/source-replay-adapter/test/source-target-replay-fixture.ts b/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts similarity index 78% rename from usvm-ts-calls/source-replay-adapter/test/source-target-replay-fixture.ts rename to usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts index e76df39389..dd20e17a49 100644 --- a/usvm-ts-calls/source-replay-adapter/test/source-target-replay-fixture.ts +++ b/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts @@ -1,11 +1,3 @@ -export function choose(value: number): number { - if (value > 0) { - return 1; - } - - return 0; -} - export function inlineChoose(value: number): number { if (value > 0) { return 1; } return 0; } export function throwsAtTarget(): never { From 886652243ae31a717679db3013e40d3d13d9e76e Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 15:13:00 +0300 Subject: [PATCH 12/13] [TS PBT] Extract the FastCheck backend module --- .github/workflows/ci.yml | 2 +- build.gradle.kts | 1 + settings.gradle.kts | 1 + usvm-ts-calls/build.gradle.kts | 9 +- {usvm-ts-pbt => usvm-ts-fast-check}/DESIGN.md | 10 +- usvm-ts-fast-check/README.md | 15 ++ usvm-ts-fast-check/build.gradle.kts | 171 ++++++++++++++++++ .../fast-check-adapter/.gitignore | 0 .../fast-check-adapter/package-lock.json | 0 .../fast-check-adapter/package.json | 0 .../fast-check-adapter/src/diagnostics.ts | 0 .../fast-check-adapter/src/entry-point.ts | 0 .../src/execute-property.ts | 0 .../fast-check-adapter/src/execution-cli.ts | 0 .../fast-check-adapter/src/js-value.ts | 0 .../src/process-group-shutdown.ts | 0 .../src/process-supervisor.ts | 0 .../fast-check-adapter/src/project-domain.ts | 0 .../fast-check-adapter/src/projection-cli.ts | 0 .../test/entry-point.test.ts | 0 .../test/execute-property.test.ts | 0 .../test/execution-cli.test.ts | 0 .../fast-check-adapter/test/js-value.test.ts | 0 .../test/process-group-shutdown.test.ts | 0 .../test/process-supervisor.test.ts | 0 .../test/project-domain.test.ts | 0 .../test/projection-cli.test.ts | 0 .../fast-check-adapter/tsconfig.json | 0 .../usvm/ts/pbt/FastCheckDiagnosticCode.kt | 46 +++++ .../org/usvm/ts/pbt/cli/FastCheckCli.kt | 28 +-- .../org/usvm/ts/pbt/cli/FastCheckOptions.kt | 18 +- .../usvm/ts/pbt/fastcheck/FastCheckBackend.kt | 14 +- .../pbt/fastcheck/FastCheckCoverageSession.kt | 18 +- .../fastcheck/FastCheckExecutionProtocol.kt | 0 .../pbt/fastcheck/FastCheckProcessClient.kt | 20 +- .../fastcheck/FastCheckProcessTransport.kt | 20 +- .../fastcheck/FastCheckProjectionClient.kt | 12 +- .../fastcheck/FastCheckProjectionProtocol.kt | 0 .../usvm/ts/pbt/fastcheck/FastCheckRuntime.kt | 4 +- .../pbt/fastcheck/FastCheckRuntimeMetadata.kt | 0 .../kotlin/org/usvm/ts/pbt/TestResources.kt | 17 ++ .../org/usvm/ts/pbt/cli/FastCheckCliTest.kt | 0 .../InstalledDistributionRegistryProvider.kt | 0 .../ts/pbt/examples/ExamplePropertiesTest.kt | 0 .../ts/pbt/fastcheck/FastCheckBackendTest.kt | 0 .../ts/pbt/fastcheck/FastCheckCoverageTest.kt | 4 +- .../fastcheck/FastCheckProcessClientTest.kt | 2 +- .../FastCheckProjectionClientTest.kt | 0 .../fastcheck/FastCheckRuntimeMetadataTest.kt | 0 ...m.ts.pbt.registry.PropertyRegistryProvider | 0 .../properties/coverage/CoverageProperties.ts | 0 .../properties/coverage/invalid-map-entry.js | 0 .../coverage/invalid-map-entry.js.map | 0 .../properties/coverage/missing-map-entry.js | 0 .../properties/coverage/package.json | 0 .../properties/coverage/source-under-test.ts | 0 .../properties/examples/PropertyExamples.ts | 0 .../execution/ExecutionProperties.ts | 0 usvm-ts-pbt/README.md | 17 +- usvm-ts-pbt/build.gradle.kts | 160 ---------------- .../org/usvm/ts/pbt/PbtDiagnosticCode.kt | 41 ----- .../pbt/coverage/RawV8SourceMapInspector.kt | 2 +- .../ts/pbt/coverage/SourceMapDiagnostics.kt | 2 +- 63 files changed, 344 insertions(+), 290 deletions(-) rename {usvm-ts-pbt => usvm-ts-fast-check}/DESIGN.md (96%) create mode 100644 usvm-ts-fast-check/README.md create mode 100644 usvm-ts-fast-check/build.gradle.kts rename {usvm-ts-pbt => usvm-ts-fast-check}/fast-check-adapter/.gitignore (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/fast-check-adapter/package-lock.json (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/fast-check-adapter/package.json (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/fast-check-adapter/src/diagnostics.ts (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/fast-check-adapter/src/entry-point.ts (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/fast-check-adapter/src/execute-property.ts (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/fast-check-adapter/src/execution-cli.ts (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/fast-check-adapter/src/js-value.ts (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/fast-check-adapter/src/process-group-shutdown.ts (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/fast-check-adapter/src/process-supervisor.ts (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/fast-check-adapter/src/project-domain.ts (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/fast-check-adapter/src/projection-cli.ts (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/fast-check-adapter/test/entry-point.test.ts (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/fast-check-adapter/test/execute-property.test.ts (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/fast-check-adapter/test/execution-cli.test.ts (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/fast-check-adapter/test/js-value.test.ts (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/fast-check-adapter/test/process-group-shutdown.test.ts (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/fast-check-adapter/test/process-supervisor.test.ts (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/fast-check-adapter/test/project-domain.test.ts (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/fast-check-adapter/test/projection-cli.test.ts (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/fast-check-adapter/tsconfig.json (100%) create mode 100644 usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/FastCheckDiagnosticCode.kt rename {usvm-ts-pbt => usvm-ts-fast-check}/src/main/kotlin/org/usvm/ts/pbt/cli/FastCheckCli.kt (92%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/main/kotlin/org/usvm/ts/pbt/cli/FastCheckOptions.kt (92%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackend.kt (92%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageSession.kt (93%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckExecutionProtocol.kt (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClient.kt (91%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessTransport.kt (95%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClient.kt (95%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionProtocol.kt (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntime.kt (94%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntimeMetadata.kt (100%) create mode 100644 usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/TestResources.kt rename {usvm-ts-pbt => usvm-ts-fast-check}/src/test/kotlin/org/usvm/ts/pbt/cli/FastCheckCliTest.kt (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/test/kotlin/org/usvm/ts/pbt/cli/InstalledDistributionRegistryProvider.kt (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/test/kotlin/org/usvm/ts/pbt/examples/ExamplePropertiesTest.kt (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackendTest.kt (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageTest.kt (98%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClientTest.kt (99%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClientTest.kt (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntimeMetadataTest.kt (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/test/resources/META-INF/services/org.usvm.ts.pbt.registry.PropertyRegistryProvider (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/test/resources/properties/coverage/CoverageProperties.ts (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/test/resources/properties/coverage/invalid-map-entry.js (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/test/resources/properties/coverage/invalid-map-entry.js.map (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/test/resources/properties/coverage/missing-map-entry.js (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/test/resources/properties/coverage/package.json (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/test/resources/properties/coverage/source-under-test.ts (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/test/resources/properties/examples/PropertyExamples.ts (100%) rename {usvm-ts-pbt => usvm-ts-fast-check}/src/test/resources/properties/execution/ExecutionProperties.ts (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13f9f2912a..50ade7a65b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -188,7 +188,7 @@ jobs: node-version: 22 - name: Run TS PBT integration baseline - run: env -u ARKANALYZER_DIR ETS_IR_PROVIDER=ts-frontend ./gradlew :usvm-ts-pbt:check + run: env -u ARKANALYZER_DIR ETS_IR_PROVIDER=ts-frontend ./gradlew :usvm-ts-pbt:check :usvm-ts-fast-check:check lint: runs-on: ubuntu-latest diff --git a/build.gradle.kts b/build.gradle.kts index a97eb8838e..181c4fb92e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -23,6 +23,7 @@ tasks.register("validateProjectList") { project(":usvm-python"), project(":usvm-ts"), project(":usvm-ts-calls"), + project(":usvm-ts-fast-check"), project(":usvm-ts-pbt"), project(":usvm-ts-dataflow"), ) diff --git a/settings.gradle.kts b/settings.gradle.kts index ba4587723c..4331282cae 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -35,6 +35,7 @@ include("usvm-jvm:usvm-jvm-test-api") include("usvm-jvm:usvm-jvm-util") include("usvm-ts") include("usvm-ts-calls") +include("usvm-ts-fast-check") include("usvm-ts-pbt") include("usvm-util") include("usvm-jvm-instrumentation") diff --git a/usvm-ts-calls/build.gradle.kts b/usvm-ts-calls/build.gradle.kts index 0d8362e592..bcc0ba431b 100644 --- a/usvm-ts-calls/build.gradle.kts +++ b/usvm-ts-calls/build.gradle.kts @@ -7,6 +7,7 @@ plugins { dependencies { implementation(project(":usvm-core")) implementation(project(":usvm-ts")) + implementation(project(":usvm-ts-fast-check")) implementation(project(":usvm-ts-pbt")) implementation(Libs.jacodb_ets) implementation(Libs.kotlinx_serialization_json) @@ -14,7 +15,7 @@ dependencies { testImplementation(Libs.logback) } -val fastCheckAdapterDir = project(":usvm-ts-pbt").layout.projectDirectory.dir("fast-check-adapter") +val fastCheckAdapterDir = project(":usvm-ts-fast-check").layout.projectDirectory.dir("fast-check-adapter") val fastCheckRuntimeProperty = "org.usvm.ts.pbt.fastcheck.runtime" val generatedBuildMetadataDirectory = layout.buildDirectory.dir("generated/resources/callsBuildMetadata") val toolRevision = providers.exec { @@ -51,7 +52,7 @@ tasks.processResources { } tasks.test { - dependsOn(":usvm-ts-pbt:buildFastCheckAdapter") + dependsOn(":usvm-ts-fast-check:buildFastCheckAdapter") systemProperty(fastCheckRuntimeProperty, fastCheckAdapterDir.asFile.absolutePath) } @@ -61,7 +62,7 @@ application { } tasks.named("run") { - dependsOn(":usvm-ts-pbt:buildFastCheckAdapter") + dependsOn(":usvm-ts-fast-check:buildFastCheckAdapter") systemProperty(fastCheckRuntimeProperty, fastCheckAdapterDir.asFile.absolutePath) } @@ -80,6 +81,6 @@ distributions { listOf("startScripts", "installDist", "distZip", "distTar").forEach { taskName -> tasks.named(taskName) { - dependsOn(":usvm-ts-pbt:buildFastCheckAdapter") + dependsOn(":usvm-ts-fast-check:buildFastCheckAdapter") } } diff --git a/usvm-ts-pbt/DESIGN.md b/usvm-ts-fast-check/DESIGN.md similarity index 96% rename from usvm-ts-pbt/DESIGN.md rename to usvm-ts-fast-check/DESIGN.md index 0e40b60571..8859c5196d 100644 --- a/usvm-ts-pbt/DESIGN.md +++ b/usvm-ts-fast-check/DESIGN.md @@ -1,11 +1,13 @@ # Kotlin–TypeScript fast-check integration -This document describes the internal boundary between Kotlin and the private Node adapter. For the public property -API and CLI examples, see [README.md](README.md). +This document describes the `usvm-ts-fast-check` boundary between Kotlin and the private Node adapter. The +backend-neutral property, coverage, and mapping contracts live in `usvm-ts-pbt`. For public backend and CLI +examples, see [README.md](README.md). ## Design goals -- Kotlin owns property definitions, validation, registries, orchestration, and public results. +- `usvm-ts-pbt` owns property definitions, validation, registries, coverage decoding, mapping, and public results. +- `usvm-ts-fast-check` owns FastCheck orchestration and runtime packaging. - 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 @@ -72,7 +74,7 @@ separate Istanbul report after a valid response. Backend identity belongs to `co Kotlin validates trusted model objects and examples early so callers get local errors. Node validates the decoded JSON again because the process boundary must not trust malformed input. Diagnostic codes have one owner per -language: `PbtDiagnosticCode.kt` for Kotlin and `diagnostics.ts` for Node. Node also sends the diagnostic category, +language: `FastCheckDiagnosticCode.kt` for Kotlin and `diagnostics.ts` for Node. Node also sends the diagnostic category, so Kotlin never infers error meaning from code prefixes. ## One property run diff --git a/usvm-ts-fast-check/README.md b/usvm-ts-fast-check/README.md new file mode 100644 index 0000000000..746c3ea1ab --- /dev/null +++ b/usvm-ts-fast-check/README.md @@ -0,0 +1,15 @@ +# USVM TypeScript FastCheck backend + +`usvm-ts-fast-check` implements the backend-neutral contracts from `usvm-ts-pbt` with FastCheck. It owns +`FastCheckBackend`, the Node adapter, supervised process transport, c8 coverage collection, CLI, and packaged +runtime. + +The public property model, validation, registries, coverage contracts and decoders, and property-to-EtsIR mapping +remain in [`usvm-ts-pbt`](../usvm-ts-pbt/README.md). + +Run the backend checks with: + +```shell +env -u ARKANALYZER_DIR ETS_IR_PROVIDER=ts-frontend \ + ./gradlew --no-daemon :usvm-ts-fast-check:check +``` diff --git a/usvm-ts-fast-check/build.gradle.kts b/usvm-ts-fast-check/build.gradle.kts new file mode 100644 index 0000000000..bd10be1065 --- /dev/null +++ b/usvm-ts-fast-check/build.gradle.kts @@ -0,0 +1,171 @@ +import groovy.json.JsonSlurper + +plugins { + id("usvm.kotlin-conventions") + kotlin("plugin.serialization") version Versions.kotlin + application +} + +dependencies { + implementation(project(":usvm-ts-pbt")) + implementation(Libs.clikt) + implementation(Libs.kotlinx_serialization_json) + + testImplementation(Libs.logback) +} + +val fastCheckAdapterDir = layout.projectDirectory.dir("fast-check-adapter") +val fastCheckAdapterPackageJson = fastCheckAdapterDir.file("package.json") +val fastCheckAdapterPackageLock = fastCheckAdapterDir.file("package-lock.json") +val fastCheckRuntimeProperty = "org.usvm.ts.pbt.fastcheck.runtime" +val generatedFastCheckRuntimeMetadataDirectory = layout.buildDirectory.dir( + "generated/resources/fastCheckRuntimeMetadata", +) +val hostOperatingSystem = System.getProperty("os.name").lowercase() +val hostPlatform = when { + hostOperatingSystem.contains("mac") -> "darwin" + hostOperatingSystem.contains("linux") -> "linux" + hostOperatingSystem.contains("windows") -> "win32" + else -> error("Unsupported fast-check runtime operating system: $hostOperatingSystem") +} +val hostArchitecture = when (val architecture = System.getProperty("os.arch").lowercase()) { + "aarch64", "arm64" -> "arm64" + "amd64", "x86_64" -> "x64" + "x86", "i386", "i686" -> "ia32" + else -> error("Unsupported fast-check runtime architecture: $architecture") +} +val fastCheckRuntimeClassifier = "$hostPlatform-$hostArchitecture" +val npmExecutable = if (hostPlatform == "win32") "npm.cmd" else "npm" + +val generateFastCheckRuntimeMetadata = tasks.register("generateFastCheckRuntimeMetadata") { + inputs.file(fastCheckAdapterPackageLock) + outputs.dir(generatedFastCheckRuntimeMetadataDirectory) + + doLast { + val packageLock = JsonSlurper().parse(fastCheckAdapterPackageLock.asFile) as? Map<*, *> + ?: error("Invalid fast-check adapter package lock") + val packages = packageLock["packages"] as? Map<*, *> + ?: error("Missing packages in fast-check adapter package lock") + fun dependencyVersion(dependency: String): String { + val metadata = packages["node_modules/$dependency"] as? Map<*, *> + ?: error("Missing locked fast-check adapter dependency: $dependency") + + return (metadata["version"] as? String) + ?.takeIf(String::isNotBlank) + ?: error("Missing locked fast-check adapter dependency version: $dependency") + } + + val metadataFile = generatedFastCheckRuntimeMetadataDirectory.get() + .file("org/usvm/ts/pbt/fastcheck/runtime-dependencies.properties") + .asFile + metadataFile.parentFile.mkdirs() + metadataFile.writeText( + """ + fast-check.version=${dependencyVersion("fast-check")} + c8.version=${dependencyVersion("c8")} + """.trimIndent() + "\n", + Charsets.UTF_8, + ) + } +} + +sourceSets.main { + resources.srcDir(generatedFastCheckRuntimeMetadataDirectory) +} + +tasks.processResources { + dependsOn(generateFastCheckRuntimeMetadata) +} + +val installFastCheckAdapter = tasks.register("installFastCheckAdapter") { + workingDir(fastCheckAdapterDir) + commandLine(npmExecutable, "ci", "--ignore-scripts") + inputs.files( + fastCheckAdapterPackageJson, + fastCheckAdapterPackageLock, + ) + inputs.property("runtimeClassifier", fastCheckRuntimeClassifier) + outputs.dir(fastCheckAdapterDir.dir("node_modules")) +} + +val verifyFastCheckAdapterRuntime = tasks.register("verifyFastCheckAdapterRuntime") { + dependsOn(installFastCheckAdapter) + val nativeRuntime = fastCheckAdapterDir.dir("node_modules/@esbuild/$fastCheckRuntimeClassifier") + + inputs.dir(nativeRuntime) + doLast { + check(nativeRuntime.asFile.isDirectory) { + "Missing esbuild runtime for $fastCheckRuntimeClassifier at ${nativeRuntime.asFile}" + } + } +} + +val buildFastCheckAdapter = tasks.register("buildFastCheckAdapter") { + dependsOn(verifyFastCheckAdapterRuntime) + workingDir(fastCheckAdapterDir) + commandLine(npmExecutable, "run", "build") + inputs.files( + fastCheckAdapterDir.file("package.json"), + fastCheckAdapterDir.file("package-lock.json"), + fastCheckAdapterDir.file("tsconfig.json"), + ) + inputs.dir(fastCheckAdapterDir.dir("src")) + inputs.dir(fastCheckAdapterDir.dir("test")) + outputs.dir(fastCheckAdapterDir.dir("dist")) +} + +tasks.named("distZip") { + archiveClassifier.set(fastCheckRuntimeClassifier) +} + +tasks.named("distTar") { + archiveClassifier.set(fastCheckRuntimeClassifier) +} + +val testFastCheckAdapter = tasks.register("testFastCheckAdapter") { + dependsOn(buildFastCheckAdapter) + workingDir(fastCheckAdapterDir) + commandLine(npmExecutable, "run", "test:compiled") + inputs.dir(fastCheckAdapterDir.dir("dist")) +} + +tasks.test { + dependsOn(buildFastCheckAdapter) + systemProperty(fastCheckRuntimeProperty, fastCheckAdapterDir.asFile.absolutePath) +} + +tasks.check { + dependsOn(testFastCheckAdapter) +} + +tasks.clean { + delete(fastCheckAdapterDir.dir("dist")) +} + +application { + mainClass = "org.usvm.ts.pbt.cli.FastCheckCliKt" + applicationDefaultJvmArgs = listOf("-Dfile.encoding=UTF-8", "-Dsun.stdout.encoding=UTF-8") +} + +tasks.named("run") { + systemProperty(fastCheckRuntimeProperty, fastCheckAdapterDir.asFile.absolutePath) +} + +distributions { + main { + contents { + into("lib/fast-check-adapter") { + from(fastCheckAdapterDir) + include("dist/src/**") + include("node_modules/**") + include("package.json") + } + } + } +} + +listOf("run", "startScripts", "installDist", "distZip", "distTar").forEach { taskName -> + tasks.named(taskName) { + dependsOn(buildFastCheckAdapter) + } +} diff --git a/usvm-ts-pbt/fast-check-adapter/.gitignore b/usvm-ts-fast-check/fast-check-adapter/.gitignore similarity index 100% rename from usvm-ts-pbt/fast-check-adapter/.gitignore rename to usvm-ts-fast-check/fast-check-adapter/.gitignore diff --git a/usvm-ts-pbt/fast-check-adapter/package-lock.json b/usvm-ts-fast-check/fast-check-adapter/package-lock.json similarity index 100% rename from usvm-ts-pbt/fast-check-adapter/package-lock.json rename to usvm-ts-fast-check/fast-check-adapter/package-lock.json diff --git a/usvm-ts-pbt/fast-check-adapter/package.json b/usvm-ts-fast-check/fast-check-adapter/package.json similarity index 100% rename from usvm-ts-pbt/fast-check-adapter/package.json rename to usvm-ts-fast-check/fast-check-adapter/package.json diff --git a/usvm-ts-pbt/fast-check-adapter/src/diagnostics.ts b/usvm-ts-fast-check/fast-check-adapter/src/diagnostics.ts similarity index 100% rename from usvm-ts-pbt/fast-check-adapter/src/diagnostics.ts rename to usvm-ts-fast-check/fast-check-adapter/src/diagnostics.ts diff --git a/usvm-ts-pbt/fast-check-adapter/src/entry-point.ts b/usvm-ts-fast-check/fast-check-adapter/src/entry-point.ts similarity index 100% rename from usvm-ts-pbt/fast-check-adapter/src/entry-point.ts rename to usvm-ts-fast-check/fast-check-adapter/src/entry-point.ts diff --git a/usvm-ts-pbt/fast-check-adapter/src/execute-property.ts b/usvm-ts-fast-check/fast-check-adapter/src/execute-property.ts similarity index 100% rename from usvm-ts-pbt/fast-check-adapter/src/execute-property.ts rename to usvm-ts-fast-check/fast-check-adapter/src/execute-property.ts diff --git a/usvm-ts-pbt/fast-check-adapter/src/execution-cli.ts b/usvm-ts-fast-check/fast-check-adapter/src/execution-cli.ts similarity index 100% rename from usvm-ts-pbt/fast-check-adapter/src/execution-cli.ts rename to usvm-ts-fast-check/fast-check-adapter/src/execution-cli.ts diff --git a/usvm-ts-pbt/fast-check-adapter/src/js-value.ts b/usvm-ts-fast-check/fast-check-adapter/src/js-value.ts similarity index 100% rename from usvm-ts-pbt/fast-check-adapter/src/js-value.ts rename to usvm-ts-fast-check/fast-check-adapter/src/js-value.ts diff --git a/usvm-ts-pbt/fast-check-adapter/src/process-group-shutdown.ts b/usvm-ts-fast-check/fast-check-adapter/src/process-group-shutdown.ts similarity index 100% rename from usvm-ts-pbt/fast-check-adapter/src/process-group-shutdown.ts rename to usvm-ts-fast-check/fast-check-adapter/src/process-group-shutdown.ts diff --git a/usvm-ts-pbt/fast-check-adapter/src/process-supervisor.ts b/usvm-ts-fast-check/fast-check-adapter/src/process-supervisor.ts similarity index 100% rename from usvm-ts-pbt/fast-check-adapter/src/process-supervisor.ts rename to usvm-ts-fast-check/fast-check-adapter/src/process-supervisor.ts diff --git a/usvm-ts-pbt/fast-check-adapter/src/project-domain.ts b/usvm-ts-fast-check/fast-check-adapter/src/project-domain.ts similarity index 100% rename from usvm-ts-pbt/fast-check-adapter/src/project-domain.ts rename to usvm-ts-fast-check/fast-check-adapter/src/project-domain.ts diff --git a/usvm-ts-pbt/fast-check-adapter/src/projection-cli.ts b/usvm-ts-fast-check/fast-check-adapter/src/projection-cli.ts similarity index 100% rename from usvm-ts-pbt/fast-check-adapter/src/projection-cli.ts rename to usvm-ts-fast-check/fast-check-adapter/src/projection-cli.ts diff --git a/usvm-ts-pbt/fast-check-adapter/test/entry-point.test.ts b/usvm-ts-fast-check/fast-check-adapter/test/entry-point.test.ts similarity index 100% rename from usvm-ts-pbt/fast-check-adapter/test/entry-point.test.ts rename to usvm-ts-fast-check/fast-check-adapter/test/entry-point.test.ts diff --git a/usvm-ts-pbt/fast-check-adapter/test/execute-property.test.ts b/usvm-ts-fast-check/fast-check-adapter/test/execute-property.test.ts similarity index 100% rename from usvm-ts-pbt/fast-check-adapter/test/execute-property.test.ts rename to usvm-ts-fast-check/fast-check-adapter/test/execute-property.test.ts diff --git a/usvm-ts-pbt/fast-check-adapter/test/execution-cli.test.ts b/usvm-ts-fast-check/fast-check-adapter/test/execution-cli.test.ts similarity index 100% rename from usvm-ts-pbt/fast-check-adapter/test/execution-cli.test.ts rename to usvm-ts-fast-check/fast-check-adapter/test/execution-cli.test.ts diff --git a/usvm-ts-pbt/fast-check-adapter/test/js-value.test.ts b/usvm-ts-fast-check/fast-check-adapter/test/js-value.test.ts similarity index 100% rename from usvm-ts-pbt/fast-check-adapter/test/js-value.test.ts rename to usvm-ts-fast-check/fast-check-adapter/test/js-value.test.ts diff --git a/usvm-ts-pbt/fast-check-adapter/test/process-group-shutdown.test.ts b/usvm-ts-fast-check/fast-check-adapter/test/process-group-shutdown.test.ts similarity index 100% rename from usvm-ts-pbt/fast-check-adapter/test/process-group-shutdown.test.ts rename to usvm-ts-fast-check/fast-check-adapter/test/process-group-shutdown.test.ts diff --git a/usvm-ts-pbt/fast-check-adapter/test/process-supervisor.test.ts b/usvm-ts-fast-check/fast-check-adapter/test/process-supervisor.test.ts similarity index 100% rename from usvm-ts-pbt/fast-check-adapter/test/process-supervisor.test.ts rename to usvm-ts-fast-check/fast-check-adapter/test/process-supervisor.test.ts diff --git a/usvm-ts-pbt/fast-check-adapter/test/project-domain.test.ts b/usvm-ts-fast-check/fast-check-adapter/test/project-domain.test.ts similarity index 100% rename from usvm-ts-pbt/fast-check-adapter/test/project-domain.test.ts rename to usvm-ts-fast-check/fast-check-adapter/test/project-domain.test.ts diff --git a/usvm-ts-pbt/fast-check-adapter/test/projection-cli.test.ts b/usvm-ts-fast-check/fast-check-adapter/test/projection-cli.test.ts similarity index 100% rename from usvm-ts-pbt/fast-check-adapter/test/projection-cli.test.ts rename to usvm-ts-fast-check/fast-check-adapter/test/projection-cli.test.ts diff --git a/usvm-ts-pbt/fast-check-adapter/tsconfig.json b/usvm-ts-fast-check/fast-check-adapter/tsconfig.json similarity index 100% rename from usvm-ts-pbt/fast-check-adapter/tsconfig.json rename to usvm-ts-fast-check/fast-check-adapter/tsconfig.json diff --git a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/FastCheckDiagnosticCode.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/FastCheckDiagnosticCode.kt new file mode 100644 index 0000000000..88b794cbd6 --- /dev/null +++ b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/FastCheckDiagnosticCode.kt @@ -0,0 +1,46 @@ +package org.usvm.ts.pbt + +/** Stable identifiers for diagnostics created by the FastCheck integration. */ +internal object FastCheckDiagnosticCode { + const val CLI_ARGUMENT_INVALID = "cli.argument.invalid" + const val CLI_COVERAGE_REQUIRED = "cli.coverage.required" + const val CLI_COVERAGE_SCOPE_INVALID = "cli.coverage.scope.invalid" + const val CLI_EXAMPLES_INVALID = "cli.examples.invalid" + const val CLI_NUM_RUNS_INVALID = "cli.num-runs.invalid" + const val CLI_PROPERTY_EMPTY = "cli.property.empty" + const val CLI_PROPERTY_INVALID = "cli.property.invalid" + const val CLI_PROPERTY_UNKNOWN = "cli.property.unknown" + const val CLI_REGISTRY_EMPTY = "cli.registry.empty" + const val CLI_REGISTRY_ID_DUPLICATE = "cli.registry.id.duplicate" + const val CLI_REGISTRY_ID_INVALID = "cli.registry.id.invalid" + const val CLI_REGISTRY_UNKNOWN = "cli.registry.unknown" + const val CLI_SINGLE_PROPERTY_REQUIRED = "cli.single-property.required" + const val CLI_SOURCE_ROOT_REQUIRED = "cli.source-root.required" + const val CLI_TIMEOUT_INVALID = "cli.timeout.invalid" + + const val REGISTRY_PROPERTY_ID_DUPLICATE = "registry.property-id.duplicate" + const val REGISTRY_PROPERTY_INVALID = "registry.property.invalid" + const val REGISTRY_PROVIDER_LOAD_FAILED = "registry.provider.load.failed" + + const val BACKEND_EXAMPLES_ARITY = "backend.examples.arity" + const val BACKEND_EXAMPLES_DOMAIN = "backend.examples.domain" + const val BACKEND_EXAMPLES_VALUE_INVALID = "backend.examples.value.invalid" + const val BACKEND_PROCESS_FAILED = "backend.process.failed" + const val BACKEND_PROCESS_INTERRUPTED = "backend.process.interrupted" + const val BACKEND_PROCESS_READ_FAILED = "backend.process.read.failed" + const val BACKEND_PROCESS_START_FAILED = "backend.process.start.failed" + const val BACKEND_PROCESS_TIMEOUT = "backend.process.timeout" + const val BACKEND_PROCESS_WRITE_FAILED = "backend.process.write.failed" + const val BACKEND_REQUEST_TOO_LARGE = "backend.request.too-large" + const val BACKEND_RESPONSE_EMPTY = "backend.response.empty" + const val BACKEND_RESPONSE_INVALID = "backend.response.invalid" + const val BACKEND_RESPONSE_TOO_LARGE = "backend.response.too-large" + const val BACKEND_RUNTIME_NOT_FOUND = "backend.runtime.not-found" + + const val COVERAGE_COLLECTOR_NOT_FOUND = "coverage.collector.not-found" + const val COVERAGE_RUNTIME_UNSUPPORTED = "coverage.runtime.unsupported" + const val COVERAGE_RUNTIME_VERSION_UNAVAILABLE = "coverage.runtime.version-unavailable" + + 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/cli/FastCheckCli.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/cli/FastCheckCli.kt similarity index 92% rename from usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/cli/FastCheckCli.kt rename to usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/cli/FastCheckCli.kt index 198ab3d74b..00a80ff686 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/cli/FastCheckCli.kt +++ b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/cli/FastCheckCli.kt @@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString -import org.usvm.ts.pbt.PbtDiagnosticCode +import org.usvm.ts.pbt.FastCheckDiagnosticCode import org.usvm.ts.pbt.backend.CoverageCapabilityLevel import org.usvm.ts.pbt.backend.PropertyBasedTestingBackend import org.usvm.ts.pbt.backend.PropertyRunConfiguration @@ -55,7 +55,7 @@ class FastCheckCli( EXIT_ERROR } catch (error: DuplicatePropertyIdException) { reportError( - code = PbtDiagnosticCode.REGISTRY_PROPERTY_ID_DUPLICATE, + code = FastCheckDiagnosticCode.REGISTRY_PROPERTY_ID_DUPLICATE, message = error.message.orEmpty(), path = "properties", propertyId = error.propertyId.value, @@ -64,7 +64,7 @@ class FastCheckCli( EXIT_ERROR } catch (error: UnknownPropertyIdException) { reportError( - code = PbtDiagnosticCode.CLI_PROPERTY_UNKNOWN, + code = FastCheckDiagnosticCode.CLI_PROPERTY_UNKNOWN, message = error.message.orEmpty(), path = "property", propertyId = error.propertyId.value, @@ -73,7 +73,7 @@ class FastCheckCli( EXIT_ERROR } catch (error: InvalidPropertyDefinitionException) { reportError( - code = PbtDiagnosticCode.REGISTRY_PROPERTY_INVALID, + code = FastCheckDiagnosticCode.REGISTRY_PROPERTY_INVALID, message = error.message.orEmpty(), path = error.result.diagnostics.firstOrNull()?.path, ) @@ -91,7 +91,7 @@ class FastCheckCli( EXIT_ERROR } catch (error: ServiceConfigurationError) { reportError( - code = PbtDiagnosticCode.REGISTRY_PROVIDER_LOAD_FAILED, + code = FastCheckDiagnosticCode.REGISTRY_PROVIDER_LOAD_FAILED, message = error.message.orEmpty(), path = "registry", ) @@ -99,7 +99,7 @@ class FastCheckCli( EXIT_ERROR } catch (error: IllegalArgumentException) { reportError( - code = PbtDiagnosticCode.CLI_ARGUMENT_INVALID, + code = FastCheckDiagnosticCode.CLI_ARGUMENT_INVALID, message = error.message.orEmpty(), ) @@ -164,7 +164,7 @@ class FastCheckCli( val properties = registry.properties if (properties.isEmpty()) { throw CliUsageException( - code = PbtDiagnosticCode.CLI_PROPERTY_EMPTY, + code = FastCheckDiagnosticCode.CLI_PROPERTY_EMPTY, message = "Selected registries contain no properties", path = "registry", ) @@ -191,7 +191,7 @@ class FastCheckCli( if (usesRunScopedControls && propertyCount != 1) { throw CliUsageException( - code = PbtDiagnosticCode.CLI_SINGLE_PROPERTY_REQUIRED, + code = FastCheckDiagnosticCode.CLI_SINGLE_PROPERTY_REQUIRED, message = "Replay paths and explicit examples require exactly one selected property", path = "property", ) @@ -210,7 +210,7 @@ class FastCheckCli( if (unknown != null) { throw CliUsageException( - code = PbtDiagnosticCode.CLI_REGISTRY_UNKNOWN, + code = FastCheckDiagnosticCode.CLI_REGISTRY_UNKNOWN, message = "Unknown registry ID $unknown; available IDs: ${availableIds.sorted().joinToString()}", path = "registry", ) @@ -227,7 +227,7 @@ class FastCheckCli( if (orderedProviders.isEmpty()) { throw CliUsageException( - code = PbtDiagnosticCode.CLI_REGISTRY_EMPTY, + code = FastCheckDiagnosticCode.CLI_REGISTRY_EMPTY, message = "No PropertyRegistryProvider services were found", path = "registry", ) @@ -239,7 +239,7 @@ class FastCheckCli( private fun validateProviderId(providerId: String) { if (!REGISTRY_ID_REGEX.matches(providerId)) { throw CliUsageException( - code = PbtDiagnosticCode.CLI_REGISTRY_ID_INVALID, + code = FastCheckDiagnosticCode.CLI_REGISTRY_ID_INVALID, message = "Invalid registry ID: $providerId", path = "registry", ) @@ -255,7 +255,7 @@ class FastCheckCli( if (duplicateRegistryId != null) { throw CliUsageException( - code = PbtDiagnosticCode.CLI_REGISTRY_ID_DUPLICATE, + code = FastCheckDiagnosticCode.CLI_REGISTRY_ID_DUPLICATE, message = "Duplicate registry ID: $duplicateRegistryId", path = "registry", ) @@ -275,7 +275,7 @@ class FastCheckCli( } private fun providerFailure(providerName: String, cause: Throwable) = CliUsageException( - code = PbtDiagnosticCode.REGISTRY_PROVIDER_LOAD_FAILED, + code = FastCheckDiagnosticCode.REGISTRY_PROVIDER_LOAD_FAILED, message = "Property registry provider $providerName failed: ${cause.message}", path = "registry", cause = cause, @@ -290,7 +290,7 @@ class FastCheckCli( } private fun invalidExamples(path: Path, cause: Exception) = CliUsageException( - code = PbtDiagnosticCode.CLI_EXAMPLES_INVALID, + code = FastCheckDiagnosticCode.CLI_EXAMPLES_INVALID, message = "Cannot read explicit examples from $path: ${cause.message}", path = "examples", cause = cause, diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/cli/FastCheckOptions.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/cli/FastCheckOptions.kt similarity index 92% rename from usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/cli/FastCheckOptions.kt rename to usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/cli/FastCheckOptions.kt index 5df77fcf52..56be2a4cc0 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/cli/FastCheckOptions.kt +++ b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/cli/FastCheckOptions.kt @@ -11,7 +11,7 @@ import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.types.int import com.github.ajalt.clikt.parameters.types.long import com.github.ajalt.clikt.parameters.types.path -import org.usvm.ts.pbt.PbtDiagnosticCode +import org.usvm.ts.pbt.FastCheckDiagnosticCode import org.usvm.ts.pbt.backend.CoverageScope import org.usvm.ts.pbt.backend.PropertyCoverageRequest import org.usvm.ts.pbt.backend.PropertyRunConfiguration @@ -54,14 +54,14 @@ internal fun parseCliOptions(args: Array): CliParseResult { CliParseResult.Help(parser.getFormattedHelp(help).orEmpty()) } catch (error: CliktError) { throw CliUsageException( - code = PbtDiagnosticCode.CLI_ARGUMENT_INVALID, + code = FastCheckDiagnosticCode.CLI_ARGUMENT_INVALID, message = error.message ?: "Invalid command line arguments", cause = error, ) } } -private class FastCheckOptionsParser : CliktCommand(name = "usvm-ts-pbt") { +private class FastCheckOptionsParser : CliktCommand(name = "usvm-ts-fast-check") { private val sourceRoots by option( "--source-root", help = "TypeScript source root; repeat for multiple roots", @@ -146,7 +146,7 @@ private class FastCheckOptionsParser : CliktCommand(name = "usvm-ts-pbt") { private fun requireSourceRoots() { if (sourceRoots.isEmpty()) { throw CliUsageException( - code = PbtDiagnosticCode.CLI_SOURCE_ROOT_REQUIRED, + code = FastCheckDiagnosticCode.CLI_SOURCE_ROOT_REQUIRED, message = "At least one --source-root is required", path = "sourceRoot", ) @@ -156,7 +156,7 @@ private class FastCheckOptionsParser : CliktCommand(name = "usvm-ts-pbt") { private fun requirePositiveRunControls() { if (numRuns <= 0) { throw CliUsageException( - code = PbtDiagnosticCode.CLI_NUM_RUNS_INVALID, + code = FastCheckDiagnosticCode.CLI_NUM_RUNS_INVALID, message = "--num-runs must be positive", path = "numRuns", ) @@ -164,7 +164,7 @@ private class FastCheckOptionsParser : CliktCommand(name = "usvm-ts-pbt") { if (timeoutMillis !in 1..PropertyRunConfiguration.MAX_TIMEOUT_MILLIS) { throw CliUsageException( - code = PbtDiagnosticCode.CLI_TIMEOUT_INVALID, + code = FastCheckDiagnosticCode.CLI_TIMEOUT_INVALID, message = "--timeout-ms must be in 1..${PropertyRunConfiguration.MAX_TIMEOUT_MILLIS}", path = "timeoutMillis", ) @@ -177,7 +177,7 @@ private class FastCheckOptionsParser : CliktCommand(name = "usvm-ts-pbt") { coverageExcludePatterns.isNotEmpty() if (!coverageEnabled && hasCoverageDetails) { throw CliUsageException( - code = PbtDiagnosticCode.CLI_COVERAGE_REQUIRED, + code = FastCheckDiagnosticCode.CLI_COVERAGE_REQUIRED, message = "Coverage scope and path rules require --coverage", path = "coverage", ) @@ -205,7 +205,7 @@ private fun parseCoverageScope(value: String): CoverageScope = when (value) { "generated-backend-wrappers" -> CoverageScope.GENERATED_BACKEND_WRAPPERS "dependencies" -> CoverageScope.DEPENDENCIES else -> throw CliUsageException( - code = PbtDiagnosticCode.CLI_COVERAGE_SCOPE_INVALID, + code = FastCheckDiagnosticCode.CLI_COVERAGE_SCOPE_INVALID, message = "Unknown coverage scope $value", path = "coverageScope", ) @@ -215,7 +215,7 @@ private fun parsePropertyId(value: String): PropertyId = try { PropertyId(value) } catch (error: IllegalArgumentException) { throw CliUsageException( - code = PbtDiagnosticCode.CLI_PROPERTY_INVALID, + code = FastCheckDiagnosticCode.CLI_PROPERTY_INVALID, message = error.message.orEmpty(), path = "property", cause = error, diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackend.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackend.kt similarity index 92% rename from usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackend.kt rename to usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackend.kt index a1afb8c29a..7b2d95510d 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackend.kt +++ b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackend.kt @@ -1,6 +1,6 @@ package org.usvm.ts.pbt.fastcheck -import org.usvm.ts.pbt.PbtDiagnosticCode +import org.usvm.ts.pbt.FastCheckDiagnosticCode import org.usvm.ts.pbt.backend.PropertyBasedTestingBackend import org.usvm.ts.pbt.backend.PropertyCoverageCapability import org.usvm.ts.pbt.backend.PropertyRunConfiguration @@ -71,7 +71,7 @@ class FastCheckBackend( configuration.examples.forEachIndexed { index, example -> if (example.size != property.inputs.size) { throw invalidRequest( - code = PbtDiagnosticCode.BACKEND_EXAMPLES_ARITY, + code = FastCheckDiagnosticCode.BACKEND_EXAMPLES_ARITY, message = "Explicit example $index has ${example.size} values, expected ${property.inputs.size}", property = property, path = "examples[$index]", @@ -89,7 +89,7 @@ class FastCheckBackend( if (value !in property.inputs[valueIndex].domain) { throw invalidRequest( - code = PbtDiagnosticCode.BACKEND_EXAMPLES_DOMAIN, + code = FastCheckDiagnosticCode.BACKEND_EXAMPLES_DOMAIN, message = "Explicit example does not belong to the declared input domain", property = property, path = path, @@ -106,7 +106,7 @@ class FastCheckBackend( ) { if (value is JsConcreteValue.Number && !hasValidEncoding(value)) { throw invalidRequest( - code = PbtDiagnosticCode.BACKEND_EXAMPLES_VALUE_INVALID, + code = FastCheckDiagnosticCode.BACKEND_EXAMPLES_VALUE_INVALID, message = "Explicit example contains an invalid tagged JavaScript number", property = property, path = path, @@ -151,7 +151,7 @@ class FastCheckBackend( if (sourceRoots.isEmpty()) { throw PbtBackendException( kind = BackendErrorKind.INVALID_REQUEST, - code = PbtDiagnosticCode.SOURCE_ROOT_INVALID, + code = FastCheckDiagnosticCode.SOURCE_ROOT_INVALID, message = "At least one TypeScript source root is required", path = "sourceRoots", ) @@ -165,7 +165,7 @@ class FastCheckBackend( if (!Files.isDirectory(realPath)) { throw PbtBackendException( kind = BackendErrorKind.INVALID_REQUEST, - code = PbtDiagnosticCode.SOURCE_ROOT_INVALID, + code = FastCheckDiagnosticCode.SOURCE_ROOT_INVALID, message = "TypeScript source root is not a directory: $sourceRoot", path = "sourceRoots[$index]", ) @@ -174,7 +174,7 @@ class FastCheckBackend( } catch (error: IOException) { throw PbtBackendException( kind = BackendErrorKind.INVALID_REQUEST, - code = PbtDiagnosticCode.SOURCE_ROOT_INVALID, + code = FastCheckDiagnosticCode.SOURCE_ROOT_INVALID, message = "Cannot resolve TypeScript source root $sourceRoot: ${error.message}", path = "sourceRoots[$index]", cause = error, diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageSession.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageSession.kt similarity index 93% rename from usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageSession.kt rename to usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageSession.kt index 2191f3f42d..d4567c9f77 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageSession.kt +++ b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageSession.kt @@ -1,6 +1,6 @@ package org.usvm.ts.pbt.fastcheck -import org.usvm.ts.pbt.PbtDiagnosticCode +import org.usvm.ts.pbt.FastCheckDiagnosticCode import org.usvm.ts.pbt.backend.CoverageScope import org.usvm.ts.pbt.backend.PropertyRunResult import org.usvm.ts.pbt.coverage.CoverageArtifactException @@ -130,7 +130,7 @@ internal class FastCheckCoverageSession private constructor( if (!Files.isRegularFile(c8EntryPoint)) { failPreparation( request = request, - code = PbtDiagnosticCode.COVERAGE_COLLECTOR_NOT_FOUND, + code = FastCheckDiagnosticCode.COVERAGE_COLLECTOR_NOT_FOUND, message = "Cannot locate c8 ${FastCheckRuntimeMetadata.coverageCollector.version} " + "in the fast-check adapter runtime", path = c8EntryPoint.toString(), @@ -149,7 +149,7 @@ internal class FastCheckCoverageSession private constructor( } private fun createWorkspace(c8EntryPoint: Path, adapterRoot: Path): CoverageWorkspace { - val root = Files.createTempDirectory("usvm-ts-pbt-coverage-") + val root = Files.createTempDirectory("usvm-ts-fast-check-coverage-") try { val configPath = Files.writeString(root.resolve("c8-config.json"), "{}") @@ -177,7 +177,7 @@ internal class FastCheckCoverageSession private constructor( } catch (error: IOException) { failPreparation( request = request, - code = PbtDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE, + code = FastCheckDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE, message = "Cannot query the Node.js runtime version: ${error.message}", cause = error, ) @@ -191,7 +191,7 @@ internal class FastCheckCoverageSession private constructor( if (process.exitValue() != 0 || version.isBlank()) { failPreparation( request = request, - code = PbtDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE, + code = FastCheckDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE, message = "Cannot query the Node.js runtime version", ) } @@ -211,7 +211,7 @@ internal class FastCheckCoverageSession private constructor( Thread.currentThread().interrupt() failPreparation( request = request, - code = PbtDiagnosticCode.BACKEND_PROCESS_INTERRUPTED, + code = FastCheckDiagnosticCode.BACKEND_PROCESS_INTERRUPTED, message = "Interrupted while querying the Node.js runtime version", kind = BackendErrorKind.PROCESS_FAILURE, cause = error, @@ -221,7 +221,7 @@ internal class FastCheckCoverageSession private constructor( process.destroyForcibly() failPreparation( request = request, - code = PbtDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE, + code = FastCheckDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE, message = "Timed out while querying the Node.js runtime version", ) } @@ -234,7 +234,7 @@ internal class FastCheckCoverageSession private constructor( if (major == null || minor == null) { failPreparation( request = request, - code = PbtDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE, + code = FastCheckDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE, message = "Cannot parse the Node.js runtime version: $version", ) } @@ -245,7 +245,7 @@ internal class FastCheckCoverageSession private constructor( if (!supported) { failPreparation( request = request, - code = PbtDiagnosticCode.COVERAGE_RUNTIME_UNSUPPORTED, + code = FastCheckDiagnosticCode.COVERAGE_RUNTIME_UNSUPPORTED, message = "Coverage requires Node.js 18.18 or newer; found $version", ) } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckExecutionProtocol.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckExecutionProtocol.kt similarity index 100% rename from usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckExecutionProtocol.kt rename to usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckExecutionProtocol.kt diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClient.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClient.kt similarity index 91% rename from usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClient.kt rename to usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClient.kt index ff759dc98b..43d3980eed 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClient.kt +++ b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClient.kt @@ -2,7 +2,7 @@ package org.usvm.ts.pbt.fastcheck import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString -import org.usvm.ts.pbt.PbtDiagnosticCode +import org.usvm.ts.pbt.FastCheckDiagnosticCode import org.usvm.ts.pbt.backend.PropertyRunResult import org.usvm.ts.pbt.manifest.PropertyManifestJson import org.usvm.ts.pbt.model.PropertyId @@ -79,7 +79,7 @@ internal class FastCheckProcessClient( if (encodedRequest.toByteArray(Charsets.UTF_8).size > MAX_REQUEST_BYTES) { throw backendError( kind = BackendErrorKind.INVALID_REQUEST, - code = PbtDiagnosticCode.BACKEND_REQUEST_TOO_LARGE, + code = FastCheckDiagnosticCode.BACKEND_REQUEST_TOO_LARGE, message = "fast-check request exceeds $MAX_REQUEST_BYTES bytes", request = request, ) @@ -97,7 +97,7 @@ internal class FastCheckProcessClient( throw backendError( kind = BackendErrorKind.PROCESS_FAILURE, - code = PbtDiagnosticCode.BACKEND_PROCESS_FAILED, + code = FastCheckDiagnosticCode.BACKEND_PROCESS_FAILED, message = "fast-check adapter exited with code ${output.exitCode}: $detail", request = request, ) @@ -106,7 +106,7 @@ internal class FastCheckProcessClient( if (output.stdout.isBlank()) { throw backendError( kind = BackendErrorKind.PROTOCOL_ERROR, - code = PbtDiagnosticCode.BACKEND_RESPONSE_EMPTY, + code = FastCheckDiagnosticCode.BACKEND_RESPONSE_EMPTY, message = "fast-check adapter returned an empty response", request = request, ) @@ -121,7 +121,7 @@ internal class FastCheckProcessClient( } catch (error: IllegalArgumentException) { throw backendError( kind = BackendErrorKind.PROTOCOL_ERROR, - code = PbtDiagnosticCode.BACKEND_RESPONSE_INVALID, + code = FastCheckDiagnosticCode.BACKEND_RESPONSE_INVALID, message = "fast-check adapter returned invalid JSON: ${error.message}", request = request, cause = error, @@ -179,7 +179,7 @@ internal class FastCheckProcessClient( } catch (error: IllegalArgumentException) { throw backendError( kind = BackendErrorKind.PROTOCOL_ERROR, - code = PbtDiagnosticCode.BACKEND_RESPONSE_INVALID, + code = FastCheckDiagnosticCode.BACKEND_RESPONSE_INVALID, message = "fast-check result property ID is invalid: ${error.message}", request = request, cause = error, @@ -199,7 +199,7 @@ internal class FastCheckProcessClient( request: FastCheckExecutionRequest, ): PbtBackendException = backendError( kind = BackendErrorKind.PROTOCOL_ERROR, - code = PbtDiagnosticCode.BACKEND_RESPONSE_INVALID, + code = FastCheckDiagnosticCode.BACKEND_RESPONSE_INVALID, message = message, request = request, ) @@ -230,8 +230,8 @@ internal class FastCheckProcessClient( } private fun FastCheckTransportException.backendErrorKind(): BackendErrorKind = when (code) { - PbtDiagnosticCode.BACKEND_REQUEST_TOO_LARGE -> BackendErrorKind.INVALID_REQUEST - PbtDiagnosticCode.BACKEND_RESPONSE_TOO_LARGE -> BackendErrorKind.PROTOCOL_ERROR - PbtDiagnosticCode.BACKEND_PROCESS_TIMEOUT -> BackendErrorKind.TIMEOUT + FastCheckDiagnosticCode.BACKEND_REQUEST_TOO_LARGE -> BackendErrorKind.INVALID_REQUEST + FastCheckDiagnosticCode.BACKEND_RESPONSE_TOO_LARGE -> BackendErrorKind.PROTOCOL_ERROR + FastCheckDiagnosticCode.BACKEND_PROCESS_TIMEOUT -> BackendErrorKind.TIMEOUT else -> BackendErrorKind.PROCESS_FAILURE } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessTransport.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessTransport.kt similarity index 95% rename from usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessTransport.kt rename to usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessTransport.kt index f7b2be7d56..fa2cb7205f 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessTransport.kt +++ b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessTransport.kt @@ -1,6 +1,6 @@ package org.usvm.ts.pbt.fastcheck -import org.usvm.ts.pbt.PbtDiagnosticCode +import org.usvm.ts.pbt.FastCheckDiagnosticCode import java.io.ByteArrayOutputStream import java.io.IOException import java.io.InputStream @@ -99,7 +99,7 @@ internal class FastCheckProcessTransport( private fun requireRequestWithinLimit(request: String, description: String) { if (request.toByteArray(Charsets.UTF_8).size > maxRequestBytes) { fail( - code = PbtDiagnosticCode.BACKEND_REQUEST_TOO_LARGE, + code = FastCheckDiagnosticCode.BACKEND_REQUEST_TOO_LARGE, message = "$description request exceeds $maxRequestBytes bytes", ) } @@ -116,14 +116,14 @@ internal class FastCheckProcessTransport( process.inputStream.readBounded(maxStdoutBytes, stream = "stdout") }, operation = "reading $description stdout", - failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + failureCode = FastCheckDiagnosticCode.BACKEND_PROCESS_READ_FAILED, ) val stderr = ProcessIoTask( future = executor.submit { process.errorStream.readBounded(maxStderrBytes, stream = "stderr") }, operation = "reading $description stderr", - failureCode = PbtDiagnosticCode.BACKEND_PROCESS_READ_FAILED, + failureCode = FastCheckDiagnosticCode.BACKEND_PROCESS_READ_FAILED, ) val writer = ProcessIoTask( future = executor.submit { @@ -132,7 +132,7 @@ internal class FastCheckProcessTransport( } }, operation = "writing the $description request", - failureCode = PbtDiagnosticCode.BACKEND_PROCESS_WRITE_FAILED, + failureCode = FastCheckDiagnosticCode.BACKEND_PROCESS_WRITE_FAILED, ) return ProcessIoTasks(stdout = stdout, stderr = stderr, writer = writer) @@ -158,7 +158,7 @@ internal class FastCheckProcessTransport( } catch (error: InterruptedException) { Thread.currentThread().interrupt() fail( - code = PbtDiagnosticCode.BACKEND_PROCESS_INTERRUPTED, + code = FastCheckDiagnosticCode.BACKEND_PROCESS_INTERRUPTED, message = "Interrupted while waiting for the $description", cause = error, ) @@ -221,7 +221,7 @@ internal class FastCheckProcessTransport( } private fun processStartFailure(description: String, error: IOException): Nothing = fail( - code = PbtDiagnosticCode.BACKEND_PROCESS_START_FAILED, + code = FastCheckDiagnosticCode.BACKEND_PROCESS_START_FAILED, message = "Failed to start $description: ${error.message}", cause = error, ) @@ -296,7 +296,7 @@ internal class FastCheckProcessTransport( } private fun timeout(description: String, reportedTimeoutMillis: Long): Nothing = fail( - code = PbtDiagnosticCode.BACKEND_PROCESS_TIMEOUT, + code = FastCheckDiagnosticCode.BACKEND_PROCESS_TIMEOUT, message = "$description exceeded the $reportedTimeoutMillis ms timeout", ) @@ -354,7 +354,7 @@ private data class ProcessIoTask( } catch (error: InterruptedException) { Thread.currentThread().interrupt() throw FastCheckTransportException( - code = PbtDiagnosticCode.BACKEND_PROCESS_INTERRUPTED, + code = FastCheckDiagnosticCode.BACKEND_PROCESS_INTERRUPTED, message = "Interrupted while $operation", cause = error, ) @@ -362,7 +362,7 @@ private data class ProcessIoTask( val cause = error.cause ?: error if (cause is ProcessOutputLimitExceeded) { throw FastCheckTransportException( - code = PbtDiagnosticCode.BACKEND_RESPONSE_TOO_LARGE, + code = FastCheckDiagnosticCode.BACKEND_RESPONSE_TOO_LARGE, message = "$description ${cause.stream} exceeds ${cause.limit} bytes", cause = cause, ) diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClient.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClient.kt similarity index 95% rename from usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClient.kt rename to usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClient.kt index b74c86c49f..c00614d8d4 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClient.kt +++ b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClient.kt @@ -2,7 +2,7 @@ package org.usvm.ts.pbt.fastcheck import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString -import org.usvm.ts.pbt.PbtDiagnosticCode +import org.usvm.ts.pbt.FastCheckDiagnosticCode import org.usvm.ts.pbt.manifest.PropertyManifestJson import org.usvm.ts.pbt.model.contains import java.nio.file.Path @@ -93,12 +93,12 @@ class FastCheckProjectionClient private constructor( private fun processFailure(output: FastCheckProcessOutput): Nothing = throw FastCheckProjectionException( - code = PbtDiagnosticCode.BACKEND_PROCESS_FAILED, + code = FastCheckDiagnosticCode.BACKEND_PROCESS_FAILED, message = "fast-check adapter exited with code ${output.exitCode}: ${output.stderr.trim()}", ) private fun emptyResponse(): Nothing = throw FastCheckProjectionException( - code = PbtDiagnosticCode.BACKEND_RESPONSE_EMPTY, + code = FastCheckDiagnosticCode.BACKEND_RESPONSE_EMPTY, message = "fast-check adapter returned an empty response", ) @@ -106,7 +106,7 @@ class FastCheckProjectionClient private constructor( PropertyManifestJson.json.decodeFromString(stdout) } catch (error: IllegalArgumentException) { throw FastCheckProjectionException( - code = PbtDiagnosticCode.BACKEND_RESPONSE_INVALID, + code = FastCheckDiagnosticCode.BACKEND_RESPONSE_INVALID, message = "fast-check adapter returned invalid JSON: ${error.message}", cause = error, ) @@ -152,7 +152,7 @@ class FastCheckProjectionClient private constructor( private fun validateRequest(request: FastCheckProjectionRequest) { if (request.numSamples !in 1..MAX_SAMPLES || request.domains.isEmpty()) { throw FastCheckProjectionException( - code = PbtDiagnosticCode.PROTOCOL_REQUEST_INVALID, + code = FastCheckDiagnosticCode.PROTOCOL_REQUEST_INVALID, message = "Request requires domains and numSamples in 1..$MAX_SAMPLES", path = "request", ) @@ -161,7 +161,7 @@ class FastCheckProjectionClient private constructor( private fun invalidResponse(message: String, path: String? = null): Nothing = throw FastCheckProjectionException( - code = PbtDiagnosticCode.BACKEND_RESPONSE_INVALID, + code = FastCheckDiagnosticCode.BACKEND_RESPONSE_INVALID, message = message, path = path, ) diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionProtocol.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionProtocol.kt similarity index 100% rename from usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionProtocol.kt rename to usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionProtocol.kt diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntime.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntime.kt similarity index 94% rename from usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntime.kt rename to usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntime.kt index 860a04f579..c193295000 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntime.kt +++ b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntime.kt @@ -1,6 +1,6 @@ package org.usvm.ts.pbt.fastcheck -import org.usvm.ts.pbt.PbtDiagnosticCode +import org.usvm.ts.pbt.FastCheckDiagnosticCode import java.nio.file.Files import java.nio.file.Path @@ -20,7 +20,7 @@ internal object FastCheckRuntime { return candidates.firstOrNull(Files::isRegularFile) ?: throw PbtBackendException( kind = BackendErrorKind.INVALID_REQUEST, - code = PbtDiagnosticCode.BACKEND_RUNTIME_NOT_FOUND, + code = FastCheckDiagnosticCode.BACKEND_RUNTIME_NOT_FOUND, message = "Cannot locate built fast-check adapter; checked $candidates", ) } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntimeMetadata.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntimeMetadata.kt similarity index 100% rename from usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntimeMetadata.kt rename to usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntimeMetadata.kt diff --git a/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/TestResources.kt b/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/TestResources.kt new file mode 100644 index 0000000000..82542885a0 --- /dev/null +++ b/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/TestResources.kt @@ -0,0 +1,17 @@ +package org.usvm.ts.pbt + +import java.nio.file.Path + +internal fun testResourcePath(name: String): Path { + val resource = requireNotNull(TestResourceMarker::class.java.getResource(name)) { + "Missing test resource: $name" + } + + require(resource.protocol == "file") { "Test resource is not a regular file-system path: $resource" } + + return Path.of(resource.toURI()) +} + +internal fun testResourcesRoot(): Path = requireNotNull(testResourcePath("/properties").parent) + +private object TestResourceMarker diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/cli/FastCheckCliTest.kt b/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/cli/FastCheckCliTest.kt similarity index 100% rename from usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/cli/FastCheckCliTest.kt rename to usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/cli/FastCheckCliTest.kt diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/cli/InstalledDistributionRegistryProvider.kt b/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/cli/InstalledDistributionRegistryProvider.kt similarity index 100% rename from usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/cli/InstalledDistributionRegistryProvider.kt rename to usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/cli/InstalledDistributionRegistryProvider.kt diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/examples/ExamplePropertiesTest.kt b/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/examples/ExamplePropertiesTest.kt similarity index 100% rename from usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/examples/ExamplePropertiesTest.kt rename to usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/examples/ExamplePropertiesTest.kt diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackendTest.kt b/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackendTest.kt similarity index 100% rename from usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackendTest.kt rename to usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackendTest.kt diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageTest.kt b/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageTest.kt similarity index 98% rename from usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageTest.kt rename to usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageTest.kt index 14cef79ef6..cfcfa58d4d 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageTest.kt +++ b/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageTest.kt @@ -206,12 +206,12 @@ class FastCheckCoverageTest { fun adapterEntryPoint(): Path = locateFile( "fast-check-adapter/dist/src/execution-cli.js", - "usvm-ts-pbt/fast-check-adapter/dist/src/execution-cli.js", + "usvm-ts-fast-check/fast-check-adapter/dist/src/execution-cli.js", ) fun sourceRoot(): Path = locateDirectory( "src/test/resources", - "usvm-ts-pbt/src/test/resources", + "usvm-ts-fast-check/src/test/resources", ) fun locateFile(vararg candidates: String): Path = candidates diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClientTest.kt b/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClientTest.kt similarity index 99% rename from usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClientTest.kt rename to usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClientTest.kt index 2d7addcfba..08220b6ff1 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClientTest.kt +++ b/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClientTest.kt @@ -394,7 +394,7 @@ class FastCheckProcessClientTest { private fun coverageWorkspaces(): Set { val temporaryRoot = Path.of(System.getProperty("java.io.tmpdir")) - return Files.newDirectoryStream(temporaryRoot, "usvm-ts-pbt-coverage-*").use { entries -> + return Files.newDirectoryStream(temporaryRoot, "usvm-ts-fast-check-coverage-*").use { entries -> entries.toHashSet() } } diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClientTest.kt b/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClientTest.kt similarity index 100% rename from usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClientTest.kt rename to usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClientTest.kt diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntimeMetadataTest.kt b/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntimeMetadataTest.kt similarity index 100% rename from usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntimeMetadataTest.kt rename to usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntimeMetadataTest.kt diff --git a/usvm-ts-pbt/src/test/resources/META-INF/services/org.usvm.ts.pbt.registry.PropertyRegistryProvider b/usvm-ts-fast-check/src/test/resources/META-INF/services/org.usvm.ts.pbt.registry.PropertyRegistryProvider similarity index 100% rename from usvm-ts-pbt/src/test/resources/META-INF/services/org.usvm.ts.pbt.registry.PropertyRegistryProvider rename to usvm-ts-fast-check/src/test/resources/META-INF/services/org.usvm.ts.pbt.registry.PropertyRegistryProvider diff --git a/usvm-ts-pbt/src/test/resources/properties/coverage/CoverageProperties.ts b/usvm-ts-fast-check/src/test/resources/properties/coverage/CoverageProperties.ts similarity index 100% rename from usvm-ts-pbt/src/test/resources/properties/coverage/CoverageProperties.ts rename to usvm-ts-fast-check/src/test/resources/properties/coverage/CoverageProperties.ts diff --git a/usvm-ts-pbt/src/test/resources/properties/coverage/invalid-map-entry.js b/usvm-ts-fast-check/src/test/resources/properties/coverage/invalid-map-entry.js similarity index 100% rename from usvm-ts-pbt/src/test/resources/properties/coverage/invalid-map-entry.js rename to usvm-ts-fast-check/src/test/resources/properties/coverage/invalid-map-entry.js diff --git a/usvm-ts-pbt/src/test/resources/properties/coverage/invalid-map-entry.js.map b/usvm-ts-fast-check/src/test/resources/properties/coverage/invalid-map-entry.js.map similarity index 100% rename from usvm-ts-pbt/src/test/resources/properties/coverage/invalid-map-entry.js.map rename to usvm-ts-fast-check/src/test/resources/properties/coverage/invalid-map-entry.js.map diff --git a/usvm-ts-pbt/src/test/resources/properties/coverage/missing-map-entry.js b/usvm-ts-fast-check/src/test/resources/properties/coverage/missing-map-entry.js similarity index 100% rename from usvm-ts-pbt/src/test/resources/properties/coverage/missing-map-entry.js rename to usvm-ts-fast-check/src/test/resources/properties/coverage/missing-map-entry.js diff --git a/usvm-ts-pbt/src/test/resources/properties/coverage/package.json b/usvm-ts-fast-check/src/test/resources/properties/coverage/package.json similarity index 100% rename from usvm-ts-pbt/src/test/resources/properties/coverage/package.json rename to usvm-ts-fast-check/src/test/resources/properties/coverage/package.json diff --git a/usvm-ts-pbt/src/test/resources/properties/coverage/source-under-test.ts b/usvm-ts-fast-check/src/test/resources/properties/coverage/source-under-test.ts similarity index 100% rename from usvm-ts-pbt/src/test/resources/properties/coverage/source-under-test.ts rename to usvm-ts-fast-check/src/test/resources/properties/coverage/source-under-test.ts diff --git a/usvm-ts-pbt/src/test/resources/properties/examples/PropertyExamples.ts b/usvm-ts-fast-check/src/test/resources/properties/examples/PropertyExamples.ts similarity index 100% rename from usvm-ts-pbt/src/test/resources/properties/examples/PropertyExamples.ts rename to usvm-ts-fast-check/src/test/resources/properties/examples/PropertyExamples.ts diff --git a/usvm-ts-pbt/src/test/resources/properties/execution/ExecutionProperties.ts b/usvm-ts-fast-check/src/test/resources/properties/execution/ExecutionProperties.ts similarity index 100% rename from usvm-ts-pbt/src/test/resources/properties/execution/ExecutionProperties.ts rename to usvm-ts-fast-check/src/test/resources/properties/execution/ExecutionProperties.ts diff --git a/usvm-ts-pbt/README.md b/usvm-ts-pbt/README.md index 5f01f441a0..41c70656c5 100644 --- a/usvm-ts-pbt/README.md +++ b/usvm-ts-pbt/README.md @@ -1,10 +1,11 @@ # USVM TypeScript property-based testing -`usvm-ts-pbt` is the Kotlin-owned integration layer for concrete property-based testing backends and USVM. -Kotlin defines each property once; fast-check is the first concrete backend. +`usvm-ts-pbt` is the backend-neutral Kotlin layer between property-based testing backends and USVM. It owns the +property model, validation, registries, coverage contracts and decoders, and property-to-EtsIR mapping. The first +concrete backend lives in [`usvm-ts-fast-check`](../usvm-ts-fast-check/README.md). -See [DESIGN.md](DESIGN.md) for component responsibilities, Kotlin–TypeScript data flow, process supervision, and -runtime packaging. +See [`usvm-ts-fast-check/DESIGN.md`](../usvm-ts-fast-check/DESIGN.md) for the FastCheck process boundary and runtime +packaging. ## Kotlin property model @@ -219,7 +220,7 @@ Register the provider in provider JAR on the application classpath, then run: ```shell -java -cp '/opt/usvm-ts-pbt/lib/*:/workspace/example-properties.jar' \ +java -cp '/opt/usvm-ts-fast-check/lib/*:/workspace/example-properties.jar' \ org.usvm.ts.pbt.cli.FastCheckCliKt \ --source-root /workspace/packages/core/src \ --registry example \ @@ -248,11 +249,11 @@ Requires JDK 11, Node.js 18.18 or newer, npm, and the repository Gradle wrapper. The private distribution pins c8 10.1.3 because it supports the module's Node 18 floor. ```shell -npm ci --prefix usvm-ts-pbt/fast-check-adapter --ignore-scripts -npm test --prefix usvm-ts-pbt/fast-check-adapter +npm ci --prefix usvm-ts-fast-check/fast-check-adapter --ignore-scripts +npm test --prefix usvm-ts-fast-check/fast-check-adapter env -u ARKANALYZER_DIR ETS_IR_PROVIDER=ts-frontend \ - ./gradlew --no-daemon :usvm-ts-pbt:check + ./gradlew --no-daemon :usvm-ts-pbt:check :usvm-ts-fast-check:check ``` To substitute a local JacoDB checkout, add `-PuseLocalJacodb=/absolute/path/to/jacodb` to the Gradle command. diff --git a/usvm-ts-pbt/build.gradle.kts b/usvm-ts-pbt/build.gradle.kts index afc82d9f47..1197ec28fa 100644 --- a/usvm-ts-pbt/build.gradle.kts +++ b/usvm-ts-pbt/build.gradle.kts @@ -1,172 +1,12 @@ -import groovy.json.JsonSlurper - plugins { id("usvm.kotlin-conventions") kotlin("plugin.serialization") version Versions.kotlin - application } dependencies { implementation(project(":usvm-ts")) implementation(Libs.jacodb_ets) - implementation(Libs.clikt) implementation(Libs.kotlinx_serialization_json) testImplementation(Libs.logback) } - -val fastCheckAdapterDir = layout.projectDirectory.dir("fast-check-adapter") -val fastCheckAdapterPackageJson = fastCheckAdapterDir.file("package.json") -val fastCheckAdapterPackageLock = fastCheckAdapterDir.file("package-lock.json") -val fastCheckRuntimeProperty = "org.usvm.ts.pbt.fastcheck.runtime" -val generatedFastCheckRuntimeMetadataDirectory = layout.buildDirectory.dir( - "generated/resources/fastCheckRuntimeMetadata", -) -val hostOperatingSystem = System.getProperty("os.name").lowercase() -val hostPlatform = when { - hostOperatingSystem.contains("mac") -> "darwin" - hostOperatingSystem.contains("linux") -> "linux" - hostOperatingSystem.contains("windows") -> "win32" - else -> error("Unsupported fast-check runtime operating system: $hostOperatingSystem") -} -val hostArchitecture = when (val architecture = System.getProperty("os.arch").lowercase()) { - "aarch64", "arm64" -> "arm64" - "amd64", "x86_64" -> "x64" - "x86", "i386", "i686" -> "ia32" - else -> error("Unsupported fast-check runtime architecture: $architecture") -} -val fastCheckRuntimeClassifier = "$hostPlatform-$hostArchitecture" -val npmExecutable = if (hostPlatform == "win32") "npm.cmd" else "npm" - -val generateFastCheckRuntimeMetadata = tasks.register("generateFastCheckRuntimeMetadata") { - inputs.file(fastCheckAdapterPackageLock) - outputs.dir(generatedFastCheckRuntimeMetadataDirectory) - - doLast { - val packageLock = JsonSlurper().parse(fastCheckAdapterPackageLock.asFile) as? Map<*, *> - ?: error("Invalid fast-check adapter package lock") - val packages = packageLock["packages"] as? Map<*, *> - ?: error("Missing packages in fast-check adapter package lock") - fun dependencyVersion(dependency: String): String { - val metadata = packages["node_modules/$dependency"] as? Map<*, *> - ?: error("Missing locked fast-check adapter dependency: $dependency") - - return (metadata["version"] as? String) - ?.takeIf(String::isNotBlank) - ?: error("Missing locked fast-check adapter dependency version: $dependency") - } - - val metadataFile = generatedFastCheckRuntimeMetadataDirectory.get() - .file("org/usvm/ts/pbt/fastcheck/runtime-dependencies.properties") - .asFile - metadataFile.parentFile.mkdirs() - metadataFile.writeText( - """ - fast-check.version=${dependencyVersion("fast-check")} - c8.version=${dependencyVersion("c8")} - """.trimIndent() + "\n", - Charsets.UTF_8, - ) - } -} - -sourceSets.main { - resources.srcDir(generatedFastCheckRuntimeMetadataDirectory) -} - -tasks.processResources { - dependsOn(generateFastCheckRuntimeMetadata) -} - -val installFastCheckAdapter = tasks.register("installFastCheckAdapter") { - workingDir(fastCheckAdapterDir) - commandLine(npmExecutable, "ci", "--ignore-scripts") - inputs.files( - fastCheckAdapterPackageJson, - fastCheckAdapterPackageLock, - ) - inputs.property("runtimeClassifier", fastCheckRuntimeClassifier) - outputs.dir(fastCheckAdapterDir.dir("node_modules")) -} - -val verifyFastCheckAdapterRuntime = tasks.register("verifyFastCheckAdapterRuntime") { - dependsOn(installFastCheckAdapter) - val nativeRuntime = fastCheckAdapterDir.dir("node_modules/@esbuild/$fastCheckRuntimeClassifier") - - inputs.dir(nativeRuntime) - doLast { - check(nativeRuntime.asFile.isDirectory) { - "Missing esbuild runtime for $fastCheckRuntimeClassifier at ${nativeRuntime.asFile}" - } - } -} - -val buildFastCheckAdapter = tasks.register("buildFastCheckAdapter") { - dependsOn(verifyFastCheckAdapterRuntime) - workingDir(fastCheckAdapterDir) - commandLine(npmExecutable, "run", "build") - inputs.files( - fastCheckAdapterDir.file("package.json"), - fastCheckAdapterDir.file("package-lock.json"), - fastCheckAdapterDir.file("tsconfig.json"), - ) - inputs.dir(fastCheckAdapterDir.dir("src")) - inputs.dir(fastCheckAdapterDir.dir("test")) - outputs.dir(fastCheckAdapterDir.dir("dist")) -} - -tasks.named("distZip") { - archiveClassifier.set(fastCheckRuntimeClassifier) -} - -tasks.named("distTar") { - archiveClassifier.set(fastCheckRuntimeClassifier) -} - -val testFastCheckAdapter = tasks.register("testFastCheckAdapter") { - dependsOn(buildFastCheckAdapter) - workingDir(fastCheckAdapterDir) - commandLine(npmExecutable, "run", "test:compiled") - inputs.dir(fastCheckAdapterDir.dir("dist")) -} - -tasks.test { - dependsOn(buildFastCheckAdapter) - systemProperty(fastCheckRuntimeProperty, fastCheckAdapterDir.asFile.absolutePath) -} - -tasks.check { - dependsOn(testFastCheckAdapter) -} - -tasks.clean { - delete(fastCheckAdapterDir.dir("dist")) -} - -application { - mainClass = "org.usvm.ts.pbt.cli.FastCheckCliKt" - applicationDefaultJvmArgs = listOf("-Dfile.encoding=UTF-8", "-Dsun.stdout.encoding=UTF-8") -} - -tasks.named("run") { - systemProperty(fastCheckRuntimeProperty, fastCheckAdapterDir.asFile.absolutePath) -} - -distributions { - main { - contents { - into("lib/fast-check-adapter") { - from(fastCheckAdapterDir) - include("dist/src/**") - include("node_modules/**") - include("package.json") - } - } - } -} - -listOf("run", "startScripts", "installDist", "distZip", "distTar").forEach { taskName -> - tasks.named(taskName) { - dependsOn(buildFastCheckAdapter) - } -} 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 65dbf5170d..8971a44c4f 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 @@ -2,46 +2,8 @@ package org.usvm.ts.pbt /** Stable identifiers for diagnostics created on the Kotlin side of the PBT boundary. */ internal object PbtDiagnosticCode { - const val CLI_ARGUMENT_INVALID = "cli.argument.invalid" - const val CLI_COVERAGE_REQUIRED = "cli.coverage.required" - const val CLI_COVERAGE_SCOPE_INVALID = "cli.coverage.scope.invalid" - const val CLI_EXAMPLES_INVALID = "cli.examples.invalid" - const val CLI_NUM_RUNS_INVALID = "cli.num-runs.invalid" - const val CLI_PROPERTY_EMPTY = "cli.property.empty" - const val CLI_PROPERTY_INVALID = "cli.property.invalid" - const val CLI_PROPERTY_UNKNOWN = "cli.property.unknown" - const val CLI_REGISTRY_EMPTY = "cli.registry.empty" - const val CLI_REGISTRY_ID_DUPLICATE = "cli.registry.id.duplicate" - const val CLI_REGISTRY_ID_INVALID = "cli.registry.id.invalid" - const val CLI_REGISTRY_UNKNOWN = "cli.registry.unknown" - const val CLI_SINGLE_PROPERTY_REQUIRED = "cli.single-property.required" - const val CLI_SOURCE_ROOT_REQUIRED = "cli.source-root.required" - const val CLI_TIMEOUT_INVALID = "cli.timeout.invalid" - - const val REGISTRY_PROPERTY_ID_DUPLICATE = "registry.property-id.duplicate" - const val REGISTRY_PROPERTY_INVALID = "registry.property.invalid" - const val REGISTRY_PROVIDER_LOAD_FAILED = "registry.provider.load.failed" - - const val BACKEND_EXAMPLES_ARITY = "backend.examples.arity" - const val BACKEND_EXAMPLES_DOMAIN = "backend.examples.domain" - const val BACKEND_EXAMPLES_VALUE_INVALID = "backend.examples.value.invalid" - const val BACKEND_PROCESS_FAILED = "backend.process.failed" - const val BACKEND_PROCESS_INTERRUPTED = "backend.process.interrupted" - const val BACKEND_PROCESS_READ_FAILED = "backend.process.read.failed" - const val BACKEND_PROCESS_START_FAILED = "backend.process.start.failed" - const val BACKEND_PROCESS_TIMEOUT = "backend.process.timeout" - const val BACKEND_PROCESS_WRITE_FAILED = "backend.process.write.failed" - const val BACKEND_REQUEST_TOO_LARGE = "backend.request.too-large" - const val BACKEND_RESPONSE_EMPTY = "backend.response.empty" - const val BACKEND_RESPONSE_INVALID = "backend.response.invalid" - const val BACKEND_RESPONSE_TOO_LARGE = "backend.response.too-large" - const val BACKEND_RUNTIME_NOT_FOUND = "backend.runtime.not-found" - - const val COVERAGE_COLLECTOR_NOT_FOUND = "coverage.collector.not-found" const val COVERAGE_REPORT_INVALID = "coverage.report.invalid" const val COVERAGE_REPORT_MISSING = "coverage.report.missing" - const val COVERAGE_RUNTIME_UNSUPPORTED = "coverage.runtime.unsupported" - const val COVERAGE_RUNTIME_VERSION_UNAVAILABLE = "coverage.runtime.version-unavailable" const val COVERAGE_SOURCE_MAP_INVALID = "coverage.source-map.invalid" const val COVERAGE_SOURCE_MAP_MISSING = "coverage.source-map.missing" @@ -61,9 +23,6 @@ internal object PbtDiagnosticCode { 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" - const val PROPERTY_ID_INVALID = "property.id.invalid" const val PROPERTY_INPUTS_EMPTY = "property.inputs.empty" const val INPUT_NAME_DUPLICATE = "input.name.duplicate" diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspector.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspector.kt index d19176a68d..7ceef0c0a0 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspector.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspector.kt @@ -18,7 +18,7 @@ import java.util.stream.Collectors import kotlin.io.path.invariantSeparatorsPathString /** Reads bounded raw V8 source-map caches that c8 does not retain in its final Istanbul report. */ -internal fun inspectRawV8SourceMapDiagnostics( +fun inspectRawV8SourceMapDiagnostics( rawDirectory: Path, sourceRoots: List, maxReportFiles: Int = MAX_RAW_V8_REPORT_FILES, diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/SourceMapDiagnostics.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/SourceMapDiagnostics.kt index 863911a0f9..7dbaf15531 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/SourceMapDiagnostics.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/SourceMapDiagnostics.kt @@ -4,7 +4,7 @@ import org.usvm.ts.pbt.PbtDiagnosticCode import org.usvm.ts.pbt.backend.CoverageDiagnostic /** Raw V8 evidence replaces the less precise source-map guesses made from the final Istanbul report. */ -internal fun mergeCoverageDiagnostics( +fun mergeCoverageDiagnostics( finalDiagnostics: List, rawDiagnostics: List, ): List { From 11ea156b5310538c7d98105dbec6dbfd39733d73 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 16:27:29 +0300 Subject: [PATCH 13/13] [TS Calls] Use weighted uncovered path selection --- .../kotlin/org/usvm/ts/calls/CallsExperiment.kt | 8 +++++++- .../usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt | 5 ++--- .../org/usvm/ts/calls/CallsExperimentTest.kt | 14 +++++++++++++- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt index abaac361af..3746582ff3 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt @@ -5,6 +5,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import org.usvm.PathSelectionStrategy import org.usvm.machine.call.TsResidualCallPolicy import org.usvm.ts.pbt.model.JsConcreteValue import org.usvm.ts.pbt.model.PropertyInput @@ -17,6 +18,9 @@ import java.util.Properties import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +internal val CALLS_PATH_SELECTION_STRATEGY = PathSelectionStrategy.CLOSEST_TO_UNCOVERED_RANDOM +internal const val CALLS_STOP_ON_COVERAGE = 0 + @Serializable internal enum class CallsExperimentProfile( val usesFrozenModels: Boolean, @@ -72,7 +76,9 @@ internal data class CallsExperimentManifest( require(perTargetBudgetMillis > 0) { "Per-target budget must be positive" } require(projects.isNotEmpty()) { "At least one project is required" } require(solver == "Z3") { "The frozen calls experiment requires the Z3 solver" } - require(searchPolicy == "BFS") { "The frozen calls experiment requires BFS search" } + require(searchPolicy == CALLS_PATH_SELECTION_STRATEGY.name) { + "The frozen calls experiment requires ${CALLS_PATH_SELECTION_STRATEGY.name} search" + } val cleanGitRevision = Regex("[0-9a-f]{40}") require(toolRevision.matches(cleanGitRevision)) { "Tool revision must identify a clean Git commit" diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt index 0232eacecf..b7767ed426 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt @@ -8,7 +8,6 @@ import org.jacodb.ets.model.EtsScene import org.jacodb.ets.model.EtsStmt import org.jacodb.ets.utils.EtsIrProvider import org.jacodb.ets.utils.loadEtsFileAutoConvert -import org.usvm.PathSelectionStrategy import org.usvm.SolverType import org.usvm.StateCollectionStrategy import org.usvm.UMachineOptions @@ -124,12 +123,12 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { TsUnknownCallModelSelection.Only(emptySet()) } val machineOptions = UMachineOptions( - pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), + pathSelectionStrategies = listOf(CALLS_PATH_SELECTION_STRATEGY), stateCollectionStrategy = StateCollectionStrategy.REACHED_TARGET, randomSeed = request.seed, timeout = request.budget, solverType = SolverType.Z3, - stopOnCoverage = 0, + stopOnCoverage = CALLS_STOP_ON_COVERAGE, stopOnTargetsReached = false, throwExceptionOnStepFailure = true, ) diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt index 2ecc895cbf..d4307c6080 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt @@ -17,6 +17,18 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class CallsExperimentTest { + @Test + fun `manifest rejects breadth first search`() { + val accepted = manifest(sourceRoot = ".", seeds = listOf(17L)) + + assertEquals(CALLS_PATH_SELECTION_STRATEGY.name, accepted.searchPolicy) + val error = assertFailsWith { + accepted.copy(searchPolicy = "BFS") + } + + assertTrue(error.message.orEmpty().contains(CALLS_PATH_SELECTION_STRATEGY.name)) + } + @Test fun `runner rotates profiles and records symbolic and replay outcomes separately`(@TempDir directory: Path) { val requests = mutableListOf() @@ -344,7 +356,7 @@ class CallsExperimentTest { toolRevision = FIXTURE_TOOL_REVISION, nativeFrontendRevision = "frontend-revision", solver = "Z3", - searchPolicy = "BFS", + searchPolicy = CALLS_PATH_SELECTION_STRATEGY.name, modelSet = CallsModelSetIdentity( ids = setOf("ts.array.pop", "ts.array.shift"), toolRevision = FIXTURE_TOOL_REVISION,