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.
- package/LICENSE +21 -0
- package/README.md +502 -2
- package/bin/covgate-postinstall.js +13 -0
- package/bin/covgate.js +5 -0
- package/docs/ai-workflow.md +196 -0
- package/docs/design.md +327 -0
- package/docs/setup.md +163 -0
- package/package.json +48 -4
- package/schema/covgate.schema.json +295 -0
- package/src/annotations.js +224 -0
- package/src/args.js +189 -0
- package/src/cli.js +486 -0
- package/src/config.js +442 -0
- package/src/coverage/index.js +85 -0
- package/src/coverage/istanbul.js +215 -0
- package/src/coverage/lcov.js +234 -0
- package/src/coverage/model.js +316 -0
- package/src/coverage/paths.js +40 -0
- package/src/errors.js +34 -0
- package/src/gate.js +203 -0
- package/src/glob.js +242 -0
- package/src/index.js +28 -0
- package/src/process.js +170 -0
- package/src/reason.js +77 -0
- package/src/report/build.js +135 -0
- package/src/report/index.js +29 -0
- package/src/report/markdown.js +343 -0
- package/src/report/text.js +267 -0
- package/src/reports.js +113 -0
- package/src/runner.js +95 -0
- package/src/scope.js +440 -0
- package/src/setup/agent.js +327 -0
- package/src/setup/config.js +112 -0
- package/src/setup/index.js +242 -0
- package/src/setup/project.js +316 -0
- package/src/setup/prompt.js +43 -0
- package/src/util.js +163 -0
- package/src/version.js +8 -0
- package/templates/AGENTS.md.snippet.md +9 -0
- package/templates/agent-stop-hook.sh +7 -0
- package/templates/covgate.config.json +98 -0
- package/templates/github-actions-coverage.yml +41 -0
- package/templates/gitignore +5 -0
- package/templates/pre-commit +10 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { canonicalPath, percentOf, relativeDisplay } from "../util.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {Object} StatementUnit
|
|
6
|
+
* @property {string} key
|
|
7
|
+
* @property {number} line
|
|
8
|
+
* @property {number} column
|
|
9
|
+
* @property {number} endLine
|
|
10
|
+
* @property {number} count
|
|
11
|
+
*
|
|
12
|
+
* @typedef {Object} FunctionUnit
|
|
13
|
+
* @property {string} key
|
|
14
|
+
* @property {string} name
|
|
15
|
+
* @property {number} line
|
|
16
|
+
* @property {number} column
|
|
17
|
+
* @property {number} endLine
|
|
18
|
+
* @property {number} count
|
|
19
|
+
*
|
|
20
|
+
* @typedef {Object} BranchUnit
|
|
21
|
+
* @property {string} key
|
|
22
|
+
* @property {number} line
|
|
23
|
+
* @property {number} column
|
|
24
|
+
* @property {string} type
|
|
25
|
+
* @property {number} groupLine
|
|
26
|
+
* @property {number} index
|
|
27
|
+
* @property {number} count
|
|
28
|
+
*
|
|
29
|
+
* @typedef {Object} FileCoverage
|
|
30
|
+
* @property {string} absPath
|
|
31
|
+
* @property {string} relPath
|
|
32
|
+
* @property {"istanbul" | "lcov"} format
|
|
33
|
+
* @property {Map<string, StatementUnit> | null} statements
|
|
34
|
+
* @property {Map<string, FunctionUnit>} functions
|
|
35
|
+
* @property {Map<string, BranchUnit>} branches
|
|
36
|
+
* @property {Map<number, number>} lines
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} absPath
|
|
41
|
+
* @param {"istanbul" | "lcov"} format
|
|
42
|
+
* @param {string} root
|
|
43
|
+
* @returns {FileCoverage}
|
|
44
|
+
*/
|
|
45
|
+
export function createFileCoverage(absPath, format, root) {
|
|
46
|
+
return {
|
|
47
|
+
absPath: path.resolve(absPath),
|
|
48
|
+
relPath: relativeDisplay(absPath, root),
|
|
49
|
+
format,
|
|
50
|
+
statements: format === "istanbul" ? new Map() : null,
|
|
51
|
+
functions: new Map(),
|
|
52
|
+
branches: new Map(),
|
|
53
|
+
lines: new Map()
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Istanbul's line coverage convention: the hit count for a line is the maximum
|
|
59
|
+
* hit count among statements that start on that line.
|
|
60
|
+
* @param {Map<string, StatementUnit> | null} statements
|
|
61
|
+
* @returns {Map<number, number>}
|
|
62
|
+
*/
|
|
63
|
+
export function deriveLinesFromStatements(statements) {
|
|
64
|
+
/** @type {Map<number, number>} */
|
|
65
|
+
const lines = new Map();
|
|
66
|
+
if (!statements) return lines;
|
|
67
|
+
for (const statement of statements.values()) {
|
|
68
|
+
const current = lines.get(statement.line);
|
|
69
|
+
if (current === undefined || statement.count > current) {
|
|
70
|
+
lines.set(statement.line, statement.count);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return lines;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Merge another report for the same file into a base record. Counts are summed
|
|
78
|
+
* so independently-sharded test runs can be combined.
|
|
79
|
+
* @param {FileCoverage} base
|
|
80
|
+
* @param {FileCoverage} incoming
|
|
81
|
+
*/
|
|
82
|
+
export function mergeFileCoverage(base, incoming) {
|
|
83
|
+
if (base.format !== incoming.format) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`同一文件收到了不同格式的覆盖率数据:${base.relPath}(${base.format} / ${incoming.format})`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if ((base.statements === null) !== (incoming.statements === null)) {
|
|
89
|
+
throw new Error(`同一文件收到了不兼容的语句覆盖率数据:${base.relPath}`);
|
|
90
|
+
}
|
|
91
|
+
if (base.statements && incoming.statements) {
|
|
92
|
+
mergeUnits(base.statements, incoming.statements, "语句", base.relPath);
|
|
93
|
+
}
|
|
94
|
+
mergeUnits(base.functions, incoming.functions, "函数", base.relPath);
|
|
95
|
+
mergeUnits(base.branches, incoming.branches, "分支", base.relPath);
|
|
96
|
+
for (const [line, count] of incoming.lines) {
|
|
97
|
+
base.lines.set(line, (base.lines.get(line) ?? 0) + count);
|
|
98
|
+
}
|
|
99
|
+
if (base.statements) {
|
|
100
|
+
base.lines = deriveLinesFromStatements(base.statements);
|
|
101
|
+
}
|
|
102
|
+
return base;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @template {{ key: string, line: number, count: number }} T
|
|
107
|
+
* @param {Map<string, T>} target
|
|
108
|
+
* @param {Map<string, T>} incoming
|
|
109
|
+
* @param {string} label
|
|
110
|
+
* @param {string} relPath
|
|
111
|
+
*/
|
|
112
|
+
function mergeUnits(target, incoming, label, relPath) {
|
|
113
|
+
for (const [key, unit] of incoming) {
|
|
114
|
+
const existing = target.get(key);
|
|
115
|
+
if (!existing) {
|
|
116
|
+
target.set(key, { ...unit });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (existing.line !== unit.line) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`${relPath} 的${label}覆盖率数据不一致(键 ${key} 对应不同行号)`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
existing.count += unit.count;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {FileCoverage} file
|
|
130
|
+
*/
|
|
131
|
+
export function metricsForFile(file) {
|
|
132
|
+
const withPercent = (value) => {
|
|
133
|
+
if (!value) return null;
|
|
134
|
+
return { ...value, percent: percentOf(value.covered, value.total) };
|
|
135
|
+
};
|
|
136
|
+
return {
|
|
137
|
+
lines: withPercent(countMap(file.lines)),
|
|
138
|
+
statements: file.statements ? withPercent(countMap(file.statements)) : null,
|
|
139
|
+
functions: withPercent(countMap(file.functions)),
|
|
140
|
+
branches: withPercent(countMap(file.branches))
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @template {number | { count: number }} T
|
|
146
|
+
* @param {Map<string, T>} map
|
|
147
|
+
*/
|
|
148
|
+
function countMap(map) {
|
|
149
|
+
let covered = 0;
|
|
150
|
+
for (const value of map.values()) {
|
|
151
|
+
const count = typeof value === "number" ? value : value.count;
|
|
152
|
+
if (count > 0) covered += 1;
|
|
153
|
+
}
|
|
154
|
+
return { covered, total: map.size };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Remove rows in [startLine, endLine] from a file's normalized coverage.
|
|
159
|
+
* Statements/functions/branches are removed when their declared start line is
|
|
160
|
+
* in the range, mirroring what a human would call "this code segment".
|
|
161
|
+
* @param {FileCoverage} file
|
|
162
|
+
* @param {Array<{ startLine: number, endLine: number }>} ranges
|
|
163
|
+
* @returns {{ lines: number, statements: number, functions: number, branches: number }}
|
|
164
|
+
*/
|
|
165
|
+
export function removeRanges(file, ranges) {
|
|
166
|
+
const inside = (/** @type {number} */ line) =>
|
|
167
|
+
ranges.some((range) => line >= range.startLine && line <= range.endLine);
|
|
168
|
+
let removed = { lines: 0, statements: 0, functions: 0, branches: 0 };
|
|
169
|
+
for (const line of [...file.lines.keys()]) {
|
|
170
|
+
if (inside(line)) {
|
|
171
|
+
file.lines.delete(line);
|
|
172
|
+
removed.lines += 1;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (file.statements) {
|
|
176
|
+
for (const [key, unit] of [...file.statements]) {
|
|
177
|
+
if (inside(unit.line)) {
|
|
178
|
+
file.statements.delete(key);
|
|
179
|
+
removed.statements += 1;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
for (const [key, unit] of [...file.functions]) {
|
|
184
|
+
if (inside(unit.line)) {
|
|
185
|
+
file.functions.delete(key);
|
|
186
|
+
removed.functions += 1;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const [key, unit] of [...file.branches]) {
|
|
190
|
+
if (inside(unit.line)) {
|
|
191
|
+
file.branches.delete(key);
|
|
192
|
+
removed.branches += 1;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return removed;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Count uncovered rows that a set of ranges would remove, without mutating.
|
|
200
|
+
* Used to detect stale suppressions.
|
|
201
|
+
* @param {FileCoverage} file
|
|
202
|
+
* @param {Array<{ startLine: number, endLine: number }>} ranges
|
|
203
|
+
*/
|
|
204
|
+
export function countUncoveredInRanges(file, ranges) {
|
|
205
|
+
const inside = (/** @type {number} */ line) =>
|
|
206
|
+
ranges.some((range) => line >= range.startLine && line <= range.endLine);
|
|
207
|
+
let lines = 0;
|
|
208
|
+
let statements = 0;
|
|
209
|
+
let functions = 0;
|
|
210
|
+
let branches = 0;
|
|
211
|
+
for (const [line, count] of file.lines) {
|
|
212
|
+
if (count === 0 && inside(line)) lines += 1;
|
|
213
|
+
}
|
|
214
|
+
if (file.statements) {
|
|
215
|
+
for (const unit of file.statements.values()) {
|
|
216
|
+
if (unit.count === 0 && inside(unit.line)) statements += 1;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const unit of file.functions.values()) {
|
|
220
|
+
if (unit.count === 0 && inside(unit.line)) functions += 1;
|
|
221
|
+
}
|
|
222
|
+
for (const unit of file.branches.values()) {
|
|
223
|
+
if (unit.count === 0 && inside(unit.line)) branches += 1;
|
|
224
|
+
}
|
|
225
|
+
return { lines, statements, functions, branches };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* @param {Iterable<number>} lines
|
|
230
|
+
* @param {number} limit
|
|
231
|
+
*/
|
|
232
|
+
export function summarizeLines(lines, limit = Number.POSITIVE_INFINITY) {
|
|
233
|
+
const sorted = [...new Set(lines)].sort((left, right) => left - right);
|
|
234
|
+
return {
|
|
235
|
+
values: sorted.slice(0, limit),
|
|
236
|
+
total: sorted.length,
|
|
237
|
+
truncated: sorted.length > limit
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* @param {Iterable<{ line: number }>} units
|
|
243
|
+
* @param {number} limit
|
|
244
|
+
*/
|
|
245
|
+
export function summarizeUnits(units, limit = Number.POSITIVE_INFINITY) {
|
|
246
|
+
const sorted = [...units].sort((left, right) => left.line - right.line);
|
|
247
|
+
return {
|
|
248
|
+
values: sorted.slice(0, limit),
|
|
249
|
+
total: sorted.length,
|
|
250
|
+
truncated: sorted.length > limit
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* @param {FileCoverage} file
|
|
256
|
+
* @param {number} [limit]
|
|
257
|
+
*/
|
|
258
|
+
export function fileReport(file, limit = Number.POSITIVE_INFINITY) {
|
|
259
|
+
const uncoveredLines = summarizeLines(
|
|
260
|
+
[...file.lines].filter(([, count]) => count === 0).map(([line]) => line),
|
|
261
|
+
limit
|
|
262
|
+
);
|
|
263
|
+
const uncoveredFunctions = summarizeUnits(
|
|
264
|
+
[...file.functions.values()].filter((unit) => unit.count === 0),
|
|
265
|
+
limit
|
|
266
|
+
);
|
|
267
|
+
const uncoveredBranches = summarizeUnits(
|
|
268
|
+
[...file.branches.values()].filter((unit) => unit.count === 0),
|
|
269
|
+
limit
|
|
270
|
+
);
|
|
271
|
+
const uncoveredStatements = file.statements
|
|
272
|
+
? summarizeUnits(
|
|
273
|
+
[...file.statements.values()].filter((unit) => unit.count === 0),
|
|
274
|
+
limit
|
|
275
|
+
)
|
|
276
|
+
: null;
|
|
277
|
+
return {
|
|
278
|
+
path: file.relPath,
|
|
279
|
+
metrics: metricsForFile(file),
|
|
280
|
+
uncovered: {
|
|
281
|
+
lines: uncoveredLines.values,
|
|
282
|
+
linesTotal: uncoveredLines.total,
|
|
283
|
+
statements: uncoveredStatements?.values.map((unit) => ({
|
|
284
|
+
line: unit.line,
|
|
285
|
+
column: unit.column
|
|
286
|
+
})) ?? null,
|
|
287
|
+
statementsTotal: uncoveredStatements?.total ?? null,
|
|
288
|
+
functions: uncoveredFunctions.values.map((unit) => ({
|
|
289
|
+
name: unit.name,
|
|
290
|
+
line: unit.line
|
|
291
|
+
})),
|
|
292
|
+
functionsTotal: uncoveredFunctions.total,
|
|
293
|
+
branches: uncoveredBranches.values.map((unit) => ({
|
|
294
|
+
line: unit.line,
|
|
295
|
+
type: unit.type,
|
|
296
|
+
index: unit.index
|
|
297
|
+
})),
|
|
298
|
+
branchesTotal: uncoveredBranches.total
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* @param {Map<string, FileCoverage>} files
|
|
305
|
+
*/
|
|
306
|
+
export function mergeCoverageFiles(files) {
|
|
307
|
+
/** @type {Map<string, FileCoverage>} */
|
|
308
|
+
const merged = new Map();
|
|
309
|
+
for (const file of files.values()) {
|
|
310
|
+
const key = canonicalPath(file.absPath);
|
|
311
|
+
const existing = merged.get(key);
|
|
312
|
+
if (existing) mergeFileCoverage(existing, file);
|
|
313
|
+
else merged.set(key, file);
|
|
314
|
+
}
|
|
315
|
+
return merged;
|
|
316
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { IS_WINDOWS, toPosix } from "../util.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Coverage tools disagree about path syntax. This normalizes the common cases:
|
|
7
|
+
* file:// URLs, Windows paths with a leading slash, mixed separators, and
|
|
8
|
+
* project-relative paths.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} rawPath
|
|
11
|
+
* @param {string} root
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
export function resolveCoverageSourcePath(rawPath, root) {
|
|
15
|
+
if (typeof rawPath !== "string" || rawPath.trim() === "") {
|
|
16
|
+
throw new Error("覆盖率报告包含空的源码路径");
|
|
17
|
+
}
|
|
18
|
+
let value = rawPath.trim();
|
|
19
|
+
if (value.startsWith("file://")) {
|
|
20
|
+
try {
|
|
21
|
+
value = fileURLToPath(value);
|
|
22
|
+
} catch {
|
|
23
|
+
// Keep the original value and let path handling produce a useful error.
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (IS_WINDOWS) {
|
|
27
|
+
if (/^\/[A-Za-z]:\//.test(value)) value = value.slice(1);
|
|
28
|
+
value = value.replaceAll("/", "\\");
|
|
29
|
+
}
|
|
30
|
+
return path.normalize(path.isAbsolute(value) ? value : path.resolve(root, value));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Paths in lcov SF records are often already relative to the project root.
|
|
35
|
+
* @param {string} rawPath
|
|
36
|
+
* @param {string} root
|
|
37
|
+
*/
|
|
38
|
+
export function displayCoveragePath(rawPath, root) {
|
|
39
|
+
return toPosix(path.relative(root, resolveCoverageSourcePath(rawPath, root)));
|
|
40
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export class CovgateError extends Error {
|
|
2
|
+
/**
|
|
3
|
+
* @param {string} message
|
|
4
|
+
* @param {{ kind?: string, exitCode?: number, details?: unknown, cause?: unknown }} [options]
|
|
5
|
+
*/
|
|
6
|
+
constructor(message, options = {}) {
|
|
7
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
8
|
+
this.name = "CovgateError";
|
|
9
|
+
this.kind = options.kind ?? "error";
|
|
10
|
+
this.exitCode = options.exitCode ?? 2;
|
|
11
|
+
this.details = options.details;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class ConfigError extends CovgateError {
|
|
16
|
+
constructor(message, options = {}) {
|
|
17
|
+
super(message, { ...options, kind: "config", exitCode: 2 });
|
|
18
|
+
this.name = "ConfigError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class ExecutionError extends CovgateError {
|
|
23
|
+
constructor(message, options = {}) {
|
|
24
|
+
super(message, { ...options, kind: "execution", exitCode: 3 });
|
|
25
|
+
this.name = "ExecutionError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class CoverageDataError extends CovgateError {
|
|
30
|
+
constructor(message, options = {}) {
|
|
31
|
+
super(message, { ...options, kind: "coverage-data", exitCode: 3 });
|
|
32
|
+
this.name = "CoverageDataError";
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/gate.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { fileReport, metricsForFile } from "./coverage/model.js";
|
|
2
|
+
import { METRICS } from "./config.js";
|
|
3
|
+
import { percentOf } from "./util.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {number} covered
|
|
7
|
+
* @param {number} total
|
|
8
|
+
* @param {number} basisPoints
|
|
9
|
+
*/
|
|
10
|
+
function meetsThreshold(covered, total, basisPoints) {
|
|
11
|
+
if (total === 0) return true;
|
|
12
|
+
return covered * 10_000 >= basisPoints * total;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {Array<{ metrics: Record<string, { covered: number, total: number } | null> }>} files
|
|
17
|
+
* @param {string} metric
|
|
18
|
+
*/
|
|
19
|
+
function aggregate(files, metric) {
|
|
20
|
+
let covered = 0;
|
|
21
|
+
let total = 0;
|
|
22
|
+
let applicable = true;
|
|
23
|
+
for (const file of files) {
|
|
24
|
+
const value = file.metrics[metric];
|
|
25
|
+
if (!value) {
|
|
26
|
+
applicable = false;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
covered += value.covered;
|
|
30
|
+
total += value.total;
|
|
31
|
+
}
|
|
32
|
+
return { covered, total, applicable };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Apply thresholds and issue state to an evaluated scope.
|
|
37
|
+
* Percentages are for display only; the gate compares exact integer counts.
|
|
38
|
+
*
|
|
39
|
+
* @param {any} scopeData
|
|
40
|
+
*/
|
|
41
|
+
export function gateScope(scopeData) {
|
|
42
|
+
const issues = [...(scopeData.issues ?? [])];
|
|
43
|
+
const warnings = [...(scopeData.warnings ?? [])];
|
|
44
|
+
const fileReports = scopeData.files.map((file) => fileReport(file));
|
|
45
|
+
const metrics = {};
|
|
46
|
+
|
|
47
|
+
for (const metric of METRICS) {
|
|
48
|
+
const counts = aggregate(fileReports, metric);
|
|
49
|
+
const threshold = scopeData.thresholds[metric];
|
|
50
|
+
if (!counts.applicable) {
|
|
51
|
+
metrics[metric] = {
|
|
52
|
+
metric,
|
|
53
|
+
applicable: false,
|
|
54
|
+
covered: 0,
|
|
55
|
+
total: 0,
|
|
56
|
+
percent: null,
|
|
57
|
+
threshold: threshold.value,
|
|
58
|
+
thresholdBasisPoints: threshold.basisPoints,
|
|
59
|
+
passed: true,
|
|
60
|
+
note: "当前覆盖率格式不提供该指标"
|
|
61
|
+
};
|
|
62
|
+
warnings.push({
|
|
63
|
+
code: "metric-not-reported",
|
|
64
|
+
severity: "warning",
|
|
65
|
+
message: `${metric} 覆盖率未由 ${scopeData.format} 报告提供,该指标不纳入本次门禁`
|
|
66
|
+
});
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const passed = meetsThreshold(counts.covered, counts.total, threshold.basisPoints);
|
|
70
|
+
metrics[metric] = {
|
|
71
|
+
metric,
|
|
72
|
+
applicable: true,
|
|
73
|
+
covered: counts.covered,
|
|
74
|
+
total: counts.total,
|
|
75
|
+
percent: percentOf(counts.covered, counts.total),
|
|
76
|
+
threshold: threshold.value,
|
|
77
|
+
thresholdBasisPoints: threshold.basisPoints,
|
|
78
|
+
passed
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const anyUnits = Object.values(metrics).some(
|
|
83
|
+
(metric) => metric.applicable && metric.total > 0
|
|
84
|
+
);
|
|
85
|
+
if (!anyUnits) {
|
|
86
|
+
issues.push({
|
|
87
|
+
code: "no-executable-coverage",
|
|
88
|
+
severity: "error",
|
|
89
|
+
message:
|
|
90
|
+
"scope 中没有任何可统计的代码单元。检查 sources.include、覆盖率报告路径和测试采集配置。"
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const errorIssues = issues.filter((issue) => issue.severity === "error");
|
|
95
|
+
const failedMetrics = Object.values(metrics).filter(
|
|
96
|
+
(metric) => metric.applicable && !metric.passed
|
|
97
|
+
);
|
|
98
|
+
const passed = errorIssues.length === 0 && failedMetrics.length === 0;
|
|
99
|
+
|
|
100
|
+
const failingFiles = fileReports
|
|
101
|
+
.filter((file) => {
|
|
102
|
+
const uncovered =
|
|
103
|
+
file.uncovered.linesTotal +
|
|
104
|
+
(file.uncovered.statementsTotal ?? 0) +
|
|
105
|
+
file.uncovered.functionsTotal +
|
|
106
|
+
file.uncovered.branchesTotal;
|
|
107
|
+
return uncovered > 0;
|
|
108
|
+
})
|
|
109
|
+
.sort((left, right) => {
|
|
110
|
+
const leftPercent = left.metrics.lines?.percent ?? 100;
|
|
111
|
+
const rightPercent = right.metrics.lines?.percent ?? 100;
|
|
112
|
+
return leftPercent - rightPercent || left.path.localeCompare(right.path, "en");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const thresholds = Object.fromEntries(
|
|
116
|
+
METRICS.map((metric) => [metric, scopeData.thresholds[metric].value])
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const result = {
|
|
120
|
+
name: scopeData.name,
|
|
121
|
+
label: scopeData.label,
|
|
122
|
+
profile: scopeData.profile,
|
|
123
|
+
format: scopeData.format,
|
|
124
|
+
passed,
|
|
125
|
+
thresholds,
|
|
126
|
+
metrics,
|
|
127
|
+
files: fileReports,
|
|
128
|
+
failingFiles,
|
|
129
|
+
expectedSourceCount: scopeData.expectedSourceCount,
|
|
130
|
+
reportedFileCount: scopeData.reportedFileCount,
|
|
131
|
+
missingSources: scopeData.missingSources,
|
|
132
|
+
extras: scopeData.extras,
|
|
133
|
+
waivers: scopeData.waivers,
|
|
134
|
+
exclusions: scopeData.exclusions,
|
|
135
|
+
issues,
|
|
136
|
+
warnings,
|
|
137
|
+
reasons: [
|
|
138
|
+
...failedMetrics.map(
|
|
139
|
+
(metric) =>
|
|
140
|
+
`${metric.metric} 覆盖率 ${metric.percent.toFixed(2)}%,低于 ${metric.threshold}%(${metric.covered}/${metric.total})`
|
|
141
|
+
),
|
|
142
|
+
...errorIssues.map((issue) => issue.message)
|
|
143
|
+
]
|
|
144
|
+
};
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {any[]} scopes
|
|
150
|
+
*/
|
|
151
|
+
export function gateAll(scopes) {
|
|
152
|
+
const results = scopes.map(gateScope);
|
|
153
|
+
return {
|
|
154
|
+
passed: results.every((scope) => scope.passed),
|
|
155
|
+
scopes: results
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {any} scopeResult
|
|
161
|
+
*/
|
|
162
|
+
export function scopeMetricSummary(scopeResult) {
|
|
163
|
+
return METRICS.map((metric) => {
|
|
164
|
+
const value = scopeResult.metrics[metric];
|
|
165
|
+
return {
|
|
166
|
+
metric,
|
|
167
|
+
covered: value.covered,
|
|
168
|
+
total: value.total,
|
|
169
|
+
percent: value.percent,
|
|
170
|
+
threshold: value.threshold,
|
|
171
|
+
passed: value.passed
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @param {any} scopeResult
|
|
178
|
+
*/
|
|
179
|
+
export function scopeCoveredUnits(scopeResult) {
|
|
180
|
+
const totals = { covered: 0, total: 0 };
|
|
181
|
+
for (const metric of METRICS) {
|
|
182
|
+
const value = scopeResult.metrics[metric];
|
|
183
|
+
if (!value.applicable) continue;
|
|
184
|
+
totals.covered += value.covered;
|
|
185
|
+
totals.total += value.total;
|
|
186
|
+
}
|
|
187
|
+
return totals;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @param {any} scopeResult
|
|
192
|
+
*/
|
|
193
|
+
export function scopeUncoveredFileCount(scopeResult) {
|
|
194
|
+
return scopeResult.failingFiles.length;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* @param {any} file
|
|
199
|
+
*/
|
|
200
|
+
export function fileHasAnyCoverage(file) {
|
|
201
|
+
const metrics = metricsForFile(file);
|
|
202
|
+
return Object.values(metrics).some((metric) => metric && metric.total > 0);
|
|
203
|
+
}
|