covgate 0.0.0-stage → 0.2.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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +502 -2
  3. package/bin/covgate-postinstall.js +13 -0
  4. package/bin/covgate.js +5 -0
  5. package/docs/ai-workflow.md +196 -0
  6. package/docs/design.md +327 -0
  7. package/docs/setup.md +163 -0
  8. package/package.json +48 -4
  9. package/schema/covgate.schema.json +295 -0
  10. package/src/annotations.js +224 -0
  11. package/src/args.js +189 -0
  12. package/src/cli.js +486 -0
  13. package/src/config.js +442 -0
  14. package/src/coverage/index.js +85 -0
  15. package/src/coverage/istanbul.js +215 -0
  16. package/src/coverage/lcov.js +234 -0
  17. package/src/coverage/model.js +316 -0
  18. package/src/coverage/paths.js +40 -0
  19. package/src/errors.js +34 -0
  20. package/src/gate.js +203 -0
  21. package/src/glob.js +242 -0
  22. package/src/index.js +28 -0
  23. package/src/process.js +170 -0
  24. package/src/reason.js +77 -0
  25. package/src/report/build.js +135 -0
  26. package/src/report/index.js +29 -0
  27. package/src/report/markdown.js +343 -0
  28. package/src/report/text.js +267 -0
  29. package/src/reports.js +113 -0
  30. package/src/runner.js +95 -0
  31. package/src/scope.js +440 -0
  32. package/src/setup/agent.js +327 -0
  33. package/src/setup/config.js +112 -0
  34. package/src/setup/index.js +242 -0
  35. package/src/setup/project.js +316 -0
  36. package/src/setup/prompt.js +43 -0
  37. package/src/util.js +163 -0
  38. package/src/version.js +8 -0
  39. package/templates/AGENTS.md.snippet.md +9 -0
  40. package/templates/agent-stop-hook.sh +7 -0
  41. package/templates/covgate.config.json +98 -0
  42. package/templates/github-actions-coverage.yml +41 -0
  43. package/templates/gitignore +5 -0
  44. package/templates/pre-commit +10 -0
