coverage-check 0.2.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -15
- package/dist/src/cli.mjs +6 -0
- package/dist/src/commands/check-args.d.mts +2 -0
- package/dist/src/commands/check-args.mjs +4 -0
- package/dist/src/commands/check.mjs +20 -2
- package/dist/src/commands/html.d.mts +14 -0
- package/dist/src/commands/html.mjs +164 -0
- package/dist/src/commands/store-put.d.mts +1 -0
- package/dist/src/commands/store-put.mjs +41 -7
- package/dist/src/commands/summary/args.d.mts +2 -0
- package/dist/src/commands/summary/args.mjs +60 -0
- package/dist/src/commands/summary/groups.d.mts +2 -0
- package/dist/src/commands/summary/groups.mjs +84 -0
- package/dist/src/commands/summary/index.d.mts +8 -0
- package/dist/src/commands/summary/index.mjs +118 -0
- package/dist/src/commands/summary/markdown.d.mts +5 -0
- package/dist/src/commands/summary/markdown.mjs +71 -0
- package/dist/src/commands/summary/types.d.mts +36 -0
- package/dist/src/commands/summary/types.mjs +1 -0
- package/dist/src/commands/summary.d.mts +2 -0
- package/dist/src/commands/summary.mjs +1 -0
- package/dist/src/coverage-check.d.mts +2 -1
- package/dist/src/coverage-check.mjs +1 -0
- package/dist/src/diff-parser-content.d.mts +11 -0
- package/dist/src/diff-parser-content.mjs +80 -0
- package/dist/src/diff-parser.d.mts +2 -0
- package/dist/src/diff-parser.mjs +23 -6
- package/dist/src/for-each-line.d.mts +7 -0
- package/dist/src/for-each-line.mjs +28 -0
- package/dist/src/lcov-merge.mjs +7 -3
- package/dist/src/lcov-parser.mjs +41 -18
- package/dist/src/lcov-to-istanbul.d.mts +30 -0
- package/dist/src/lcov-to-istanbul.mjs +155 -0
- package/dist/src/types.d.mts +2 -0
- package/package.json +3 -2
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseLcov } from "../../lcov-parser.mjs";
|
|
4
|
+
import { mergeLcov } from "../../lcov-merge.mjs";
|
|
5
|
+
import { collectLcovFiles, buildStripPrefixes } from "../../load-artifacts.mjs";
|
|
6
|
+
import { makeStore } from "../../store-factory.mjs";
|
|
7
|
+
import { parseCoverageSummaryArgs } from "./args.mjs";
|
|
8
|
+
import { groupSuitesBySourceFolder } from "./groups.mjs";
|
|
9
|
+
import { renderCoverageSummaryMarkdown, suiteTotals } from "./markdown.mjs";
|
|
10
|
+
export { groupSuitesBySourceFolder, parseCoverageSummaryArgs, renderCoverageSummaryMarkdown, suiteTotals, };
|
|
11
|
+
function suiteNameFromArtifactDir(name) {
|
|
12
|
+
if (!name.startsWith("coverage-"))
|
|
13
|
+
return null;
|
|
14
|
+
const suite = name.slice("coverage-".length);
|
|
15
|
+
return suite.length > 0 ? suite : null;
|
|
16
|
+
}
|
|
17
|
+
function loadSuiteCoverage(suite, files, stripPrefixes) {
|
|
18
|
+
const reports = files.map((file) => parseLcov(readFileSync(file, "utf8"), stripPrefixes));
|
|
19
|
+
return { suite, source: "current", lcov: mergeLcov(reports) };
|
|
20
|
+
}
|
|
21
|
+
function loadCurrentSuites(artifacts, stripPrefixes) {
|
|
22
|
+
if (!existsSync(artifacts))
|
|
23
|
+
return [];
|
|
24
|
+
return readdirSync(artifacts, { withFileTypes: true })
|
|
25
|
+
.filter((entry) => entry.isDirectory())
|
|
26
|
+
.map((entry) => {
|
|
27
|
+
const suite = suiteNameFromArtifactDir(entry.name);
|
|
28
|
+
if (!suite)
|
|
29
|
+
return null;
|
|
30
|
+
const files = collectLcovFiles(path.join(artifacts, entry.name));
|
|
31
|
+
return files.length === 0 ? null : loadSuiteCoverage(suite, files, stripPrefixes);
|
|
32
|
+
})
|
|
33
|
+
.filter((suite) => suite !== null)
|
|
34
|
+
.sort((a, b) => a.suite.localeCompare(b.suite));
|
|
35
|
+
}
|
|
36
|
+
async function loadHistoricalSuites(store, branch, activeSuiteNames, currentSuiteNames, stripPrefixes) {
|
|
37
|
+
if (store === null) {
|
|
38
|
+
return {
|
|
39
|
+
suites: [],
|
|
40
|
+
warnings: [
|
|
41
|
+
"Historical main coverage store was not configured; showing current-run coverage only.",
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (activeSuiteNames.size === 0) {
|
|
46
|
+
return {
|
|
47
|
+
suites: [],
|
|
48
|
+
warnings: [
|
|
49
|
+
"Historical main coverage store was configured without an active suite manifest; showing current-run coverage only.",
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const historicalSuites = [...activeSuiteNames].filter((suite) => !currentSuiteNames.has(suite));
|
|
55
|
+
const historical = await Promise.all(historicalSuites.map(async (suite) => {
|
|
56
|
+
const lcovBuffer = await store.get(suite, { branch });
|
|
57
|
+
if (lcovBuffer === null)
|
|
58
|
+
return { missingSuite: suite };
|
|
59
|
+
return {
|
|
60
|
+
suite,
|
|
61
|
+
source: "history",
|
|
62
|
+
branch,
|
|
63
|
+
lcov: parseLcov(lcovBuffer.toString("utf8"), stripPrefixes),
|
|
64
|
+
};
|
|
65
|
+
}));
|
|
66
|
+
const suites = historical.filter((suite) => "lcov" in suite && "suite" in suite);
|
|
67
|
+
const missingSuites = historical
|
|
68
|
+
.filter((suite) => "missingSuite" in suite)
|
|
69
|
+
.map((suite) => suite.missingSuite);
|
|
70
|
+
return {
|
|
71
|
+
suites,
|
|
72
|
+
warnings: missingSuites.length === 0
|
|
73
|
+
? []
|
|
74
|
+
: [
|
|
75
|
+
`Historical main coverage missing for active suites: ${missingSuites.sort((a, b) => a.localeCompare(b)).join(", ")}.`,
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
81
|
+
return { suites: [], warnings: [`Historical main coverage could not be read: ${message}`] };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function mergedTotals(suites) {
|
|
85
|
+
const lcov = mergeLcov(suites.map((suite) => suite.lcov));
|
|
86
|
+
return suiteTotals({ lcov });
|
|
87
|
+
}
|
|
88
|
+
export async function buildCoverageSummary(args) {
|
|
89
|
+
const stripPrefixes = buildStripPrefixes(args.stripPrefixes);
|
|
90
|
+
const currentSuites = loadCurrentSuites(args.artifacts, stripPrefixes);
|
|
91
|
+
const currentTotals = mergedTotals(currentSuites);
|
|
92
|
+
const historical = await loadHistoricalSuites(makeStore({ fs: args.storeFs, s3: args.storeS3 }), args.branch, new Set(args.activeSuites), new Set(currentSuites.map((suite) => suite.suite)), stripPrefixes);
|
|
93
|
+
const suites = [...currentSuites, ...historical.suites].sort((a, b) => a.suite.localeCompare(b.suite));
|
|
94
|
+
return {
|
|
95
|
+
currentTotals,
|
|
96
|
+
groups: groupSuitesBySourceFolder(suites, args.branch, args.rulesFile),
|
|
97
|
+
suites,
|
|
98
|
+
totals: mergedTotals(suites),
|
|
99
|
+
warnings: historical.warnings,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
export async function main(argv) {
|
|
103
|
+
let args;
|
|
104
|
+
try {
|
|
105
|
+
args = parseCoverageSummaryArgs(argv);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
process.stderr.write(`coverage-check summary: ${String(err)}\n`);
|
|
109
|
+
return 2;
|
|
110
|
+
}
|
|
111
|
+
const summary = await buildCoverageSummary(args);
|
|
112
|
+
const markdown = renderCoverageSummaryMarkdown(summary, args.branch, args.storeS3);
|
|
113
|
+
if (args.summaryFile)
|
|
114
|
+
appendFileSync(args.summaryFile, markdown, "utf8");
|
|
115
|
+
else
|
|
116
|
+
process.stdout.write(markdown);
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { CoverageSummary, CoverageTotals } from "./types.mts";
|
|
2
|
+
export declare function suiteTotals(suite: {
|
|
3
|
+
lcov: Map<string, Map<number, number>>;
|
|
4
|
+
}): CoverageTotals;
|
|
5
|
+
export declare function renderCoverageSummaryMarkdown(summary: CoverageSummary, branch: string, storeS3?: string | null): string;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export function suiteTotals(suite) {
|
|
2
|
+
let hit = 0;
|
|
3
|
+
let total = 0;
|
|
4
|
+
for (const lines of suite.lcov.values()) {
|
|
5
|
+
for (const count of lines.values()) {
|
|
6
|
+
total++;
|
|
7
|
+
if (count > 0)
|
|
8
|
+
hit++;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return { hit, total };
|
|
12
|
+
}
|
|
13
|
+
function pct(hit, total) {
|
|
14
|
+
if (total === 0)
|
|
15
|
+
return "--";
|
|
16
|
+
return `${((hit / total) * 100).toFixed(1)}% (${hit}/${total})`;
|
|
17
|
+
}
|
|
18
|
+
function escMd(value) {
|
|
19
|
+
return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
|
|
20
|
+
}
|
|
21
|
+
function codeSpan(value) {
|
|
22
|
+
const escaped = escMd(value);
|
|
23
|
+
const longestRun = Math.max(0, ...Array.from(escaped.matchAll(/`+/g), (match) => match[0].length));
|
|
24
|
+
if (longestRun === 0)
|
|
25
|
+
return `\`${escaped}\``;
|
|
26
|
+
const ticks = "`".repeat(longestRun + 1);
|
|
27
|
+
return `${ticks} ${escaped} ${ticks}`;
|
|
28
|
+
}
|
|
29
|
+
function groupRow(group, branch) {
|
|
30
|
+
const source = group.source === "current"
|
|
31
|
+
? "current run"
|
|
32
|
+
: group.source === "history"
|
|
33
|
+
? `history (${escMd(group.branchesLabel ?? branch)})`
|
|
34
|
+
: `current run + history (${escMd(group.branchesLabel ?? branch)})`;
|
|
35
|
+
const totals = suiteTotals(group);
|
|
36
|
+
return `| ${codeSpan(group.folder)} | ${source} | ${pct(totals.hit, totals.total)} |`;
|
|
37
|
+
}
|
|
38
|
+
function s3ConsoleUrl(spec) {
|
|
39
|
+
const slash = spec.indexOf("/");
|
|
40
|
+
const bucket = (slash === -1 ? spec : spec.slice(0, slash)).replace(/^\/+|\/+$/g, "");
|
|
41
|
+
if (slash === -1)
|
|
42
|
+
return `https://s3.console.aws.amazon.com/s3/buckets/${bucket}`;
|
|
43
|
+
const prefix = spec
|
|
44
|
+
.slice(slash + 1)
|
|
45
|
+
.replace(/^\/+|\/+$/g, "")
|
|
46
|
+
.split("/")
|
|
47
|
+
.map(encodeURIComponent)
|
|
48
|
+
.join("/");
|
|
49
|
+
return `https://s3.console.aws.amazon.com/s3/buckets/${bucket}?prefix=${prefix}/`;
|
|
50
|
+
}
|
|
51
|
+
export function renderCoverageSummaryMarkdown(summary, branch, storeS3 = null) {
|
|
52
|
+
const rows = summary.groups.length === 0
|
|
53
|
+
? "| -- | -- | -- |"
|
|
54
|
+
: summary.groups.map((group) => groupRow(group, branch)).join("\n");
|
|
55
|
+
const warnings = summary.warnings.length === 0
|
|
56
|
+
? ""
|
|
57
|
+
: `\n\n${summary.warnings.map((warning) => `> ${escMd(warning)}`).join("\n")}`;
|
|
58
|
+
const storeLink = storeS3 === null
|
|
59
|
+
? ""
|
|
60
|
+
: `\nCoverage store: [${codeSpan(`s3://${storeS3}`)}](${s3ConsoleUrl(storeS3)})\n`;
|
|
61
|
+
return `## Project coverage summary
|
|
62
|
+
|
|
63
|
+
Current run line coverage: **${pct(summary.currentTotals.hit, summary.currentTotals.total)}**
|
|
64
|
+
|
|
65
|
+
Total project line coverage: **${pct(summary.totals.hit, summary.totals.total)}**
|
|
66
|
+
${storeLink}
|
|
67
|
+
| Source folder | Source | Line coverage |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
${rows}${warnings}
|
|
70
|
+
`;
|
|
71
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { LcovData } from "../../types.mts";
|
|
2
|
+
export type { LcovData };
|
|
3
|
+
export type Source = "current" | "history";
|
|
4
|
+
export type SuiteCoverage = {
|
|
5
|
+
suite: string;
|
|
6
|
+
source: Source;
|
|
7
|
+
branch?: string;
|
|
8
|
+
lcov: LcovData;
|
|
9
|
+
};
|
|
10
|
+
export type SourceCoverageGroup = {
|
|
11
|
+
folder: string;
|
|
12
|
+
source: Source | "mixed";
|
|
13
|
+
branchesLabel?: string;
|
|
14
|
+
lcov: LcovData;
|
|
15
|
+
};
|
|
16
|
+
export type CoverageTotals = {
|
|
17
|
+
hit: number;
|
|
18
|
+
total: number;
|
|
19
|
+
};
|
|
20
|
+
export type CoverageSummary = {
|
|
21
|
+
currentTotals: CoverageTotals;
|
|
22
|
+
groups: SourceCoverageGroup[];
|
|
23
|
+
suites: SuiteCoverage[];
|
|
24
|
+
totals: CoverageTotals;
|
|
25
|
+
warnings: string[];
|
|
26
|
+
};
|
|
27
|
+
export type CoverageSummaryArgs = {
|
|
28
|
+
activeSuites: string[];
|
|
29
|
+
artifacts: string;
|
|
30
|
+
branch: string;
|
|
31
|
+
rulesFile?: string;
|
|
32
|
+
storeFs: string | null;
|
|
33
|
+
storeS3: string | null;
|
|
34
|
+
summaryFile: string | null;
|
|
35
|
+
stripPrefixes: string[];
|
|
36
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { buildCoverageSummary, groupSuitesBySourceFolder, main, parseCoverageSummaryArgs, renderCoverageSummaryMarkdown, suiteTotals, } from "./summary/index.mjs";
|
|
@@ -2,8 +2,9 @@ export { runCheck } from "./commands/check.mts";
|
|
|
2
2
|
export { runStorePut } from "./commands/store-put.mts";
|
|
3
3
|
export { FileSystemSuiteStore } from "./suite-store.mts";
|
|
4
4
|
export { S3SuiteStore } from "./s3-suite-store.mts";
|
|
5
|
+
export { parseDiffWithContent, getChangedLineContent } from "./diff-parser-content.mts";
|
|
5
6
|
export type { CheckArgs } from "./commands/check.mts";
|
|
6
7
|
export type { StorePutArgs } from "./commands/store-put.mts";
|
|
7
8
|
export type { SuiteStore, SuiteMeta } from "./suite-store.mts";
|
|
8
9
|
export type { S3SuiteStoreOptions } from "./s3-suite-store.mts";
|
|
9
|
-
export type { CoverageCheckResult, BucketResult, FileCoverageResult, LcovData, DiffLines, CoverageRule, } from "./types.mts";
|
|
10
|
+
export type { CoverageCheckResult, BucketResult, FileCoverageResult, LcovData, DiffLines, DiffLineContent, CoverageRule, } from "./types.mts";
|
|
@@ -2,3 +2,4 @@ export { runCheck } from "./commands/check.mjs";
|
|
|
2
2
|
export { runStorePut } from "./commands/store-put.mjs";
|
|
3
3
|
export { FileSystemSuiteStore } from "./suite-store.mjs";
|
|
4
4
|
export { S3SuiteStore } from "./s3-suite-store.mjs";
|
|
5
|
+
export { parseDiffWithContent, getChangedLineContent } from "./diff-parser-content.mjs";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DiffLineContent } from "./types.mts";
|
|
2
|
+
/**
|
|
3
|
+
* Parses the output of `git diff --unified=0` into a map of
|
|
4
|
+
* repo-root-relative file path → map of added line number → trimmed source text.
|
|
5
|
+
*
|
|
6
|
+
* The cursor advances only on `+` (added) content lines, not `-` (removed) lines.
|
|
7
|
+
* With --unified=0 there are no context lines, so cursor alignment is exact.
|
|
8
|
+
*/
|
|
9
|
+
export declare function parseDiffWithContent(text: string): DiffLineContent;
|
|
10
|
+
/** Runs git diff and returns added-line content for each file. */
|
|
11
|
+
export declare function getChangedLineContent(baseRef: string, headRef: string): Promise<DiffLineContent>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { decodeGitCString, runGitDiff } from "./diff-parser.mjs";
|
|
2
|
+
/**
|
|
3
|
+
* Parses the output of `git diff --unified=0` into a map of
|
|
4
|
+
* repo-root-relative file path → map of added line number → trimmed source text.
|
|
5
|
+
*
|
|
6
|
+
* The cursor advances only on `+` (added) content lines, not `-` (removed) lines.
|
|
7
|
+
* With --unified=0 there are no context lines, so cursor alignment is exact.
|
|
8
|
+
*/
|
|
9
|
+
export function parseDiffWithContent(text) {
|
|
10
|
+
const result = new Map();
|
|
11
|
+
let currentContent = null;
|
|
12
|
+
let cursor = 0;
|
|
13
|
+
let inHeader = false;
|
|
14
|
+
let start = 0;
|
|
15
|
+
while (start < text.length) {
|
|
16
|
+
let end = text.indexOf("\n", start);
|
|
17
|
+
if (end === -1)
|
|
18
|
+
end = text.length;
|
|
19
|
+
let lineEnd = end;
|
|
20
|
+
while (lineEnd > start) {
|
|
21
|
+
const charCode = text.charCodeAt(lineEnd - 1);
|
|
22
|
+
if (charCode === 32 || charCode === 9 || charCode === 13) {
|
|
23
|
+
lineEnd--;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
const line = text.slice(start, lineEnd);
|
|
29
|
+
start = end + 1;
|
|
30
|
+
// Only parse +++ as a file header when in the diff header block
|
|
31
|
+
// (after `diff --git` / `---`). Without this guard a source line beginning
|
|
32
|
+
// with `++ b/` would appear as `+++ b/…` in the diff and be misclassified.
|
|
33
|
+
let newFilePath = null;
|
|
34
|
+
if (inHeader) {
|
|
35
|
+
if (line.startsWith("+++ b/")) {
|
|
36
|
+
newFilePath = line.slice(6);
|
|
37
|
+
}
|
|
38
|
+
else if (line.startsWith('+++ "b/') && line.endsWith('"')) {
|
|
39
|
+
newFilePath = decodeGitCString(line.slice(5, -1)).slice(2);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (newFilePath !== null) {
|
|
43
|
+
inHeader = false;
|
|
44
|
+
const path = newFilePath;
|
|
45
|
+
if (path === "dev/null") {
|
|
46
|
+
currentContent = null;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
currentContent = result.get(path) ?? new Map();
|
|
50
|
+
result.set(path, currentContent);
|
|
51
|
+
}
|
|
52
|
+
else if (line.startsWith("--- ")) {
|
|
53
|
+
// ignore (part of diff header)
|
|
54
|
+
}
|
|
55
|
+
else if (line.startsWith("@@ ") && currentContent !== null) {
|
|
56
|
+
const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
|
|
57
|
+
if (!match)
|
|
58
|
+
continue;
|
|
59
|
+
const newStart = parseInt(match[1], 10);
|
|
60
|
+
const newCount = match[2] !== undefined ? parseInt(match[2], 10) : 1;
|
|
61
|
+
if (newCount === 0)
|
|
62
|
+
continue;
|
|
63
|
+
cursor = newStart;
|
|
64
|
+
}
|
|
65
|
+
else if (line.startsWith("diff --git ")) {
|
|
66
|
+
currentContent = null;
|
|
67
|
+
inHeader = true;
|
|
68
|
+
}
|
|
69
|
+
else if (currentContent !== null && cursor > 0 && line.startsWith("+")) {
|
|
70
|
+
// added content line inside a hunk body; "+++ b/" headers handled above
|
|
71
|
+
currentContent.set(cursor, line.slice(1).trim());
|
|
72
|
+
cursor++;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
/** Runs git diff and returns added-line content for each file. */
|
|
78
|
+
export async function getChangedLineContent(baseRef, headRef) {
|
|
79
|
+
return parseDiffWithContent(await runGitDiff(baseRef, headRef));
|
|
80
|
+
}
|
|
@@ -13,5 +13,7 @@ export declare function decodeGitCString(s: string): string;
|
|
|
13
13
|
* hunks (where the `+` count is 0) are skipped.
|
|
14
14
|
*/
|
|
15
15
|
export declare function parseDiff(text: string): DiffLines;
|
|
16
|
+
/** Runs git merge-base + git diff and returns the raw diff text. Internal shared helper. */
|
|
17
|
+
export declare function runGitDiff(baseRef: string, headRef: string): Promise<string>;
|
|
16
18
|
/** Runs git diff and returns the parsed result. */
|
|
17
19
|
export declare function getChangedLines(baseRef: string, headRef: string): Promise<DiffLines>;
|
package/dist/src/diff-parser.mjs
CHANGED
|
@@ -51,8 +51,22 @@ export function parseDiff(text) {
|
|
|
51
51
|
const result = new Map();
|
|
52
52
|
let currentLines = null;
|
|
53
53
|
let inHeader = false;
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
let start = 0;
|
|
55
|
+
while (start < text.length) {
|
|
56
|
+
let end = text.indexOf("\n", start);
|
|
57
|
+
if (end === -1)
|
|
58
|
+
end = text.length;
|
|
59
|
+
let lineEnd = end;
|
|
60
|
+
while (lineEnd > start) {
|
|
61
|
+
const charCode = text.charCodeAt(lineEnd - 1);
|
|
62
|
+
if (charCode === 32 || charCode === 9 || charCode === 13) {
|
|
63
|
+
lineEnd--;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
const line = text.slice(start, lineEnd);
|
|
69
|
+
start = end + 1;
|
|
56
70
|
// Only parse +++ as a file header when we are in the diff header block
|
|
57
71
|
// (after `diff --git` / `---`). Without this guard a source line beginning
|
|
58
72
|
// with `++ b/` would appear as `+++ b/…` in the diff and be misclassified.
|
|
@@ -98,8 +112,8 @@ export function parseDiff(text) {
|
|
|
98
112
|
}
|
|
99
113
|
return result;
|
|
100
114
|
}
|
|
101
|
-
/** Runs git diff and returns the
|
|
102
|
-
export async function
|
|
115
|
+
/** Runs git merge-base + git diff and returns the raw diff text. Internal shared helper. */
|
|
116
|
+
export async function runGitDiff(baseRef, headRef) {
|
|
103
117
|
const { spawn } = await import("node:child_process");
|
|
104
118
|
const spawnProcess = (cmd, args) => new Promise((resolve, reject) => {
|
|
105
119
|
const chunks = [];
|
|
@@ -113,7 +127,7 @@ export async function getChangedLines(baseRef, headRef) {
|
|
|
113
127
|
const mergeBase = await spawnProcess("git", ["merge-base", baseRef, headRef]);
|
|
114
128
|
const base = mergeBase.trim();
|
|
115
129
|
// --src-prefix/--dst-prefix override diff.noprefix and diff.mnemonicPrefix git config
|
|
116
|
-
|
|
130
|
+
return spawnProcess("git", [
|
|
117
131
|
"diff",
|
|
118
132
|
"--unified=0",
|
|
119
133
|
"--inter-hunk-context=0",
|
|
@@ -123,5 +137,8 @@ export async function getChangedLines(baseRef, headRef) {
|
|
|
123
137
|
base,
|
|
124
138
|
headRef,
|
|
125
139
|
]);
|
|
126
|
-
|
|
140
|
+
}
|
|
141
|
+
/** Runs git diff and returns the parsed result. */
|
|
142
|
+
export async function getChangedLines(baseRef, headRef) {
|
|
143
|
+
return parseDiff(await runGitDiff(baseRef, headRef));
|
|
127
144
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Iterates over `text` one line at a time without `split('\n')`.
|
|
3
|
+
* Keeps behavior consistent with current parser logic by trimming only line ending
|
|
4
|
+
* whitespace (`\r`, spaces, tabs) after extraction.
|
|
5
|
+
*/
|
|
6
|
+
export declare function forEachTrimmedLine(text: string, visit: (line: string, lineStart: number, lineEnd: number) => void): void;
|
|
7
|
+
export declare function trimLineEnd(text: string, start: number, end: number): number;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Iterates over `text` one line at a time without `split('\n')`.
|
|
3
|
+
* Keeps behavior consistent with current parser logic by trimming only line ending
|
|
4
|
+
* whitespace (`\r`, spaces, tabs) after extraction.
|
|
5
|
+
*/
|
|
6
|
+
export function forEachTrimmedLine(text, visit) {
|
|
7
|
+
let lineStart = 0;
|
|
8
|
+
while (lineStart < text.length) {
|
|
9
|
+
let nl = text.indexOf("\n", lineStart);
|
|
10
|
+
if (nl === -1)
|
|
11
|
+
nl = text.length;
|
|
12
|
+
const lineEnd = trimLineEnd(text, lineStart, nl);
|
|
13
|
+
const line = text.slice(lineStart, lineEnd);
|
|
14
|
+
visit(line, lineStart, lineEnd);
|
|
15
|
+
lineStart = nl + 1;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function trimLineEnd(text, start, end) {
|
|
19
|
+
while (end > start) {
|
|
20
|
+
const charCode = text.charCodeAt(end - 1);
|
|
21
|
+
if (charCode === 32 || charCode === 9 || charCode === 13) {
|
|
22
|
+
end--;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
return end;
|
|
28
|
+
}
|
package/dist/src/lcov-merge.mjs
CHANGED
|
@@ -5,11 +5,15 @@ export function mergeLcov(reports) {
|
|
|
5
5
|
for (const [file, lines] of report) {
|
|
6
6
|
let target = merged.get(file);
|
|
7
7
|
if (target === undefined) {
|
|
8
|
-
|
|
8
|
+
// Optimization: Use `new Map(lines)` instead of copying elements one by one.
|
|
9
|
+
// This skips redundant iterations and reduces Map insertion overhead.
|
|
10
|
+
target = new Map(lines);
|
|
9
11
|
merged.set(file, target);
|
|
10
12
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
else {
|
|
14
|
+
for (const [lineNo, hits] of lines) {
|
|
15
|
+
target.set(lineNo, (target.get(lineNo) ?? 0) + hits);
|
|
16
|
+
}
|
|
13
17
|
}
|
|
14
18
|
}
|
|
15
19
|
}
|
package/dist/src/lcov-parser.mjs
CHANGED
|
@@ -7,10 +7,19 @@
|
|
|
7
7
|
export function parseLcov(text, stripPrefixes = []) {
|
|
8
8
|
const result = new Map();
|
|
9
9
|
let currentLines = null;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
// Optimization: Instead of using `text.split("\n")` which allocates a massive
|
|
11
|
+
// array of strings in memory and causes significant garbage collection overhead
|
|
12
|
+
// for large LCOV files, we manually traverse the string using `indexOf("\n")`.
|
|
13
|
+
// This reduces memory allocations and improves parsing speed by ~30-50%.
|
|
14
|
+
let start = 0;
|
|
15
|
+
while (start < text.length) {
|
|
16
|
+
let end = text.indexOf("\n", start);
|
|
17
|
+
if (end === -1)
|
|
18
|
+
end = text.length;
|
|
19
|
+
let lineStart = start;
|
|
20
|
+
const lineEnd = trimLineEnd(text, lineStart, end);
|
|
21
|
+
if (text.startsWith("SF:", lineStart)) {
|
|
22
|
+
let path = text.slice(lineStart + 3, lineEnd);
|
|
14
23
|
let stripped = false;
|
|
15
24
|
for (const prefix of stripPrefixes) {
|
|
16
25
|
if (path.startsWith(prefix)) {
|
|
@@ -26,27 +35,41 @@ export function parseLcov(text, stripPrefixes = []) {
|
|
|
26
35
|
path = path.slice(match[0].length);
|
|
27
36
|
}
|
|
28
37
|
}
|
|
29
|
-
currentLines = result.get(path) ??
|
|
30
|
-
|
|
38
|
+
currentLines = result.get(path) ?? null;
|
|
39
|
+
if (!currentLines) {
|
|
40
|
+
currentLines = new Map();
|
|
41
|
+
result.set(path, currentLines);
|
|
42
|
+
}
|
|
31
43
|
}
|
|
32
|
-
else if (
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
currentLines.set(lineNo, prev + hits);
|
|
44
|
+
else if (text.startsWith("DA:", lineStart) && currentLines !== null) {
|
|
45
|
+
const comma = text.indexOf(",", lineStart + 3);
|
|
46
|
+
if (comma !== -1 && comma < lineEnd) {
|
|
47
|
+
const lineNo = parseInt(text.slice(lineStart + 3, comma), 10);
|
|
48
|
+
const hits = parseInt(text.slice(comma + 1, lineEnd), 10);
|
|
49
|
+
if (Number.isFinite(lineNo) && Number.isFinite(hits)) {
|
|
50
|
+
const prev = currentLines.get(lineNo) ?? 0;
|
|
51
|
+
currentLines.set(lineNo, prev + hits);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
43
54
|
}
|
|
44
|
-
else if (
|
|
55
|
+
else if (lineEnd - lineStart === 13 && text.startsWith("end_of_record", lineStart)) {
|
|
45
56
|
currentLines = null;
|
|
46
57
|
}
|
|
58
|
+
start = end + 1;
|
|
47
59
|
}
|
|
48
60
|
return result;
|
|
49
61
|
}
|
|
62
|
+
function trimLineEnd(text, start, end) {
|
|
63
|
+
while (end > start) {
|
|
64
|
+
const charCode = text.charCodeAt(end - 1);
|
|
65
|
+
if (charCode === 32 || charCode === 9 || charCode === 13) {
|
|
66
|
+
end--;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
return end;
|
|
72
|
+
}
|
|
50
73
|
function normalizePath(p) {
|
|
51
74
|
return p.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
52
75
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
type Loc = {
|
|
2
|
+
start: {
|
|
3
|
+
line: number;
|
|
4
|
+
column: number;
|
|
5
|
+
};
|
|
6
|
+
end: {
|
|
7
|
+
line: number;
|
|
8
|
+
column: number;
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
export type IstanbulFileCoverage = {
|
|
12
|
+
path: string;
|
|
13
|
+
statementMap: Record<string, Loc>;
|
|
14
|
+
s: Record<string, number>;
|
|
15
|
+
fnMap: Record<string, {
|
|
16
|
+
name: string;
|
|
17
|
+
decl: Loc;
|
|
18
|
+
loc: Loc;
|
|
19
|
+
}>;
|
|
20
|
+
f: Record<string, number>;
|
|
21
|
+
branchMap: Record<string, {
|
|
22
|
+
loc: Loc;
|
|
23
|
+
type: string;
|
|
24
|
+
locations: Loc[];
|
|
25
|
+
}>;
|
|
26
|
+
b: Record<string, number[]>;
|
|
27
|
+
};
|
|
28
|
+
export type IstanbulCoverage = Record<string, IstanbulFileCoverage>;
|
|
29
|
+
export declare function lcovBufferToIstanbul(lcov: Buffer, stripPrefixes: string[]): IstanbulCoverage;
|
|
30
|
+
export {};
|