From 93e8e13bb9aaad69d741a8e34f7a50324eb8098e Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Wed, 30 Sep 2026 23:45:40 +0300 Subject: [PATCH 1/8] Map exact c8 TypeScript if arms to CFG coverage --- usvm-ts-fast-check/README.md | 14 + .../fast-check-adapter/package-lock.json | 8 +- .../fast-check-adapter/package.json | 9 +- .../src/coverage-branches.ts | 336 ++++++++++++++++++ .../test/coverage-branches.test.ts | 262 ++++++++++++++ .../pbt/fastcheck/FastCheckCoverageSession.kt | 98 ++++- .../ts/pbt/fastcheck/FastCheckCoverageTest.kt | 109 +++++- .../properties/coverage/CoverageProperties.ts | 17 + .../coverage/nested-source-under-test.ts | 11 + .../coverage/nonterminal-source-under-test.ts | 7 + 10 files changed, 856 insertions(+), 15 deletions(-) create mode 100644 usvm-ts-fast-check/fast-check-adapter/src/coverage-branches.ts create mode 100644 usvm-ts-fast-check/fast-check-adapter/test/coverage-branches.test.ts create mode 100644 usvm-ts-fast-check/src/test/resources/properties/coverage/nested-source-under-test.ts create mode 100644 usvm-ts-fast-check/src/test/resources/properties/coverage/nonterminal-source-under-test.ts diff --git a/usvm-ts-fast-check/README.md b/usvm-ts-fast-check/README.md index 746c3ea1ab..97bf0d95c6 100644 --- a/usvm-ts-fast-check/README.md +++ b/usvm-ts-fast-check/README.md @@ -7,6 +7,20 @@ 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). +## Branch coverage boundary + +One c8 execution produces the existing source-mapped Istanbul statement report and raw V8 ranges. The bounded +TypeScript branch converter reads those same raw ranges, the executed source snapshot, and the source map. It emits +ordered `if` arms only when the original TypeScript points map back exactly, each arm has a distinct V8 execution +point, and the arm counts add up to the count at the condition. A supported `if` without `else` additionally needs +a single terminating `return` or `throw` in its true arm and a following statement that is reached only on false. +Nested `if` statements are supported under those conditions. Ambiguous counts or ranges and unsupported constructs +produce diagnostics; exact statement mappings remain available. + +Loops, `switch`, conditional expressions, logical short-circuit ranges, and function or script ranges are never +reinterpreted as `if` arms. The converter does not instrument the TypeScript program or execute it a second time. +The resulting edges are coverage artifacts; target-selection integration belongs to #399/#355. + Run the backend checks with: ```shell diff --git a/usvm-ts-fast-check/fast-check-adapter/package-lock.json b/usvm-ts-fast-check/fast-check-adapter/package-lock.json index 594dfb5981..3db9388792 100644 --- a/usvm-ts-fast-check/fast-check-adapter/package-lock.json +++ b/usvm-ts-fast-check/fast-check-adapter/package-lock.json @@ -8,13 +8,14 @@ "name": "@usvm/fast-check-adapter", "version": "0.1.0", "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", "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 +1305,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-fast-check/fast-check-adapter/package.json b/usvm-ts-fast-check/fast-check-adapter/package.json index 4e41b2c075..f27cceecb5 100644 --- a/usvm-ts-fast-check/fast-check-adapter/package.json +++ b/usvm-ts-fast-check/fast-check-adapter/package.json @@ -9,16 +9,17 @@ "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-replay.test.js dist/test/source-captures.test.js dist/test/local-source-closure.test.js" + "test:compiled": "node --test dist/test/coverage-branches.test.js 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-replay.test.js dist/test/source-captures.test.js dist/test/local-source-closure.test.js" }, "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", "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" }, "overrides": { "c8": { diff --git a/usvm-ts-fast-check/fast-check-adapter/src/coverage-branches.ts b/usvm-ts-fast-check/fast-check-adapter/src/coverage-branches.ts new file mode 100644 index 0000000000..7d78736563 --- /dev/null +++ b/usvm-ts-fast-check/fast-check-adapter/src/coverage-branches.ts @@ -0,0 +1,336 @@ +import { readFile, readdir, realpath } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + generatedPositionFor, + originalPositionFor, + TraceMap, +} from '@jridgewell/trace-mapping'; +import ts from 'typescript'; + +interface V8Range { + startOffset: number; + endOffset: number; + count: number; +} + +interface V8Function { + ranges: V8Range[]; + isBlockCoverage: boolean; +} + +interface V8Script { + url: string; + functions: V8Function[]; +} + +interface SourceMapCacheEntry { + lineLengths: number[]; + data: { + version: 3; + sources: string[]; + sourcesContent?: (string | null)[]; + mappings: string; + names: string[]; + }; +} + +interface V8Report { + result?: V8Script[]; + 'source-map-cache'?: Record; +} + +interface Position { + line: number; + column: number; +} + +interface Range { + start: Position; + end: Position; +} + +interface Branch { + branchId: number; + type: 'if'; + location: Range; + arms: { location: Range; hits: number }[]; +} + +interface BranchDiagnostic { + code: string; + message: string; + path: string; +} + +interface BranchReport { + files: { path: string; branches: Branch[] }[]; + diagnostics: BranchDiagnostic[]; +} + +interface ScriptCoverage { + script: V8Script; + cache: SourceMapCacheEntry; + path: string; +} + +/** Reconstructs only source-mapped TypeScript if arms whose V8 counters agree. */ +export async function convertV8IfBranches(rawDirectory: string, sourceRoots: string[]): Promise { + const roots = await Promise.all(sourceRoots.map(root => realpath(root))); + const names = (await readdir(rawDirectory)).filter(name => name.endsWith('.json')).sort(); + const reports = await Promise.all(names.map(async name => + JSON.parse(await readFile(path.join(rawDirectory, name), 'utf8')) as V8Report, + )); + const scripts = await collectScripts(reports, roots); + const files: BranchReport['files'] = []; + const diagnostics: BranchDiagnostic[] = []; + + for (const [sourcePath, candidates] of scripts) { + if (candidates.length !== 1 || candidates[0] === undefined) { + diagnostics.push(diagnostic( + sourcePath, + 'ambiguous', + 'The TypeScript source has several V8 scripts or a script without source-map data', + )); + continue; + } + + const converted = await convertScript(candidates[0]); + files.push({ path: sourcePath, branches: converted.branches.map((branch, branchId) => ({ + ...branch, + branchId, + })) }); + diagnostics.push(...converted.diagnostics); + } + + return { files, diagnostics }; +} + +async function collectScripts(reports: V8Report[], roots: string[]): Promise> { + const scripts = new Map(); + + for (const report of reports) { + for (const script of report.result ?? []) { + if (!script.url.startsWith('file:')) continue; + + const sourcePath = await realpath(fileURLToPath(script.url)).catch(() => undefined); + if (sourcePath === undefined || !sourcePath.endsWith('.ts')) continue; + if (!roots.some(root => sourcePath.startsWith(`${root}${path.sep}`))) continue; + + const cache = report['source-map-cache']?.[script.url]; + const candidates = scripts.get(sourcePath) ?? []; + candidates.push(cache === undefined ? undefined : { script, cache, path: sourcePath }); + scripts.set(sourcePath, candidates); + } + } + + return scripts; +} + +async function convertScript(coverage: ScriptCoverage): Promise<{ + branches: Branch[]; + diagnostics: BranchDiagnostic[]; +}> { + const source = await readFile(coverage.path, 'utf8'); + const sourceIndex = coverage.cache.data.sources.findIndex(candidate => + candidate.startsWith('file:') && fileURLToPath(candidate) === coverage.path, + ); + const coveredSource = coverage.cache.data.sourcesContent?.[sourceIndex]; + if (coveredSource !== source) { + return { + branches: [], + diagnostics: [diagnostic(coverage.path, 'unsupported', 'Covered TypeScript source differs from the current file')], + }; + } + + const ast = ts.createSourceFile(coverage.path, source, ts.ScriptTarget.Latest, true); + const map = new TraceMap(coverage.cache.data); + const branches: Branch[] = []; + const diagnostics: BranchDiagnostic[] = []; + + function visit(node: ts.Node, unsupportedAncestor: boolean): void { + const unsupported = unsupportedAncestor || isRepeatedOrExceptional(node); + + if (ts.isIfStatement(node)) { + const result = convertIf(node, ast, coverage, map, unsupported); + if (result.branch !== undefined) branches.push(result.branch); + if (result.reason !== undefined) diagnostics.push(diagnostic(coverage.path, result.kind, result.reason)); + } + + ts.forEachChild(node, child => visit(child, unsupported)); + } + + visit(ast, false); + + return { branches, diagnostics }; +} + +function convertIf( + node: ts.IfStatement, + ast: ts.SourceFile, + coverage: ScriptCoverage, + map: TraceMap, + unsupportedAncestor: boolean, +): { branch?: Branch; reason?: string; kind: 'unsupported' | 'ambiguous' } { + if (unsupportedAncestor || hasUnsupportedExpression(node.expression)) { + return { kind: 'unsupported', reason: 'If condition is inside an unsupported control-flow construct' }; + } + + const trueBody = firstExecutable(node.thenStatement); + const falseBody = node.elseStatement === undefined + ? falseSuccessor(node) + : firstExecutable(node.elseStatement); + if (trueBody === undefined) { + return { kind: 'unsupported', reason: 'Empty true arm has no V8 execution point' }; + } + if (falseBody === undefined) { + const reason = node.elseStatement === undefined + ? 'An if without else requires a terminating true arm and a following false successor' + : 'Empty false arm has no V8 execution point'; + + return { kind: 'unsupported', reason }; + } + + const conditionHits = countAt(node.getStart(ast), ast, coverage, map); + const trueHits = countAt(trueBody.getStart(ast), ast, coverage, map); + const falseHits = countAt(falseBody.getStart(ast), ast, coverage, map); + + if (conditionHits === undefined || trueHits === undefined || falseHits === undefined || + trueHits < 0 || falseHits < 0 || trueHits + falseHits !== conditionHits) { + return { kind: 'ambiguous', reason: 'V8 ranges and exact TypeScript source-map points do not identify both if arms' }; + } + + const branch: Branch = { + branchId: 0, + type: 'if', + // The predicate span excludes nested if statements from this branch's EtsIR target candidates. + location: sourceRange(node.expression, ast), + arms: [ + { location: sourceRange(node.thenStatement, ast), hits: trueHits }, + { + location: node.elseStatement === undefined + ? sourceRange(node.expression, ast) + : sourceRange(node.elseStatement, ast), + hits: falseHits, + }, + ], + }; + + return { branch, kind: 'ambiguous' }; +} + +function countAt( + sourceOffset: number, + ast: ts.SourceFile, + coverage: ScriptCoverage, + map: TraceMap, +): number | undefined { + const sourcePosition = ast.getLineAndCharacterOfPosition(sourceOffset); + const sourceUrl = coverage.cache.data.sources.find(candidate => + candidate.startsWith('file:') && fileURLToPath(candidate) === coverage.path, + ); + if (sourceUrl === undefined) return undefined; + + const generated = generatedPositionFor(map, { + source: sourceUrl, + line: sourcePosition.line + 1, + column: sourcePosition.character, + }); + if (generated.line === null || generated.column === null) return undefined; + + const original = originalPositionFor(map, { line: generated.line, column: generated.column }); + if (original.source !== sourceUrl || original.line !== sourcePosition.line + 1 || + original.column !== sourcePosition.character) return undefined; + + const lineLengths = coverage.cache.lineLengths; + if (lineLengths.some(length => !Number.isSafeInteger(length) || length < 0) || + generated.line > lineLengths.length || generated.column > (lineLengths[generated.line - 1] ?? -1)) { + return undefined; + } + + const offset = lineLengths.slice(0, generated.line - 1).reduce((sum, length) => sum + length + 1, 0) + + generated.column; + const functions = coverage.script.functions.filter(fn => fn.isBlockCoverage && + fn.ranges.length > 0 && contains(fn.ranges[0] as V8Range, offset)); + const orderedFunctions = functions.sort((left, right) => + rangeLength(left.ranges[0] as V8Range) - rangeLength(right.ranges[0] as V8Range), + ); + const functionCoverage = orderedFunctions[0]; + if (functionCoverage === undefined) return undefined; + if (orderedFunctions[1] !== undefined && + rangeLength(orderedFunctions[1].ranges[0] as V8Range) === rangeLength(functionCoverage.ranges[0] as V8Range)) { + return undefined; + } + + const ranges = functionCoverage.ranges.filter(range => contains(range, offset)); + const orderedRanges = ranges.sort((left, right) => rangeLength(left) - rangeLength(right)); + const innermost = orderedRanges[0]; + if (innermost === undefined || orderedRanges[1] !== undefined && + rangeLength(orderedRanges[1]) === rangeLength(innermost)) return undefined; + + return Number.isSafeInteger(innermost.count) && innermost.count >= 0 ? innermost.count : undefined; +} + +function firstExecutable(statement: ts.Statement): ts.Statement | undefined { + if (ts.isBlock(statement)) return statement.statements[0]; + + return statement; +} + +function falseSuccessor(node: ts.IfStatement): ts.Statement | undefined { + const consequent = node.thenStatement; + const soleStatement = ts.isBlock(consequent) ? consequent.statements[0] : consequent; + const terminates = soleStatement !== undefined && + (!ts.isBlock(consequent) || consequent.statements.length === 1) && + (ts.isReturnStatement(soleStatement) || ts.isThrowStatement(soleStatement)); + if (!terminates) return undefined; + + const parent = node.parent; + if (!ts.isBlock(parent) && !ts.isSourceFile(parent)) return undefined; + + const index = parent.statements.indexOf(node); + + return index < 0 ? undefined : parent.statements[index + 1]; +} + +const isRepeatedOrExceptional = (node: ts.Node): boolean => + ts.isForStatement(node) || ts.isForInStatement(node) || ts.isForOfStatement(node) || + ts.isWhileStatement(node) || ts.isDoStatement(node) || ts.isSwitchStatement(node) || + ts.isTryStatement(node) || ts.isConditionalExpression(node) || + ts.isBinaryExpression(node) && [ + ts.SyntaxKind.AmpersandAmpersandToken, + ts.SyntaxKind.BarBarToken, + ts.SyntaxKind.QuestionQuestionToken, + ].includes(node.operatorToken.kind); + +function hasUnsupportedExpression(node: ts.Node): boolean { + if (isRepeatedOrExceptional(node)) return true; + + return ts.forEachChild(node, child => hasUnsupportedExpression(child) ? true : undefined) === true; +} + +function sourceRange(node: ts.Node, ast: ts.SourceFile): Range { + const start = ast.getLineAndCharacterOfPosition(node.getStart(ast)); + const end = ast.getLineAndCharacterOfPosition(node.getEnd()); + + return { + start: { line: start.line + 1, column: start.character }, + end: { line: end.line + 1, column: end.character }, + }; +} + +const contains = (range: V8Range, offset: number): boolean => + range.startOffset <= offset && offset < range.endOffset; + +const rangeLength = (range: V8Range): number => range.endOffset - range.startOffset; + +function diagnostic(sourcePath: string, kind: string, message: string): BranchDiagnostic { + return { code: `coverage.branch.${kind}`, message, path: sourcePath }; +} + +if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [rawDirectory, ...sourceRoots] = process.argv.slice(2); + if (rawDirectory === undefined) throw new Error('Raw V8 coverage directory is required'); + + process.stdout.write(`${JSON.stringify(await convertV8IfBranches(rawDirectory, sourceRoots))}\n`); +} diff --git a/usvm-ts-fast-check/fast-check-adapter/test/coverage-branches.test.ts b/usvm-ts-fast-check/fast-check-adapter/test/coverage-branches.test.ts new file mode 100644 index 0000000000..e0a3e06717 --- /dev/null +++ b/usvm-ts-fast-check/fast-check-adapter/test/coverage-branches.test.ts @@ -0,0 +1,262 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { cp, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { convertV8IfBranches } from '../src/coverage-branches.js'; + +const adapterRoot = path.resolve(fileURLToPath(new URL('../', import.meta.url)), '..'); +const c8 = path.join(adapterRoot, 'node_modules/c8/bin/c8.js'); + +async function runCoverage(source: string): Promise<{ + root: string; + raw: string; + sourcePath: string; +}> { + const root = await mkdtemp(path.join(os.tmpdir(), 'usvm-branch-test-')); + const sourcePath = path.join(root, 'subject.ts'); + const raw = path.join(root, 'raw'); + await writeFile(sourcePath, source); + + const result = spawnSync(process.execPath, [ + c8, + '--reporter=json', + `--reports-dir=${path.join(root, 'report')}`, + `--temp-directory=${raw}`, + '--allowExternal', + '--exclude=__usvm_no_default_excludes__', + process.execPath, + '--import', + 'tsx', + sourcePath, + ], { cwd: adapterRoot, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + + return { root, raw, sourcePath }; +} + +test('real c8 and tsx preserve true, false, mixed, and nested TypeScript arms', async () => { + for (const [calls, expected] of [ + ['nested(20);', [[1, 0], [1, 0]]], + ['nested(-1);', [[0, 1], [0, 0]]], + ['nested(20); nested(5); nested(-1);', [[2, 1], [1, 1]]], + ] as const) { + const fixture = await runCoverage(` +export function nested(value: number): string { + if (value > 0) { + if (value > 10) { + return 'large'; + } else { + return 'small'; + } + } else { + return 'negative'; + } +} +${calls} +`); + + try { + const report = await convertV8IfBranches(fixture.raw, [fixture.root]); + const branches = report.files[0]?.branches ?? []; + + assert.deepEqual(branches.map(branch => branch.arms.map(arm => arm.hits)), expected); + assert.deepEqual(branches.map(branch => branch.location.start.line), [3, 4]); + assert.deepEqual(report.diagnostics, []); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + } +}); + +test('if without else observes an actual false successor and rejects throwing conditions', async () => { + const fixture = await runCoverage(` +function gate(value: number): boolean { + if (value < 0) throw new Error('guard failed'); + return value > 0; +} +function subject(value: number): number { + if (gate(value)) { + return 1; + } + return 0; +} +for (const value of [-1, 1]) { + try { subject(value); } catch { /* expected */ } +} +`); + + try { + const report = await convertV8IfBranches(fixture.raw, [fixture.root]); + + assert.equal(report.files[0]?.branches.some(branch => branch.location.start.line === 7), false); + assert.equal(report.diagnostics.some(diagnostic => diagnostic.code === 'coverage.branch.ambiguous'), true); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('if without else maps both arms when the true arm terminates', async () => { + for (const [calls, expected] of [ + ['subject(1);', [1, 0]], + ['subject(-1);', [0, 1]], + ['subject(1); subject(-1);', [1, 1]], + ] as const) { + const fixture = await runCoverage(` +function subject(value: number): number { + if (value > 0) { + return 1; + } + return 0; +} +${calls} +`); + + try { + const report = await convertV8IfBranches(fixture.raw, [fixture.root]); + + assert.deepEqual(report.files[0]?.branches[0]?.arms.map(arm => arm.hits), expected); + assert.deepEqual(report.diagnostics, []); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + } +}); + +test('duplicate V8 scripts remain ambiguous', async () => { + const fixture = await runCoverage(` +function subject(value: number): number { + if (value > 0) return 1; + else return 0; +} +subject(1); +`); + + try { + const reports = (await readdir(fixture.raw)).filter(name => name.endsWith('.json')); + let scriptReport: string | undefined; + for (const name of reports) { + const report = JSON.parse(await readFile(path.join(fixture.raw, name), 'utf8')) as { + result?: { url: string }[]; + }; + + if (report.result?.some(script => script.url.endsWith('/subject.ts'))) { + scriptReport = name; + break; + } + } + assert.ok(scriptReport); + await cp(path.join(fixture.raw, scriptReport), path.join(fixture.raw, 'duplicate.json')); + + const duplicate = await convertV8IfBranches(fixture.raw, [fixture.root]); + + assert.deepEqual(duplicate.files, []); + assert.equal(duplicate.diagnostics[0]?.code, 'coverage.branch.ambiguous'); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('unmapped source-map positions do not produce branch counts', async () => { + const fixture = await runCoverage(` +function subject(value: number): number { + if (value > 0) return 1; + else return 0; +} +subject(1); +`); + + try { + let changed = 0; + for (const name of await readdir(fixture.raw)) { + if (!name.endsWith('.json')) continue; + + const reportPath = path.join(fixture.raw, name); + const report = JSON.parse(await readFile(reportPath, 'utf8')) as { + 'source-map-cache'?: Record; + }; + const entry = Object.entries(report['source-map-cache'] ?? {}) + .find(([url]) => url.endsWith('/subject.ts'))?.[1]; + if (entry === undefined) continue; + + entry.data.mappings = ''; + await writeFile(reportPath, JSON.stringify(report)); + changed += 1; + } + assert.ok(changed > 0); + + const converted = await convertV8IfBranches(fixture.raw, [fixture.root]); + + assert.deepEqual(converted.files[0]?.branches, []); + assert.equal(converted.diagnostics[0]?.code, 'coverage.branch.ambiguous'); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('function, script, switch, conditional, and logical ranges are not if arms', async () => { + const fixture = await runCoverage(` +function subject(value: number): boolean { + switch (value) { + case 1: return value > 0 && value < 2; + default: return value === 0 ? true : false; + } +} +subject(1); +`); + + try { + const converted = await convertV8IfBranches(fixture.raw, [fixture.root]); + + assert.deepEqual(converted.files[0]?.branches, []); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('if ranges inside loops are explicitly unsupported', async () => { + const fixture = await runCoverage(` +function subject(value: number): number { + for (let index = 0; index < 2; index += 1) { + if (value > index) return index; + else value += 1; + } + return -1; +} +subject(1); +`); + + try { + const converted = await convertV8IfBranches(fixture.raw, [fixture.root]); + + assert.deepEqual(converted.files[0]?.branches, []); + assert.equal(converted.diagnostics[0]?.code, 'coverage.branch.unsupported'); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('early exits and UTF-16 columns retain exact source positions', async () => { + const fixture = await runCoverage(` +function subject(value: number): number { + const marker = '😀'; if (value < 0) return -1; + if (value > 0) return marker.length; + return 0; +} +subject(-1); +subject(1); +`); + + try { + const converted = await convertV8IfBranches(fixture.raw, [fixture.root]); + const branches = converted.files[0]?.branches ?? []; + + assert.deepEqual(branches.map(branch => branch.arms.map(arm => arm.hits)), [[1, 1], [1, 0]]); + assert.equal(branches[0]?.location.start.column, 27); + assert.deepEqual(converted.diagnostics, []); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); diff --git a/usvm-ts-fast-check/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 index 227eeb468d..fcf3b53dea 100644 --- a/usvm-ts-fast-check/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,13 +1,19 @@ package org.usvm.ts.pbt.fastcheck +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString import org.usvm.ts.pbt.FastCheckDiagnosticCode +import org.usvm.ts.pbt.backend.BranchCoverage +import org.usvm.ts.pbt.backend.CoverageDiagnostic import org.usvm.ts.pbt.backend.CoverageScope +import org.usvm.ts.pbt.backend.PropertyCoverageArtifact import org.usvm.ts.pbt.backend.PropertyRunResult import org.usvm.ts.pbt.coverage.CoverageArtifactException import org.usvm.ts.pbt.coverage.IstanbulCoverageContext import org.usvm.ts.pbt.coverage.decodeIstanbulCoverageReport import org.usvm.ts.pbt.coverage.inspectRawV8SourceMapDiagnostics import org.usvm.ts.pbt.coverage.mergeCoverageDiagnostics +import org.usvm.ts.pbt.manifest.PropertyManifestJson import java.io.IOException import java.nio.file.Files import java.nio.file.Path @@ -67,7 +73,15 @@ internal class FastCheckCoverageSession private constructor( rawDiagnostics = rawDiagnostics, ) - finalArtifact.copy(diagnostics = diagnostics) + val branchReport = if (finalArtifact.files.isEmpty()) { + RawBranchReport(files = emptyList(), diagnostics = emptyList()) + } else { + convertBranches() + } + appendExactBranches( + artifact = finalArtifact.copy(diagnostics = diagnostics), + report = branchReport, + ) } catch (error: CoverageArtifactException) { fail( code = error.diagnostic.code, @@ -95,6 +109,71 @@ internal class FastCheckCoverageSession private constructor( return paths } + private fun convertBranches(): RawBranchReport { + val converter = workspace.adapterRoot.resolve("dist/src/coverage-branches.js") + val transport = FastCheckProcessTransport( + nodeExecutable = nodeExecutable, + maxRequestBytes = 1, + maxStdoutBytes = MAX_BRANCH_REPORT_BYTES, + maxStderrBytes = MAX_BRANCH_ERROR_BYTES, + shutdownGraceMillis = BRANCH_SHUTDOWN_GRACE_MILLIS, + ) + val output = try { + transport.invoke( + command = listOf(nodeExecutable, converter.toString(), workspace.rawDirectory.toString()) + + request.sourceRoots, + request = "", + timeoutMillis = BRANCH_CONVERSION_TIMEOUT_MILLIS, + reportedTimeoutMillis = BRANCH_CONVERSION_TIMEOUT_MILLIS, + description = "TypeScript branch converter", + ) + } catch (error: FastCheckTransportException) { + return unsupportedBranches("Cannot convert TypeScript branches: ${error.message}") + } + if (output.exitCode != 0) { + return unsupportedBranches("TypeScript branch conversion failed: ${output.stderr.trim()}") + } + + return runCatching { + PropertyManifestJson.json.decodeFromString(output.stdout) + }.getOrElse { error -> + unsupportedBranches("Cannot read TypeScript branch coverage: ${error.message}") + } + } + + private fun appendExactBranches( + artifact: PropertyCoverageArtifact, + report: RawBranchReport, + ): PropertyCoverageArtifact { + val exactByPath = report.files.associate { file -> file.path to file.branches } + val retainedPaths = artifact.files.mapTo(hashSetOf()) { file -> file.path } + val files = artifact.files.map { file -> + val nextId = (file.branches.maxOfOrNull(BranchCoverage::branchId) ?: -1) + 1 + val exactBranches = exactByPath[file.path].orEmpty().mapIndexed { index, branch -> + branch.copy(branchId = nextId + index) + } + + file.copy(branches = file.branches + exactBranches) + } + + return artifact.copy( + files = files, + diagnostics = artifact.diagnostics + report.diagnostics.filter { diagnostic -> + diagnostic.path == null || diagnostic.path in retainedPaths + }, + ) + } + + private fun unsupportedBranches(message: String) = RawBranchReport( + files = emptyList(), + diagnostics = listOf( + CoverageDiagnostic( + code = "coverage.branch.unsupported", + message = message, + ), + ), + ) + override fun close() { workspace.root.toFile().deleteRecursively() } @@ -258,12 +337,29 @@ internal class FastCheckCoverageSession private constructor( private const val NODE_VERSION_TIMEOUT_MILLIS = 5_000L private const val VERSION_SHUTDOWN_GRACE_MILLIS = 100L private const val MAX_VERSION_OUTPUT_BYTES = 1_000 + + private const val BRANCH_CONVERSION_TIMEOUT_MILLIS = 10_000L + private const val BRANCH_SHUTDOWN_GRACE_MILLIS = 500L + private const val MAX_BRANCH_REPORT_BYTES = 10_000_000 + private const val MAX_BRANCH_ERROR_BYTES = 1_000 private const val MINIMUM_NODE_MAJOR_VERSION = 18 private const val MINIMUM_NODE_MINOR_VERSION = 18 private val NODE_VERSION_PATTERN = Regex("""^v(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$""") } } +@Serializable +private data class RawBranchReport( + val files: List, + val diagnostics: List, +) + +@Serializable +private data class RawBranchFile( + val path: String, + val branches: List, +) + private data class CoverageWorkspace( val root: Path, val configPath: Path, diff --git a/usvm-ts-fast-check/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 index cfcfa58d4d..4c4dee032c 100644 --- a/usvm-ts-fast-check/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 @@ -1,5 +1,8 @@ package org.usvm.ts.pbt.fastcheck +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert import org.junit.jupiter.api.Test import org.usvm.ts.pbt.backend.CoverageScope import org.usvm.ts.pbt.backend.PropertyCoverageRequest @@ -7,7 +10,11 @@ import org.usvm.ts.pbt.backend.PropertyRunConfiguration import org.usvm.ts.pbt.backend.PropertyRunResult import org.usvm.ts.pbt.backend.PropertyRunStatus import org.usvm.ts.pbt.backend.SourceFileCoverage +import org.usvm.ts.pbt.manifest.toManifest +import org.usvm.ts.pbt.mapping.EtsMappingStatus +import org.usvm.ts.pbt.mapping.PropertyEtsMapper import org.usvm.ts.pbt.model.IntegerDomain +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 @@ -58,12 +65,9 @@ class FastCheckCoverageTest { assertEquals(listOf(2), zeroHitBranchLines(nonPositiveFile)) assertEquals(1L, statementHitsAtLine(positiveFile, line = 2)) assertEquals(1L, statementHitsAtLine(nonPositiveFile, line = 2)) - assertTrue( - (positiveFile.branches + nonPositiveFile.branches).all { branch -> - branch.type == "branch" && branch.arms.size == 1 - }, - "The pinned c8/V8 collector must expose its backend-specific single-arm branch shape", - ) + assertEquals(listOf(1L, 0L), exactIf(positiveFile).arms.map { arm -> arm.hits }) + assertEquals(listOf(0L, 1L), exactIf(nonPositiveFile).arms.map { arm -> arm.hits }) + assertTrue(positiveFile.branches.any { branch -> branch.type == "branch" && branch.arms.size == 1 }) } @Test @@ -84,6 +88,97 @@ class FastCheckCoverageTest { assertEquals(listOf(5), zeroHitBranchLines(file)) } + @Test + fun `real backend reports mixed TypeScript branch hits`() { + val result = backend.run( + property = property( + exportName = "alwaysCovers", + domain = IntegerDomain(min = -1, max = 1), + ), + configuration = configuration.copy( + numRuns = 3, + examples = listOf( + listOf(JsConcreteValue.number(-1.0)), + listOf(JsConcreteValue.number(1.0)), + ), + ), + ) + + assertEquals(PropertyRunStatus.SUCCESS, result.status) + val armHits = exactIf(sourceUnderTest(result)).arms.map { arm -> arm.hits } + assertEquals(3L, armHits.sum()) + assertTrue(armHits.all { hits -> hits >= 1L }) + } + + @Test + fun `real TypeScript branch arms map to ordered EtsIR successors`() { + val definition = property( + exportName = "coversPositive", + domain = IntegerDomain(min = 1, max = 1), + ) + val result = backend.run(property = definition, configuration = configuration) + val source = sourceRoot().resolve("properties/coverage/source-under-test.ts").toRealPath() + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val mapped = mapper.map(definition.toManifest(), assertNotNull(result.coverage)) + val branch = mapped.coverage.branches.single { candidate -> candidate.coverage.type == "if" } + + assertEquals(EtsMappingStatus.EXACT, branch.mapping.status) + assertEquals(listOf(1L, 0L), branch.arms.map { arm -> arm.coverage.hits }) + assertEquals(listOf(true, false), branch.arms.map { arm -> arm.mapping.targets.single().outcome }) + } + + @Test + fun `nested TypeScript branches map to distinct EtsIR conditions`() { + val definition = property( + exportName = "coversNested", + domain = IntegerDomain(min = 20, max = 20), + ) + val result = backend.run(property = definition, configuration = configuration) + val source = sourceRoot().resolve("properties/coverage/nested-source-under-test.ts").toRealPath() + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val mapped = mapper.map(definition.toManifest(), assertNotNull(result.coverage)) + val branches = mapped.coverage.branches.filter { branch -> branch.coverage.type == "if" } + + assertEquals(2, branches.size) + assertTrue(branches.all { branch -> branch.mapping.status == EtsMappingStatus.EXACT }) + assertEquals(listOf(1L, 0L), branches[0].arms.map { arm -> arm.coverage.hits }) + assertEquals(listOf(1L, 0L), branches[1].arms.map { arm -> arm.coverage.hits }) + val outcomes = branches.map { branch -> + branch.arms.map { arm -> arm.mapping.targets.single().outcome } + } + assertEquals(listOf(listOf(true, false), listOf(true, false)), outcomes) + } + + @Test + fun `unsupported TypeScript branch retains exact statement coverage`() { + val result = backend.run( + property = property( + exportName = "coversNonterminal", + domain = IntegerDomain(min = 1, max = 1), + ), + configuration = configuration, + ) + + val coverage = assertNotNull(result.coverage) + val file = coverage.files.single { candidate -> + candidate.path.endsWith("properties/coverage/nonterminal-source-under-test.ts") + } + + assertTrue(file.statements.any { statement -> statement.hits > 0 }) + assertTrue(file.branches.none { branch -> branch.type == "if" }) + assertTrue(coverage.diagnostics.any { diagnostic -> diagnostic.code == "coverage.branch.unsupported" }) + } + @Test fun `real c8 reports a missing referenced source map when the final report omits the script`() { val module = "properties/coverage/missing-map-entry.js" @@ -168,6 +263,8 @@ class FastCheckCoverageTest { return artifact.files.single { file -> file.path.endsWith("properties/coverage/source-under-test.ts") } } + private fun exactIf(file: SourceFileCoverage) = file.branches.single { branch -> branch.type == "if" } + private fun statementHitsAtLine(file: SourceFileCoverage, line: Int): Long = file.statements .filter { statement -> statement.location.start.line == line } .maxOf { statement -> statement.hits } diff --git a/usvm-ts-fast-check/src/test/resources/properties/coverage/CoverageProperties.ts b/usvm-ts-fast-check/src/test/resources/properties/coverage/CoverageProperties.ts index 1b012ceb1c..653db26a2d 100644 --- a/usvm-ts-fast-check/src/test/resources/properties/coverage/CoverageProperties.ts +++ b/usvm-ts-fast-check/src/test/resources/properties/coverage/CoverageProperties.ts @@ -1,4 +1,6 @@ import { classify } from './source-under-test.ts'; +import { classifyNested } from './nested-source-under-test.ts'; +import { nonterminal } from './nonterminal-source-under-test.ts'; export function coversPositive(value: number): boolean { return classify(value) === 'positive'; @@ -12,3 +14,18 @@ export function failsAfterClassifying(value: number): boolean { classify(value); return false; } + +export function alwaysCovers(value: number): boolean { + classify(value); + return true; +} + +export function coversNested(value: number): boolean { + classifyNested(value); + return true; +} + +export function coversNonterminal(value: number): boolean { + nonterminal(value); + return true; +} diff --git a/usvm-ts-fast-check/src/test/resources/properties/coverage/nested-source-under-test.ts b/usvm-ts-fast-check/src/test/resources/properties/coverage/nested-source-under-test.ts new file mode 100644 index 0000000000..fa46738aeb --- /dev/null +++ b/usvm-ts-fast-check/src/test/resources/properties/coverage/nested-source-under-test.ts @@ -0,0 +1,11 @@ +export function classifyNested(value: number): string { + if (value > 0) { + if (value > 10) { + return 'large'; + } else { + return 'small'; + } + } else { + return 'non-positive'; + } +} diff --git a/usvm-ts-fast-check/src/test/resources/properties/coverage/nonterminal-source-under-test.ts b/usvm-ts-fast-check/src/test/resources/properties/coverage/nonterminal-source-under-test.ts new file mode 100644 index 0000000000..b9dfccaaf9 --- /dev/null +++ b/usvm-ts-fast-check/src/test/resources/properties/coverage/nonterminal-source-under-test.ts @@ -0,0 +1,7 @@ +export function nonterminal(value: number): number { + let result = 0; + if (value > 0) { + result += 1; + } + return result; +} From 0475a55e95909f51c2baac5d7b98a831b2661719 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Tue, 13 Oct 2026 01:49:23 +0300 Subject: [PATCH 2/8] Verify declaration-first coverage arms against native invocation counts --- .../test/coverage-branches.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/usvm-ts-fast-check/fast-check-adapter/test/coverage-branches.test.ts b/usvm-ts-fast-check/fast-check-adapter/test/coverage-branches.test.ts index e0a3e06717..140b2b8a65 100644 --- a/usvm-ts-fast-check/fast-check-adapter/test/coverage-branches.test.ts +++ b/usvm-ts-fast-check/fast-check-adapter/test/coverage-branches.test.ts @@ -260,3 +260,32 @@ subject(1); await rm(fixture.root, { recursive: true, force: true }); } }); + +test('declaration-first arms use arm entries rather than nested function invocation counts', async () => { + const fixture = await runCoverage(` +export function declaredFirst(value: number): string { + if (value > 0) { + function positive(): string { return 'yes'; } + positive(); + return positive(); + } else { + function negative(): string { return 'no'; } + if (value === 0) negative(); + return 'no'; + } +} +declaredFirst(1); +declaredFirst(0); +declaredFirst(-1); +`); + + try { + const report = await convertV8IfBranches(fixture.raw, [fixture.root]); + const outer = report.files[0]?.branches.find(branch => branch.location.start.line === 3); + + assert.notEqual(outer, undefined, JSON.stringify(report.diagnostics)); + assert.deepEqual(outer?.arms.map(arm => arm.hits), [1, 2]); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); From 6ecf059352ed682595bff10d3413943467761482 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Tue, 13 Oct 2026 01:58:41 +0300 Subject: [PATCH 3/8] Verify generated branch coverage against pinned module identity --- .../src/coverage-branches.ts | 215 ++++++++++++++++-- .../test/coverage-branches.test.ts | 118 +++++++++- 2 files changed, 310 insertions(+), 23 deletions(-) diff --git a/usvm-ts-fast-check/fast-check-adapter/src/coverage-branches.ts b/usvm-ts-fast-check/fast-check-adapter/src/coverage-branches.ts index 7d78736563..4aa78be072 100644 --- a/usvm-ts-fast-check/fast-check-adapter/src/coverage-branches.ts +++ b/usvm-ts-fast-check/fast-check-adapter/src/coverage-branches.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { readFile, readdir, realpath } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -72,20 +73,48 @@ interface ScriptCoverage { script: V8Script; cache: SourceMapCacheEntry; path: string; + sourceUrl: string; + scriptPath: string; +} + +export interface GeneratedModuleIdentity { + originalPath: string; + originalSha256: string; + generatedModule: string; + generatedSha256: string; + sourceMapSha256: string; } /** Reconstructs only source-mapped TypeScript if arms whose V8 counters agree. */ -export async function convertV8IfBranches(rawDirectory: string, sourceRoots: string[]): Promise { +export async function convertV8IfBranches( + rawDirectory: string, + sourceRoots: string[], + generatedIdentity?: GeneratedModuleIdentity, +): Promise { const roots = await Promise.all(sourceRoots.map(root => realpath(root))); + const lexicalRoots = [...sourceRoots.map(root => path.resolve(root)), ...roots.flatMap(root => + process.platform === 'darwin' && root.startsWith('/private/') ? [root.slice('/private'.length)] : [])]; + const identity = generatedIdentity === undefined ? undefined : { + ...generatedIdentity, + originalPath: await realpath(generatedIdentity.originalPath).catch(() => generatedIdentity.originalPath), + generatedModule: await realpath(generatedIdentity.generatedModule).catch(() => generatedIdentity.generatedModule), + }; const names = (await readdir(rawDirectory)).filter(name => name.endsWith('.json')).sort(); const reports = await Promise.all(names.map(async name => JSON.parse(await readFile(path.join(rawDirectory, name), 'utf8')) as V8Report, )); - const scripts = await collectScripts(reports, roots); + const collected = await collectScripts(reports, roots, lexicalRoots, identity); + const scripts = collected.scripts; const files: BranchReport['files'] = []; - const diagnostics: BranchDiagnostic[] = []; + const diagnostics: BranchDiagnostic[] = [...collected.diagnostics]; for (const [sourcePath, candidates] of scripts) { + if (identity !== undefined && sourcePath === identity.originalPath && + (collected.generatedScriptCount !== 1 || collected.diagnostics.some(entry => + entry.path === sourcePath && ['coverage.branch.ambiguous', 'coverage.branch.unknown'].includes(entry.code)))) { + continue; + } + if (candidates.length !== 1 || candidates[0] === undefined) { diagnostics.push(diagnostic( sourcePath, @@ -95,7 +124,16 @@ export async function convertV8IfBranches(rawDirectory: string, sourceRoots: str continue; } - const converted = await convertScript(candidates[0]); + const candidate = candidates[0]; + if (identity !== undefined && candidate.scriptPath === identity.generatedModule) { + const identityDiagnostic = await verifyGeneratedIdentity(candidate, identity); + if (identityDiagnostic !== undefined) { + diagnostics.push(identityDiagnostic); + continue; + } + } + + const converted = await convertScript(candidate); files.push({ path: sourcePath, branches: converted.branches.map((branch, branchId) => ({ ...branch, branchId, @@ -103,38 +141,169 @@ export async function convertV8IfBranches(rawDirectory: string, sourceRoots: str diagnostics.push(...converted.diagnostics); } + if (identity !== undefined && !scripts.has(identity.originalPath)) { + diagnostics.push(diagnostic(identity.originalPath, 'unknown', + 'Generated V8 script has no unambiguous source-map link to the requested TypeScript source')); + } + return { files, diagnostics }; } -async function collectScripts(reports: V8Report[], roots: string[]): Promise> { +async function collectScripts( + reports: V8Report[], + roots: string[], + lexicalRoots: string[], + generatedIdentity?: GeneratedModuleIdentity, +): Promise<{ + scripts: Map; + diagnostics: BranchDiagnostic[]; + generatedScriptCount: number; +}> { const scripts = new Map(); + const diagnostics: BranchDiagnostic[] = []; + let generatedScriptCount = 0; for (const report of reports) { for (const script of report.result ?? []) { if (!script.url.startsWith('file:')) continue; - const sourcePath = await realpath(fileURLToPath(script.url)).catch(() => undefined); - if (sourcePath === undefined || !sourcePath.endsWith('.ts')) continue; - if (!roots.some(root => sourcePath.startsWith(`${root}${path.sep}`))) continue; - + const scriptPath = await realpath(fileURLToPath(script.url)).catch(() => undefined); + if (scriptPath === undefined) continue; const cache = report['source-map-cache']?.[script.url]; - const candidates = scripts.get(sourcePath) ?? []; - candidates.push(cache === undefined ? undefined : { script, cache, path: sourcePath }); - scripts.set(sourcePath, candidates); + if (generatedIdentity !== undefined && scriptPath === generatedIdentity.generatedModule) { + generatedScriptCount += 1; + if (cache?.data === undefined) { + diagnostics.push(diagnostic(generatedIdentity.originalPath, 'unknown', + 'Generated V8 script has no usable source-map cache entry')); + continue; + } + } + + const sources = cache?.data?.sources ?? []; + const mappedSources = await Promise.all(sources.map(async sourceUrl => { + if (!sourceUrl.startsWith('file:')) return { sourceUrl, sourcePath: undefined }; + + let lexicalPath: string; + try { + lexicalPath = path.resolve(fileURLToPath(sourceUrl)); + } catch { + return { sourceUrl, sourcePath: undefined }; + } + if (!lexicalPath.endsWith('.ts') || + (!isUnderRoot(lexicalPath, lexicalRoots) && !isUnderRoot(lexicalPath, roots))) { + return { sourceUrl, sourcePath: undefined }; + } + + const sourcePath = await realpath(lexicalPath).catch(() => undefined); + return { sourceUrl, sourcePath }; + })); + const selected = mappedSources.filter(({ sourcePath }) => sourcePath !== undefined && + sourcePath.endsWith('.ts') && isUnderRoot(sourcePath, roots)); + + if (selected.length === 0 && scriptPath.endsWith('.ts') && isUnderRoot(scriptPath, roots)) { + selected.push({ sourcePath: scriptPath, sourceUrl: pathToFileURL(scriptPath).href }); + } + + if (generatedIdentity !== undefined && scriptPath === generatedIdentity.generatedModule && + (selected.length !== 1 || selected[0]?.sourcePath !== generatedIdentity.originalPath)) { + diagnostics.push(diagnostic(generatedIdentity.originalPath, 'ambiguous', + 'Generated V8 script does not map to exactly the requested TypeScript source')); + continue; + } + + for (const { sourcePath, sourceUrl } of selected) { + if (sourcePath === undefined) continue; + if (scriptPath !== sourcePath && scriptPath !== generatedIdentity?.generatedModule) { + diagnostics.push(diagnostic(sourcePath, 'unknown', + 'Generated V8 script requires a pinned module identity for exact branch conversion')); + continue; + } + if (generatedIdentity !== undefined && sourcePath === generatedIdentity.originalPath && + scriptPath !== generatedIdentity.generatedModule) { + diagnostics.push(diagnostic(sourcePath, 'ambiguous', + 'Requested TypeScript source also appears under another V8 script')); + continue; + } + + const candidates = scripts.get(sourcePath) ?? []; + candidates.push(cache?.data === undefined || cache.data === null + ? undefined + : { script, cache, path: sourcePath, sourceUrl, scriptPath }); + scripts.set(sourcePath, candidates); + } } } - return scripts; + if (generatedIdentity !== undefined && generatedScriptCount === 0) { + diagnostics.push(diagnostic(generatedIdentity.originalPath, 'unknown', + 'Generated module is absent from raw V8 coverage')); + } + if (generatedIdentity !== undefined && generatedScriptCount > 1) { + diagnostics.push(diagnostic(generatedIdentity.originalPath, 'ambiguous', + 'Generated module appears in several raw V8 scripts')); + } + + return { scripts, diagnostics, generatedScriptCount }; } +function isUnderRoot(file: string, roots: string[]): boolean { + return roots.some(root => { + const relative = path.relative(root, file); + return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) + && !path.isAbsolute(relative); + }); +} + +async function verifyGeneratedIdentity( + coverage: ScriptCoverage, + identity: GeneratedModuleIdentity, +): Promise { + const generated = await readFile(identity.generatedModule, 'utf8').catch(() => undefined); + const sourceMap = await readFile(`${identity.generatedModule}.map`, 'utf8').catch(() => undefined); + const source = await readFile(identity.originalPath, 'utf8').catch(() => undefined); + if (generated === undefined || sourceMap === undefined || source === undefined || + sha256(generated) !== identity.generatedSha256 || sha256(sourceMap) !== identity.sourceMapSha256 || + sha256(source) !== identity.originalSha256) { + return diagnostic(identity.originalPath, 'unsupported', 'Covered generated module or original source changed'); + } + + const lineLengths = generated.split('\n').map(line => line.endsWith('\r') ? line.length - 1 : line.length); + if (generated.endsWith('\n')) lineLengths.pop(); + if (JSON.stringify(lineLengths) !== JSON.stringify(coverage.cache.lineLengths)) { + return diagnostic(identity.originalPath, 'ambiguous', 'V8 generated script lengths differ from the pinned module'); + } + + let parsedMap: { mappings?: string }; + try { + parsedMap = JSON.parse(sourceMap) as { mappings?: string }; + } catch { + return diagnostic(identity.originalPath, 'unsupported', 'Pinned generated source map is invalid'); + } + if (parsedMap.mappings !== coverage.cache.data.mappings) { + return diagnostic(identity.originalPath, 'ambiguous', 'V8 source-map cache differs from the pinned map'); + } + + return undefined; +} + +const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex'); + async function convertScript(coverage: ScriptCoverage): Promise<{ branches: Branch[]; diagnostics: BranchDiagnostic[]; }> { const source = await readFile(coverage.path, 'utf8'); - const sourceIndex = coverage.cache.data.sources.findIndex(candidate => - candidate.startsWith('file:') && fileURLToPath(candidate) === coverage.path, + const sourceIndexes = coverage.cache.data.sources.flatMap((candidate, index) => + candidate === coverage.sourceUrl ? [index] : [], ); + if (sourceIndexes.length !== 1) { + return { + branches: [], + diagnostics: [diagnostic(coverage.path, 'ambiguous', 'V8 source map does not identify one TypeScript source')], + }; + } + + const sourceIndex = sourceIndexes[0] as number; const coveredSource = coverage.cache.data.sourcesContent?.[sourceIndex]; if (coveredSource !== source) { return { @@ -226,10 +395,7 @@ function countAt( map: TraceMap, ): number | undefined { const sourcePosition = ast.getLineAndCharacterOfPosition(sourceOffset); - const sourceUrl = coverage.cache.data.sources.find(candidate => - candidate.startsWith('file:') && fileURLToPath(candidate) === coverage.path, - ); - if (sourceUrl === undefined) return undefined; + const sourceUrl = coverage.sourceUrl; const generated = generatedPositionFor(map, { source: sourceUrl, @@ -329,8 +495,15 @@ function diagnostic(sourcePath: string, kind: string, message: string): BranchDi } if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) { - const [rawDirectory, ...sourceRoots] = process.argv.slice(2); + const [rawDirectory, ...argumentsAfterRaw] = process.argv.slice(2); if (rawDirectory === undefined) throw new Error('Raw V8 coverage directory is required'); + const identityArguments = argumentsAfterRaw.filter(argument => argument.startsWith('--generated-identity=')); + if (identityArguments.length > 1) throw new Error('Only one generated identity is supported'); + + const identityArgument = identityArguments[0]; + const generatedIdentity = identityArgument === undefined ? undefined + : JSON.parse(identityArgument.slice('--generated-identity='.length)) as GeneratedModuleIdentity; + const sourceRoots = argumentsAfterRaw.filter(argument => !argument.startsWith('--generated-identity=')); - process.stdout.write(`${JSON.stringify(await convertV8IfBranches(rawDirectory, sourceRoots))}\n`); + process.stdout.write(`${JSON.stringify(await convertV8IfBranches(rawDirectory, sourceRoots, generatedIdentity))}\n`); } diff --git a/usvm-ts-fast-check/fast-check-adapter/test/coverage-branches.test.ts b/usvm-ts-fast-check/fast-check-adapter/test/coverage-branches.test.ts index 140b2b8a65..a28bf963a2 100644 --- a/usvm-ts-fast-check/fast-check-adapter/test/coverage-branches.test.ts +++ b/usvm-ts-fast-check/fast-check-adapter/test/coverage-branches.test.ts @@ -1,11 +1,13 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { cp, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { cp, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; -import { convertV8IfBranches } from '../src/coverage-branches.js'; +import ts from 'typescript'; +import { convertV8IfBranches, type GeneratedModuleIdentity } from '../src/coverage-branches.js'; const adapterRoot = path.resolve(fileURLToPath(new URL('../', import.meta.url)), '..'); const c8 = path.join(adapterRoot, 'node_modules/c8/bin/c8.js'); @@ -37,6 +39,118 @@ async function runCoverage(source: string): Promise<{ return { root, raw, sourcePath }; } +async function runGeneratedCoverage(source: string): Promise<{ + root: string; + raw: string; + identity: GeneratedModuleIdentity; +}> { + const root = await mkdtemp(path.join(os.tmpdir(), 'usvm-generated-branch-')); + const sourceRoot = path.join(root, 'original'); + const generatedRoot = path.join(root, 'generated'); + await Promise.all([mkdir(sourceRoot), mkdir(generatedRoot)]); + const originalPath = path.join(sourceRoot, 'subject.ts'); + const generatedModule = path.join(generatedRoot, 'subject.mjs'); + const raw = path.join(root, 'raw'); + await writeFile(originalPath, source); + + const output = ts.transpileModule(source, { + fileName: originalPath, + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext, + sourceMap: true, inlineSources: true }, + }); + const map = JSON.parse(output.sourceMapText ?? '{}') as Record; + map.file = path.basename(generatedModule); + map.sourceRoot = ''; + map.sources = [originalPath]; + const sourceMap = JSON.stringify(map); + const generatedSource = output.outputText.replace(/\/\/# sourceMappingURL=.*$/m, + `//# sourceMappingURL=${path.basename(generatedModule)}.map`); + await writeFile(generatedModule, generatedSource); + await writeFile(`${generatedModule}.map`, sourceMap); + + const result = spawnSync(process.execPath, [ + c8, '--reporter=json', `--reports-dir=${path.join(root, 'report')}`, + `--temp-directory=${raw}`, '--exclude-after-remap', '--allowExternal', + '--exclude=__usvm_no_default_excludes__', process.execPath, generatedModule, + ], { cwd: adapterRoot, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + + const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex'); + return { root, raw, identity: { + originalPath: await realpath(originalPath), originalSha256: sha256(source), + generatedModule: await realpath(generatedModule), + generatedSha256: sha256(generatedSource), sourceMapSha256: sha256(sourceMap), + } }; +} + +test('generated JavaScript maps only original ordered if arms across true, false, and mixed runs', async () => { + for (const [calls, hits] of [ + ['subject(1);', [1, 0]], + ['subject(-1);', [0, 1]], + ['subject(1); subject(-1);', [1, 1]], + ] as const) { + const fixture = await runGeneratedCoverage(` +export function subject(value: number): boolean { + if (value > 0) { return true; } + else { return false; } +} +${calls} +`); + + try { + const report = await convertV8IfBranches(fixture.raw, [path.dirname(fixture.identity.originalPath)], + fixture.identity); + + assert.deepEqual(report.files.map(file => file.path), [fixture.identity.originalPath]); + assert.deepEqual(report.files[0]?.branches.map(branch => branch.arms.map(arm => arm.hits)), [hits]); + assert.deepEqual(report.diagnostics, []); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + } +}); + +test('missing, duplicate, and changed generated maps cannot produce exact branch counts', async () => { + const fixture = await runGeneratedCoverage(` +export function subject(value: number): boolean { + if (value > 0) return true; + else return false; +} +subject(1); +`); + + try { + const sourceRoot = path.dirname(fixture.identity.originalPath); + const changed = await convertV8IfBranches(fixture.raw, [sourceRoot], { + ...fixture.identity, generatedSha256: '0'.repeat(64), + }); + assert.deepEqual(changed.files, []); + assert.equal(changed.diagnostics[0]?.code, 'coverage.branch.unsupported'); + + const reports = (await readdir(fixture.raw)).filter(name => name.endsWith('.json')); + const reportPath = path.join(fixture.raw, reports[0] as string); + const rawReport = JSON.parse(await readFile(reportPath, 'utf8')) as { + result?: { url: string }[]; + 'source-map-cache'?: Record; + }; + const generatedUrl = rawReport.result?.find(script => script.url.includes('/subject.mjs'))?.url; + assert.ok(generatedUrl); + + delete rawReport['source-map-cache']?.[generatedUrl]; + await writeFile(reportPath, JSON.stringify(rawReport)); + const missing = await convertV8IfBranches(fixture.raw, [sourceRoot], fixture.identity); + assert.deepEqual(missing.files, []); + assert.equal(missing.diagnostics.some(entry => entry.code === 'coverage.branch.unknown'), true); + + await cp(reportPath, path.join(fixture.raw, 'duplicate.json')); + const duplicate = await convertV8IfBranches(fixture.raw, [sourceRoot], fixture.identity); + assert.deepEqual(duplicate.files, []); + assert.equal(duplicate.diagnostics.some(entry => entry.code === 'coverage.branch.ambiguous'), true); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + test('real c8 and tsx preserve true, false, mixed, and nested TypeScript arms', async () => { for (const [calls, expected] of [ ['nested(20);', [[1, 0], [1, 0]]], From 7527736b6d336de55923afe8c22d01515c74c3b2 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Tue, 13 Oct 2026 03:45:48 +0300 Subject: [PATCH 4/8] Collect passive source-mapped coverage in the shared PBT adapter --- .../src/coverage-launcher.ts | 21 +++ .../fast-check-adapter/src/execution-cli.ts | 22 ++- .../src/execution-coverage-worker.ts | 62 ++++++++ .../src/execution-coverage.ts | 93 ++++++++++++ .../test/execution-cli.test.ts | 138 +++++++++++++++++- .../pbt/fastcheck/FastCheckCoverageSession.kt | 1 + 6 files changed, 325 insertions(+), 12 deletions(-) create mode 100644 usvm-ts-fast-check/fast-check-adapter/src/coverage-launcher.ts create mode 100644 usvm-ts-fast-check/fast-check-adapter/src/execution-coverage-worker.ts create mode 100644 usvm-ts-fast-check/fast-check-adapter/src/execution-coverage.ts diff --git a/usvm-ts-fast-check/fast-check-adapter/src/coverage-launcher.ts b/usvm-ts-fast-check/fast-check-adapter/src/coverage-launcher.ts new file mode 100644 index 0000000000..0aea0ed985 --- /dev/null +++ b/usvm-ts-fast-check/fast-check-adapter/src/coverage-launcher.ts @@ -0,0 +1,21 @@ +import { spawn } from 'node:child_process'; + +const entryPoint = process.argv[2]; +const { NODE_V8_COVERAGE: rawDirectory, ...environment } = process.env; +if (entryPoint === undefined || rawDirectory === undefined || rawDirectory.length === 0) { + throw new Error('Coverage launcher requires an adapter entry point and NODE_V8_COVERAGE directory'); +} + +// c8 enables Node's native exit collector in this launcher only. The actual +// source executes in a child whose report is written by our passive collector, +// so Node's shutdown serializer cannot invoke user-installed prototype hooks. +const child = spawn(process.execPath, ['--enable-source-maps', entryPoint], { + env: { ...environment, USVM_V8_COVERAGE_DIRECTORY: rawDirectory }, + stdio: 'inherit', +}); +const exitCode = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', code => resolve(code ?? 1)); +}); + +process.exit(exitCode); diff --git a/usvm-ts-fast-check/fast-check-adapter/src/execution-cli.ts b/usvm-ts-fast-check/fast-check-adapter/src/execution-cli.ts index e2fce65034..f9746a5bf3 100644 --- a/usvm-ts-fast-check/fast-check-adapter/src/execution-cli.ts +++ b/usvm-ts-fast-check/fast-check-adapter/src/execution-cli.ts @@ -1,4 +1,5 @@ import { adapterDiagnostic } from './diagnostics.js'; +import { startExecutionCoverage } from './execution-coverage.js'; import { stringifyProtocolJson } from './protocol-json.js'; import { executeProperty, type FastCheckExecutionSuccess } from './execute-property.js'; import { ProtocolError, protocolError } from './js-value.js'; @@ -12,18 +13,25 @@ interface FastCheckExecutionFailure { const writeProtocolOutput = process.stdout.write.bind(process.stdout) as typeof process.stdout.write; process.stdout.write = process.stderr.write.bind(process.stderr) as typeof process.stdout.write; -let response: FastCheckExecutionSuccess | FastCheckExecutionFailure; +const coverage = await startExecutionCoverage(); try { - const request = parseRequest(await readStdin()); + let response: FastCheckExecutionSuccess | FastCheckExecutionFailure; + try { + const request = parseRequest(await readStdin()); + + response = await executeProperty(request); + } catch (error: unknown) { + if (!(error instanceof ProtocolError)) throw error; - response = await executeProperty(request); -} catch (error: unknown) { - if (!(error instanceof ProtocolError)) throw error; + response = protocolErrorResponse(error); + } - response = protocolErrorResponse(error); + await coverage?.write(); + await writeResponse(response); +} finally { + await coverage?.close(); } -await writeResponse(response); process.exit(0); async function readStdin(): Promise { diff --git a/usvm-ts-fast-check/fast-check-adapter/src/execution-coverage-worker.ts b/usvm-ts-fast-check/fast-check-adapter/src/execution-coverage-worker.ts new file mode 100644 index 0000000000..0f821d5e85 --- /dev/null +++ b/usvm-ts-fast-check/fast-check-adapter/src/execution-coverage-worker.ts @@ -0,0 +1,62 @@ +import { Session, type Debugger, type Profiler } from 'node:inspector'; +import { parentPort } from 'node:worker_threads'; + +export interface ExecutionCoverageSnapshot { + kind: 'coverage'; + result: Profiler.ScriptCoverage[]; + scripts: Record; +} + +const port = parentPort; +if (port === null) throw new Error('Coverage collector requires a worker'); + +const session = new Session(); +const parsedScripts = new Map(); +session.connectToMainThread(); +session.on('Debugger.scriptParsed', ({ params }) => parsedScripts.set(params.scriptId, params)); + +// Keep the port referenced while inspector replies are pending. A one-shot +// listener would let this worker exit before an asynchronous snapshot finishes. +port.on('message', () => { + void takeSnapshot().then(snapshot => port.postMessage(snapshot)).catch(error => { + throw error; + }); +}); + +await post('Profiler.enable'); +await post('Debugger.enable'); +await post('Profiler.startPreciseCoverage', { callCount: true, detailed: true }); + +port.postMessage('ready'); + +async function takeSnapshot(): Promise { + const coverage = await post('Profiler.takePreciseCoverage'); + const scripts: ExecutionCoverageSnapshot['scripts'] = Object.create(null); + for (const script of coverage.result) { + if (!script.url.startsWith('file:')) continue; + + const { scriptSource } = await post( + 'Debugger.getScriptSource', { scriptId: script.scriptId }, + ); + const sourceMapUrl = parsedScripts.get(script.scriptId)?.sourceMapURL; + scripts[script.url] = { + // V8 offsets are UTF-16 offsets. Keep CR in a CRLF line, like Node's cache. + lineLengths: scriptSource.split(/[\n\u2028\u2029]/).map(line => line.length), + ...(sourceMapUrl === undefined || sourceMapUrl.length === 0 ? {} : { sourceMapUrl }), + }; + } + + await post('Profiler.stopPreciseCoverage'); + session.disconnect(); + + return { kind: 'coverage', result: coverage.result, scripts }; +} + +function post(method: string, params: object = {}): Promise { + return new Promise((resolve, reject) => { + session.post(method, params, (error, result) => { + if (error !== null) reject(error); + else resolve(result as T); + }); + }); +} diff --git a/usvm-ts-fast-check/fast-check-adapter/src/execution-coverage.ts b/usvm-ts-fast-check/fast-check-adapter/src/execution-coverage.ts new file mode 100644 index 0000000000..1e914d65e3 --- /dev/null +++ b/usvm-ts-fast-check/fast-check-adapter/src/execution-coverage.ts @@ -0,0 +1,93 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { findSourceMap } from 'node:module'; +import path from 'node:path'; +import { Worker } from 'node:worker_threads'; +import type { ExecutionCoverageSnapshot } from './execution-coverage-worker.js'; +import { stringifyProtocolJson } from './protocol-json.js'; + +export interface ExecutionCoverage { + write(): Promise; + close(): Promise; +} + +/** Collect coverage without invoking serializers installed by the source under test. */ +export async function startExecutionCoverage(): Promise { + const rawDirectory = process.env.USVM_V8_COVERAGE_DIRECTORY; + if (rawDirectory === undefined || rawDirectory.length === 0) return undefined; + + // Inspector's protocol serializer also consults inherited toJSON, so its + // session lives in a worker that never executes the source under test. + const worker = new Worker(new URL('./execution-coverage-worker.js', import.meta.url), { + env: { ...process.env, NODE_V8_COVERAGE: '' }, + }); + let workerFailure: Error | undefined; + let workerExited = false; + worker.on('error', error => { workerFailure = error; }); + worker.on('exit', () => { workerExited = true; }); + + function receive(): Promise<'ready' | ExecutionCoverageSnapshot> { + if (workerFailure !== undefined) return Promise.reject(workerFailure); + if (workerExited) return Promise.reject(new Error('Coverage collector exited before producing a report')); + + return new Promise((resolve, reject) => { + const cleanUp = (): void => { + worker.off('message', onMessage); + worker.off('error', onError); + worker.off('exit', onExit); + }; + const onMessage = (message: 'ready' | ExecutionCoverageSnapshot): void => { + cleanUp(); + resolve(message); + }; + const onError = (error: Error): void => { + cleanUp(); + reject(error); + }; + const onExit = (): void => onError(new Error('Coverage collector exited before producing a report')); + worker.once('message', onMessage); + worker.once('error', onError); + worker.once('exit', onExit); + }); + } + + try { + const ready = await receive(); + if (ready !== 'ready') throw new Error('Unexpected coverage collector startup response'); + } catch (error) { + await worker.terminate(); + throw error; + } + + // Native coverage is configured when a Node isolate starts. Restoring only + // the environment here enables user-created workers and subprocesses without + // enabling an exit collector in this already-running main isolate. + process.env.NODE_V8_COVERAGE = rawDirectory; + + return { + async write(): Promise { + const pending = receive(); + worker.postMessage('take'); + const snapshot = await pending; + if (snapshot === 'ready') throw new Error('Coverage collector did not produce a snapshot'); + + const sourceMapCache: Record = Object.create(null); + for (const [url, script] of Object.entries(snapshot.scripts)) { + const sourceMap = findSourceMap(url); + if (sourceMap !== undefined || script.sourceMapUrl !== undefined) { + sourceMapCache[url] = { + lineLengths: script.lineLengths, + data: sourceMap?.payload ?? null, + url: sourceMap === undefined ? script.sourceMapUrl : null, + }; + } + } + + const report = { result: snapshot.result, 'source-map-cache': sourceMapCache }; + mkdirSync(rawDirectory, { recursive: true }); + writeFileSync(path.join(rawDirectory, `coverage-${process.pid}-usvm.json`), stringifyProtocolJson(report)); + }, + async close(): Promise { + await worker.terminate(); + }, + }; +} diff --git a/usvm-ts-fast-check/fast-check-adapter/test/execution-cli.test.ts b/usvm-ts-fast-check/fast-check-adapter/test/execution-cli.test.ts index f8c5339820..e45dfc49ba 100644 --- a/usvm-ts-fast-check/fast-check-adapter/test/execution-cli.test.ts +++ b/usvm-ts-fast-check/fast-check-adapter/test/execution-cli.test.ts @@ -1,14 +1,16 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; -import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import type { Readable } from 'node:stream'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import test from 'node:test'; import type { ProtocolDiagnostic } from '../src/js-value.js'; +import { convertV8IfBranches } from '../src/coverage-branches.js'; const cliPath = fileURLToPath(new URL('../src/execution-cli.js', import.meta.url)); +const coverageLauncher = fileURLToPath(new URL('../src/coverage-launcher.js', import.meta.url)); test('execution CLI emits one successful response document', async () => { const sourceRoot = await realpath(await mkdtemp(path.join(tmpdir(), 'usvm-execution-cli-'))); @@ -61,6 +63,123 @@ export function predicate(value: boolean) { } }); +for (const installSerializer of [false, true]) { + test(`execution coverage preserves imported source maps with prototype serializers=${installSerializer}`, async () => { + const sourceRoot = await realpath(await mkdtemp(path.join(tmpdir(), 'usvm-execution-coverage-'))); + const raw = path.join(sourceRoot, 'raw'); + const helperPath = path.join(sourceRoot, 'helper.ts'); + const propertyPath = path.join(sourceRoot, 'property.ts'); + try { + await writeFile(helperPath, ` +export function helper(value: boolean): boolean { + ${installSerializer ? ` + for (const prototype of [Object.prototype, Array.prototype]) { + Object.defineProperty(prototype, 'toJSON', { + get() { throw new Error('Inherited serializer getter must not run'); }, + }); + }` : ''} + if (value) { return true; } + else { return false; } +} +`); + await writeFile(propertyPath, ` +import { helper } from './helper'; +export function predicate(value: boolean): boolean { return helper(value); } +`); + + const invocation = await invokeCli(JSON.stringify(executionRequest(sourceRoot)), { + coverageDirectory: raw, + timeoutMillis: 10_000, + }); + assert.equal(invocation.timedOut, false); + assert.equal(invocation.exitCode, 0, invocation.stderr); + assert.equal(invocation.stderr, ''); + + const response = JSON.parse(invocation.stdout) as Record; + const reports = await Promise.all((await readdir(raw)).filter(name => name.endsWith('.json')) + .map(async name => JSON.parse(await readFile(path.join(raw, name), 'utf8')) as { + result: { url: string; functions: { functionName: string; ranges: { count: number }[] }[] }[]; + 'source-map-cache'?: Record; + })); + + assert.equal(response.status, 'ok'); + for (const [sourcePath, functionName] of [[propertyPath, 'predicate'], [helperPath, 'helper']] as const) { + const url = pathToFileURL(sourcePath).href; + const scripts = reports.flatMap(report => report.result.filter(script => script.url === url)); + assert.equal(scripts.length, 1, `Expected exactly one coverage script for ${url}`); + const invokedFunction = scripts[0]?.functions.find(fn => fn.functionName === functionName); + assert.equal(invokedFunction?.ranges[0]?.count, 1); + + const maps = reports.flatMap(report => report['source-map-cache']?.[url] ?? []); + assert.equal(maps.length, 1, `Expected exactly one source map for ${url}`); + assert.deepEqual(maps[0]?.data.sources, [url]); + assert.ok((maps[0]?.lineLengths.length ?? 0) > 0); + } + + const branches = await convertV8IfBranches(raw, [sourceRoot]); + const helper = branches.files.find(file => file.path === helperPath); + assert.deepEqual(helper?.branches.map(branch => branch.arms.map(arm => arm.hits)), [[1, 0]]); + assert.deepEqual(branches.diagnostics, []); + } finally { + await rm(sourceRoot, { recursive: true, force: true }); + } + }); +} + +test('execution coverage retains user worker and subprocess counters without duplicating main', async () => { + const sourceRoot = await realpath(await mkdtemp(path.join(tmpdir(), 'usvm-descendant-coverage-'))); + const raw = path.join(sourceRoot, 'raw'); + const descendantPath = path.join(sourceRoot, 'descendant.cjs'); + const propertyPath = path.join(sourceRoot, 'property.ts'); + try { + await writeFile(descendantPath, 'function descendant() { return 1; } descendant();\n'); + await writeFile(propertyPath, ` +import { Worker } from 'node:worker_threads'; +import { spawn } from 'node:child_process'; +export async function predicate(value: boolean): Promise { + await new Promise((resolve, reject) => { + const worker = new Worker(${JSON.stringify(descendantPath)}); + worker.once('error', reject); + worker.once('exit', code => code === 0 ? resolve() : reject(new Error('worker failed'))); + }); + await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [${JSON.stringify(descendantPath)}], { stdio: 'ignore' }); + child.once('error', reject); + child.once('close', code => code === 0 ? resolve() : reject(new Error('subprocess failed'))); + }); + return value; +} +`); + const request = executionRequest(sourceRoot); + const manifest = request.manifest as { predicate: { executionKind: string } }; + manifest.predicate.executionKind = 'async'; + request.timeoutMillis = 5_000; + + const invocation = await invokeCli(JSON.stringify(request), { + coverageDirectory: raw, + timeoutMillis: 10_000, + }); + + assert.equal(invocation.timedOut, false); + assert.equal(invocation.exitCode, 0, invocation.stderr); + assert.equal(invocation.stderr, ''); + assert.equal((JSON.parse(invocation.stdout) as { status: string }).status, 'ok'); + const reports = await Promise.all((await readdir(raw)).filter(name => name.endsWith('.json')) + .map(async name => JSON.parse(await readFile(path.join(raw, name), 'utf8')) as { + result: { url: string; functions: { functionName: string; ranges: { count: number }[] }[] }[]; + })); + const descendantUrl = pathToFileURL(descendantPath).href; + const descendants = reports.flatMap(report => report.result.filter(script => script.url === descendantUrl)); + assert.equal(descendants.length, 2); + assert.deepEqual(descendants.map(script => script.functions.find(fn => fn.functionName === 'descendant') + ?.ranges[0]?.count), [1, 1]); + const propertyUrl = pathToFileURL(propertyPath).href; + assert.equal(reports.flatMap(report => report.result.filter(script => script.url === propertyUrl)).length, 1); + } finally { + await rm(sourceRoot, { recursive: true, force: true }); + } +}); + test('execution CLI keeps user logging outside the protocol response', async () => { const sourceRoot = await realpath(await mkdtemp(path.join(tmpdir(), 'usvm-execution-cli-'))); try { @@ -137,14 +256,23 @@ interface CliInvocation { timedOut: boolean; } -async function invokeCli(input: string, timeoutMillis = 3_000): Promise { - const child = spawn(process.execPath, [cliPath], { stdio: ['pipe', 'pipe', 'pipe'] }); +async function invokeCli(input: string, options: { + timeoutMillis?: number; + coverageDirectory?: string; +} = {}): Promise { + const args = options.coverageDirectory === undefined ? [cliPath] : [coverageLauncher, cliPath]; + const environment = options.coverageDirectory === undefined ? process.env + : { ...process.env, NODE_V8_COVERAGE: options.coverageDirectory }; + const child = spawn(process.execPath, args, { + stdio: ['pipe', 'pipe', 'pipe'], + env: environment, + }); let timedOut = false; const timeout = setTimeout(() => { timedOut = true; child.kill(); - }, timeoutMillis); + }, options.timeoutMillis ?? 3_000); child.stdin.end(input); diff --git a/usvm-ts-fast-check/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 index fcf3b53dea..542fc050fd 100644 --- a/usvm-ts-fast-check/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 @@ -41,6 +41,7 @@ internal class FastCheckCoverageSession private constructor( add("--exclude-node-modules=false") } add(nodeExecutable) + add(workspace.adapterRoot.resolve("dist/src/coverage-launcher.js").toString()) add(adapterEntryPoint.toString()) } From e1a127a7d4541440dd5e84ba3694fd85e09e829e Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Tue, 13 Oct 2026 03:49:23 +0300 Subject: [PATCH 5/8] Clear inherited coverage environment before main shutdown --- .../fast-check-adapter/src/execution-coverage.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/usvm-ts-fast-check/fast-check-adapter/src/execution-coverage.ts b/usvm-ts-fast-check/fast-check-adapter/src/execution-coverage.ts index 1e914d65e3..3931dd187b 100644 --- a/usvm-ts-fast-check/fast-check-adapter/src/execution-coverage.ts +++ b/usvm-ts-fast-check/fast-check-adapter/src/execution-coverage.ts @@ -65,6 +65,10 @@ export async function startExecutionCoverage(): Promise { + // Descendants already hold their inherited environment. Clear the main + // process before coverage is drained or any shutdown hook can serialize it. + delete process.env.NODE_V8_COVERAGE; + const pending = receive(); worker.postMessage('take'); const snapshot = await pending; @@ -87,6 +91,7 @@ export async function startExecutionCoverage(): Promise { + delete process.env.NODE_V8_COVERAGE; await worker.terminate(); }, }; From da52be61c9c87fad0f25ace5d77384bba753eee7 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Tue, 13 Oct 2026 04:11:25 +0300 Subject: [PATCH 6/8] Explicitly disable inherited native coverage in execution process --- .../fast-check-adapter/src/coverage-launcher.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/usvm-ts-fast-check/fast-check-adapter/src/coverage-launcher.ts b/usvm-ts-fast-check/fast-check-adapter/src/coverage-launcher.ts index 0aea0ed985..30b968facc 100644 --- a/usvm-ts-fast-check/fast-check-adapter/src/coverage-launcher.ts +++ b/usvm-ts-fast-check/fast-check-adapter/src/coverage-launcher.ts @@ -10,7 +10,9 @@ if (entryPoint === undefined || rawDirectory === undefined || rawDirectory.lengt // source executes in a child whose report is written by our passive collector, // so Node's shutdown serializer cannot invoke user-installed prototype hooks. const child = spawn(process.execPath, ['--enable-source-maps', entryPoint], { - env: { ...environment, USVM_V8_COVERAGE_DIRECTORY: rawDirectory }, + // Node forwards NODE_V8_COVERAGE even when an explicit env omits it. + // An empty value disables that special inheritance for this main isolate. + env: { ...environment, NODE_V8_COVERAGE: '', USVM_V8_COVERAGE_DIRECTORY: rawDirectory }, stdio: 'inherit', }); const exitCode = await new Promise((resolve, reject) => { From 2f906e2b2c8ee142b19cadb28256bfb6119435d5 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Tue, 13 Oct 2026 04:23:02 +0300 Subject: [PATCH 7/8] Verify generated source lengths using native V8 line boundaries --- .../src/coverage-branches.ts | 5 +- .../test/coverage-branches.test.ts | 57 +++++++++++++++++-- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/usvm-ts-fast-check/fast-check-adapter/src/coverage-branches.ts b/usvm-ts-fast-check/fast-check-adapter/src/coverage-branches.ts index 4aa78be072..67d26044c2 100644 --- a/usvm-ts-fast-check/fast-check-adapter/src/coverage-branches.ts +++ b/usvm-ts-fast-check/fast-check-adapter/src/coverage-branches.ts @@ -267,8 +267,9 @@ async function verifyGeneratedIdentity( return diagnostic(identity.originalPath, 'unsupported', 'Covered generated module or original source changed'); } - const lineLengths = generated.split('\n').map(line => line.endsWith('\r') ? line.length - 1 : line.length); - if (generated.endsWith('\n')) lineLengths.pop(); + // Match Node's source-map cache and V8's UTF-16 offsets, including CR and + // the final empty line after a line terminator. + const lineLengths = generated.split(/[\n\u2028\u2029]/).map(line => line.length); if (JSON.stringify(lineLengths) !== JSON.stringify(coverage.cache.lineLengths)) { return diagnostic(identity.originalPath, 'ambiguous', 'V8 generated script lengths differ from the pinned module'); } diff --git a/usvm-ts-fast-check/fast-check-adapter/test/coverage-branches.test.ts b/usvm-ts-fast-check/fast-check-adapter/test/coverage-branches.test.ts index a28bf963a2..612c2e6441 100644 --- a/usvm-ts-fast-check/fast-check-adapter/test/coverage-branches.test.ts +++ b/usvm-ts-fast-check/fast-check-adapter/test/coverage-branches.test.ts @@ -4,7 +4,7 @@ import { createHash } from 'node:crypto'; import { cp, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import test from 'node:test'; import ts from 'typescript'; import { convertV8IfBranches, type GeneratedModuleIdentity } from '../src/coverage-branches.js'; @@ -39,7 +39,10 @@ async function runCoverage(source: string): Promise<{ return { root, raw, sourcePath }; } -async function runGeneratedCoverage(source: string): Promise<{ +async function runGeneratedCoverage(source: string, options: { + newLine?: ts.NewLineKind; + trailingLineBreak?: boolean; +} = {}): Promise<{ root: string; raw: string; identity: GeneratedModuleIdentity; @@ -56,15 +59,17 @@ async function runGeneratedCoverage(source: string): Promise<{ const output = ts.transpileModule(source, { fileName: originalPath, compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext, - sourceMap: true, inlineSources: true }, + sourceMap: true, inlineSources: true, newLine: options.newLine ?? ts.NewLineKind.LineFeed }, }); const map = JSON.parse(output.sourceMapText ?? '{}') as Record; map.file = path.basename(generatedModule); map.sourceRoot = ''; map.sources = [originalPath]; const sourceMap = JSON.stringify(map); + const newLine = options.newLine === ts.NewLineKind.CarriageReturnLineFeed ? '\r\n' : '\n'; + const trailingLineBreak = options.trailingLineBreak === true ? newLine : ''; const generatedSource = output.outputText.replace(/\/\/# sourceMappingURL=.*$/m, - `//# sourceMappingURL=${path.basename(generatedModule)}.map`); + `//# sourceMappingURL=${path.basename(generatedModule)}.map`) + trailingLineBreak; await writeFile(generatedModule, generatedSource); await writeFile(`${generatedModule}.map`, sourceMap); @@ -151,6 +156,50 @@ subject(1); } }); +test('generated identity preserves CRLF and trailing empty line lengths', async () => { + for (const newLine of [ts.NewLineKind.LineFeed, ts.NewLineKind.CarriageReturnLineFeed]) { + const fixture = await runGeneratedCoverage(` +export function subject(value: number): boolean { + if (value > 0) { return true; } + else { return false; } +} +subject(1); +`, { newLine, trailingLineBreak: true }); + + try { + const sourceRoot = path.dirname(fixture.identity.originalPath); + const report = await convertV8IfBranches(fixture.raw, [sourceRoot], fixture.identity); + + assert.deepEqual(report.files.map(file => file.path), [fixture.identity.originalPath]); + assert.deepEqual(report.files[0]?.branches.map(branch => branch.arms.map(arm => arm.hits)), [[1, 0]]); + assert.deepEqual(report.diagnostics, []); + + const names = (await readdir(fixture.raw)).filter(name => name.endsWith('.json')); + const reports = await Promise.all(names.map(async name => ({ + path: path.join(fixture.raw, name), + data: JSON.parse(await readFile(path.join(fixture.raw, name), 'utf8')) as { + 'source-map-cache'?: Record; + }, + }))); + const generatedUrl = pathToFileURL(fixture.identity.generatedModule).href; + const raw = reports.find(candidate => candidate.data['source-map-cache']?.[generatedUrl] !== undefined); + const lengths = raw?.data['source-map-cache']?.[generatedUrl]?.lineLengths; + assert.ok(raw); + assert.ok(lengths); + assert.equal(lengths.at(-1), 0); + + lengths[lengths.length - 1] = 1; + await writeFile(raw.path, JSON.stringify(raw.data)); + const changed = await convertV8IfBranches(fixture.raw, [sourceRoot], fixture.identity); + + assert.deepEqual(changed.files, []); + assert.equal(changed.diagnostics.some(entry => entry.code === 'coverage.branch.ambiguous'), true); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + } +}); + test('real c8 and tsx preserve true, false, mixed, and nested TypeScript arms', async () => { for (const [calls, expected] of [ ['nested(20);', [[1, 0], [1, 0]]], From 2b4bd0ff01d8b7855b8e80105d8990b14b618fac Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Tue, 13 Oct 2026 04:38:49 +0300 Subject: [PATCH 8/8] Preserve loader source content before collecting execution coverage --- .../src/coverage-launcher.ts | 4 ++- .../src/execution-coverage-bootstrap.ts | 15 ++++++++++ .../test/execution-cli.test.ts | 30 +++++++++++++++++-- 3 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 usvm-ts-fast-check/fast-check-adapter/src/execution-coverage-bootstrap.ts diff --git a/usvm-ts-fast-check/fast-check-adapter/src/coverage-launcher.ts b/usvm-ts-fast-check/fast-check-adapter/src/coverage-launcher.ts index 30b968facc..9c45b80ab7 100644 --- a/usvm-ts-fast-check/fast-check-adapter/src/coverage-launcher.ts +++ b/usvm-ts-fast-check/fast-check-adapter/src/coverage-launcher.ts @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; const entryPoint = process.argv[2]; const { NODE_V8_COVERAGE: rawDirectory, ...environment } = process.env; @@ -9,7 +10,8 @@ if (entryPoint === undefined || rawDirectory === undefined || rawDirectory.lengt // c8 enables Node's native exit collector in this launcher only. The actual // source executes in a child whose report is written by our passive collector, // so Node's shutdown serializer cannot invoke user-installed prototype hooks. -const child = spawn(process.execPath, ['--enable-source-maps', entryPoint], { +const bootstrap = fileURLToPath(new URL('./execution-coverage-bootstrap.js', import.meta.url)); +const child = spawn(process.execPath, ['--enable-source-maps', bootstrap, entryPoint], { // Node forwards NODE_V8_COVERAGE even when an explicit env omits it. // An empty value disables that special inheritance for this main isolate. env: { ...environment, NODE_V8_COVERAGE: '', USVM_V8_COVERAGE_DIRECTORY: rawDirectory }, diff --git a/usvm-ts-fast-check/fast-check-adapter/src/execution-coverage-bootstrap.ts b/usvm-ts-fast-check/fast-check-adapter/src/execution-coverage-bootstrap.ts new file mode 100644 index 0000000000..ff9330689e --- /dev/null +++ b/usvm-ts-fast-check/fast-check-adapter/src/execution-coverage-bootstrap.ts @@ -0,0 +1,15 @@ +import { pathToFileURL } from 'node:url'; + +const entryPoint = process.argv[2]; +const rawDirectory = process.env.USVM_V8_COVERAGE_DIRECTORY; +if (entryPoint === undefined || rawDirectory === undefined || rawDirectory.length === 0) { + throw new Error('Coverage bootstrap requires an adapter entry point and coverage directory'); +} + +// Native coverage setup has already finished with an empty environment value. +// Restore it before tsx is imported: tsx decides whether to embed the original +// sources when its transform module is initialized, before the first import. +process.env.NODE_V8_COVERAGE = rawDirectory; +process.argv.splice(1, 1); + +await import(pathToFileURL(entryPoint).href); diff --git a/usvm-ts-fast-check/fast-check-adapter/test/execution-cli.test.ts b/usvm-ts-fast-check/fast-check-adapter/test/execution-cli.test.ts index e45dfc49ba..767df48fe5 100644 --- a/usvm-ts-fast-check/fast-check-adapter/test/execution-cli.test.ts +++ b/usvm-ts-fast-check/fast-check-adapter/test/execution-cli.test.ts @@ -98,8 +98,14 @@ export function predicate(value: boolean): boolean { return helper(value); } const response = JSON.parse(invocation.stdout) as Record; const reports = await Promise.all((await readdir(raw)).filter(name => name.endsWith('.json')) .map(async name => JSON.parse(await readFile(path.join(raw, name), 'utf8')) as { - result: { url: string; functions: { functionName: string; ranges: { count: number }[] }[] }[]; - 'source-map-cache'?: Record; + result: { url: string; functions: { + functionName: string; + ranges: { startOffset: number; endOffset: number; count: number }[]; + }[] }[]; + 'source-map-cache'?: Record; })); assert.equal(response.status, 'ok'); @@ -107,12 +113,23 @@ export function predicate(value: boolean): boolean { return helper(value); } const url = pathToFileURL(sourcePath).href; const scripts = reports.flatMap(report => report.result.filter(script => script.url === url)); assert.equal(scripts.length, 1, `Expected exactly one coverage script for ${url}`); - const invokedFunction = scripts[0]?.functions.find(fn => fn.functionName === functionName); + // tsx gives an export getter the same name as the function. Select the + // larger body range so the assertion checks the actual invocation. + const namedFunctions = scripts[0]?.functions.filter(fn => fn.functionName === functionName) ?? []; + const invokedFunction = namedFunctions.sort((left, right) => { + const leftRange = left.ranges[0]; + const rightRange = right.ranges[0]; + assert.ok(leftRange); + assert.ok(rightRange); + + return rightRange.endOffset - rightRange.startOffset - (leftRange.endOffset - leftRange.startOffset); + })[0]; assert.equal(invokedFunction?.ranges[0]?.count, 1); const maps = reports.flatMap(report => report['source-map-cache']?.[url] ?? []); assert.equal(maps.length, 1, `Expected exactly one source map for ${url}`); assert.deepEqual(maps[0]?.data.sources, [url]); + assert.deepEqual(maps[0]?.data.sourcesContent, [await readFile(sourcePath, 'utf8')]); assert.ok((maps[0]?.lineLengths.length ?? 0) > 0); } @@ -120,6 +137,13 @@ export function predicate(value: boolean): boolean { return helper(value); } const helper = branches.files.find(file => file.path === helperPath); assert.deepEqual(helper?.branches.map(branch => branch.arms.map(arm => arm.hits)), [[1, 0]]); assert.deepEqual(branches.diagnostics, []); + + await writeFile(helperPath, `${await readFile(helperPath, 'utf8')}\n// Changed after execution.\n`); + const changed = await convertV8IfBranches(raw, [sourceRoot]); + + assert.deepEqual(changed.files.find(file => file.path === helperPath)?.branches, []); + assert.equal(changed.diagnostics.some(entry => entry.path === helperPath && + entry.code === 'coverage.branch.unsupported'), true); } finally { await rm(sourceRoot, { recursive: true, force: true }); }