Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions src/merge-segments-across-entries.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// llvm-cov export emits one `data[]` entry per profiled binary. The static
// region table for a given source file is identical across entries, so a file
// touched by multiple binaries appears with the same segment coordinates but
// potentially different `count` and `hasCount` values. Aggregating segments by
// position lets `lineCoverageFromSegments` and `regionCoverageFromSegments`
// score the union of coverage across binaries without double-counting the
// denominator.
//
// llvm-cov segment tuple: [line, column, count, hasCount, isRegionEntry, isGapRegion]

const LINE = 0;
const COLUMN = 1;
const COUNT = 2;
const HAS_COUNT = 3;
const IS_REGION_ENTRY = 4;
const IS_GAP_REGION = 5;

/**
* @param {import("./line-coverage-from-segments.mjs").Segment} segment
* @returns {string}
*/
function segmentPositionKey(segment) {
return `${segment[LINE]}:${segment[COLUMN]}:${segment[IS_REGION_ENTRY]}:${segment[IS_GAP_REGION]}`;
}

/**
* @param {import("./line-coverage-from-segments.mjs").Segment} left
* @param {import("./line-coverage-from-segments.mjs").Segment} right
* @returns {number}
*/
function compareSegmentsByPosition(left, right) {
if (left[LINE] !== right[LINE]) {
return left[LINE] - right[LINE];
}

if (left[COLUMN] !== right[COLUMN]) {
return left[COLUMN] - right[COLUMN];
}

if (left[IS_REGION_ENTRY] !== right[IS_REGION_ENTRY]) {
return Number(right[IS_REGION_ENTRY]) - Number(left[IS_REGION_ENTRY]);
}

return Number(left[IS_GAP_REGION]) - Number(right[IS_GAP_REGION]);
}
Comment thread
mcharytoniuk marked this conversation as resolved.

/**
* @param {import("./line-coverage-from-segments.mjs").Segment[][]} segmentArrays
* @returns {import("./line-coverage-from-segments.mjs").Segment[]}
*/
export function mergeSegmentsAcrossEntries(segmentArrays) {
/** @type {Map<string, import("./line-coverage-from-segments.mjs").Segment>} */
const mergedByPosition = new Map();

for (const segments of segmentArrays) {
for (const segment of segments) {
const key = segmentPositionKey(segment);
const existing = mergedByPosition.get(key);

if (existing) {
existing[COUNT] = Math.max(existing[COUNT], segment[COUNT]);
existing[HAS_COUNT] = existing[HAS_COUNT] || segment[HAS_COUNT];
} else {
mergedByPosition.set(key, [
segment[LINE],
segment[COLUMN],
segment[COUNT],
segment[HAS_COUNT],
segment[IS_REGION_ENTRY],
segment[IS_GAP_REGION],
]);
}
}
}

return [...mergedByPosition.values()].sort(compareSegmentsByPosition);
}
103 changes: 70 additions & 33 deletions src/parse-llvm-cov-json.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ import { readFileSync } from "node:fs";

import { lineCoverageFromSegments } from "./line-coverage-from-segments.mjs";
import { mergeFunctionInstantiations } from "./merge-function-instantiations.mjs";
import { mergeSegmentsAcrossEntries } from "./merge-segments-across-entries.mjs";
import { regionCoverageFromSegments } from "./region-coverage-from-segments.mjs";
import { workspaceRelativeCrate } from "./workspace-relative-crate.mjs";

// llvm-cov's per-file `summary` is computed per instantiation, so it undercounts
// a crate that is compiled both as a unit-test binary and as a dependency. The
// `segments` array and the `functions` list, however, carry the merged counts
// across every instantiation, so coverage is derived from those instead.
// `segments` array and the `functions` list carry merged counts within a single
// `data[]` entry, but llvm-cov export emits one entry per profiled binary, so a
// file or function touched by multiple binaries appears in multiple entries.
// Coverage is therefore derived by aggregating segments and function entries
// across all data entries before computing per-crate stats.

/**
* @typedef {object} CrateStats
Expand Down Expand Up @@ -72,6 +76,46 @@ function crateStatsFor(crateStats, crateName) {
return fresh;
}

/**
* @param {LlvmCovJson} data
* @returns {Map<string, import("./line-coverage-from-segments.mjs").Segment[][]>}
*/
function collectSegmentsByFilename(data) {
/** @type {Map<string, import("./line-coverage-from-segments.mjs").Segment[][]>} */
const segmentsByFilename = new Map();

for (const dataEntry of data.data) {
for (const fileEntry of dataEntry.files) {
const existing = segmentsByFilename.get(fileEntry.filename);

if (existing) {
existing.push(fileEntry.segments);
} else {
segmentsByFilename.set(fileEntry.filename, [fileEntry.segments]);
}
}
}

return segmentsByFilename;
}

