coverage-check 0.2.2 → 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 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
  }
@@ -35,6 +35,7 @@ export async function runCheck(args) {
35
35
  const stripPrefixes = buildStripPrefixes(args.stripPrefixes);
36
36
  const reports = [];
37
37
  const suiteSources = [];
38
+ const parsedSources = [];
38
39
  if (args.store !== null) {
39
40
  const suites = await args.store.list();
40
41
  for (const suite of suites) {
@@ -45,6 +46,7 @@ export async function runCheck(args) {
45
46
  const lcov = parseLcov(buf.toString("utf8"), stripPrefixes);
46
47
  reports.push(lcov);
47
48
  suiteSources.push({ suite, source: "store", lcov });
49
+ parsedSources.push({ name: `suite '${suite}'`, lcov });
48
50
  }
49
51
  }
50
52
  }
@@ -54,6 +56,7 @@ export async function runCheck(args) {
54
56
  const lcov = parseLcov(readFileSync(f, "utf8"), stripPrefixes);
55
57
  reports.push(lcov);
56
58
  freshLcovs.push(lcov);
59
+ parsedSources.push({ name: `file '${f}'`, lcov });
57
60
  }
58
61
  if (freshLcovs.length > 0) {
59
62
  suiteSources.push({
@@ -75,6 +78,27 @@ export async function runCheck(args) {
75
78
  stderr(`coverage-check: git diff failed: ${err}`);
76
79
  return 2;
77
80
  }
81
+ if (diff.size > 0) {
82
+ for (const { name, lcov: sourceLcov } of parsedSources) {
83
+ let contributes = false;
84
+ for (const [file, changedLines] of diff) {
85
+ const fileLines = sourceLcov.get(file);
86
+ if (fileLines) {
87
+ for (const lineNo of changedLines) {
88
+ if (fileLines.has(lineNo)) {
89
+ contributes = true;
90
+ break;
91
+ }
92
+ }
93
+ }
94
+ if (contributes)
95
+ break;
96
+ }
97
+ if (!contributes) {
98
+ stderr(`coverage-check: warning: coverage from ${name} contributed 0 coverable lines to the patch result. This may indicate a path prefix mismatch.`);
99
+ }
100
+ }
101
+ }
78
102
  const { buckets, informational } = computePatchCoverage(diff, lcov, rules);
79
103
  const passed = buckets.every((b) => b.passed);
80
104
  const result = { buckets, informational, passed };
@@ -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,6 +1,7 @@
1
1
  import type { SuiteStore } from "../suite-store.mts";
2
2
  export type StorePutArgs = {
3
3
  suite: string;
4
+ suitePrefix: string;
4
5
  store: SuiteStore;
5
6
  artifacts: string;
6
7
  stripPrefixes: string[];
@@ -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
- assertSafePathComponent(args.suite, "suite");
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
- return runStorePut(args);
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
- stderr(`coverage-check store-put: no lcov.info files found under ${args.artifacts}`);
91
- return 2;
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,2 @@
1
+ import type { CoverageSummaryArgs } from "./types.mts";
2
+ export declare function parseCoverageSummaryArgs(argv: string[]): CoverageSummaryArgs;
@@ -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,2 @@
1
+ import type { SourceCoverageGroup, SuiteCoverage } from "./types.mts";
2
+ export declare function groupSuitesBySourceFolder(suites: SuiteCoverage[], branch: string, rulesFile?: string): SourceCoverageGroup[];
@@ -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,2 @@
1
+ export { buildCoverageSummary, groupSuitesBySourceFolder, main, parseCoverageSummaryArgs, renderCoverageSummaryMarkdown, suiteTotals, } from "./summary/index.mts";
2
+ export type { CoverageSummary, CoverageSummaryArgs, SuiteCoverage } from "./summary/index.mts";
@@ -0,0 +1 @@
1
+ export { buildCoverageSummary, groupSuitesBySourceFolder, main, parseCoverageSummaryArgs, renderCoverageSummaryMarkdown, suiteTotals, } from "./summary/index.mjs";
@@ -11,13 +11,21 @@ export function parseLcov(text, stripPrefixes = []) {
11
11
  const line = raw.trimEnd();
12
12
  if (line.startsWith("SF:")) {
13
13
  let path = line.slice(3);
14
+ let stripped = false;
14
15
  for (const prefix of stripPrefixes) {
15
16
  if (path.startsWith(prefix)) {
16
17
  path = path.slice(prefix.length);
18
+ stripped = true;
17
19
  break;
18
20
  }
19
21
  }
20
22
  path = normalizePath(path);
23
+ if (!stripped && (path.startsWith("/") || /^[A-Z]:\//i.test(path))) {
24
+ const match = path.match(/^.*?\/_?work\/([^/]+)\/\1\//);
25
+ if (match) {
26
+ path = path.slice(match[0].length);
27
+ }
28
+ }
21
29
  currentLines = result.get(path) ?? new Map();
22
30
  result.set(path, currentLines);
23
31
  }
@@ -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.2.2",
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",