coverage-check 0.2.3 → 0.4.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/dist/src/cli.mjs +6 -0
- 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/lcov-to-istanbul.d.mts +30 -0
- package/dist/src/lcov-to-istanbul.mjs +140 -0
- package/package.json +3 -2
package/dist/src/cli.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { main as checkMain } from "./commands/check.mjs";
|
|
2
2
|
import { main as storePutMain } from "./commands/store-put.mjs";
|
|
3
|
+
import { main as htmlMain } from "./commands/html.mjs";
|
|
4
|
+
import { main as summaryMain } from "./commands/summary.mjs";
|
|
3
5
|
const stderr = (msg) => process.stderr.write(`${msg}\n`);
|
|
4
6
|
export async function main(argv) {
|
|
5
7
|
const sub = argv[0];
|
|
@@ -9,6 +11,10 @@ export async function main(argv) {
|
|
|
9
11
|
return checkMain(argv.slice(1));
|
|
10
12
|
if (sub === "store-put")
|
|
11
13
|
return storePutMain(argv.slice(1));
|
|
14
|
+
if (sub === "html")
|
|
15
|
+
return htmlMain(argv.slice(1));
|
|
16
|
+
if (sub === "summary")
|
|
17
|
+
return summaryMain(argv.slice(1));
|
|
12
18
|
stderr(`coverage-check: unknown subcommand: ${JSON.stringify(sub)}`);
|
|
13
19
|
return 2;
|
|
14
20
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type CoverageHtmlArgs = {
|
|
2
|
+
activeSuites: string[];
|
|
3
|
+
artifacts: string;
|
|
4
|
+
branch: string;
|
|
5
|
+
output: string;
|
|
6
|
+
storeFs: string | null;
|
|
7
|
+
storeS3: string | null;
|
|
8
|
+
stripPrefixes: string[];
|
|
9
|
+
};
|
|
10
|
+
export declare function parseCoverageHtmlArgs(argv: string[]): CoverageHtmlArgs;
|
|
11
|
+
export declare function buildCoverageHtml(args: CoverageHtmlArgs): Promise<{
|
|
12
|
+
warnings: string[];
|
|
13
|
+
}>;
|
|
14
|
+
export declare function main(argv: string[]): Promise<number>;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { collectLcovFiles, buildStripPrefixes } from "../load-artifacts.mjs";
|
|
4
|
+
import { lcovBufferToIstanbul } from "../lcov-to-istanbul.mjs";
|
|
5
|
+
import { makeStore } from "../store-factory.mjs";
|
|
6
|
+
function requireValue(flag, value) {
|
|
7
|
+
if (value === undefined || value.startsWith("--"))
|
|
8
|
+
throw new Error(`${flag} requires a value`);
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
export function parseCoverageHtmlArgs(argv) {
|
|
12
|
+
const args = {
|
|
13
|
+
activeSuites: [],
|
|
14
|
+
artifacts: "./coverage-artifacts",
|
|
15
|
+
branch: "main",
|
|
16
|
+
output: "./coverage-html",
|
|
17
|
+
storeFs: null,
|
|
18
|
+
storeS3: null,
|
|
19
|
+
stripPrefixes: [],
|
|
20
|
+
};
|
|
21
|
+
for (let i = 0; i < argv.length; i++) {
|
|
22
|
+
const flag = argv[i];
|
|
23
|
+
const value = () => {
|
|
24
|
+
const parsed = requireValue(flag, argv[i + 1]);
|
|
25
|
+
i++;
|
|
26
|
+
return parsed;
|
|
27
|
+
};
|
|
28
|
+
switch (flag) {
|
|
29
|
+
case "--active-suite":
|
|
30
|
+
args.activeSuites.push(value());
|
|
31
|
+
break;
|
|
32
|
+
case "--artifacts":
|
|
33
|
+
args.artifacts = value();
|
|
34
|
+
break;
|
|
35
|
+
case "--branch":
|
|
36
|
+
args.branch = value();
|
|
37
|
+
break;
|
|
38
|
+
case "--output":
|
|
39
|
+
args.output = value();
|
|
40
|
+
break;
|
|
41
|
+
case "--store-fs":
|
|
42
|
+
args.storeFs = value();
|
|
43
|
+
break;
|
|
44
|
+
case "--store-s3":
|
|
45
|
+
args.storeS3 = value();
|
|
46
|
+
break;
|
|
47
|
+
case "--strip-prefix":
|
|
48
|
+
args.stripPrefixes.push(value());
|
|
49
|
+
break;
|
|
50
|
+
default:
|
|
51
|
+
throw new Error(`unknown flag: ${flag}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (args.storeFs && args.storeS3)
|
|
55
|
+
throw new Error("--store-fs and --store-s3 are mutually exclusive");
|
|
56
|
+
if (args.branch.length === 0)
|
|
57
|
+
throw new Error("--branch must not be empty");
|
|
58
|
+
return args;
|
|
59
|
+
}
|
|
60
|
+
function suiteNameFromArtifactDir(name) {
|
|
61
|
+
if (!name.startsWith("coverage-"))
|
|
62
|
+
return null;
|
|
63
|
+
const suite = name.slice("coverage-".length);
|
|
64
|
+
return suite.length > 0 ? suite : null;
|
|
65
|
+
}
|
|
66
|
+
function loadCurrentSuiteBuffers(artifacts) {
|
|
67
|
+
if (!existsSync(artifacts))
|
|
68
|
+
return [];
|
|
69
|
+
return readdirSync(artifacts, { withFileTypes: true })
|
|
70
|
+
.filter((entry) => entry.isDirectory())
|
|
71
|
+
.flatMap((entry) => {
|
|
72
|
+
const suite = suiteNameFromArtifactDir(entry.name);
|
|
73
|
+
if (!suite)
|
|
74
|
+
return [];
|
|
75
|
+
const files = collectLcovFiles(path.join(artifacts, entry.name));
|
|
76
|
+
if (files.length === 0)
|
|
77
|
+
return [];
|
|
78
|
+
return [
|
|
79
|
+
{
|
|
80
|
+
suite,
|
|
81
|
+
source: "current",
|
|
82
|
+
lcov: Buffer.concat(files.flatMap((f) => [readFileSync(f), Buffer.from("\n")])),
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
})
|
|
86
|
+
.sort((a, b) => a.suite.localeCompare(b.suite));
|
|
87
|
+
}
|
|
88
|
+
async function loadHistoricalSuiteBuffers(store, branch, activeSuiteNames, currentSuiteNames) {
|
|
89
|
+
if (store === null) {
|
|
90
|
+
return {
|
|
91
|
+
suites: [],
|
|
92
|
+
warnings: [
|
|
93
|
+
"Historical main coverage store was not configured; showing current-run coverage only.",
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (activeSuiteNames.size === 0) {
|
|
98
|
+
return {
|
|
99
|
+
suites: [],
|
|
100
|
+
warnings: [
|
|
101
|
+
"Historical main coverage store was configured without an active suite manifest; showing current-run coverage only.",
|
|
102
|
+
],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const historicalNames = [...activeSuiteNames].filter((s) => !currentSuiteNames.has(s));
|
|
107
|
+
const results = await Promise.all(historicalNames.map(async (suite) => {
|
|
108
|
+
const lcov = await store.get(suite, { branch });
|
|
109
|
+
return lcov === null ? { missingSuite: suite } : { suite, lcov };
|
|
110
|
+
}));
|
|
111
|
+
const suites = results
|
|
112
|
+
.filter((r) => "lcov" in r)
|
|
113
|
+
.map((r) => ({ suite: r.suite, source: "history", lcov: r.lcov }));
|
|
114
|
+
const missing = results
|
|
115
|
+
.filter((r) => "missingSuite" in r)
|
|
116
|
+
.map((r) => r.missingSuite);
|
|
117
|
+
return {
|
|
118
|
+
suites,
|
|
119
|
+
warnings: missing.length === 0
|
|
120
|
+
? []
|
|
121
|
+
: [
|
|
122
|
+
`Historical main coverage missing for active suites: ${missing.sort((a, b) => a.localeCompare(b)).join(", ")}.`,
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
128
|
+
return { suites: [], warnings: [`Historical main coverage could not be read: ${message}`] };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export async function buildCoverageHtml(args) {
|
|
132
|
+
const stripPrefixes = buildStripPrefixes(args.stripPrefixes);
|
|
133
|
+
const currentSuites = loadCurrentSuiteBuffers(args.artifacts);
|
|
134
|
+
const historical = await loadHistoricalSuiteBuffers(makeStore({ fs: args.storeFs, s3: args.storeS3 }), args.branch, new Set(args.activeSuites), new Set(currentSuites.map((s) => s.suite)));
|
|
135
|
+
const allSuites = [...currentSuites, ...historical.suites];
|
|
136
|
+
const { CoverageReport } = await import("monocart-coverage-reports");
|
|
137
|
+
const report = new CoverageReport({
|
|
138
|
+
name: "merged coverage",
|
|
139
|
+
outputDir: args.output,
|
|
140
|
+
reports: [["html"]],
|
|
141
|
+
lcov: true,
|
|
142
|
+
});
|
|
143
|
+
for (const suite of allSuites) {
|
|
144
|
+
await report.add(lcovBufferToIstanbul(suite.lcov, stripPrefixes));
|
|
145
|
+
}
|
|
146
|
+
if (allSuites.length > 0) {
|
|
147
|
+
await report.generate();
|
|
148
|
+
}
|
|
149
|
+
return { warnings: historical.warnings };
|
|
150
|
+
}
|
|
151
|
+
export async function main(argv) {
|
|
152
|
+
let args;
|
|
153
|
+
try {
|
|
154
|
+
args = parseCoverageHtmlArgs(argv);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
process.stderr.write(`coverage-check html: ${String(err)}\n`);
|
|
158
|
+
return 2;
|
|
159
|
+
}
|
|
160
|
+
const { warnings } = await buildCoverageHtml(args);
|
|
161
|
+
for (const w of warnings)
|
|
162
|
+
process.stderr.write(`${w}\n`);
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
2
3
|
import { parseLcov } from "../lcov-parser.mjs";
|
|
3
4
|
import { mergeLcov, toLcov } from "../lcov-merge.mjs";
|
|
4
5
|
import { collectLcovFiles, buildStripPrefixes } from "../load-artifacts.mjs";
|
|
@@ -11,6 +12,7 @@ function parseArgs(argv) {
|
|
|
11
12
|
let storeS3 = null;
|
|
12
13
|
const args = {
|
|
13
14
|
suite: "",
|
|
15
|
+
suitePrefix: "coverage-",
|
|
14
16
|
artifacts: "./coverage-artifacts",
|
|
15
17
|
stripPrefixes: [],
|
|
16
18
|
sha: undefined,
|
|
@@ -30,6 +32,9 @@ function parseArgs(argv) {
|
|
|
30
32
|
case "--suite":
|
|
31
33
|
args.suite = val();
|
|
32
34
|
break;
|
|
35
|
+
case "--suite-prefix":
|
|
36
|
+
args.suitePrefix = val();
|
|
37
|
+
break;
|
|
33
38
|
case "--store":
|
|
34
39
|
case "--store-fs":
|
|
35
40
|
storeFs = val();
|
|
@@ -53,8 +58,6 @@ function parseArgs(argv) {
|
|
|
53
58
|
throw new Error(`unknown flag: ${flag}`);
|
|
54
59
|
}
|
|
55
60
|
}
|
|
56
|
-
if (!args.suite)
|
|
57
|
-
throw new Error("--suite is required");
|
|
58
61
|
if (storeFs && storeS3)
|
|
59
62
|
throw new Error("--store-fs and --store-s3 are mutually exclusive");
|
|
60
63
|
if (!storeFs && !storeS3)
|
|
@@ -64,7 +67,9 @@ function parseArgs(argv) {
|
|
|
64
67
|
if (hasSha !== hasBranch) {
|
|
65
68
|
throw new Error("--sha and --branch must be provided together");
|
|
66
69
|
}
|
|
67
|
-
|
|
70
|
+
if (args.suite) {
|
|
71
|
+
assertSafePathComponent(args.suite, "suite");
|
|
72
|
+
}
|
|
68
73
|
if (args.sha !== undefined)
|
|
69
74
|
assertSafePathComponent(args.sha, "sha");
|
|
70
75
|
if (args.branch !== undefined && args.branch.length === 0) {
|
|
@@ -82,13 +87,42 @@ export async function main(argv) {
|
|
|
82
87
|
stderr(`coverage-check store-put: ${String(err)}`);
|
|
83
88
|
return 2;
|
|
84
89
|
}
|
|
85
|
-
|
|
90
|
+
if (args.suite) {
|
|
91
|
+
return runStorePut(args);
|
|
92
|
+
}
|
|
93
|
+
return runStorePutMultiSuite(args);
|
|
94
|
+
}
|
|
95
|
+
async function runStorePutMultiSuite(args) {
|
|
96
|
+
let subdirs;
|
|
97
|
+
try {
|
|
98
|
+
subdirs = readdirSync(args.artifacts, { withFileTypes: true })
|
|
99
|
+
.filter((e) => e.isDirectory() && e.name.startsWith(args.suitePrefix))
|
|
100
|
+
.map((e) => e.name);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
if (err.code === "ENOENT") {
|
|
104
|
+
stdout(`coverage-check store-put: artifacts directory not found at ${args.artifacts}; nothing to store`);
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
if (subdirs.length === 0) {
|
|
110
|
+
stdout(`coverage-check store-put: no subdirectories matching prefix "${args.suitePrefix}" under ${args.artifacts}; nothing to store`);
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
for (const subdirName of subdirs) {
|
|
114
|
+
const suite = subdirName.slice(args.suitePrefix.length);
|
|
115
|
+
const suiteDir = join(args.artifacts, subdirName);
|
|
116
|
+
const suiteArgs = { ...args, suite, artifacts: suiteDir };
|
|
117
|
+
await runStorePut(suiteArgs);
|
|
118
|
+
}
|
|
119
|
+
return 0;
|
|
86
120
|
}
|
|
87
121
|
export async function runStorePut(args) {
|
|
88
122
|
const lcovFiles = collectLcovFiles(args.artifacts);
|
|
89
123
|
if (lcovFiles.length === 0) {
|
|
90
|
-
|
|
91
|
-
return
|
|
124
|
+
stdout(`coverage-check store-put: no lcov.info files under ${args.artifacts}; skipping suite "${args.suite}"`);
|
|
125
|
+
return 0;
|
|
92
126
|
}
|
|
93
127
|
const stripPrefixes = buildStripPrefixes(args.stripPrefixes);
|
|
94
128
|
const reports = lcovFiles.map((f) => parseLcov(readFileSync(f, "utf8"), stripPrefixes));
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
function requireValue(flag, value) {
|
|
2
|
+
if (value === undefined || value.startsWith("--"))
|
|
3
|
+
throw new Error(`${flag} requires a value`);
|
|
4
|
+
return value;
|
|
5
|
+
}
|
|
6
|
+
export function parseCoverageSummaryArgs(argv) {
|
|
7
|
+
const args = {
|
|
8
|
+
activeSuites: [],
|
|
9
|
+
artifacts: "./coverage-artifacts",
|
|
10
|
+
branch: "main",
|
|
11
|
+
storeFs: null,
|
|
12
|
+
storeS3: null,
|
|
13
|
+
summaryFile: process.env["GITHUB_STEP_SUMMARY"] ?? null,
|
|
14
|
+
stripPrefixes: [],
|
|
15
|
+
};
|
|
16
|
+
for (let i = 0; i < argv.length; i++) {
|
|
17
|
+
const flag = argv[i];
|
|
18
|
+
const value = () => {
|
|
19
|
+
const parsed = requireValue(flag, argv[i + 1]);
|
|
20
|
+
i++;
|
|
21
|
+
return parsed;
|
|
22
|
+
};
|
|
23
|
+
switch (flag) {
|
|
24
|
+
case "--active-suite":
|
|
25
|
+
args.activeSuites.push(value());
|
|
26
|
+
break;
|
|
27
|
+
case "--artifacts":
|
|
28
|
+
args.artifacts = value();
|
|
29
|
+
break;
|
|
30
|
+
case "--branch":
|
|
31
|
+
args.branch = value();
|
|
32
|
+
break;
|
|
33
|
+
case "--rules":
|
|
34
|
+
args.rulesFile = value();
|
|
35
|
+
break;
|
|
36
|
+
case "--store-fs":
|
|
37
|
+
args.storeFs = value();
|
|
38
|
+
break;
|
|
39
|
+
case "--store-s3":
|
|
40
|
+
args.storeS3 = value();
|
|
41
|
+
break;
|
|
42
|
+
case "--strip-prefix":
|
|
43
|
+
args.stripPrefixes.push(value());
|
|
44
|
+
break;
|
|
45
|
+
case "--summary-file":
|
|
46
|
+
args.summaryFile = value();
|
|
47
|
+
break;
|
|
48
|
+
case "--no-summary-file":
|
|
49
|
+
args.summaryFile = null;
|
|
50
|
+
break;
|
|
51
|
+
default:
|
|
52
|
+
throw new Error(`unknown flag: ${flag}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (args.storeFs && args.storeS3)
|
|
56
|
+
throw new Error("--store-fs and --store-s3 are mutually exclusive");
|
|
57
|
+
if (args.branch.length === 0)
|
|
58
|
+
throw new Error("--branch must not be empty");
|
|
59
|
+
return args;
|
|
60
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { load as loadYaml } from "js-yaml";
|
|
4
|
+
import { mergeLcov } from "../../lcov-merge.mjs";
|
|
5
|
+
const otherGroup = { folder: "other", prefix: "" };
|
|
6
|
+
function isCoverageRulesFile(value) {
|
|
7
|
+
return typeof value === "object" && value !== null;
|
|
8
|
+
}
|
|
9
|
+
function staticFolderFromGlob(pattern) {
|
|
10
|
+
const normalized = pattern.replace(/\\/g, "/");
|
|
11
|
+
const firstGlobIndex = normalized.search(/[*?[\]{}]/);
|
|
12
|
+
const staticPrefix = firstGlobIndex === -1 ? normalized : normalized.slice(0, firstGlobIndex);
|
|
13
|
+
const folder = staticPrefix.replace(/\/+$/, "");
|
|
14
|
+
return folder === "" ? null : folder;
|
|
15
|
+
}
|
|
16
|
+
function loadCoverageGroupDefinitions(rulesFile) {
|
|
17
|
+
const resolvedFile = rulesFile ?? path.join(process.cwd(), ".coverage-rules.yml");
|
|
18
|
+
if (!existsSync(resolvedFile))
|
|
19
|
+
return [];
|
|
20
|
+
const parsed = loadYaml(readFileSync(resolvedFile, "utf8"));
|
|
21
|
+
const rules = isCoverageRulesFile(parsed) && Array.isArray(parsed.rules) ? parsed.rules : [];
|
|
22
|
+
return rules.flatMap((rule) => {
|
|
23
|
+
const paths = Array.isArray(rule.paths)
|
|
24
|
+
? rule.paths
|
|
25
|
+
: rule.paths === undefined
|
|
26
|
+
? []
|
|
27
|
+
: [rule.paths];
|
|
28
|
+
return paths.flatMap((pattern) => {
|
|
29
|
+
const folder = staticFolderFromGlob(pattern);
|
|
30
|
+
return folder === null ? [] : [{ folder, prefix: `${folder}/` }];
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function groupForFile(filePath, definitions) {
|
|
35
|
+
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
36
|
+
return (definitions.find((definition) => normalizedPath === definition.folder || normalizedPath.startsWith(definition.prefix)) ?? otherGroup);
|
|
37
|
+
}
|
|
38
|
+
export function groupSuitesBySourceFolder(suites, branch, rulesFile) {
|
|
39
|
+
const definitions = loadCoverageGroupDefinitions(rulesFile);
|
|
40
|
+
const groups = new Map();
|
|
41
|
+
for (const suite of suites) {
|
|
42
|
+
const suiteReportsByGroup = new Map();
|
|
43
|
+
for (const [filePath, lines] of suite.lcov.entries()) {
|
|
44
|
+
const definition = groupForFile(filePath, definitions);
|
|
45
|
+
const group = groups.get(definition.folder) ?? {
|
|
46
|
+
branches: new Set(),
|
|
47
|
+
current: false,
|
|
48
|
+
history: false,
|
|
49
|
+
reports: [],
|
|
50
|
+
};
|
|
51
|
+
if (suite.source === "current")
|
|
52
|
+
group.current = true;
|
|
53
|
+
else {
|
|
54
|
+
group.history = true;
|
|
55
|
+
group.branches.add(suite.branch ?? branch);
|
|
56
|
+
}
|
|
57
|
+
const suiteReport = suiteReportsByGroup.get(definition.folder) ?? new Map();
|
|
58
|
+
suiteReport.set(filePath, new Map(lines));
|
|
59
|
+
suiteReportsByGroup.set(definition.folder, suiteReport);
|
|
60
|
+
groups.set(definition.folder, group);
|
|
61
|
+
}
|
|
62
|
+
for (const [folder, report] of suiteReportsByGroup.entries()) {
|
|
63
|
+
groups.get(folder)?.reports.push(report);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const coverageGroups = [];
|
|
67
|
+
for (const definition of [...definitions, otherGroup]) {
|
|
68
|
+
const group = groups.get(definition.folder);
|
|
69
|
+
if (!group)
|
|
70
|
+
continue;
|
|
71
|
+
const coverageGroup = {
|
|
72
|
+
folder: definition.folder,
|
|
73
|
+
source: group.current && group.history ? "mixed" : group.current ? "current" : "history",
|
|
74
|
+
lcov: mergeLcov(group.reports),
|
|
75
|
+
};
|
|
76
|
+
if (group.branches.size > 0) {
|
|
77
|
+
coverageGroup.branchesLabel = [...group.branches]
|
|
78
|
+
.sort((a, b) => a.localeCompare(b))
|
|
79
|
+
.join(", ");
|
|
80
|
+
}
|
|
81
|
+
coverageGroups.push(coverageGroup);
|
|
82
|
+
}
|
|
83
|
+
return coverageGroups;
|
|
84
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { parseCoverageSummaryArgs } from "./args.mts";
|
|
2
|
+
import { groupSuitesBySourceFolder } from "./groups.mts";
|
|
3
|
+
import { renderCoverageSummaryMarkdown, suiteTotals } from "./markdown.mts";
|
|
4
|
+
import type { CoverageSummary, CoverageSummaryArgs, SuiteCoverage } from "./types.mts";
|
|
5
|
+
export type { CoverageSummary, CoverageSummaryArgs, SuiteCoverage };
|
|
6
|
+
export { groupSuitesBySourceFolder, parseCoverageSummaryArgs, renderCoverageSummaryMarkdown, suiteTotals, };
|
|
7
|
+
export declare function buildCoverageSummary(args: CoverageSummaryArgs): Promise<CoverageSummary>;
|
|
8
|
+
export declare function main(argv: string[]): Promise<number>;
|
|
@@ -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";
|
|
@@ -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 {};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
function loc(line) {
|
|
2
|
+
return { start: { line, column: 0 }, end: { line, column: 999 } };
|
|
3
|
+
}
|
|
4
|
+
function normalizeFilePath(filePath, stripPrefixes) {
|
|
5
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
6
|
+
for (const prefix of stripPrefixes) {
|
|
7
|
+
const p = prefix.replace(/\\/g, "/").replace(/\/$/, "");
|
|
8
|
+
if (normalized === p)
|
|
9
|
+
return "";
|
|
10
|
+
if (normalized.startsWith(`${p}/`))
|
|
11
|
+
return normalized.slice(p.length + 1);
|
|
12
|
+
}
|
|
13
|
+
return normalized;
|
|
14
|
+
}
|
|
15
|
+
export function lcovBufferToIstanbul(lcov, stripPrefixes) {
|
|
16
|
+
const coverage = {};
|
|
17
|
+
let filePath = null;
|
|
18
|
+
const pendingFnLines = new Map();
|
|
19
|
+
// Accumulate branches at file scope so counts from all records for the same
|
|
20
|
+
// file are merged before converting to Istanbul arrays.
|
|
21
|
+
// filePath → blockKey → branchId → count
|
|
22
|
+
const fileBranches = new Map();
|
|
23
|
+
function flush() {
|
|
24
|
+
pendingFnLines.clear();
|
|
25
|
+
}
|
|
26
|
+
for (const line of lcov.toString("utf8").split(/\r?\n/)) {
|
|
27
|
+
if (line === "end_of_record") {
|
|
28
|
+
flush();
|
|
29
|
+
filePath = null;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (line.startsWith("SF:")) {
|
|
33
|
+
flush();
|
|
34
|
+
filePath = normalizeFilePath(line.slice(3).trim(), stripPrefixes);
|
|
35
|
+
if (filePath && !coverage[filePath]) {
|
|
36
|
+
coverage[filePath] = {
|
|
37
|
+
path: filePath,
|
|
38
|
+
statementMap: {},
|
|
39
|
+
s: {},
|
|
40
|
+
fnMap: {},
|
|
41
|
+
f: {},
|
|
42
|
+
branchMap: {},
|
|
43
|
+
b: {},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (!filePath)
|
|
49
|
+
continue;
|
|
50
|
+
const fileCov = coverage[filePath]; // filePath was validated against coverage when SF: was processed
|
|
51
|
+
if (line.startsWith("DA:")) {
|
|
52
|
+
const [lineNo, hits] = line.slice(3).split(",", 2);
|
|
53
|
+
const l = Number.parseInt(lineNo, 10);
|
|
54
|
+
const h = Number.parseInt(hits ?? "", 10);
|
|
55
|
+
if (!Number.isInteger(l) || !Number.isInteger(h))
|
|
56
|
+
continue;
|
|
57
|
+
const key = String(l);
|
|
58
|
+
if (fileCov.statementMap[key] === undefined) {
|
|
59
|
+
fileCov.statementMap[key] = loc(l);
|
|
60
|
+
fileCov.s[key] = h;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
fileCov.s[key] = fileCov.s[key] + h;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else if (line.startsWith("FN:") || line.startsWith("FNL:")) {
|
|
67
|
+
const rest = line.slice(line.startsWith("FNL:") ? 4 : 3);
|
|
68
|
+
const firstComma = rest.indexOf(",");
|
|
69
|
+
if (firstComma === -1)
|
|
70
|
+
continue;
|
|
71
|
+
const l = Number.parseInt(rest.slice(0, firstComma), 10);
|
|
72
|
+
const afterFirst = rest.slice(firstComma + 1);
|
|
73
|
+
// Handle both FN:start,name and FN:start,end,name (LCOV 2.x three-field form)
|
|
74
|
+
const secondComma = afterFirst.indexOf(",");
|
|
75
|
+
const name = secondComma === -1 ? afterFirst : afterFirst.slice(secondComma + 1);
|
|
76
|
+
if (Number.isInteger(l) && name)
|
|
77
|
+
pendingFnLines.set(name, l);
|
|
78
|
+
}
|
|
79
|
+
else if (line.startsWith("FNDA:") || line.startsWith("FNA:")) {
|
|
80
|
+
const rest = line.slice(line.startsWith("FNA:") ? 4 : 5);
|
|
81
|
+
const commaIdx = rest.indexOf(",");
|
|
82
|
+
if (commaIdx === -1)
|
|
83
|
+
continue;
|
|
84
|
+
const h = Number.parseInt(rest.slice(0, commaIdx), 10);
|
|
85
|
+
const name = rest.slice(commaIdx + 1);
|
|
86
|
+
if (!Number.isInteger(h) || !name)
|
|
87
|
+
continue;
|
|
88
|
+
const startLine = pendingFnLines.get(name) ?? 0;
|
|
89
|
+
const key = startLine > 0 ? `${name}@${startLine}` : name;
|
|
90
|
+
const fnLoc = loc(startLine);
|
|
91
|
+
if (fileCov.fnMap[key] === undefined) {
|
|
92
|
+
fileCov.fnMap[key] = { name, decl: fnLoc, loc: fnLoc };
|
|
93
|
+
fileCov.f[key] = h;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
fileCov.f[key] = fileCov.f[key] + h;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else if (line.startsWith("BRDA:")) {
|
|
100
|
+
const parts = line.slice(5).split(",", 4);
|
|
101
|
+
const lineNo = Number.parseInt(parts[0], 10);
|
|
102
|
+
const blockId = parts[1] ?? "";
|
|
103
|
+
const branchId = parts[2] ?? "";
|
|
104
|
+
const taken = parts[3] === "-" ? 0 : Number.parseInt(parts[3] ?? "", 10);
|
|
105
|
+
if (!Number.isInteger(lineNo) || !blockId || !branchId || !Number.isInteger(taken))
|
|
106
|
+
continue;
|
|
107
|
+
const blockKey = `${lineNo}-${blockId}`;
|
|
108
|
+
let fileBlocks = fileBranches.get(filePath);
|
|
109
|
+
if (!fileBlocks) {
|
|
110
|
+
fileBlocks = new Map();
|
|
111
|
+
fileBranches.set(filePath, fileBlocks);
|
|
112
|
+
}
|
|
113
|
+
let blockBranches = fileBlocks.get(blockKey);
|
|
114
|
+
if (!blockBranches) {
|
|
115
|
+
blockBranches = new Map();
|
|
116
|
+
fileBlocks.set(blockKey, blockBranches);
|
|
117
|
+
}
|
|
118
|
+
blockBranches.set(branchId, (blockBranches.get(branchId) ?? 0) + taken);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
flush();
|
|
122
|
+
// Convert per-file branch accumulator to Istanbul branchMap/b arrays.
|
|
123
|
+
// All records for a file are fully merged before sorting, so branchId
|
|
124
|
+
// sets that differ across records are handled correctly.
|
|
125
|
+
for (const [fp, blocks] of fileBranches) {
|
|
126
|
+
const fileCov = coverage[fp]; // fp was validated against coverage when added to fileBranches
|
|
127
|
+
for (const [blockKey, branches] of blocks) {
|
|
128
|
+
const lineNo = Number.parseInt(blockKey.split("-")[0], 10); // blockKey is always "N-blockId"
|
|
129
|
+
const branchLoc = loc(lineNo);
|
|
130
|
+
const sorted = [...branches.entries()].sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true }));
|
|
131
|
+
fileCov.branchMap[blockKey] = {
|
|
132
|
+
loc: branchLoc,
|
|
133
|
+
type: "branch",
|
|
134
|
+
locations: sorted.map(() => branchLoc),
|
|
135
|
+
};
|
|
136
|
+
fileCov.b[blockKey] = sorted.map(([, count]) => count);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return coverage;
|
|
140
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coverage-check",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Patch-coverage gate: checks that newly added lines meet per-path coverage thresholds. Supports per-suite LCOV accumulation for conditional CI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jonathan Ong",
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@aws-sdk/client-s3": "^3.1048.0",
|
|
35
|
-
"js-yaml": "^4.1.1"
|
|
35
|
+
"js-yaml": "^4.1.1",
|
|
36
|
+
"monocart-coverage-reports": "^2.12.11"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
38
39
|
"@types/js-yaml": "^4.0.9",
|