@levr-one/cli 0.12.8 → 0.12.10
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/{addHandler-ybX54_3f.js → addHandler-CD1pda0z.js} +7 -7
- package/dist/{appendHandler-CKkl9ymw.js → appendHandler-BYOoCNXJ.js} +9 -9
- package/dist/{artifact-upload-Ba801tb6.js → artifact-upload-t6kL20b5.js} +2 -2
- package/dist/{attachHandler-CfNvVAV2.js → attachHandler-Ojk7mc2E.js} +9 -9
- package/dist/bindingsHandler-Dxx2LIci.js +2781 -0
- package/dist/change-scope-DBmyRWZV.js +379 -0
- package/dist/changedCoverageHandler-B8Zh5WIK.js +200 -0
- package/dist/cli.js +300 -15
- package/dist/{connect-yq3fGXkW.js → connect-A5VSJUv5.js} +3 -3
- package/dist/coverage-defaults-Ct3KNnGl.js +24 -0
- package/dist/{currentHandler-PaLZKLGF.js → currentHandler-DmffKHyZ.js} +1 -1
- package/dist/dependencyAuditHandler-CdpW-IvY.js +291 -0
- package/dist/dist-DU3htaZ_.js +325 -0
- package/dist/{find-workspace-4Us2vEb7.js → find-workspace-IWolSyrf.js} +1 -1
- package/dist/gate-op-BINrIT2l.js +278 -0
- package/dist/{importHandler-bYBWYC_0.js → importHandler-86cjc2Zs.js} +6 -6
- package/dist/inspect-CAxNgTwj.js +4 -0
- package/dist/{inspect-BbL7kDe-.js → inspect-CinRBSv6.js} +2 -277
- package/dist/{listHandler-Du_YGzre.js → listHandler-BoCzGUDK.js} +5 -5
- package/dist/{loginHandler-PbKmDAGo.js → loginHandler-CXmNViCo.js} +6 -6
- package/dist/{logoutHandler-DxnzGPYx.js → logoutHandler-DwTURB_A.js} +2 -2
- package/dist/{pushHandler-BulraEwP.js → pushHandler-BkCmzQz7.js} +9 -330
- package/dist/{resolve-token-DKEXT7SA.js → resolve-token-D1zteEfm.js} +2 -2
- package/dist/{resolve-workspace-D8y7twod.js → resolve-workspace-CJN1plpd.js} +2 -2
- package/dist/{runHandler-B-u-0eRk.js → runHandler-BLkPzImG.js} +8 -7
- package/dist/{sdk-client-BgzKzVIR.js → sdk-client-CSy2LOEh.js} +1 -1
- package/dist/{selectHandler-C0gs-Q70.js → selectHandler-Dp74pefc.js} +5 -5
- package/dist/{statusHandler-Dnnh80ZN.js → statusHandler-ByTUMdKK.js} +3 -3
- package/dist/{token-refresh-BpFuU4Ij.js → token-refresh-DEmYbJ8V.js} +1 -1
- package/package.json +1 -1
- package/dist/inspect-EGW-cgVD.js +0 -3
- /package/dist/{env-DjeRwmZ7.js → env-Cv-anxJA.js} +0 -0
- /package/dist/{refsHandler--ySvLjmX.js → refsHandler-BPlcdJvR.js} +0 -0
- /package/dist/{sleep-CGBHjsj5.js → sleep-DNLj0FMk.js} +0 -0
- /package/dist/{workspace-store-Bmw104Qa.js → workspace-store-BJyB_WEG.js} +0 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { detectCiEnvironment } from "./dist-DU3htaZ_.js";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path$1 from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
//#region src/commands/gates/change-scope.ts
|
|
7
|
+
const runGit = (args, cwd) => {
|
|
8
|
+
const r = spawnSync("git", [
|
|
9
|
+
"-c",
|
|
10
|
+
"core.quotePath=false",
|
|
11
|
+
...args
|
|
12
|
+
], {
|
|
13
|
+
cwd,
|
|
14
|
+
encoding: "utf8",
|
|
15
|
+
maxBuffer: 256 * 1024 * 1024
|
|
16
|
+
});
|
|
17
|
+
if (r.error) return {
|
|
18
|
+
code: 127,
|
|
19
|
+
stdout: "",
|
|
20
|
+
stderr: r.error.message
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
code: r.status ?? 1,
|
|
24
|
+
stdout: r.stdout,
|
|
25
|
+
stderr: r.stderr
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
var NoMergeBaseError = class extends Error {};
|
|
29
|
+
var GitError = class extends Error {};
|
|
30
|
+
function gitOrThrow(git, args, cwd) {
|
|
31
|
+
const r = git(args, cwd);
|
|
32
|
+
if (r.code !== 0) throw new GitError(`git ${args.join(" ")} failed (exit ${r.code}): ${r.stderr.trim()}`);
|
|
33
|
+
return r.stdout;
|
|
34
|
+
}
|
|
35
|
+
function refExists(git, ref, cwd) {
|
|
36
|
+
return git([
|
|
37
|
+
"rev-parse",
|
|
38
|
+
"--verify",
|
|
39
|
+
"--quiet",
|
|
40
|
+
`${ref}^{commit}`
|
|
41
|
+
], cwd).code === 0;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The `--base` value that means "not given" besides an empty one (ENG-7618
|
|
45
|
+
* D3). A gate binding cannot pass an empty argument (the server's check
|
|
46
|
+
* renderer refuses an empty argv element), so an omitted `base` param
|
|
47
|
+
* renders as this word instead.
|
|
48
|
+
*/
|
|
49
|
+
const AUTO_BASE = "auto";
|
|
50
|
+
/** The ref to diff against, and where it came from. Pure apart from `git`. */
|
|
51
|
+
function resolveBaseRef(git, cwd, base, ciTargetBranch) {
|
|
52
|
+
const flag = base?.trim();
|
|
53
|
+
if (flag && flag !== AUTO_BASE) return {
|
|
54
|
+
ref: flag,
|
|
55
|
+
source: "flag"
|
|
56
|
+
};
|
|
57
|
+
const target = ciTargetBranch?.trim();
|
|
58
|
+
if (target) {
|
|
59
|
+
const remote = `origin/${target}`;
|
|
60
|
+
return {
|
|
61
|
+
ref: refExists(git, remote, cwd) ? remote : target,
|
|
62
|
+
source: "ci"
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
ref: "origin/HEAD",
|
|
67
|
+
source: "origin-head"
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** Repo-relative pathspecs for `--scope` (resolved against `cwd`). */
|
|
71
|
+
function scopePathspecs(root, cwd, scope) {
|
|
72
|
+
const specs = [];
|
|
73
|
+
for (const raw of scope ?? []) {
|
|
74
|
+
const trimmed = raw.trim();
|
|
75
|
+
if (!trimmed) continue;
|
|
76
|
+
const rel = path$1.relative(root, path$1.resolve(cwd, trimmed));
|
|
77
|
+
if (rel === ".." || rel.startsWith(`..${path$1.sep}`) || path$1.isAbsolute(rel)) throw new GitError(`--scope "${raw}" resolves outside the repository`);
|
|
78
|
+
specs.push(rel === "" ? "." : rel.split(path$1.sep).join("/"));
|
|
79
|
+
}
|
|
80
|
+
return specs;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* `git diff --name-status -z -M` output → path, status and rename source.
|
|
84
|
+
* Entries are NUL-separated: `<status>\0<path>\0` or, for a rename or copy,
|
|
85
|
+
* `R<score>\0<old>\0<new>\0`.
|
|
86
|
+
*/
|
|
87
|
+
function parseNameStatus(out) {
|
|
88
|
+
const parts = out.split("\0");
|
|
89
|
+
const entries = [];
|
|
90
|
+
for (let i = 0; i < parts.length; i++) {
|
|
91
|
+
const code = parts[i];
|
|
92
|
+
if (!code) continue;
|
|
93
|
+
const kind = code[0];
|
|
94
|
+
if (kind === "R" || kind === "C") {
|
|
95
|
+
const oldPath = parts[++i] ?? "";
|
|
96
|
+
const newPath = parts[++i] ?? "";
|
|
97
|
+
entries.push(kind === "R" ? {
|
|
98
|
+
path: newPath,
|
|
99
|
+
status: "renamed",
|
|
100
|
+
oldPath
|
|
101
|
+
} : {
|
|
102
|
+
path: newPath,
|
|
103
|
+
status: "added"
|
|
104
|
+
});
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const file = parts[++i] ?? "";
|
|
108
|
+
if (kind === "A") entries.push({
|
|
109
|
+
path: file,
|
|
110
|
+
status: "added"
|
|
111
|
+
});
|
|
112
|
+
else if (kind === "D") entries.push({
|
|
113
|
+
path: file,
|
|
114
|
+
status: "deleted"
|
|
115
|
+
});
|
|
116
|
+
else entries.push({
|
|
117
|
+
path: file,
|
|
118
|
+
status: "modified"
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return entries;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* `git diff --numstat -z -M` output → the paths git counts as binary (`-` for
|
|
125
|
+
* both counts). Unlike the patch's `Binary files … differ` line, numstat marks
|
|
126
|
+
* a binary that is only renamed, only changes mode, or is deleted: observed
|
|
127
|
+
* with git 2.54, an exact (100%) binary rename and a mode-only binary change
|
|
128
|
+
* both print `-\t-`, never `0\t0` (the change-scope test asserts all three
|
|
129
|
+
* against real git, a TAB-named file included). Entries are
|
|
130
|
+
* `<add>\t<del>\t<path>\0`, or `<add>\t<del>\t\0<old>\0<new>\0` for a rename;
|
|
131
|
+
* a deleted file is keyed by its old path, as `ChangedFile.path` is.
|
|
132
|
+
*/
|
|
133
|
+
function parseNumstatBinary(out) {
|
|
134
|
+
const binary = /* @__PURE__ */ new Set();
|
|
135
|
+
const parts = out.split("\0");
|
|
136
|
+
for (let i = 0; i < parts.length; i++) {
|
|
137
|
+
const entry = parts[i];
|
|
138
|
+
if (!entry) continue;
|
|
139
|
+
const fields = /^([^\t]*)\t([^\t]*)\t([\s\S]*)$/.exec(entry);
|
|
140
|
+
if (!fields) continue;
|
|
141
|
+
const [, added, deleted, file] = fields;
|
|
142
|
+
let target = file ?? "";
|
|
143
|
+
if (target === "") {
|
|
144
|
+
i += 1;
|
|
145
|
+
target = parts[++i] ?? "";
|
|
146
|
+
}
|
|
147
|
+
if (added === "-" && deleted === "-" && target) binary.add(target);
|
|
148
|
+
}
|
|
149
|
+
return binary;
|
|
150
|
+
}
|
|
151
|
+
const C_ESCAPES = {
|
|
152
|
+
a: 7,
|
|
153
|
+
b: 8,
|
|
154
|
+
t: 9,
|
|
155
|
+
n: 10,
|
|
156
|
+
v: 11,
|
|
157
|
+
f: 12,
|
|
158
|
+
r: 13,
|
|
159
|
+
"\"": 34,
|
|
160
|
+
"\\": 92
|
|
161
|
+
};
|
|
162
|
+
/**
|
|
163
|
+
* A path as a patch header prints it → the repository path. git C-quotes a
|
|
164
|
+
* path holding a quote, backslash or control character (`"b/quo\"te.ts"`,
|
|
165
|
+
* octal escapes for non-UTF-8 bytes even with `core.quotePath=false`), and
|
|
166
|
+
* appends a TAB to an unquoted `---`/`+++` path that contains a space. Taken
|
|
167
|
+
* verbatim, either form misses the name-status path and the file's added
|
|
168
|
+
* lines silently become none. Returns null for `/dev/null`.
|
|
169
|
+
*/
|
|
170
|
+
function parseDiffPath(raw, side) {
|
|
171
|
+
let value = raw;
|
|
172
|
+
if (value.startsWith("\"") && value.endsWith("\"") && value.length >= 2) {
|
|
173
|
+
const bytes = [];
|
|
174
|
+
const body = value.slice(1, -1);
|
|
175
|
+
for (let i = 0; i < body.length; i++) {
|
|
176
|
+
const ch = body[i];
|
|
177
|
+
if (ch !== "\\") {
|
|
178
|
+
bytes.push(...Buffer.from(ch, "utf8"));
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const next = body[i + 1] ?? "";
|
|
182
|
+
const octal = /^[0-7]{3}/.exec(body.slice(i + 1));
|
|
183
|
+
if (octal) {
|
|
184
|
+
bytes.push(parseInt(octal[0], 8));
|
|
185
|
+
i += 3;
|
|
186
|
+
} else {
|
|
187
|
+
bytes.push(C_ESCAPES[next] ?? next.charCodeAt(0));
|
|
188
|
+
i += 1;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
value = Buffer.from(bytes).toString("utf8");
|
|
192
|
+
} else value = value.replace(/\t$/, "");
|
|
193
|
+
if (value === "/dev/null") return null;
|
|
194
|
+
const prefix = `${side}/`;
|
|
195
|
+
return value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* `git diff --unified=0` output → added line numbers per new-side path, and
|
|
199
|
+
* the paths git reports as binary. Hunk headers are `@@ -a[,b] +c[,d] @@`;
|
|
200
|
+
* `d` omitted means 1 and `d == 0` adds nothing.
|
|
201
|
+
*/
|
|
202
|
+
function parseUnifiedDiff(diff) {
|
|
203
|
+
const added = /* @__PURE__ */ new Map();
|
|
204
|
+
const binary = /* @__PURE__ */ new Set();
|
|
205
|
+
let current = null;
|
|
206
|
+
for (const line of diff.split("\n")) {
|
|
207
|
+
if (line.startsWith("diff --git ")) {
|
|
208
|
+
current = null;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (line.startsWith("+++ ")) {
|
|
212
|
+
current = parseDiffPath(line.slice(4), "b");
|
|
213
|
+
if (current !== null && !added.has(current)) added.set(current, []);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (line.startsWith("Binary files ")) {
|
|
217
|
+
const m = / and ("(?:[^"\\]|\\.)*"|.+) differ$/.exec(line);
|
|
218
|
+
const target = m?.[1] ? parseDiffPath(m[1], "b") : null;
|
|
219
|
+
if (target !== null) binary.add(target);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (line.startsWith("@@") && current !== null) {
|
|
223
|
+
const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
|
224
|
+
if (!m) continue;
|
|
225
|
+
const start = Number(m[1]);
|
|
226
|
+
const count = m[2] === void 0 ? 1 : Number(m[2]);
|
|
227
|
+
const lines = added.get(current) ?? [];
|
|
228
|
+
for (let n = 0; n < count; n++) lines.push(start + n);
|
|
229
|
+
added.set(current, lines);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
added,
|
|
234
|
+
binary
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/** True when the first 8000 bytes contain a NUL, git's own binary test. */
|
|
238
|
+
function looksBinary(content) {
|
|
239
|
+
return content.subarray(0, 8e3).includes(0);
|
|
240
|
+
}
|
|
241
|
+
/** Every line of an untracked file, 1..N (none for an empty or binary file). */
|
|
242
|
+
function allLines(absPath) {
|
|
243
|
+
let content;
|
|
244
|
+
try {
|
|
245
|
+
content = fs.readFileSync(absPath);
|
|
246
|
+
} catch {
|
|
247
|
+
return {
|
|
248
|
+
lines: [],
|
|
249
|
+
binary: false
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
if (looksBinary(content)) return {
|
|
253
|
+
lines: [],
|
|
254
|
+
binary: true
|
|
255
|
+
};
|
|
256
|
+
const text = content.toString("utf8");
|
|
257
|
+
if (text.length === 0) return {
|
|
258
|
+
lines: [],
|
|
259
|
+
binary: false
|
|
260
|
+
};
|
|
261
|
+
const count = text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
|
|
262
|
+
return {
|
|
263
|
+
lines: Array.from({ length: count }, (_, i) => i + 1),
|
|
264
|
+
binary: false
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
/** The change: base, merge base and every changed file inside the scope. */
|
|
268
|
+
function computeChangeScope(options) {
|
|
269
|
+
const git = options.git ?? runGit;
|
|
270
|
+
const root = path$1.resolve(gitOrThrow(git, ["rev-parse", "--show-toplevel"], options.cwd).trim());
|
|
271
|
+
const ciTarget = options.ciTargetBranch !== void 0 ? options.ciTargetBranch : detectCiEnvironment()?.pr_target_branch;
|
|
272
|
+
const { ref, source } = resolveBaseRef(git, root, options.base, ciTarget);
|
|
273
|
+
const mb = git([
|
|
274
|
+
"merge-base",
|
|
275
|
+
"HEAD",
|
|
276
|
+
ref
|
|
277
|
+
], root);
|
|
278
|
+
const mergeBase = mb.stdout.trim();
|
|
279
|
+
if (mb.code !== 0 || !mergeBase) throw new NoMergeBaseError(`no merge base between HEAD and ${ref} (from ${source === "flag" ? "--base" : source === "ci" ? "the CI PR target branch" : "origin/HEAD"})` + (mb.stderr.trim() ? `: ${mb.stderr.trim()}` : "") + ". Pass --base <ref>, or fetch the base branch first.");
|
|
280
|
+
const specs = scopePathspecs(root, options.cwd, options.scope);
|
|
281
|
+
const pathspec = specs.length > 0 ? ["--", ...specs] : [];
|
|
282
|
+
const statuses = parseNameStatus(gitOrThrow(git, [
|
|
283
|
+
"diff",
|
|
284
|
+
"--name-status",
|
|
285
|
+
"-z",
|
|
286
|
+
"-M",
|
|
287
|
+
"--no-ext-diff",
|
|
288
|
+
mergeBase,
|
|
289
|
+
...pathspec
|
|
290
|
+
], root));
|
|
291
|
+
const { added, binary } = parseUnifiedDiff(gitOrThrow(git, [
|
|
292
|
+
"diff",
|
|
293
|
+
"--unified=0",
|
|
294
|
+
"--no-color",
|
|
295
|
+
"--no-ext-diff",
|
|
296
|
+
"-M",
|
|
297
|
+
mergeBase,
|
|
298
|
+
...pathspec
|
|
299
|
+
], root));
|
|
300
|
+
for (const file of parseNumstatBinary(gitOrThrow(git, [
|
|
301
|
+
"diff",
|
|
302
|
+
"--numstat",
|
|
303
|
+
"-z",
|
|
304
|
+
"-M",
|
|
305
|
+
"--no-ext-diff",
|
|
306
|
+
mergeBase,
|
|
307
|
+
...pathspec
|
|
308
|
+
], root))) binary.add(file);
|
|
309
|
+
const files = statuses.map((s) => ({
|
|
310
|
+
...s,
|
|
311
|
+
addedLines: s.status === "deleted" ? [] : added.get(s.path) ?? [],
|
|
312
|
+
binary: binary.has(s.path),
|
|
313
|
+
untracked: false
|
|
314
|
+
}));
|
|
315
|
+
const untracked = gitOrThrow(git, [
|
|
316
|
+
"ls-files",
|
|
317
|
+
"--others",
|
|
318
|
+
"--exclude-standard",
|
|
319
|
+
"-z",
|
|
320
|
+
...pathspec
|
|
321
|
+
], root).split("\0").filter((p) => p.length > 0);
|
|
322
|
+
for (const file of untracked) {
|
|
323
|
+
const { lines, binary: isBinary } = allLines(path$1.join(root, file));
|
|
324
|
+
files.push({
|
|
325
|
+
path: file,
|
|
326
|
+
status: "added",
|
|
327
|
+
addedLines: lines,
|
|
328
|
+
binary: isBinary,
|
|
329
|
+
untracked: true
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
return {
|
|
333
|
+
root,
|
|
334
|
+
baseRef: ref,
|
|
335
|
+
baseSource: source,
|
|
336
|
+
mergeBase,
|
|
337
|
+
files
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* A file's content at the merge base through `git show`, or null when it did
|
|
342
|
+
* not exist there (an untracked or added file: every entry in it is new).
|
|
343
|
+
*/
|
|
344
|
+
function readAtBase(scope, file, git = runGit) {
|
|
345
|
+
if (file.untracked || file.status === "added") return null;
|
|
346
|
+
const atBase = file.oldPath ?? file.path;
|
|
347
|
+
const r = git(["show", `${scope.mergeBase}:${atBase}`], scope.root);
|
|
348
|
+
return r.code === 0 ? r.stdout : null;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* A minimal glob for exclusion lists, matched against a repo-relative path.
|
|
352
|
+
* `**` crosses directories, `*` and `?` do not. A pattern with no `/` matches
|
|
353
|
+
* the basename (`*.d.ts`, `build.rs`); a leading `**\/` also matches at the
|
|
354
|
+
* root (`**\/src/gen/**` matches `src/gen/x.ts`).
|
|
355
|
+
*/
|
|
356
|
+
function globToRegExp(glob) {
|
|
357
|
+
const source = glob.includes("/") ? glob : `**/${glob}`;
|
|
358
|
+
let re = "";
|
|
359
|
+
for (let i = 0; i < source.length; i++) {
|
|
360
|
+
const ch = source[i];
|
|
361
|
+
if (ch === "*") if (source[i + 1] === "*") if (source[i + 2] === "/") {
|
|
362
|
+
re += "(?:.*/)?";
|
|
363
|
+
i += 2;
|
|
364
|
+
} else {
|
|
365
|
+
re += ".*";
|
|
366
|
+
i += 1;
|
|
367
|
+
}
|
|
368
|
+
else re += "[^/]*";
|
|
369
|
+
else if (ch === "?") re += "[^/]";
|
|
370
|
+
else re += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
371
|
+
}
|
|
372
|
+
return /* @__PURE__ */ new RegExp(`^${re}$`);
|
|
373
|
+
}
|
|
374
|
+
function matchesAny(file, globs) {
|
|
375
|
+
return globs.some((g) => globToRegExp(g).test(file));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
//#endregion
|
|
379
|
+
export { GitError, NoMergeBaseError, computeChangeScope, matchesAny, readAtBase, runGit };
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { COVERAGE_DEFAULT_EXCLUDES } from "./coverage-defaults-Ct3KNnGl.js";
|
|
2
|
+
import "./dist-DU3htaZ_.js";
|
|
3
|
+
import { GitError, NoMergeBaseError, computeChangeScope, matchesAny } from "./change-scope-DBmyRWZV.js";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as path$1 from "node:path";
|
|
6
|
+
|
|
7
|
+
//#region src/commands/gates/changedCoverageHandler.ts
|
|
8
|
+
/**
|
|
9
|
+
* `levr gates changed-coverage` (ENG-7618 D4.2): line coverage over only the
|
|
10
|
+
* lines the change added, read from an lcov file. The `changed_line_coverage`
|
|
11
|
+
* gate runs it.
|
|
12
|
+
*
|
|
13
|
+
* A line counts only when lcov marks it executable (a `DA` record). Every
|
|
14
|
+
* in-scope changed `.ts`, `.tsx` or `.rs` file that is not excluded MUST have
|
|
15
|
+
* an `SF` record: a missing record is missing evidence, so it exits 2 naming
|
|
16
|
+
* the file, never "zero lines" (review R1 C1/C6).
|
|
17
|
+
*
|
|
18
|
+
* Exit 0 when coverage is at or above `--min`; 1 below it, printing every
|
|
19
|
+
* uncovered `file:line`; 2 for a usage error, no merge base, no lcov file, a
|
|
20
|
+
* changed file with no lcov record, or a crash. Zero changed executable lines
|
|
21
|
+
* passes and says so; fewer than SMALL_CHANGE_LINES passes as "not enforced"
|
|
22
|
+
* (the SonarQube small-change rule).
|
|
23
|
+
*/
|
|
24
|
+
/** Source extensions whose changed lines are measured. */
|
|
25
|
+
const COVERAGE_SOURCE_EXTENSIONS = [
|
|
26
|
+
".ts",
|
|
27
|
+
".tsx",
|
|
28
|
+
".rs"
|
|
29
|
+
];
|
|
30
|
+
/** Below this many changed executable lines the threshold is not enforced. */
|
|
31
|
+
const SMALL_CHANGE_LINES = 20;
|
|
32
|
+
/**
|
|
33
|
+
* Parse lcov `SF:` / `DA:<line>,<hits>` / `end_of_record`. Other records
|
|
34
|
+
* (functions, branches, checksums) are ignored. A file listed twice (two
|
|
35
|
+
* test runs merged) keeps the larger hit count per line.
|
|
36
|
+
*/
|
|
37
|
+
function parseLcov(text) {
|
|
38
|
+
const records = /* @__PURE__ */ new Map();
|
|
39
|
+
let current = null;
|
|
40
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
41
|
+
const line = raw.trim();
|
|
42
|
+
if (line.startsWith("SF:")) {
|
|
43
|
+
const file = line.slice(3);
|
|
44
|
+
current = records.get(file) ?? /* @__PURE__ */ new Map();
|
|
45
|
+
records.set(file, current);
|
|
46
|
+
} else if (line.startsWith("DA:") && current) {
|
|
47
|
+
const [lineNo, hits] = line.slice(3).split(",");
|
|
48
|
+
const n = Number(lineNo);
|
|
49
|
+
const h = Number(hits);
|
|
50
|
+
if (Number.isInteger(n) && Number.isFinite(h)) current.set(n, Math.max(current.get(n) ?? 0, h));
|
|
51
|
+
} else if (line === "end_of_record") current = null;
|
|
52
|
+
}
|
|
53
|
+
return records;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* lcov SF paths → repo-root-relative paths (review R2 C6). An absolute path
|
|
57
|
+
* is made relative to the repository root; a relative one is resolved
|
|
58
|
+
* against `lcovRoot` (repo-relative, e.g. `apps/backender` for an app-rooted
|
|
59
|
+
* run). A path outside the repository is returned in `outside`.
|
|
60
|
+
*/
|
|
61
|
+
function normaliseLcovPaths(records, repoRoot, lcovRoot) {
|
|
62
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
63
|
+
const outside = [];
|
|
64
|
+
const base = path$1.resolve(repoRoot, lcovRoot);
|
|
65
|
+
for (const [sf, lines] of records) {
|
|
66
|
+
const abs = path$1.isAbsolute(sf) ? path$1.normalize(sf) : path$1.resolve(base, sf);
|
|
67
|
+
const rel = path$1.relative(repoRoot, abs);
|
|
68
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${path$1.sep}`) || path$1.isAbsolute(rel)) {
|
|
69
|
+
outside.push(sf);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const key = rel.split(path$1.sep).join("/");
|
|
73
|
+
const merged = byFile.get(key) ?? /* @__PURE__ */ new Map();
|
|
74
|
+
for (const [n, h] of lines) merged.set(n, Math.max(merged.get(n) ?? 0, h));
|
|
75
|
+
byFile.set(key, merged);
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
byFile,
|
|
79
|
+
outside
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Every changed in-scope source file that is not excluded: each must have an
|
|
84
|
+
* lcov record. A file the change only shortened, or renamed untouched, is
|
|
85
|
+
* still changed, so it needs one too even though it adds no lines to count.
|
|
86
|
+
* Only a deleted file is exempt: there is nothing left to measure.
|
|
87
|
+
*/
|
|
88
|
+
function measuredFiles(files, excludes) {
|
|
89
|
+
return files.filter((f) => f.status !== "deleted" && COVERAGE_SOURCE_EXTENSIONS.some((ext) => f.path.endsWith(ext)) && !matchesAny(f.path, excludes));
|
|
90
|
+
}
|
|
91
|
+
/** Changed executable lines, covered lines and the uncovered `file:line`s. */
|
|
92
|
+
function measureCoverage(files, byFile) {
|
|
93
|
+
const result = {
|
|
94
|
+
executable: 0,
|
|
95
|
+
covered: 0,
|
|
96
|
+
uncovered: [],
|
|
97
|
+
missingRecords: []
|
|
98
|
+
};
|
|
99
|
+
for (const file of files) {
|
|
100
|
+
const hits = byFile.get(file.path);
|
|
101
|
+
if (!hits) {
|
|
102
|
+
result.missingRecords.push(file.path);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
for (const n of file.addedLines) {
|
|
106
|
+
const h = hits.get(n);
|
|
107
|
+
if (h === void 0) continue;
|
|
108
|
+
result.executable++;
|
|
109
|
+
if (h > 0) result.covered++;
|
|
110
|
+
else result.uncovered.push(`${file.path}:${n}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
function fail2(message) {
|
|
116
|
+
return {
|
|
117
|
+
exitCode: 2,
|
|
118
|
+
stdout: "",
|
|
119
|
+
stderr: `gates changed-coverage: ${message}\n`
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/** The command body; `execute…` below maps any throw to exit 2. */
|
|
123
|
+
function runChangedCoverage(flags, cwd = process.cwd(), scopeOptions = {}) {
|
|
124
|
+
const min = Number(flags.min);
|
|
125
|
+
if (flags.min.trim() === "" || !Number.isFinite(min) || min < 0 || min > 100) return fail2(`--min "${flags.min}" must be a number from 0 to 100`);
|
|
126
|
+
if (!flags.lcov.trim()) return fail2("--lcov is required");
|
|
127
|
+
const lcovPath = path$1.resolve(cwd, flags.lcov);
|
|
128
|
+
if (!fs.existsSync(lcovPath)) return fail2(`no lcov file at ${flags.lcov}; run the tests with lcov coverage first`);
|
|
129
|
+
try {
|
|
130
|
+
const scope = computeChangeScope({
|
|
131
|
+
cwd,
|
|
132
|
+
base: flags.base,
|
|
133
|
+
scope: flags.scope,
|
|
134
|
+
...scopeOptions
|
|
135
|
+
});
|
|
136
|
+
const excludes = [...COVERAGE_DEFAULT_EXCLUDES, ...flags.exclude ?? []];
|
|
137
|
+
const files = measuredFiles(scope.files, excludes);
|
|
138
|
+
const lcovTime = fs.statSync(lcovPath).mtimeMs;
|
|
139
|
+
const newer = files.filter((f) => {
|
|
140
|
+
const abs = path$1.join(scope.root, f.path);
|
|
141
|
+
return fs.existsSync(abs) && fs.statSync(abs).mtimeMs > lcovTime;
|
|
142
|
+
}).map((f) => f.path);
|
|
143
|
+
if (newer.length > 0) return fail2(`${flags.lcov} is older than ${newer.length} changed file(s) it would measure (${newer.join(", ")}); run the tests with coverage again so it describes the current code (a coverage file restored from a cache, or a git checkout, stash or rebase after the test run, does this too)`);
|
|
144
|
+
const { byFile, outside } = normaliseLcovPaths(parseLcov(fs.readFileSync(lcovPath, "utf8")), scope.root, flags["lcov-root"] || ".");
|
|
145
|
+
const notes = outside.length > 0 ? `ignored ${outside.length} lcov record(s) outside the repository: ${outside.join(", ")}\n` : "";
|
|
146
|
+
const result = measureCoverage(files, byFile);
|
|
147
|
+
if (result.missingRecords.length > 0) return {
|
|
148
|
+
exitCode: 2,
|
|
149
|
+
stdout: "",
|
|
150
|
+
stderr: notes + result.missingRecords.map((f) => `gates changed-coverage: no lcov record for ${f}\n`).join("") + `A changed source file with no lcov record is missing evidence, never zero lines. Run its tests with coverage, check --lcov-root (now "${flags["lcov-root"] || "."}"), or exclude it with --exclude.\n`
|
|
151
|
+
};
|
|
152
|
+
const where = `against ${scope.baseRef} (merge base ${scope.mergeBase.slice(0, 12)})`;
|
|
153
|
+
if (result.executable === 0) return {
|
|
154
|
+
exitCode: 0,
|
|
155
|
+
stdout: `${notes}PASS 0 changed executable lines in ${files.length} file(s) ${where}; nothing to measure\n`,
|
|
156
|
+
stderr: ""
|
|
157
|
+
};
|
|
158
|
+
const pct = 100 * result.covered / result.executable;
|
|
159
|
+
const summary = `${result.covered}/${result.executable} changed executable lines covered (${pct.toFixed(1)}%, min ${min}%) ${where}`;
|
|
160
|
+
if (result.executable < SMALL_CHANGE_LINES) return {
|
|
161
|
+
exitCode: 0,
|
|
162
|
+
stdout: `${notes}PASS ${result.executable} changed lines, not enforced (fewer than ${SMALL_CHANGE_LINES}): ${summary}\n`,
|
|
163
|
+
stderr: ""
|
|
164
|
+
};
|
|
165
|
+
if (pct >= min) return {
|
|
166
|
+
exitCode: 0,
|
|
167
|
+
stdout: `${notes}PASS ${summary}\n`,
|
|
168
|
+
stderr: ""
|
|
169
|
+
};
|
|
170
|
+
return {
|
|
171
|
+
exitCode: 1,
|
|
172
|
+
stdout: `${notes}FAIL ${summary}\nuncovered changed lines:\n` + result.uncovered.map((l) => ` ${l}\n`).join(""),
|
|
173
|
+
stderr: ""
|
|
174
|
+
};
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (error instanceof NoMergeBaseError || error instanceof GitError) return fail2(error.message);
|
|
177
|
+
return fail2(`crashed: ${error instanceof Error ? error.message : String(error)}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The whole command minus process I/O, so tests can drive it. Any throw —
|
|
182
|
+
* from validation, path resolution or anything below — is exit 2, never an
|
|
183
|
+
* uncaught error: stricli would exit 1 for it, which reads as a verdict.
|
|
184
|
+
*/
|
|
185
|
+
function executeChangedCoverage(flags, cwd, scopeOptions = {}) {
|
|
186
|
+
try {
|
|
187
|
+
return runChangedCoverage(flags, cwd ?? process.cwd(), scopeOptions);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
return fail2(`crashed: ${error instanceof Error ? error.message : String(error)}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function gatesChangedCoverageHandler(flags) {
|
|
193
|
+
const result = executeChangedCoverage(flags);
|
|
194
|
+
if (result.stdout.length > 0) this.process.stdout.write(result.stdout);
|
|
195
|
+
if (result.stderr.length > 0) this.process.stderr.write(result.stderr);
|
|
196
|
+
this.process.exitCode = result.exitCode;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
//#endregion
|
|
200
|
+
export { gatesChangedCoverageHandler };
|