/**
* @param {LlvmCovJson} data
* @returns {import("./merge-function-instantiations.mjs").FunctionEntry[]}
*/
function collectFunctionEntries(data) {
/** @type {import("./merge-function-instantiations.mjs").FunctionEntry[]} */
const functionEntries = [];

for (const dataEntry of data.data) {
for (const functionEntry of dataEntry.functions) {
functionEntries.push(functionEntry);
}
}

return functionEntries;
}

/**
* @param {string} jsonPath
* @param {string} workspaceRoot
Expand All @@ -83,45 +127,38 @@ export function parseLlvmCovJson(jsonPath, workspaceRoot) {
/** @type {Map<string, CrateStats>} */
const crateStats = new Map();

for (const dataEntry of data.data) {
for (const fileEntry of dataEntry.files) {
const crateName = workspaceRelativeCrate(
fileEntry.filename,
workspaceRoot,
);
for (const [filename, segmentArrays] of collectSegmentsByFilename(data)) {
const crateName = workspaceRelativeCrate(filename, workspaceRoot);

if (crateName === null) {
continue;
}
if (crateName === null) {
continue;
}

const stats = crateStatsFor(crateStats, crateName);
const mergedSegments = mergeSegmentsAcrossEntries(segmentArrays);
const stats = crateStatsFor(crateStats, crateName);

addCoverage(
stats.regions,
regionCoverageFromSegments(fileEntry.segments),
);
addCoverage(stats.lines, lineCoverageFromSegments(fileEntry.segments));
}
addCoverage(stats.regions, regionCoverageFromSegments(mergedSegments));
addCoverage(stats.lines, lineCoverageFromSegments(mergedSegments));
}

for (const mergedFunction of mergeFunctionInstantiations(
dataEntry.functions,
)) {
const crateName = workspaceRelativeCrate(
mergedFunction.filename,
workspaceRoot,
);
for (const mergedFunction of mergeFunctionInstantiations(
collectFunctionEntries(data),
)) {
const crateName = workspaceRelativeCrate(
mergedFunction.filename,
workspaceRoot,
);

if (crateName === null) {
continue;
}
if (crateName === null) {
continue;
}

const stats = crateStatsFor(crateStats, crateName);
const stats = crateStatsFor(crateStats, crateName);

stats.functions.count += 1;
stats.functions.count += 1;

if (mergedFunction.covered) {
stats.functions.covered += 1;
}
if (mergedFunction.covered) {
stats.functions.covered += 1;
}
}

Expand Down
61 changes: 61 additions & 0 deletions tests/fixtures/multi-binary-llvm-cov.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{
"data": [
{
"files": [
{
"filename": "/workspace/shared_crate/src/lib.rs",
"segments": [
[10, 5, 3, true, true, false],
[12, 6, 0, false, false, false],
[20, 5, 0, true, true, false],
[22, 6, 0, false, false, false]
]
}
],
"functions": [
{
"name": "_RNvCsbinary0_shared_crate_foo",
"count": 3,
"filenames": ["/workspace/shared_crate/src/lib.rs"],
"regions": [[10, 5, 12, 6, 3, 0, 0, 0]]
},
{
"name": "_RNvCsbinary0_shared_crate_bar",
"count": 0,
"filenames": ["/workspace/shared_crate/src/lib.rs"],
"regions": [[20, 5, 22, 6, 0, 0, 0, 0]]
}
]
},
{
"files": [
{
"filename": "/workspace/shared_crate/src/lib.rs",
"segments": [
[10, 5, 0, true, true, false],
[12, 6, 0, false, false, false],
[20, 5, 0, true, true, false],
[22, 6, 0, false, false, false]
]
}
],
"functions": [
{
"name": "_RNvCsbinary1_shared_crate_foo",
"count": 0,
"filenames": ["/workspace/shared_crate/src/lib.rs"],
"regions": [[10, 5, 12, 6, 0, 0, 0, 0]]
},
{
"name": "_RNvCsbinary1_shared_crate_bar",
"count": 0,
"filenames": ["/workspace/shared_crate/src/lib.rs"],
"regions": [[20, 5, 22, 6, 0, 0, 0, 0]]
}
]
}
],
"type": "llvm.coverage.json.export",
"version": "3.0.1",
"cargo_llvm_cov": {}
}
84 changes: 84 additions & 0 deletions tests/merge-segments-across-entries.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { strict as assert } from "node:assert";
import { test } from "node:test";

import { mergeSegmentsAcrossEntries } from "../src/merge-segments-across-entries.mjs";

// llvm-cov segment tuple: [line, column, count, hasCount, isRegionEntry, isGapRegion]

test("keeps the highest count when the same position appears in multiple entries", () => {
const merged = mergeSegmentsAcrossEntries([
[[10, 5, 0, true, true, false]],
[[10, 5, 7, true, true, false]],
]);

assert.deepEqual(merged, [[10, 5, 7, true, true, false]]);
});

test("ORs hasCount across entries when one entry lacks a counted segment at the position", () => {
const merged = mergeSegmentsAcrossEntries([
[[10, 5, 0, false, true, false]],
[[10, 5, 0, true, true, false]],
]);

assert.deepEqual(merged, [[10, 5, 0, true, true, false]]);
});

test("preserves distinct positions and returns them sorted by line then column", () => {
const merged = mergeSegmentsAcrossEntries([
[
[20, 1, 0, true, true, false],
[10, 5, 0, true, true, false],
],
[[10, 9, 2, true, true, false]],
]);

assert.deepEqual(merged, [
[10, 5, 0, true, true, false],
[10, 9, 2, true, true, false],
[20, 1, 0, true, true, false],
]);
});

test("treats a gap-region segment as distinct from a non-gap segment at the same position", () => {
const merged = mergeSegmentsAcrossEntries([
[[10, 5, 0, true, true, false]],
[[10, 5, 0, true, true, true]],
]);

assert.deepEqual(merged, [
[10, 5, 0, true, true, false],
[10, 5, 0, true, true, true],
]);
});

test("returns an empty array when no entries are provided", () => {
assert.deepEqual(mergeSegmentsAcrossEntries([]), []);
});

test("orders region-entry segments before non-region segments at the same position so startsSkippedRegion sees the structural marker first", () => {
const skippedRegionMarker = [10, 5, 0, false, true, false];
const nonRegionSegment = [10, 5, 5, true, false, false];

const merged = mergeSegmentsAcrossEntries([
[nonRegionSegment],
[skippedRegionMarker],
]);

assert.deepEqual(merged, [skippedRegionMarker, nonRegionSegment]);
});

test("produces identical output regardless of the order of data[] entries with distinct-flag segments at the same line and column", () => {
const regionEntrySegment = [10, 5, 3, true, true, false];
const nonRegionSegment = [10, 5, 0, true, false, false];

const forward = mergeSegmentsAcrossEntries([
[regionEntrySegment],
[nonRegionSegment],
]);
const reverse = mergeSegmentsAcrossEntries([
[nonRegionSegment],
[regionEntrySegment],
]);

assert.deepEqual(forward, reverse);
});
23 changes: 23 additions & 0 deletions tests/parse-llvm-cov-json.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ const WINDOWS_FIXTURE_PATH = resolve(
"windows-llvm-cov.json",
);

const MULTI_BINARY_FIXTURE_PATH = resolve(
dirname(fileURLToPath(import.meta.url)),
"fixtures",
"multi-binary-llvm-cov.json",
);

const WORKSPACE_ROOT = "/workspace";

test("groups files by their first path component", () => {
Expand Down Expand Up @@ -59,3 +65,20 @@ test("groups files by crate for a Windows-style llvm-cov report", () => {
assert.ok(crateStats.has("spiffe_svid_manager"));
assert.ok(crateStats.has("spiffe_svid_manager_tests"));
});

test("merges coverage across data entries when the same source file is reported by multiple test binaries", () => {
// The multi-binary fixture reports `shared_crate/src/lib.rs` twice: binary 0
// executes `foo` (count > 0) while binary 1 only compiles it (count 0). Both
// binaries see `bar` as never executed. Without cross-entry merging, foo and
// bar would each be counted twice and the file's 2 regions / 6 lines would
// be doubled to 4 / 12.
const crateStats = parseLlvmCovJson(
MULTI_BINARY_FIXTURE_PATH,
WORKSPACE_ROOT,
);
const sharedCrate = crateStats.get("shared_crate");

assert.deepEqual(sharedCrate.functions, { count: 2, covered: 1 });
assert.deepEqual(sharedCrate.regions, { count: 2, covered: 1 });
assert.deepEqual(sharedCrate.lines, { count: 6, covered: 3 });
});