@levr-one/cli 0.12.7 → 0.12.9

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.
Files changed (35) hide show
  1. package/dist/{addHandler-ybX54_3f.js → addHandler-CD1pda0z.js} +7 -7
  2. package/dist/{appendHandler-CKkl9ymw.js → appendHandler-BYOoCNXJ.js} +9 -9
  3. package/dist/{artifact-upload-Ba801tb6.js → artifact-upload-t6kL20b5.js} +2 -2
  4. package/dist/{attachHandler-CfNvVAV2.js → attachHandler-Ojk7mc2E.js} +9 -9
  5. package/dist/bindingsHandler-Dxx2LIci.js +2781 -0
  6. package/dist/change-scope-DBmyRWZV.js +379 -0
  7. package/dist/changedCoverageHandler-B8Zh5WIK.js +200 -0
  8. package/dist/cli.js +300 -15
  9. package/dist/{connect-yq3fGXkW.js → connect-A5VSJUv5.js} +3 -3
  10. package/dist/coverage-defaults-Ct3KNnGl.js +24 -0
  11. package/dist/{currentHandler-PaLZKLGF.js → currentHandler-DmffKHyZ.js} +1 -1
  12. package/dist/dependencyAuditHandler-CdpW-IvY.js +291 -0
  13. package/dist/dist-DU3htaZ_.js +325 -0
  14. package/dist/{find-workspace-4Us2vEb7.js → find-workspace-IWolSyrf.js} +1 -1
  15. package/dist/gate-op-BINrIT2l.js +278 -0
  16. package/dist/{importHandler-bYBWYC_0.js → importHandler-86cjc2Zs.js} +6 -6
  17. package/dist/inspect-CAxNgTwj.js +4 -0
  18. package/dist/{inspect-BbL7kDe-.js → inspect-CinRBSv6.js} +2 -277
  19. package/dist/{listHandler-Du_YGzre.js → listHandler-BoCzGUDK.js} +5 -5
  20. package/dist/{loginHandler-PbKmDAGo.js → loginHandler-CXmNViCo.js} +6 -6
  21. package/dist/{logoutHandler-DxnzGPYx.js → logoutHandler-DwTURB_A.js} +2 -2
  22. package/dist/{pushHandler-BulraEwP.js → pushHandler-BkCmzQz7.js} +9 -330
  23. package/dist/{resolve-token-DKEXT7SA.js → resolve-token-D1zteEfm.js} +2 -2
  24. package/dist/{resolve-workspace-D8y7twod.js → resolve-workspace-CJN1plpd.js} +2 -2
  25. package/dist/{runHandler-B-u-0eRk.js → runHandler-BLkPzImG.js} +8 -7
  26. package/dist/{sdk-client-BgzKzVIR.js → sdk-client-CSy2LOEh.js} +1 -1
  27. package/dist/{selectHandler-C0gs-Q70.js → selectHandler-Dp74pefc.js} +5 -5
  28. package/dist/{statusHandler-Dnnh80ZN.js → statusHandler-ByTUMdKK.js} +3 -3
  29. package/dist/{token-refresh-BpFuU4Ij.js → token-refresh-DEmYbJ8V.js} +1 -1
  30. package/package.json +1 -1
  31. package/dist/inspect-EGW-cgVD.js +0 -3
  32. /package/dist/{env-DjeRwmZ7.js → env-Cv-anxJA.js} +0 -0
  33. /package/dist/{refsHandler--ySvLjmX.js → refsHandler-BPlcdJvR.js} +0 -0
  34. /package/dist/{sleep-CGBHjsj5.js → sleep-DNLj0FMk.js} +0 -0
  35. /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 };