package/src/reports.js ADDED
@@ -0,0 +1,113 @@
1
+ import { stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { expandBraces, expandGlob, walkFiles } from "./glob.js";
4
+ import { canonicalPath, toPosix } from "./util.js";
5
+
6
+ const MAGIC = /[*?\[\]{}]/;
7
+
8
+ /**
9
+ * Directory paths are a convenience. Pick only conventional coverage report
10
+ * names there, so coverage-summary.json and HTML assets are not parsed as
11
+ * independent coverage databases. Use an explicit file or glob for custom names.
12
+ *
13
+ * @param {string} format
14
+ * @param {string} file
15
+ */
16
+ function directoryReportAllowed(format, file) {
17
+ const extension = path.extname(file).toLowerCase();
18
+ const basename = path.basename(file).toLowerCase();
19
+ if (format === "lcov") {
20
+ return extension === ".info" || extension === ".lcov";
21
+ }
22
+ if (format === "istanbul") {
23
+ return basename === "coverage-final.json";
24
+ }
25
+ return (
26
+ basename === "coverage-final.json" ||
27
+ extension === ".info" ||
28
+ extension === ".lcov"
29
+ );
30
+ }
31
+
32
+ /**
33
+ * Expand coverage.paths. Explicit files, directories and globs are supported.
34
+ * Directories are scanned for conventional report names.
35
+ *
36
+ * @param {any} scope
37
+ * @param {string} root
38
+ * @returns {Promise<string[]>}
39
+ */
40
+ export async function expandCoverageReportFiles(scope, root) {
41
+ const found = new Map();
42
+ for (const rawPattern of scope.coverage.paths) {
43
+ for (const pattern of expandBraces(rawPattern)) {
44
+ const absolute = path.isAbsolute(pattern)
45
+ ? path.normalize(pattern)
46
+ : path.resolve(root, pattern);
47
+ if (!MAGIC.test(absolute)) {
48
+ let info;
49
+ try {
50
+ info = await stat(absolute);
51
+ } catch (error) {
52
+ if (error && error.code === "ENOENT") continue;
53
+ throw error;
54
+ }
55
+ if (info.isFile()) {
56
+ found.set(canonicalPath(absolute), absolute);
57
+ } else if (info.isDirectory()) {
58
+ for (const file of await walkFiles(absolute)) {
59
+ if (directoryReportAllowed(scope.coverage.format, file)) {
60
+ found.set(canonicalPath(file), file);
61
+ }
62
+ }
63
+ }
64
+ continue;
65
+ }
66
+ for (const file of await expandGlob(pattern, root)) {
67
+ found.set(canonicalPath(file), file);
68
+ }
69
+ }
70
+ }
71
+ return [...found.values()].sort((left, right) =>
72
+ toPosix(left).localeCompare(toPosix(right), "en")
73
+ );
74
+ }
75
+
76
+ /**
77
+ * @param {string[]} files
78
+ * @returns {Promise<Map<string, { path: string, mtimeMs: number, size: number }>>}
79
+ */
80
+ export async function snapshotReportFiles(files) {
81
+ const snapshot = new Map();
82
+ for (const file of files) {
83
+ const info = await stat(file);
84
+ snapshot.set(canonicalPath(file), {
85
+ path: file,
86
+ mtimeMs: info.mtimeMs,
87
+ size: info.size
88
+ });
89
+ }
90
+ return snapshot;
91
+ }
92
+
93
+ /**
94
+ * Detect reports left over from a previous run. A report is fresh when it is
95
+ * newly created or its mtime/size changed during this invocation.
96
+ *
97
+ * @param {Map<string, { path: string, mtimeMs: number, size: number }>} before
98
+ * @param {string[]} after
99
+ */
100
+ export async function findStaleReports(before, after) {
101
+ const stale = [];
102
+ for (const file of after) {
103
+ const key = canonicalPath(file);
104
+ const previous = before.get(key);
105
+ if (!previous) continue;
106
+ const info = await stat(file);
107
+ if (info.mtimeMs === previous.mtimeMs && info.size === previous.size) {
108
+ stale.push(file);
109
+ }
110
+ }
111
+ return stale;
112
+ }
113
+
package/src/runner.js ADDED
@@ -0,0 +1,95 @@
1
+ import { ExecutionError } from "./errors.js";
2
+ import { runShellCommand } from "./process.js";
3
+ import { formatDuration, relativeDisplay, tail } from "./util.js";
4
+
5
+ /**
6
+ * @param {boolean} enabled
7
+ */
8
+ function makeStreamWriter(enabled) {
9
+ if (!enabled) return undefined;
10
+ return (/** @type {string} */ chunk) => {
11
+ process.stderr.write(chunk);
12
+ };
13
+ }
14
+
15
+ /**
16
+ * @param {any} scope
17
+ * @param {{ verbose?: boolean, root?: string, logger?: { info: (message: string) => void } }} options
18
+ */
19
+ export async function runScopeCommands(scope, options = {}) {
20
+ const results = [];
21
+ const logger = options.logger;
22
+ for (const command of scope.commands) {
23
+ logger?.info(` ▶ ${scope.label} / ${command.name}: ${command.run}`);
24
+ let result;
25
+ try {
26
+ result = await runShellCommand({
27
+ command: command.run,
28
+ cwd: command.cwd,
29
+ env: command.env,
30
+ timeoutMs: command.timeoutMs,
31
+ onStdout: makeStreamWriter(options.verbose === true),
32
+ onStderr: makeStreamWriter(options.verbose === true)
33
+ });
34
+ } catch (error) {
35
+ throw new ExecutionError(
36
+ `${scope.label} / ${command.name} 启动失败:${error.message}`,
37
+ {
38
+ cause: error,
39
+ details: {
40
+ scope: scope.name,
41
+ command: { ...command },
42
+ cwd: command.cwd
43
+ }
44
+ }
45
+ );
46
+ }
47
+ const record = {
48
+ scope: scope.name,
49
+ name: command.name,
50
+ command: command.run,
51
+ cwd: relativeDisplay(command.cwd, options.root ?? process.cwd()),
52
+ exitCode: result.code,
53
+ signal: result.signal,
54
+ timedOut: result.timedOut,
55
+ durationMs: result.durationMs,
56
+ stdout: result.stdout,
57
+ stderr: result.stderr,
58
+ stdoutTruncated: result.stdoutTruncated,
59
+ stderrTruncated: result.stderrTruncated
60
+ };
61
+ results.push(record);
62
+ if (result.timedOut) {
63
+ throw new ExecutionError(
64
+ `${scope.label} / ${command.name} 超时(${command.timeoutSeconds}s)`,
65
+ { details: { ...record, priorCommands: results.slice(0, -1) } }
66
+ );
67
+ }
68
+ if (result.code !== 0) {
69
+ throw new ExecutionError(
70
+ `${scope.label} / ${command.name} 失败(退出码 ${result.code ?? "null"})`,
71
+ { details: { ...record, priorCommands: results.slice(0, -1) } }
72
+ );
73
+ }
74
+ logger?.info(` ✓ 完成,用时 ${formatDuration(result.durationMs)}`);
75
+ }
76
+ return results;
77
+ }
78
+
79
+ /**
80
+ * Print a failed command's captured output to stderr.
81
+ * @param {any} record
82
+ */
83
+ export function printCommandFailure(record) {
84
+ if (!record) return;
85
+ if (record.stdout?.trim()) {
86
+ process.stderr.write("\n--- 测试命令 stdout(末尾)---\n");
87
+ process.stderr.write(tail(record.stdout, 20_000));
88
+ if (!record.stdout.endsWith("\n")) process.stderr.write("\n");
89
+ }
90
+ if (record.stderr?.trim()) {
91
+ process.stderr.write("\n--- 测试命令 stderr(末尾)---\n");
92
+ process.stderr.write(tail(record.stderr, 20_000));
93
+ if (!record.stderr.endsWith("\n")) process.stderr.write("\n");
94
+ }
95
+ }
package/src/scope.js ADDED
@@ -0,0 +1,440 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { parseAnnotations } from "./annotations.js";
3
+ import { expandFiles, matchGlob } from "./glob.js";
4
+ import { validateReason } from "./reason.js";
5
+ import {
6
+ countUncoveredInRanges,
7
+ removeRanges
8
+ } from "./coverage/model.js";
9
+ import {
10
+ canonicalPath,
11
+ relativeDisplay
12
+ } from "./util.js";
13
+
14
+ /**
15
+ * @param {"fail" | "warn"} policy
16
+ * @returns {"error" | "warning"}
17
+ */
18
+ function severityFor(policy) {
19
+ return policy === "fail" ? "error" : "warning";
20
+ }
21
+
22
+ /**
23
+ * @param {Array<{ startLine: number, endLine: number }>} ranges
24
+ */
25
+ function mergeRanges(ranges) {
26
+ const sorted = [...ranges].sort(
27
+ (left, right) => left.startLine - right.startLine || left.endLine - right.endLine
28
+ );
29
+ const merged = [];
30
+ for (const range of sorted) {
31
+ const previous = merged.at(-1);
32
+ if (previous && range.startLine <= previous.endLine) {
33
+ previous.endLine = Math.max(previous.endLine, range.endLine);
34
+ } else {
35
+ merged.push({ ...range });
36
+ }
37
+ }
38
+ return merged;
39
+ }
40
+
41
+ /**
42
+ * @param {Array<{ startLine: number, endLine: number, directiveLine: number }>} ranges
43
+ */
44
+ function findOverlaps(ranges) {
45
+ const sorted = [...ranges].sort(
46
+ (left, right) => left.startLine - right.startLine || left.endLine - right.endLine
47
+ );
48
+ const overlaps = [];
49
+ let previous = null;
50
+ for (const range of sorted) {
51
+ if (previous && range.startLine <= previous.endLine) {
52
+ overlaps.push([previous, range]);
53
+ }
54
+ if (!previous || range.endLine > previous.endLine) previous = range;
55
+ }
56
+ return overlaps;
57
+ }
58
+
59
+ /**
60
+ * Apply exclusions/waivers to one scope and produce the pre-gate audit data.
61
+ *
62
+ * @param {{
63
+ * scope: any,
64
+ * root: string,
65
+ * coverage: { format: string, files: Map<string, any> },
66
+ * waiverPolicy: any,
67
+ * now?: Date
68
+ * }} options
69
+ */
70
+ export async function evaluateScope({ scope, root, coverage, waiverPolicy, now = new Date() }) {
71
+ /** @type {Array<any>} */
72
+ const issues = [];
73
+ /** @type {Array<any>} */
74
+ const warnings = [];
75
+ /** @type {Array<any>} */
76
+ const waivers = [];
77
+ /** @type {Array<any>} */
78
+ const exclusions = [];
79
+ /** @type {Set<string>} */
80
+ const excludedKeys = new Set();
81
+ /** @type {Set<string>} */
82
+ const fileIgnoreKeys = new Set();
83
+
84
+ const addIssue = (issue) => issues.push(issue);
85
+
86
+ let discovered;
87
+ try {
88
+ discovered = await expandFiles(scope.sources.include, root);
89
+ } catch (error) {
90
+ addIssue({
91
+ code: "source-discovery-failed",
92
+ severity: "error",
93
+ message: `无法展开 sources.include:${error.message}`
94
+ });
95
+ discovered = [];
96
+ }
97
+ const expected = new Map();
98
+ for (const absPath of discovered) {
99
+ expected.set(canonicalPath(absPath), {
100
+ absPath,
101
+ relPath: relativeDisplay(absPath, root)
102
+ });
103
+ }
104
+ if (expected.size === 0) {
105
+ addIssue({
106
+ code: "no-source-files",
107
+ severity: "error",
108
+ message: "sources.include 没有匹配到任何源码文件,无法验证覆盖率"
109
+ });
110
+ }
111
+
112
+ // Config exclusions are file-level policy. Every reason is audited and every
113
+ // pattern must still match at least one source file.
114
+ for (const entry of scope.exclude) {
115
+ const matched = [];
116
+ for (const [key, file] of expected) {
117
+ if (matchGlob(file.relPath, entry.pattern)) matched.push({ key, file });
118
+ }
119
+ const validation = validateReason(
120
+ entry.reason,
121
+ waiverPolicy,
122
+ { owner: entry.owner, expires: entry.expires },
123
+ now
124
+ );
125
+ for (const message of validation.errors) {
126
+ addIssue({
127
+ code: "invalid-exclusion",
128
+ severity: "error",
129
+ message: `exclude "${entry.pattern}" 的说明无效:${message}`
130
+ });
131
+ }
132
+ if (validation.expired) {
133
+ addIssue({
134
+ code: "expired-exclusion",
135
+ severity: severityFor(waiverPolicy.expiry),
136
+ message: `exclude "${entry.pattern}" 已于 ${entry.expires} 过期,请重新评估`
137
+ });
138
+ }
139
+ if (matched.length === 0) {
140
+ addIssue({
141
+ code: "stale-exclusion",
142
+ severity: severityFor(waiverPolicy.stale),
143
+ message: `exclude "${entry.pattern}" 没有匹配任何源码文件,请删除这条失效规则`
144
+ });
145
+ }
146
+ for (const { key } of matched) {
147
+ excludedKeys.add(key);
148
+ expected.delete(key);
149
+ }
150
+ exclusions.push({
151
+ source: "config",
152
+ pattern: entry.pattern,
153
+ reason: entry.reason,
154
+ owner: entry.owner,
155
+ expires: entry.expires,
156
+ ticket: entry.ticket,
157
+ files: matched.map(({ file }) => file.relPath).sort(),
158
+ fileCount: matched.length,
159
+ status: validation.expired ? "expired" : matched.length === 0 ? "stale" : "applied",
160
+ validationErrors: validation.errors
161
+ });
162
+ }
163
+
164
+ // Read and scan every remaining expected source file. We need this before
165
+ // deciding missing coverage so file-level waivers can suppress that check
166
+ // with a documented reason.
167
+ const scans = new Map();
168
+ for (const [key, file] of expected) {
169
+ let text;
170
+ try {
171
+ text = await readFile(file.absPath, "utf8");
172
+ } catch (error) {
173
+ addIssue({
174
+ code: "source-read-failed",
175
+ severity: "error",
176
+ file: file.relPath,
177
+ message: `无法读取源码文件:${error.message}`
178
+ });
179
+ continue;
180
+ }
181
+ const parsed = parseAnnotations(text, file.relPath, waiverPolicy, now);
182
+ scans.set(key, { ...file, text, parsed });
183
+ }
184
+
185
+ // File-level waivers remove a whole source file. They are allowed to cover a
186
+ // missing report entry, but not a fully covered file (that would be stale).
187
+ for (const [key, scan] of scans) {
188
+ if (!scan.parsed.fileIgnore) continue;
189
+ const directive = scan.parsed.fileIgnore;
190
+ const coverageFile = coverage.files.get(key);
191
+ let uncovered = { lines: 0, statements: 0, functions: 0, branches: 0 };
192
+ if (coverageFile) {
193
+ uncovered = countUncoveredInRanges(coverageFile, [
194
+ { startLine: 1, endLine: Number.MAX_SAFE_INTEGER }
195
+ ]);
196
+ }
197
+ const uncoveredTotal = Object.values(uncovered).reduce((sum, count) => sum + count, 0);
198
+ const stale = coverageFile !== undefined && uncoveredTotal === 0;
199
+ if (stale) {
200
+ addIssue({
201
+ code: "stale-waiver",
202
+ severity: severityFor(waiverPolicy.stale),
203
+ file: scan.relPath,
204
+ line: directive.directiveLine,
205
+ message: "covgate-ignore-file 所在文件已经被完全覆盖;请删除这条豁免"
206
+ });
207
+ }
208
+ if (directive.expired) {
209
+ addIssue({
210
+ code: "expired-waiver",
211
+ severity: severityFor(waiverPolicy.expiry),
212
+ file: scan.relPath,
213
+ line: directive.directiveLine,
214
+ message: `豁免已于 ${directive.expires} 过期,请重新评估`
215
+ });
216
+ }
217
+ expected.delete(key);
218
+ excludedKeys.add(key);
219
+ fileIgnoreKeys.add(key);
220
+ coverage.files.delete(key);
221
+ waivers.push({
222
+ source: "inline",
223
+ kind: "file",
224
+ file: scan.relPath,
225
+ startLine: 1,
226
+ endLine: scan.text.split(/\r?\n/).length,
227
+ directiveLine: directive.directiveLine,
228
+ reason: directive.reason,
229
+ owner: directive.owner,
230
+ expires: directive.expires,
231
+ ticket: directive.ticket,
232
+ status: directive.expired ? "expired" : stale ? "stale" : "applied",
233
+ stale,
234
+ expired: directive.expired,
235
+ removedUncovered: { ...uncovered, files: 1 }
236
+ });
237
+ }
238
+
239
+ // Parser errors are fatal regardless of stale/expiry policy: a malformed
240
+ // waiver must not silently fall through as ordinary code.
241
+ for (const [key, scan] of scans) {
242
+ if (!expected.has(key) && !fileIgnoreKeys.has(key)) continue;
243
+ for (const error of scan.parsed.errors) {
244
+ addIssue({
245
+ code: "invalid-waiver",
246
+ severity: "error",
247
+ file: scan.relPath,
248
+ line: error.line,
249
+ message: error.message
250
+ });
251
+ }
252
+ if (fileIgnoreKeys.has(key)) continue;
253
+ if (scan.parsed.nativeIgnores.length > 0) {
254
+ addIssue({
255
+ code: "native-ignore",
256
+ severity: "error",
257
+ file: scan.relPath,
258
+ lines: scan.parsed.nativeIgnores.map((entry) => entry.line),
259
+ message:
260
+ "检测到原生覆盖率忽略注释(istanbul/c8/v8/node:coverage)。" +
261
+ "原生注释会让代码从报告中消失,绕过审计;请删除它,改用带原因的 covgate-ignore-* 指令。"
262
+ });
263
+ }
264
+ }
265
+
266
+ // Check for overlapping inline ranges. Overlaps make it impossible to know
267
+ // which explanation is responsible for which suppression.
268
+ for (const [key, scan] of scans) {
269
+ if (!expected.has(key)) continue;
270
+ const ranges = scan.parsed.directives.map((directive) => ({
271
+ startLine: directive.startLine,
272
+ endLine: directive.endLine,
273
+ directiveLine: directive.directiveLine
274
+ }));
275
+ for (const [previous, current] of findOverlaps(ranges)) {
276
+ addIssue({
277
+ code: "overlapping-waivers",
278
+ severity: "error",
279
+ file: scan.relPath,
280
+ line: current.directiveLine,
281
+ message: `豁免区间与第 ${previous.directiveLine} 行的豁免重叠,请合并为一条说明`
282
+ });
283
+ }
284
+ }
285
+
286
+ // Missing coverage: source includes is the source of truth. This is what
287
+ // prevents an AI from simply never importing a new file.
288
+ const missingSources = [];
289
+ for (const [key, file] of expected) {
290
+ if (coverage.files.has(key)) continue;
291
+ missingSources.push(file.relPath);
292
+ addIssue({
293
+ code: "missing-coverage",
294
+ severity: "error",
295
+ file: file.relPath,
296
+ message:
297
+ "源码文件未出现在任何覆盖率报告中(可能没有被测试加载)。" +
298
+ "请补充测试;如果确实无法统计,请用 covgate-ignore-file 写明原因。"
299
+ });
300
+ }
301
+
302
+ // Apply line/range waivers and annotate their effectiveness before mutating
303
+ // the coverage maps.
304
+ for (const [key, scan] of scans) {
305
+ if (!expected.has(key)) continue;
306
+ const directives = scan.parsed.directives;
307
+ if (directives.length === 0) continue;
308
+ const file = coverage.files.get(key);
309
+ for (const directive of directives) {
310
+ if (directive.expired) {
311
+ addIssue({
312
+ code: "expired-waiver",
313
+ severity: severityFor(waiverPolicy.expiry),
314
+ file: scan.relPath,
315
+ line: directive.directiveLine,
316
+ message: `豁免已于 ${directive.expires} 过期,请重新评估`
317
+ });
318
+ }
319
+ if (file) {
320
+ const uncovered = countUncoveredInRanges(file, [directive]);
321
+ const total = Object.values(uncovered).reduce((sum, count) => sum + count, 0);
322
+ if (total === 0) {
323
+ addIssue({
324
+ code: "stale-waiver",
325
+ severity: severityFor(waiverPolicy.stale),
326
+ file: scan.relPath,
327
+ line: directive.directiveLine,
328
+ message:
329
+ "豁免区间没有移除任何未覆盖代码;它可能已经失效,或说明/区间写错了位置"
330
+ });
331
+ }
332
+ waivers.push({
333
+ source: "inline",
334
+ kind:
335
+ directive.kind === "next-line"
336
+ ? "next-line"
337
+ : directive.kind === "line"
338
+ ? "line"
339
+ : "range",
340
+ file: scan.relPath,
341
+ startLine: directive.startLine,
342
+ endLine: directive.endLine,
343
+ directiveLine: directive.directiveLine,
344
+ reason: directive.reason,
345
+ owner: directive.owner,
346
+ expires: directive.expires,
347
+ ticket: directive.ticket,
348
+ status: directive.expired ? "expired" : total === 0 ? "stale" : "applied",
349
+ stale: total === 0,
350
+ expired: directive.expired,
351
+ removedUncovered: uncovered
352
+ });
353
+ } else {
354
+ addIssue({
355
+ code: "stale-waiver",
356
+ severity: severityFor(waiverPolicy.stale),
357
+ file: scan.relPath,
358
+ line: directive.directiveLine,
359
+ message: "该文件没有覆盖率记录,行级豁免无法生效;请补测试或使用 covgate-ignore-file"
360
+ });
361
+ waivers.push({
362
+ source: "inline",
363
+ kind: "range",
364
+ file: scan.relPath,
365
+ startLine: directive.startLine,
366
+ endLine: directive.endLine,
367
+ directiveLine: directive.directiveLine,
368
+ reason: directive.reason,
369
+ owner: directive.owner,
370
+ expires: directive.expires,
371
+ ticket: directive.ticket,
372
+ status: directive.expired ? "expired" : "stale",
373
+ stale: true,
374
+ expired: directive.expired,
375
+ removedUncovered: { lines: 0, statements: 0, functions: 0, branches: 0 }
376
+ });
377
+ }
378
+ }
379
+ if (file) {
380
+ const ranges = mergeRanges(
381
+ directives.map((directive) => ({
382
+ startLine: directive.startLine,
383
+ endLine: directive.endLine
384
+ }))
385
+ );
386
+ removeRanges(file, ranges);
387
+ }
388
+ }
389
+
390
+ // Anything outside sources.include is intentionally not gated, but surfacing
391
+ // it helps catch an accidentally narrow include pattern.
392
+ const extras = [];
393
+ for (const [key, file] of coverage.files) {
394
+ if (expected.has(key) || excludedKeys.has(key) || fileIgnoreKeys.has(key)) continue;
395
+ extras.push(file.relPath);
396
+ }
397
+ if (extras.length > 0) {
398
+ warnings.push({
399
+ code: "coverage-outside-sources",
400
+ severity: "warning",
401
+ message:
402
+ `覆盖率报告中有 ${extras.length} 个文件不在 sources.include 内,已忽略。` +
403
+ "如果其中有需要门禁的源码,请扩大 sources.include。",
404
+ files: extras.slice(0, 20)
405
+ });
406
+ }
407
+
408
+ if (expected.size === 0) {
409
+ addIssue({
410
+ code: "no-gated-sources",
411
+ severity: "error",
412
+ message:
413
+ "应用排除和文件豁免后,没有任何源码需要门禁。不能通过“排除全部文件”让门禁虚假通过。"
414
+ });
415
+ }
416
+
417
+ const filteredCoverage = new Map();
418
+ for (const [key, file] of coverage.files) {
419
+ if (expected.has(key)) filteredCoverage.set(key, file);
420
+ }
421
+
422
+ return {
423
+ name: scope.name,
424
+ label: scope.label,
425
+ profile: scope.profile,
426
+ thresholds: scope.thresholds,
427
+ format: coverage.format,
428
+ files: [...filteredCoverage.values()].sort((left, right) =>
429
+ left.relPath.localeCompare(right.relPath, "en")
430
+ ),
431
+ expectedSourceCount: expected.size,
432
+ reportedFileCount: coverage.files.size,
433
+ missingSources,
434
+ extras,
435
+ waivers,
436
+ exclusions,
437
+ issues,
438
+ warnings
439
+ };
440
+ }