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
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
|
+
}
|