coverage-check 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -5
- package/bin/coverage-check.mjs +4 -0
- package/dist/src/cli.d.mts +1 -0
- package/dist/src/cli.mjs +14 -0
- package/dist/src/commands/check-args.d.mts +20 -0
- package/dist/src/commands/check-args.mjs +89 -0
- package/dist/src/commands/check.d.mts +4 -0
- package/dist/src/commands/check.mjs +128 -0
- package/dist/src/commands/store-put.d.mts +11 -0
- package/dist/src/commands/store-put.mjs +104 -0
- package/{src/coverage-check.mts → dist/src/coverage-check.d.mts} +1 -9
- package/dist/src/coverage-check.mjs +4 -0
- package/dist/src/diff-parser.d.mts +17 -0
- package/dist/src/diff-parser.mjs +127 -0
- package/dist/src/github-comment.d.mts +9 -0
- package/dist/src/github-comment.mjs +66 -0
- package/dist/src/lcov-merge.d.mts +5 -0
- package/dist/src/lcov-merge.mjs +29 -0
- package/dist/src/lcov-parser.d.mts +8 -0
- package/dist/src/lcov-parser.mjs +44 -0
- package/dist/src/load-artifacts.d.mts +9 -0
- package/dist/src/load-artifacts.mjs +41 -0
- package/dist/src/patch-coverage.d.mts +5 -0
- package/dist/src/patch-coverage.mjs +65 -0
- package/dist/src/report.d.mts +4 -0
- package/dist/src/report.mjs +65 -0
- package/dist/src/rules.d.mts +4 -0
- package/dist/src/rules.mjs +30 -0
- package/dist/src/s3-suite-store.d.mts +28 -0
- package/dist/src/s3-suite-store.mjs +147 -0
- package/dist/src/s3-utils.d.mts +2 -0
- package/dist/src/s3-utils.mjs +14 -0
- package/dist/src/step-summary.d.mts +9 -0
- package/dist/src/step-summary.mjs +70 -0
- package/dist/src/store-factory.d.mts +11 -0
- package/dist/src/store-factory.mjs +23 -0
- package/dist/src/suite-store.d.mts +51 -0
- package/dist/src/suite-store.mjs +154 -0
- package/dist/src/types.d.mts +36 -0
- package/dist/src/types.mjs +1 -0
- package/package.json +19 -5
- package/bin/coverage-check.mts +0 -6
- package/src/cli.mts +0 -15
- package/src/cli.test.mts +0 -45
- package/src/commands/check-args.mts +0 -110
- package/src/commands/check.mts +0 -147
- package/src/commands/check.test.mts +0 -870
- package/src/commands/store-put.mts +0 -115
- package/src/commands/store-put.test.mts +0 -248
- package/src/diff-parser.mts +0 -127
- package/src/diff-parser.test.mts +0 -178
- package/src/github-comment.mts +0 -79
- package/src/github-comment.test.mts +0 -63
- package/src/lcov-merge.mts +0 -34
- package/src/lcov-merge.test.mts +0 -57
- package/src/lcov-parser.mts +0 -46
- package/src/lcov-parser.test.mts +0 -86
- package/src/load-artifacts.mts +0 -42
- package/src/load-artifacts.test.mts +0 -115
- package/src/patch-coverage.mts +0 -82
- package/src/patch-coverage.test.mts +0 -91
- package/src/report.mts +0 -78
- package/src/report.test.mts +0 -142
- package/src/rules.mts +0 -34
- package/src/rules.test.mts +0 -98
- package/src/s3-suite-store.mts +0 -138
- package/src/s3-suite-store.test.mts +0 -308
- package/src/step-summary.mts +0 -89
- package/src/step-summary.test.mts +0 -189
- package/src/store-factory.mts +0 -23
- package/src/store-factory.test.mts +0 -67
- package/src/suite-store.mts +0 -112
- package/src/suite-store.test.mts +0 -209
- package/src/types.mts +0 -43
|
@@ -1,115 +0,0 @@
|
|
|
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 { makeStore } from "../store-factory.mts";
|
|
6
|
-
import { assertSafePathComponent } from "../suite-store.mts";
|
|
7
|
-
import type { SuiteStore } from "../suite-store.mts";
|
|
8
|
-
|
|
9
|
-
const stdout = (msg: string) => process.stdout.write(`${msg}\n`);
|
|
10
|
-
const stderr = (msg: string) => process.stderr.write(`${msg}\n`);
|
|
11
|
-
|
|
12
|
-
export type StorePutArgs = {
|
|
13
|
-
suite: string;
|
|
14
|
-
store: SuiteStore;
|
|
15
|
-
artifacts: string;
|
|
16
|
-
stripPrefixes: string[];
|
|
17
|
-
sha: string;
|
|
18
|
-
branch: string;
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
function parseArgs(argv: string[]): StorePutArgs {
|
|
22
|
-
let storeFs: string | null = null;
|
|
23
|
-
let storeS3: string | null = null;
|
|
24
|
-
const args = {
|
|
25
|
-
suite: "",
|
|
26
|
-
artifacts: "./coverage-artifacts",
|
|
27
|
-
stripPrefixes: [] as string[],
|
|
28
|
-
sha: "",
|
|
29
|
-
branch: "",
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
for (let i = 0; i < argv.length; i++) {
|
|
33
|
-
const flag = argv[i]!;
|
|
34
|
-
const next = argv[i + 1];
|
|
35
|
-
const val = (): string => {
|
|
36
|
-
if (next === undefined || next.startsWith("--")) {
|
|
37
|
-
throw new Error(`${flag} requires a value`);
|
|
38
|
-
}
|
|
39
|
-
i++;
|
|
40
|
-
return next;
|
|
41
|
-
};
|
|
42
|
-
switch (flag) {
|
|
43
|
-
case "--suite":
|
|
44
|
-
args.suite = val();
|
|
45
|
-
break;
|
|
46
|
-
case "--store":
|
|
47
|
-
case "--store-fs":
|
|
48
|
-
storeFs = val();
|
|
49
|
-
break;
|
|
50
|
-
case "--store-s3":
|
|
51
|
-
storeS3 = val();
|
|
52
|
-
break;
|
|
53
|
-
case "--artifacts":
|
|
54
|
-
args.artifacts = val();
|
|
55
|
-
break;
|
|
56
|
-
case "--strip-prefix":
|
|
57
|
-
args.stripPrefixes.push(val());
|
|
58
|
-
break;
|
|
59
|
-
case "--sha":
|
|
60
|
-
args.sha = val();
|
|
61
|
-
break;
|
|
62
|
-
case "--branch":
|
|
63
|
-
args.branch = val();
|
|
64
|
-
break;
|
|
65
|
-
default:
|
|
66
|
-
throw new Error(`unknown flag: ${flag}`);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (!args.suite) throw new Error("--suite is required");
|
|
71
|
-
if (storeFs && storeS3) throw new Error("--store-fs and --store-s3 are mutually exclusive");
|
|
72
|
-
if (!storeFs && !storeS3) throw new Error("--store-fs/--store or --store-s3 is required");
|
|
73
|
-
if (!args.sha) throw new Error("--sha is required");
|
|
74
|
-
if (!args.branch) throw new Error("--branch is required");
|
|
75
|
-
assertSafePathComponent(args.suite, "suite");
|
|
76
|
-
assertSafePathComponent(args.sha, "sha");
|
|
77
|
-
assertSafePathComponent(args.branch, "branch");
|
|
78
|
-
|
|
79
|
-
const store = makeStore({ fs: storeFs, s3: storeS3 })!;
|
|
80
|
-
return { ...args, store };
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export async function main(argv: string[]): Promise<number> {
|
|
84
|
-
let args: StorePutArgs;
|
|
85
|
-
try {
|
|
86
|
-
args = parseArgs(argv);
|
|
87
|
-
} catch (err) {
|
|
88
|
-
stderr(`coverage-check store-put: ${String(err)}`);
|
|
89
|
-
return 2;
|
|
90
|
-
}
|
|
91
|
-
return runStorePut(args);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export async function runStorePut(args: StorePutArgs): Promise<number> {
|
|
95
|
-
const lcovFiles = collectLcovFiles(args.artifacts);
|
|
96
|
-
if (lcovFiles.length === 0) {
|
|
97
|
-
stderr(`coverage-check store-put: no lcov.info files found under ${args.artifacts}`);
|
|
98
|
-
return 2;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const stripPrefixes = buildStripPrefixes(args.stripPrefixes);
|
|
102
|
-
const reports = lcovFiles.map((f) => parseLcov(readFileSync(f, "utf8"), stripPrefixes));
|
|
103
|
-
const merged = mergeLcov(reports);
|
|
104
|
-
const lcovText = toLcov(merged);
|
|
105
|
-
|
|
106
|
-
await args.store.put(args.suite, Buffer.from(lcovText, "utf8"), {
|
|
107
|
-
sha: args.sha,
|
|
108
|
-
branch: args.branch,
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
stdout(
|
|
112
|
-
`coverage-check store-put: stored suite "${args.suite}" (${lcovFiles.length} file(s)) sha=${args.sha} branch=${args.branch}`,
|
|
113
|
-
);
|
|
114
|
-
return 0;
|
|
115
|
-
}
|
|
@@ -1,248 +0,0 @@
|
|
|
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, vi } 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", "--sha", "abc", "--branch", "main"])).toBe(2);
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it("returns 2 when --store is missing", async () => {
|
|
14
|
-
expect(await main(["--suite", "backend", "--sha", "abc", "--branch", "main"])).toBe(2);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it("returns 2 when --sha is missing", async () => {
|
|
18
|
-
expect(await main(["--suite", "backend", "--store", "/tmp/s", "--branch", "main"])).toBe(2);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("returns 2 when --branch is missing", async () => {
|
|
22
|
-
expect(await main(["--suite", "backend", "--store", "/tmp/s", "--sha", "abc"])).toBe(2);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it("returns 2 when a flag token follows as the value (e.g. --suite --store)", async () => {
|
|
26
|
-
expect(await main(["--suite", "--store"])).toBe(2);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("accepts --store-s3 flag (returns 2 when no lcov files, not unknown-flag error)", async () => {
|
|
30
|
-
const chunks: string[] = [];
|
|
31
|
-
vi.spyOn(process.stderr, "write").mockImplementation((c: unknown) => {
|
|
32
|
-
chunks.push(String(c));
|
|
33
|
-
return true;
|
|
34
|
-
});
|
|
35
|
-
try {
|
|
36
|
-
expect(
|
|
37
|
-
await main([
|
|
38
|
-
"--suite",
|
|
39
|
-
"backend",
|
|
40
|
-
"--store-s3",
|
|
41
|
-
"my-bucket/prefix",
|
|
42
|
-
"--sha",
|
|
43
|
-
"abc",
|
|
44
|
-
"--branch",
|
|
45
|
-
"main",
|
|
46
|
-
"--artifacts",
|
|
47
|
-
"/tmp/__nonexistent_dir__",
|
|
48
|
-
]),
|
|
49
|
-
).toBe(2);
|
|
50
|
-
const stderr = chunks.join("");
|
|
51
|
-
expect(stderr).toContain("no lcov.info files found");
|
|
52
|
-
expect(stderr).not.toContain("unknown flag");
|
|
53
|
-
} finally {
|
|
54
|
-
vi.restoreAllMocks();
|
|
55
|
-
}
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("returns 2 when both --store-fs and --store-s3 are provided", async () => {
|
|
59
|
-
expect(
|
|
60
|
-
await main([
|
|
61
|
-
"--suite",
|
|
62
|
-
"backend",
|
|
63
|
-
"--store-fs",
|
|
64
|
-
"/tmp/s",
|
|
65
|
-
"--store-s3",
|
|
66
|
-
"bucket",
|
|
67
|
-
"--sha",
|
|
68
|
-
"abc",
|
|
69
|
-
"--branch",
|
|
70
|
-
"main",
|
|
71
|
-
]),
|
|
72
|
-
).toBe(2);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("returns 2 on unknown flag", async () => {
|
|
76
|
-
expect(
|
|
77
|
-
await main([
|
|
78
|
-
"--suite",
|
|
79
|
-
"backend",
|
|
80
|
-
"--store",
|
|
81
|
-
"/tmp/s",
|
|
82
|
-
"--sha",
|
|
83
|
-
"abc",
|
|
84
|
-
"--branch",
|
|
85
|
-
"main",
|
|
86
|
-
"--unknown",
|
|
87
|
-
]),
|
|
88
|
-
).toBe(2);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("returns 2 when a flag is missing its value", async () => {
|
|
92
|
-
expect(await main(["--suite"])).toBe(2);
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
describe("runStorePut", () => {
|
|
97
|
-
let tmpDir: string;
|
|
98
|
-
let artifactsDir: string;
|
|
99
|
-
let storeDir: string;
|
|
100
|
-
let store: FileSystemSuiteStore;
|
|
101
|
-
|
|
102
|
-
beforeEach(() => {
|
|
103
|
-
tmpDir = mkdtempSync(join(tmpdir(), "store-put-test-"));
|
|
104
|
-
artifactsDir = join(tmpDir, "artifacts");
|
|
105
|
-
storeDir = join(tmpDir, "store");
|
|
106
|
-
mkdirSync(artifactsDir);
|
|
107
|
-
mkdirSync(storeDir);
|
|
108
|
-
store = new FileSystemSuiteStore(storeDir);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
afterEach(() => {
|
|
112
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("returns 2 when no lcov.info files found in artifacts", async () => {
|
|
116
|
-
expect(
|
|
117
|
-
await runStorePut({
|
|
118
|
-
suite: "backend",
|
|
119
|
-
store,
|
|
120
|
-
artifacts: artifactsDir,
|
|
121
|
-
stripPrefixes: [],
|
|
122
|
-
sha: "abc123",
|
|
123
|
-
branch: "main",
|
|
124
|
-
}),
|
|
125
|
-
).toBe(2);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it("stores merged lcov from artifacts and returns 0", async () => {
|
|
129
|
-
writeFileSync(
|
|
130
|
-
join(artifactsDir, "lcov.info"),
|
|
131
|
-
"SF:backend/foo.mts\nDA:1,1\nDA:2,0\nend_of_record\n",
|
|
132
|
-
);
|
|
133
|
-
|
|
134
|
-
expect(
|
|
135
|
-
await runStorePut({
|
|
136
|
-
suite: "backend",
|
|
137
|
-
store,
|
|
138
|
-
artifacts: artifactsDir,
|
|
139
|
-
stripPrefixes: [],
|
|
140
|
-
sha: "abc123",
|
|
141
|
-
branch: "main",
|
|
142
|
-
}),
|
|
143
|
-
).toBe(0);
|
|
144
|
-
|
|
145
|
-
const suites = await store.list();
|
|
146
|
-
expect(suites).toContain("backend");
|
|
147
|
-
|
|
148
|
-
const buf = await store.get("backend", { branch: "main" });
|
|
149
|
-
expect(buf).not.toBeNull();
|
|
150
|
-
const lcovText = buf!.toString();
|
|
151
|
-
expect(lcovText).toContain("SF:backend/foo.mts");
|
|
152
|
-
expect(lcovText).toContain("DA:1,1");
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("merges multiple lcov files from nested subdirectories", async () => {
|
|
156
|
-
const sub = join(artifactsDir, "shard1");
|
|
157
|
-
mkdirSync(sub);
|
|
158
|
-
writeFileSync(join(artifactsDir, "lcov.info"), "SF:backend/a.mts\nDA:1,1\nend_of_record\n");
|
|
159
|
-
writeFileSync(join(sub, "lcov.info"), "SF:backend/b.mts\nDA:2,1\nend_of_record\n");
|
|
160
|
-
|
|
161
|
-
expect(
|
|
162
|
-
await runStorePut({
|
|
163
|
-
suite: "backend",
|
|
164
|
-
store,
|
|
165
|
-
artifacts: artifactsDir,
|
|
166
|
-
stripPrefixes: [],
|
|
167
|
-
sha: "abc123",
|
|
168
|
-
branch: "main",
|
|
169
|
-
}),
|
|
170
|
-
).toBe(0);
|
|
171
|
-
|
|
172
|
-
const buf = await store.get("backend", { branch: "main" });
|
|
173
|
-
const lcovText = buf!.toString();
|
|
174
|
-
expect(lcovText).toContain("SF:backend/a.mts");
|
|
175
|
-
expect(lcovText).toContain("SF:backend/b.mts");
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it("accepts --strip-prefix flag via main()", async () => {
|
|
179
|
-
writeFileSync(
|
|
180
|
-
join(artifactsDir, "lcov.info"),
|
|
181
|
-
"SF:/home/runner/work/repo/backend/foo.mts\nDA:1,1\nend_of_record\n",
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
expect(
|
|
185
|
-
await main([
|
|
186
|
-
"--suite",
|
|
187
|
-
"backend",
|
|
188
|
-
"--store",
|
|
189
|
-
storeDir,
|
|
190
|
-
"--artifacts",
|
|
191
|
-
artifactsDir,
|
|
192
|
-
"--strip-prefix",
|
|
193
|
-
"/home/runner/work/repo",
|
|
194
|
-
"--sha",
|
|
195
|
-
"abc123",
|
|
196
|
-
"--branch",
|
|
197
|
-
"main",
|
|
198
|
-
]),
|
|
199
|
-
).toBe(0);
|
|
200
|
-
|
|
201
|
-
const buf = await store.get("backend", { branch: "main" });
|
|
202
|
-
expect(buf!.toString()).toContain("SF:backend/foo.mts");
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it("--store-fs is an alias for --store", async () => {
|
|
206
|
-
writeFileSync(join(artifactsDir, "lcov.info"), "SF:web/app.tsx\nDA:10,1\nend_of_record\n");
|
|
207
|
-
|
|
208
|
-
expect(
|
|
209
|
-
await main([
|
|
210
|
-
"--suite",
|
|
211
|
-
"frontend",
|
|
212
|
-
"--store-fs",
|
|
213
|
-
storeDir,
|
|
214
|
-
"--artifacts",
|
|
215
|
-
artifactsDir,
|
|
216
|
-
"--sha",
|
|
217
|
-
"deadbeef",
|
|
218
|
-
"--branch",
|
|
219
|
-
"main",
|
|
220
|
-
]),
|
|
221
|
-
).toBe(0);
|
|
222
|
-
|
|
223
|
-
const stored = readFileSync(join(storeDir, "frontend", "sha", "deadbeef", "lcov.info"), "utf8");
|
|
224
|
-
expect(stored).toContain("SF:web/app.tsx");
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
it("round-trips through main() CLI interface", async () => {
|
|
228
|
-
writeFileSync(join(artifactsDir, "lcov.info"), "SF:web/app.tsx\nDA:10,1\nend_of_record\n");
|
|
229
|
-
|
|
230
|
-
expect(
|
|
231
|
-
await main([
|
|
232
|
-
"--suite",
|
|
233
|
-
"frontend",
|
|
234
|
-
"--store",
|
|
235
|
-
storeDir,
|
|
236
|
-
"--artifacts",
|
|
237
|
-
artifactsDir,
|
|
238
|
-
"--sha",
|
|
239
|
-
"deadbeef",
|
|
240
|
-
"--branch",
|
|
241
|
-
"main",
|
|
242
|
-
]),
|
|
243
|
-
).toBe(0);
|
|
244
|
-
|
|
245
|
-
const stored = readFileSync(join(storeDir, "frontend", "sha", "deadbeef", "lcov.info"), "utf8");
|
|
246
|
-
expect(stored).toContain("SF:web/app.tsx");
|
|
247
|
-
});
|
|
248
|
-
});
|
package/src/diff-parser.mts
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
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
|
-
}
|
package/src/diff-parser.test.mts
DELETED
|
@@ -1,178 +0,0 @@
|
|
|
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
|
-
});
|