coverage-check 0.1.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/LICENSE +21 -0
- package/README.md +152 -0
- package/bin/coverage-check.mts +6 -0
- package/package.json +59 -0
- package/src/cli.mts +15 -0
- package/src/cli.test.mts +45 -0
- package/src/commands/check.mts +200 -0
- package/src/commands/check.test.mts +642 -0
- package/src/commands/store-put.mts +100 -0
- package/src/commands/store-put.test.mts +154 -0
- package/src/coverage-check.mts +15 -0
- package/src/diff-parser.mts +127 -0
- package/src/diff-parser.test.mts +178 -0
- package/src/github-comment.mts +74 -0
- package/src/github-comment.test.mts +64 -0
- package/src/lcov-merge.mts +34 -0
- package/src/lcov-merge.test.mts +57 -0
- package/src/lcov-parser.mts +46 -0
- package/src/lcov-parser.test.mts +86 -0
- package/src/load-artifacts.mts +42 -0
- package/src/load-artifacts.test.mts +115 -0
- package/src/patch-coverage.mts +82 -0
- package/src/patch-coverage.test.mts +91 -0
- package/src/report.mts +87 -0
- package/src/report.test.mts +159 -0
- package/src/rules.mts +34 -0
- package/src/rules.test.mts +98 -0
- package/src/suite-store.mts +62 -0
- package/src/suite-store.test.mts +115 -0
- package/src/types.mts +43 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { parseLcov } from "../lcov-parser.mts";
|
|
3
|
+
import { mergeLcov, toLcov } from "../lcov-merge.mts";
|
|
4
|
+
import { collectLcovFiles, buildStripPrefixes } from "../load-artifacts.mts";
|
|
5
|
+
import { FileSystemSuiteStore } from "../suite-store.mts";
|
|
6
|
+
|
|
7
|
+
const stdout = (msg: string) => process.stdout.write(`${msg}\n`);
|
|
8
|
+
const stderr = (msg: string) => process.stderr.write(`${msg}\n`);
|
|
9
|
+
|
|
10
|
+
export type StorePutArgs = {
|
|
11
|
+
suite: string;
|
|
12
|
+
store: string;
|
|
13
|
+
artifacts: string;
|
|
14
|
+
stripPrefixes: string[];
|
|
15
|
+
sha: string | null;
|
|
16
|
+
ref: string | null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function parseArgs(argv: string[]): StorePutArgs {
|
|
20
|
+
const args: StorePutArgs = {
|
|
21
|
+
suite: "",
|
|
22
|
+
store: "",
|
|
23
|
+
artifacts: "./coverage-artifacts",
|
|
24
|
+
stripPrefixes: [],
|
|
25
|
+
sha: null,
|
|
26
|
+
ref: null,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
for (let i = 0; i < argv.length; i++) {
|
|
30
|
+
const flag = argv[i]!;
|
|
31
|
+
const next = argv[i + 1];
|
|
32
|
+
const val = (): string => {
|
|
33
|
+
if (next === undefined) throw new Error(`${flag} requires a value`);
|
|
34
|
+
i++;
|
|
35
|
+
return next;
|
|
36
|
+
};
|
|
37
|
+
switch (flag) {
|
|
38
|
+
case "--suite":
|
|
39
|
+
args.suite = val();
|
|
40
|
+
break;
|
|
41
|
+
case "--store":
|
|
42
|
+
args.store = val();
|
|
43
|
+
break;
|
|
44
|
+
case "--artifacts":
|
|
45
|
+
args.artifacts = val();
|
|
46
|
+
break;
|
|
47
|
+
case "--strip-prefix":
|
|
48
|
+
args.stripPrefixes.push(val());
|
|
49
|
+
break;
|
|
50
|
+
case "--sha":
|
|
51
|
+
args.sha = val();
|
|
52
|
+
break;
|
|
53
|
+
case "--ref":
|
|
54
|
+
args.ref = val();
|
|
55
|
+
break;
|
|
56
|
+
default:
|
|
57
|
+
throw new Error(`unknown flag: ${flag}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!args.suite) throw new Error("--suite is required");
|
|
62
|
+
if (!args.store) throw new Error("--store is required");
|
|
63
|
+
return args;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function main(argv: string[]): Promise<number> {
|
|
67
|
+
let args: StorePutArgs;
|
|
68
|
+
try {
|
|
69
|
+
args = parseArgs(argv);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
/* c8 ignore next */
|
|
72
|
+
stderr(`coverage-check store-put: ${err instanceof Error ? err.message : err}`);
|
|
73
|
+
return 2;
|
|
74
|
+
}
|
|
75
|
+
return runStorePut(args);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function runStorePut(args: StorePutArgs): Promise<number> {
|
|
79
|
+
const lcovFiles = collectLcovFiles(args.artifacts);
|
|
80
|
+
if (lcovFiles.length === 0) {
|
|
81
|
+
stderr(`coverage-check store-put: no lcov.info files found under ${args.artifacts}`);
|
|
82
|
+
return 2;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const stripPrefixes = buildStripPrefixes(args.stripPrefixes);
|
|
86
|
+
const reports = lcovFiles.map((f) => parseLcov(readFileSync(f, "utf8"), stripPrefixes));
|
|
87
|
+
const merged = mergeLcov(reports);
|
|
88
|
+
const lcovText = toLcov(merged);
|
|
89
|
+
|
|
90
|
+
const store = new FileSystemSuiteStore(args.store);
|
|
91
|
+
await store.put(args.suite, Buffer.from(lcovText, "utf8"), {
|
|
92
|
+
sha: args.sha ?? undefined,
|
|
93
|
+
ref: args.ref ?? undefined,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
stdout(
|
|
97
|
+
`coverage-check store-put: stored suite "${args.suite}" (${lcovFiles.length} file(s)) → ${args.store}`,
|
|
98
|
+
);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { main, runStorePut } from "./store-put.mts";
|
|
6
|
+
import { FileSystemSuiteStore } from "../suite-store.mts";
|
|
7
|
+
|
|
8
|
+
describe("main argument parsing", () => {
|
|
9
|
+
it("returns 2 when --suite is missing", async () => {
|
|
10
|
+
expect(await main(["--store", "/tmp/store"])).toBe(2);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns 2 when --store is missing", async () => {
|
|
14
|
+
expect(await main(["--suite", "backend"])).toBe(2);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns 2 on unknown flag", async () => {
|
|
18
|
+
expect(await main(["--suite", "backend", "--store", "/tmp/s", "--unknown"])).toBe(2);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("returns 2 when a flag is missing its value", async () => {
|
|
22
|
+
expect(await main(["--suite"])).toBe(2);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("runStorePut", () => {
|
|
27
|
+
let tmpDir: string;
|
|
28
|
+
let artifactsDir: string;
|
|
29
|
+
let storeDir: string;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
tmpDir = mkdtempSync(join(tmpdir(), "store-put-test-"));
|
|
33
|
+
artifactsDir = join(tmpDir, "artifacts");
|
|
34
|
+
storeDir = join(tmpDir, "store");
|
|
35
|
+
mkdirSync(artifactsDir);
|
|
36
|
+
mkdirSync(storeDir);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns 2 when no lcov.info files found in artifacts", async () => {
|
|
44
|
+
expect(
|
|
45
|
+
await runStorePut({
|
|
46
|
+
suite: "backend",
|
|
47
|
+
store: storeDir,
|
|
48
|
+
artifacts: artifactsDir,
|
|
49
|
+
stripPrefixes: [],
|
|
50
|
+
sha: null,
|
|
51
|
+
ref: null,
|
|
52
|
+
}),
|
|
53
|
+
).toBe(2);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("stores merged lcov from artifacts and returns 0", async () => {
|
|
57
|
+
writeFileSync(
|
|
58
|
+
join(artifactsDir, "lcov.info"),
|
|
59
|
+
"SF:backend/foo.mts\nDA:1,1\nDA:2,0\nend_of_record\n",
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
expect(
|
|
63
|
+
await runStorePut({
|
|
64
|
+
suite: "backend",
|
|
65
|
+
store: storeDir,
|
|
66
|
+
artifacts: artifactsDir,
|
|
67
|
+
stripPrefixes: [],
|
|
68
|
+
sha: "abc123",
|
|
69
|
+
ref: "refs/heads/main",
|
|
70
|
+
}),
|
|
71
|
+
).toBe(0);
|
|
72
|
+
|
|
73
|
+
const store = new FileSystemSuiteStore(storeDir);
|
|
74
|
+
const suites = await store.list();
|
|
75
|
+
expect(suites).toContain("backend");
|
|
76
|
+
|
|
77
|
+
const buf = await store.get("backend");
|
|
78
|
+
expect(buf).not.toBeNull();
|
|
79
|
+
const lcovText = buf!.toString();
|
|
80
|
+
expect(lcovText).toContain("SF:backend/foo.mts");
|
|
81
|
+
expect(lcovText).toContain("DA:1,1");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("merges multiple lcov files from nested subdirectories", async () => {
|
|
85
|
+
const sub = join(artifactsDir, "shard1");
|
|
86
|
+
mkdirSync(sub);
|
|
87
|
+
writeFileSync(join(artifactsDir, "lcov.info"), "SF:backend/a.mts\nDA:1,1\nend_of_record\n");
|
|
88
|
+
writeFileSync(join(sub, "lcov.info"), "SF:backend/b.mts\nDA:2,1\nend_of_record\n");
|
|
89
|
+
|
|
90
|
+
expect(
|
|
91
|
+
await runStorePut({
|
|
92
|
+
suite: "backend",
|
|
93
|
+
store: storeDir,
|
|
94
|
+
artifacts: artifactsDir,
|
|
95
|
+
stripPrefixes: [],
|
|
96
|
+
sha: null,
|
|
97
|
+
ref: null,
|
|
98
|
+
}),
|
|
99
|
+
).toBe(0);
|
|
100
|
+
|
|
101
|
+
const store = new FileSystemSuiteStore(storeDir);
|
|
102
|
+
const buf = await store.get("backend");
|
|
103
|
+
const lcovText = buf!.toString();
|
|
104
|
+
expect(lcovText).toContain("SF:backend/a.mts");
|
|
105
|
+
expect(lcovText).toContain("SF:backend/b.mts");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("accepts --strip-prefix flag", async () => {
|
|
109
|
+
writeFileSync(
|
|
110
|
+
join(artifactsDir, "lcov.info"),
|
|
111
|
+
"SF:/home/runner/work/repo/backend/foo.mts\nDA:1,1\nend_of_record\n",
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(
|
|
115
|
+
await main([
|
|
116
|
+
"--suite",
|
|
117
|
+
"backend",
|
|
118
|
+
"--store",
|
|
119
|
+
storeDir,
|
|
120
|
+
"--artifacts",
|
|
121
|
+
artifactsDir,
|
|
122
|
+
"--strip-prefix",
|
|
123
|
+
"/home/runner/work/repo",
|
|
124
|
+
]),
|
|
125
|
+
).toBe(0);
|
|
126
|
+
|
|
127
|
+
const store = new FileSystemSuiteStore(storeDir);
|
|
128
|
+
const buf = await store.get("backend");
|
|
129
|
+
// After stripping the prefix, the stored lcov should have the normalized path
|
|
130
|
+
expect(buf!.toString()).toContain("SF:backend/foo.mts");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("round-trips through main() CLI interface", async () => {
|
|
134
|
+
writeFileSync(join(artifactsDir, "lcov.info"), "SF:web/app.tsx\nDA:10,1\nend_of_record\n");
|
|
135
|
+
|
|
136
|
+
expect(
|
|
137
|
+
await main([
|
|
138
|
+
"--suite",
|
|
139
|
+
"frontend",
|
|
140
|
+
"--store",
|
|
141
|
+
storeDir,
|
|
142
|
+
"--artifacts",
|
|
143
|
+
artifactsDir,
|
|
144
|
+
"--sha",
|
|
145
|
+
"deadbeef",
|
|
146
|
+
"--ref",
|
|
147
|
+
"refs/heads/feat",
|
|
148
|
+
]),
|
|
149
|
+
).toBe(0);
|
|
150
|
+
|
|
151
|
+
const stored = readFileSync(join(storeDir, "frontend", "lcov.info"), "utf8");
|
|
152
|
+
expect(stored).toContain("SF:web/app.tsx");
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { runCheck } from "./commands/check.mts";
|
|
2
|
+
export { runStorePut } from "./commands/store-put.mts";
|
|
3
|
+
export { FileSystemSuiteStore } from "./suite-store.mts";
|
|
4
|
+
|
|
5
|
+
export type { CheckArgs } from "./commands/check.mts";
|
|
6
|
+
export type { StorePutArgs } from "./commands/store-put.mts";
|
|
7
|
+
export type { SuiteStore, SuiteMeta } from "./suite-store.mts";
|
|
8
|
+
export type {
|
|
9
|
+
CoverageCheckResult,
|
|
10
|
+
BucketResult,
|
|
11
|
+
FileCoverageResult,
|
|
12
|
+
LcovData,
|
|
13
|
+
DiffLines,
|
|
14
|
+
CoverageRule,
|
|
15
|
+
} from "./types.mts";
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { DiffLines } from "./types.mts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Decodes a git C-string (inner content between surrounding double-quotes).
|
|
5
|
+
* Git quotes unusual paths (non-ASCII, spaces, etc.) with core.quotePath=true.
|
|
6
|
+
* Handles octal byte escapes (\nnn), \\, \", \n, \t.
|
|
7
|
+
*/
|
|
8
|
+
export function decodeGitCString(s: string): string {
|
|
9
|
+
const bytes: number[] = [];
|
|
10
|
+
for (let i = 0; i < s.length; ) {
|
|
11
|
+
if (s[i] === "\\" && i + 1 < s.length) {
|
|
12
|
+
const next = s[i + 1]!;
|
|
13
|
+
if (next >= "0" && next <= "7") {
|
|
14
|
+
bytes.push(parseInt(s.slice(i + 1, i + 4), 8));
|
|
15
|
+
i += 4;
|
|
16
|
+
} else if (next === "\\") {
|
|
17
|
+
bytes.push(92);
|
|
18
|
+
i += 2;
|
|
19
|
+
} else if (next === '"') {
|
|
20
|
+
bytes.push(34);
|
|
21
|
+
i += 2;
|
|
22
|
+
} else if (next === "n") {
|
|
23
|
+
bytes.push(10);
|
|
24
|
+
i += 2;
|
|
25
|
+
} else if (next === "t") {
|
|
26
|
+
bytes.push(9);
|
|
27
|
+
i += 2;
|
|
28
|
+
} else {
|
|
29
|
+
bytes.push(s.charCodeAt(i));
|
|
30
|
+
i++;
|
|
31
|
+
}
|
|
32
|
+
} else {
|
|
33
|
+
bytes.push(s.charCodeAt(i));
|
|
34
|
+
i++;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return Buffer.from(new Uint8Array(bytes)).toString("utf8");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parses the output of `git diff --unified=0` into a map of
|
|
42
|
+
* repo-root-relative file path → set of added/modified line numbers.
|
|
43
|
+
*
|
|
44
|
+
* Only added lines (lines in the new version) are tracked. Deleted-only
|
|
45
|
+
* hunks (where the `+` count is 0) are skipped.
|
|
46
|
+
*/
|
|
47
|
+
export function parseDiff(text: string): DiffLines {
|
|
48
|
+
const result: DiffLines = new Map();
|
|
49
|
+
let currentLines: Set<number> | null = null;
|
|
50
|
+
let inHeader = false;
|
|
51
|
+
|
|
52
|
+
for (const raw of text.split("\n")) {
|
|
53
|
+
const line = raw.trimEnd();
|
|
54
|
+
|
|
55
|
+
// Only parse +++ as a file header when we are in the diff header block
|
|
56
|
+
// (after `diff --git` / `---`). Without this guard a source line beginning
|
|
57
|
+
// with `++ b/` would appear as `+++ b/…` in the diff and be misclassified.
|
|
58
|
+
let newFilePath: string | null = null;
|
|
59
|
+
if (inHeader) {
|
|
60
|
+
if (line.startsWith("+++ b/")) {
|
|
61
|
+
newFilePath = line.slice(6);
|
|
62
|
+
} else if (line.startsWith('+++ "b/') && line.endsWith('"')) {
|
|
63
|
+
newFilePath = decodeGitCString(line.slice(5, -1)).slice(2);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (newFilePath !== null) {
|
|
68
|
+
inHeader = false;
|
|
69
|
+
const path = newFilePath;
|
|
70
|
+
if (path === "dev/null") {
|
|
71
|
+
currentLines = null;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
currentLines = result.get(path) ?? new Set();
|
|
75
|
+
result.set(path, currentLines);
|
|
76
|
+
} else if (line.startsWith("--- ")) {
|
|
77
|
+
// ignore (part of diff header)
|
|
78
|
+
} else if (line.startsWith("@@ ") && currentLines !== null) {
|
|
79
|
+
// @@ -old_start[,old_count] +new_start[,new_count] @@
|
|
80
|
+
const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
|
|
81
|
+
if (!match) continue;
|
|
82
|
+
const newStart = parseInt(match[1]!, 10);
|
|
83
|
+
const newCount = match[2] !== undefined ? parseInt(match[2], 10) : 1;
|
|
84
|
+
if (newCount === 0) continue;
|
|
85
|
+
for (let i = 0; i < newCount; i++) {
|
|
86
|
+
currentLines.add(newStart + i);
|
|
87
|
+
}
|
|
88
|
+
} else if (line.startsWith("diff --git ")) {
|
|
89
|
+
currentLines = null;
|
|
90
|
+
inHeader = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Runs git diff and returns the parsed result. */
|
|
98
|
+
export async function getChangedLines(baseRef: string, headRef: string): Promise<DiffLines> {
|
|
99
|
+
const { spawn } = await import("node:child_process");
|
|
100
|
+
const spawnProcess = (cmd: string, args: string[]) =>
|
|
101
|
+
new Promise<string>((resolve, reject) => {
|
|
102
|
+
const chunks: Buffer[] = [];
|
|
103
|
+
const proc = spawn(cmd, args, { stdio: ["ignore", "pipe", "inherit"] });
|
|
104
|
+
proc.stdout.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
105
|
+
proc.on("error", reject);
|
|
106
|
+
proc.on("close", (code) =>
|
|
107
|
+
code === 0
|
|
108
|
+
? resolve(Buffer.concat(chunks).toString("utf8"))
|
|
109
|
+
: reject(new Error(`${cmd} exited with code ${code}`)),
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const mergeBase = await spawnProcess("git", ["merge-base", baseRef, headRef]);
|
|
114
|
+
const base = mergeBase.trim();
|
|
115
|
+
// --src-prefix/--dst-prefix override diff.noprefix and diff.mnemonicPrefix git config
|
|
116
|
+
const diff = await spawnProcess("git", [
|
|
117
|
+
"diff",
|
|
118
|
+
"--unified=0",
|
|
119
|
+
"--inter-hunk-context=0",
|
|
120
|
+
"--no-color",
|
|
121
|
+
"--src-prefix=a/",
|
|
122
|
+
"--dst-prefix=b/",
|
|
123
|
+
base,
|
|
124
|
+
headRef,
|
|
125
|
+
]);
|
|
126
|
+
return parseDiff(diff);
|
|
127
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { decodeGitCString, parseDiff } from "./diff-parser.mts";
|
|
3
|
+
|
|
4
|
+
const SIMPLE_DIFF = `
|
|
5
|
+
diff --git a/web/components/Foo.tsx b/web/components/Foo.tsx
|
|
6
|
+
index abc..def 100644
|
|
7
|
+
--- a/web/components/Foo.tsx
|
|
8
|
+
+++ b/web/components/Foo.tsx
|
|
9
|
+
@@ -1,3 +1,5 @@
|
|
10
|
+
+import React from 'react'
|
|
11
|
+
+
|
|
12
|
+
export function Foo() {
|
|
13
|
+
- return null
|
|
14
|
+
+ return <div />
|
|
15
|
+
}
|
|
16
|
+
+export const BAR = 1
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
const DELETION_DIFF = `
|
|
20
|
+
diff --git a/backend/foo.mts b/backend/foo.mts
|
|
21
|
+
--- a/backend/foo.mts
|
|
22
|
+
+++ b/backend/foo.mts
|
|
23
|
+
@@ -5,3 +5,0 @@
|
|
24
|
+
-deleted line
|
|
25
|
+
-deleted line
|
|
26
|
+
-deleted line
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
const MULTI_FILE_DIFF = `
|
|
30
|
+
diff --git a/web/components/A.tsx b/web/components/A.tsx
|
|
31
|
+
--- a/web/components/A.tsx
|
|
32
|
+
+++ b/web/components/A.tsx
|
|
33
|
+
@@ -1,1 +1,2 @@
|
|
34
|
+
unchanged
|
|
35
|
+
+added
|
|
36
|
+
diff --git a/backend/b.mts b/backend/b.mts
|
|
37
|
+
--- a/backend/b.mts
|
|
38
|
+
+++ b/backend/b.mts
|
|
39
|
+
@@ -10,0 +11,1 @@
|
|
40
|
+
+new line
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
describe("parseDiff", () => {
|
|
44
|
+
it("parses added lines from a hunk", () => {
|
|
45
|
+
const result = parseDiff(SIMPLE_DIFF);
|
|
46
|
+
const fooLines = result.get("web/components/Foo.tsx")!;
|
|
47
|
+
expect(fooLines.has(1)).toBe(true);
|
|
48
|
+
expect(fooLines.has(5)).toBe(true);
|
|
49
|
+
expect(fooLines.size).toBe(5);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("skips pure-deletion hunks (count=0)", () => {
|
|
53
|
+
const result = parseDiff(DELETION_DIFF);
|
|
54
|
+
const lines = result.get("backend/foo.mts");
|
|
55
|
+
expect(!lines || lines.size === 0).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("handles multiple files", () => {
|
|
59
|
+
const result = parseDiff(MULTI_FILE_DIFF);
|
|
60
|
+
expect(result.has("web/components/A.tsx")).toBe(true);
|
|
61
|
+
expect(result.has("backend/b.mts")).toBe(true);
|
|
62
|
+
expect(result.get("backend/b.mts")?.has(11)).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("skips deleted files (+++ b/dev/null)", () => {
|
|
66
|
+
const diff = `
|
|
67
|
+
diff --git a/backend/deleted.mts b/backend/deleted.mts
|
|
68
|
+
--- a/backend/deleted.mts
|
|
69
|
+
+++ b/dev/null
|
|
70
|
+
@@ -1,3 +0,0 @@
|
|
71
|
+
-deleted line 1
|
|
72
|
+
-deleted line 2
|
|
73
|
+
-deleted line 3
|
|
74
|
+
`;
|
|
75
|
+
const result = parseDiff(diff);
|
|
76
|
+
expect(result.has("dev/null")).toBe(false);
|
|
77
|
+
expect(result.has("backend/deleted.mts")).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("handles hunk headers with trailing section context text", () => {
|
|
81
|
+
const diff = `
|
|
82
|
+
diff --git a/backend/x.mts b/backend/x.mts
|
|
83
|
+
--- a/backend/x.mts
|
|
84
|
+
+++ b/backend/x.mts
|
|
85
|
+
@@ -1,3 +1,5 @@ export function Foo() {
|
|
86
|
+
+import React from 'react'
|
|
87
|
+
+
|
|
88
|
+
export function Foo() {
|
|
89
|
+
- return null
|
|
90
|
+
+ return <div />
|
|
91
|
+
}
|
|
92
|
+
+export const BAR = 1
|
|
93
|
+
`;
|
|
94
|
+
const result = parseDiff(diff);
|
|
95
|
+
expect(result.get("backend/x.mts")?.size).toBe(5);
|
|
96
|
+
expect(result.get("backend/x.mts")?.has(1)).toBe(true);
|
|
97
|
+
expect(result.get("backend/x.mts")?.has(5)).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("handles comma-less hunk lines (single-line change)", () => {
|
|
101
|
+
const diff = `
|
|
102
|
+
diff --git a/backend/x.mts b/backend/x.mts
|
|
103
|
+
--- a/backend/x.mts
|
|
104
|
+
+++ b/backend/x.mts
|
|
105
|
+
@@ -1 +1 @@
|
|
106
|
+
-old
|
|
107
|
+
+new
|
|
108
|
+
`;
|
|
109
|
+
const result = parseDiff(diff);
|
|
110
|
+
expect(result.get("backend/x.mts")?.has(1)).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("does not misclassify content lines starting with +++ b/ as file headers", () => {
|
|
114
|
+
const diff = `
|
|
115
|
+
diff --git a/backend/foo.mts b/backend/foo.mts
|
|
116
|
+
--- a/backend/foo.mts
|
|
117
|
+
+++ b/backend/foo.mts
|
|
118
|
+
@@ -1,1 +1,2 @@
|
|
119
|
+
unchanged
|
|
120
|
+
+++ b/this-is-content-not-a-header
|
|
121
|
+
`;
|
|
122
|
+
const result = parseDiff(diff);
|
|
123
|
+
expect(result.has("backend/foo.mts")).toBe(true);
|
|
124
|
+
expect(result.has("this-is-content-not-a-header")).toBe(false);
|
|
125
|
+
expect(result.get("backend/foo.mts")?.has(2)).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("skips malformed hunk header lines (@@ with no valid pattern)", () => {
|
|
129
|
+
const diff = `
|
|
130
|
+
diff --git a/backend/x.mts b/backend/x.mts
|
|
131
|
+
--- a/backend/x.mts
|
|
132
|
+
+++ b/backend/x.mts
|
|
133
|
+
@@ bad hunk header @@
|
|
134
|
+
+new line
|
|
135
|
+
`;
|
|
136
|
+
const result = parseDiff(diff);
|
|
137
|
+
// malformed @@ line is skipped; no lines added
|
|
138
|
+
const lines = result.get("backend/x.mts");
|
|
139
|
+
expect(!lines || lines.size === 0).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("handles git-quoted paths (core.quotePath=true)", () => {
|
|
143
|
+
const diff = `
|
|
144
|
+
diff --git "a/backend/caf\\303\\251.mts" "b/backend/caf\\303\\251.mts"
|
|
145
|
+
--- "a/backend/caf\\303\\251.mts"
|
|
146
|
+
+++ "b/backend/caf\\303\\251.mts"
|
|
147
|
+
@@ -1,1 +1,2 @@
|
|
148
|
+
existing
|
|
149
|
+
+new line
|
|
150
|
+
`;
|
|
151
|
+
const result = parseDiff(diff);
|
|
152
|
+
expect(result.has("backend/café.mts")).toBe(true);
|
|
153
|
+
expect(result.get("backend/café.mts")?.has(2)).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("decodeGitCString", () => {
|
|
158
|
+
it("returns plain ASCII unchanged", () => {
|
|
159
|
+
expect(decodeGitCString("backend/foo.mts")).toBe("backend/foo.mts");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("decodes octal UTF-8 byte sequences", () => {
|
|
163
|
+
expect(decodeGitCString("caf\\303\\251.mts")).toBe("café.mts");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("decodes backslash and double-quote escapes", () => {
|
|
167
|
+
expect(decodeGitCString('path\\\\to\\"file')).toBe('path\\to"file');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("decodes \\n and \\t escapes", () => {
|
|
171
|
+
expect(decodeGitCString("line\\nbreak")).toBe("line\nbreak");
|
|
172
|
+
expect(decodeGitCString("tab\\there")).toBe("tab\there");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("passes through unknown escape sequences unchanged", () => {
|
|
176
|
+
expect(decodeGitCString("\\z")).toBe("\\z");
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { COMMENT_MARKER } from "./report.mts";
|
|
2
|
+
|
|
3
|
+
export type GhRunner = (args: string[]) => Promise<string>;
|
|
4
|
+
|
|
5
|
+
/* c8 ignore start */
|
|
6
|
+
async function defaultGhRunner(args: string[]): Promise<string> {
|
|
7
|
+
const { spawn } = await import("node:child_process");
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const chunks: Buffer[] = [];
|
|
10
|
+
const proc = spawn("gh", args, { stdio: ["ignore", "pipe", "inherit"] });
|
|
11
|
+
proc.stdout.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
12
|
+
proc.on("error", reject);
|
|
13
|
+
proc.on("close", (code: number | null) =>
|
|
14
|
+
code === 0
|
|
15
|
+
? resolve(Buffer.concat(chunks).toString("utf8"))
|
|
16
|
+
: reject(new Error(`gh ${args[0]} exited with code ${code}`)),
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
/* c8 ignore stop */
|
|
21
|
+
|
|
22
|
+
/** Finds the ID of the existing coverage-check sticky comment, if any. */
|
|
23
|
+
async function findExistingComment(repo: string, pr: number, gh: GhRunner): Promise<number | null> {
|
|
24
|
+
try {
|
|
25
|
+
const raw = await gh([
|
|
26
|
+
"api",
|
|
27
|
+
`repos/${repo}/issues/${pr}/comments`,
|
|
28
|
+
"--paginate",
|
|
29
|
+
"-q",
|
|
30
|
+
`first(.[] | select(.body | startswith("${COMMENT_MARKER}"))) | .id`,
|
|
31
|
+
]);
|
|
32
|
+
// --paginate applies the jq filter per page; take the first valid ID across all lines
|
|
33
|
+
const id = raw
|
|
34
|
+
.split("\n")
|
|
35
|
+
.map((line) => parseInt(line.trim(), 10))
|
|
36
|
+
.find((n) => Number.isFinite(n) && n > 0);
|
|
37
|
+
return id ?? null;
|
|
38
|
+
} catch (err) {
|
|
39
|
+
process.stderr.write(`coverage-check: warning: failed to look up existing comment: ${err}\n`);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Posts or updates the sticky coverage-check comment on a pull request.
|
|
46
|
+
*
|
|
47
|
+
* - On failure: upserts the failure comment body (POST if absent, PATCH if exists).
|
|
48
|
+
* - On pass: if a marker comment exists (from any prior run), replaces it with a
|
|
49
|
+
* pass body. If no marker comment exists, stays silent.
|
|
50
|
+
*/
|
|
51
|
+
export async function upsertComment(
|
|
52
|
+
body: string,
|
|
53
|
+
repo: string,
|
|
54
|
+
pr: number,
|
|
55
|
+
passed: boolean,
|
|
56
|
+
gh: GhRunner = defaultGhRunner,
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
const existingId = await findExistingComment(repo, pr, gh);
|
|
59
|
+
|
|
60
|
+
if (passed && existingId === null) return; // nothing to update
|
|
61
|
+
|
|
62
|
+
if (existingId !== null) {
|
|
63
|
+
await gh([
|
|
64
|
+
"api",
|
|
65
|
+
`repos/${repo}/issues/comments/${existingId}`,
|
|
66
|
+
"-X",
|
|
67
|
+
"PATCH",
|
|
68
|
+
"-f",
|
|
69
|
+
`body=${body}`,
|
|
70
|
+
]);
|
|
71
|
+
} else {
|
|
72
|
+
await gh(["api", `repos/${repo}/issues/${pr}/comments`, "-f", `body=${body}`]);
|
|
73
|
+
}
|
|
74
|
+
}
|