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/glob.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { IS_WINDOWS, canonicalPath, toPosix } from "./util.js";
|
|
4
|
+
|
|
5
|
+
const SKIP_DIRECTORIES = new Set(["node_modules", ".git", ".covgate", ".hg", ".svn"]);
|
|
6
|
+
const MAGIC = /[*?\[\]{}]/;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} pattern
|
|
10
|
+
* @returns {string[]}
|
|
11
|
+
*/
|
|
12
|
+
export function expandBraces(pattern) {
|
|
13
|
+
const open = pattern.indexOf("{");
|
|
14
|
+
if (open === -1) return [pattern];
|
|
15
|
+
const close = pattern.indexOf("}", open + 1);
|
|
16
|
+
if (close === -1) return [pattern];
|
|
17
|
+
const before = pattern.slice(0, open);
|
|
18
|
+
const body = pattern.slice(open + 1, close);
|
|
19
|
+
const after = pattern.slice(close + 1);
|
|
20
|
+
const choices = body.split(",");
|
|
21
|
+
if (choices.length === 1) return [pattern];
|
|
22
|
+
return choices.flatMap((choice) => expandBraces(`${before}${choice}${after}`));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} pattern
|
|
27
|
+
*/
|
|
28
|
+
function patternHasMagic(pattern) {
|
|
29
|
+
return MAGIC.test(pattern);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} absolutePattern
|
|
34
|
+
*/
|
|
35
|
+
function absoluteBase(absolutePattern) {
|
|
36
|
+
const normalized = toPosix(absolutePattern);
|
|
37
|
+
const segments = normalized.split("/");
|
|
38
|
+
let magicIndex = segments.length;
|
|
39
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
40
|
+
if (patternHasMagic(segments[index])) {
|
|
41
|
+
magicIndex = index;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (magicIndex === segments.length) {
|
|
46
|
+
return path.normalize(absolutePattern);
|
|
47
|
+
}
|
|
48
|
+
const baseSegments = segments.slice(0, magicIndex);
|
|
49
|
+
const joined = baseSegments.join("/");
|
|
50
|
+
if (!joined) return path.parse(absolutePattern).root;
|
|
51
|
+
return path.normalize(joined);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Convert a glob segment into a regular expression fragment.
|
|
56
|
+
* Supports **, *, ?, character classes and brace-free patterns.
|
|
57
|
+
* @param {string} glob
|
|
58
|
+
* @param {boolean} caseInsensitive
|
|
59
|
+
*/
|
|
60
|
+
function globToRegExp(glob, caseInsensitive = false) {
|
|
61
|
+
let source = "";
|
|
62
|
+
for (let index = 0; index < glob.length; index += 1) {
|
|
63
|
+
const character = glob[index];
|
|
64
|
+
if (character === "*") {
|
|
65
|
+
if (glob[index + 1] === "*") {
|
|
66
|
+
while (glob[index + 1] === "*") index += 1;
|
|
67
|
+
if (glob[index + 1] === "/") {
|
|
68
|
+
source += "(?:.*/)?";
|
|
69
|
+
index += 1;
|
|
70
|
+
} else {
|
|
71
|
+
source += ".*";
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
source += "[^/]*";
|
|
75
|
+
}
|
|
76
|
+
} else if (character === "?") {
|
|
77
|
+
source += "[^/]";
|
|
78
|
+
} else if (character === "[") {
|
|
79
|
+
let close = glob.indexOf("]", index + 1);
|
|
80
|
+
if (close === -1) {
|
|
81
|
+
source += "\\[";
|
|
82
|
+
} else {
|
|
83
|
+
let body = glob.slice(index + 1, close);
|
|
84
|
+
if (body.startsWith("!")) body = `^${body.slice(1)}`;
|
|
85
|
+
source += `[${body}]`;
|
|
86
|
+
index = close;
|
|
87
|
+
}
|
|
88
|
+
} else if ("\\^$+.()|{}".includes(character)) {
|
|
89
|
+
source += `\\${character}`;
|
|
90
|
+
} else {
|
|
91
|
+
source += character;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return new RegExp(`^${source}$`, caseInsensitive ? "i" : "");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {string} pattern
|
|
99
|
+
* @param {{ caseInsensitive?: boolean }} [options]
|
|
100
|
+
*/
|
|
101
|
+
export function compileGlob(pattern, options = {}) {
|
|
102
|
+
let normalized = toPosix(pattern).trim();
|
|
103
|
+
if (!normalized) {
|
|
104
|
+
return { regex: /^$/, normalized };
|
|
105
|
+
}
|
|
106
|
+
if (normalized.startsWith("./")) normalized = normalized.slice(2);
|
|
107
|
+
const caseInsensitive = options.caseInsensitive ?? IS_WINDOWS;
|
|
108
|
+
if (!normalized.includes("/") && !normalized.startsWith("**")) {
|
|
109
|
+
normalized = `**/${normalized}`;
|
|
110
|
+
}
|
|
111
|
+
return { regex: globToRegExp(normalized, caseInsensitive), normalized };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Match a project-relative path against a glob. A pattern without a slash
|
|
116
|
+
* matches a basename at any depth, which is convenient for config exclusions.
|
|
117
|
+
* @param {string} relativePath
|
|
118
|
+
* @param {string} pattern
|
|
119
|
+
* @param {{ caseInsensitive?: boolean }} [options]
|
|
120
|
+
*/
|
|
121
|
+
export function matchGlob(relativePath, pattern, options = {}) {
|
|
122
|
+
const normalizedPath = toPosix(relativePath).replace(/^\.\//, "");
|
|
123
|
+
return compileGlob(pattern, options).regex.test(normalizedPath);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {string} relativePath
|
|
128
|
+
* @param {Array<{ pattern: string }>} entries
|
|
129
|
+
*/
|
|
130
|
+
export function matchAnyGlob(relativePath, entries) {
|
|
131
|
+
return entries.find((entry) => matchGlob(relativePath, entry.pattern));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {string} directory
|
|
136
|
+
* @returns {Promise<string[]>}
|
|
137
|
+
*/
|
|
138
|
+
export async function walkFiles(directory) {
|
|
139
|
+
const result = [];
|
|
140
|
+
const stack = [directory];
|
|
141
|
+
while (stack.length > 0) {
|
|
142
|
+
const current = stack.pop();
|
|
143
|
+
let entries;
|
|
144
|
+
try {
|
|
145
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (error && (error.code === "ENOENT" || error.code === "ENOTDIR")) continue;
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
for (const entry of entries) {
|
|
151
|
+
if (entry.isSymbolicLink()) continue;
|
|
152
|
+
const absolute = path.join(current, entry.name);
|
|
153
|
+
if (entry.isDirectory()) {
|
|
154
|
+
if (SKIP_DIRECTORIES.has(entry.name)) continue;
|
|
155
|
+
stack.push(absolute);
|
|
156
|
+
} else if (entry.isFile()) {
|
|
157
|
+
result.push(absolute);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Expand one or more glob patterns to absolute file paths.
|
|
166
|
+
* Symlinked files/directories are intentionally not followed, to keep source
|
|
167
|
+
* discovery deterministic and avoid cycles.
|
|
168
|
+
*
|
|
169
|
+
* @param {string | string[]} patterns
|
|
170
|
+
* @param {string} root
|
|
171
|
+
* @param {{ includeDirectories?: boolean }} [options]
|
|
172
|
+
* @returns {Promise<string[]>}
|
|
173
|
+
*/
|
|
174
|
+
export async function expandGlob(patterns, root, options = {}) {
|
|
175
|
+
const list = Array.isArray(patterns) ? patterns : [patterns];
|
|
176
|
+
const found = new Map();
|
|
177
|
+
for (const rawPattern of list) {
|
|
178
|
+
if (typeof rawPattern !== "string" || rawPattern.trim() === "") continue;
|
|
179
|
+
for (const pattern of expandBraces(rawPattern.trim())) {
|
|
180
|
+
if (toPosix(pattern).includes("..")) {
|
|
181
|
+
// Allowed, but intentionally resolved through path.resolve below.
|
|
182
|
+
}
|
|
183
|
+
const absolutePattern = path.isAbsolute(pattern)
|
|
184
|
+
? path.normalize(pattern)
|
|
185
|
+
: path.resolve(root, pattern);
|
|
186
|
+
if (!patternHasMagic(absolutePattern)) {
|
|
187
|
+
let info;
|
|
188
|
+
try {
|
|
189
|
+
info = await stat(absolutePattern);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (error && error.code === "ENOENT") continue;
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
if (info.isFile()) {
|
|
195
|
+
found.set(canonicalPath(absolutePattern), absolutePattern);
|
|
196
|
+
} else if (info.isDirectory() && options.includeDirectories) {
|
|
197
|
+
for (const file of await walkFiles(absolutePattern)) {
|
|
198
|
+
found.set(canonicalPath(file), file);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const base = absoluteBase(absolutePattern);
|
|
204
|
+
let baseInfo;
|
|
205
|
+
try {
|
|
206
|
+
baseInfo = await stat(base);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (error && error.code === "ENOENT") continue;
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
const candidates = baseInfo.isDirectory() ? await walkFiles(base) : [base];
|
|
212
|
+
const absolutePatternPosix = toPosix(absolutePattern);
|
|
213
|
+
const regex = globToRegExp(absolutePatternPosix, IS_WINDOWS);
|
|
214
|
+
for (const candidate of candidates) {
|
|
215
|
+
if (regex.test(toPosix(candidate))) {
|
|
216
|
+
found.set(canonicalPath(candidate), candidate);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return [...found.values()].sort((left, right) =>
|
|
222
|
+
toPosix(left).localeCompare(toPosix(right), "en")
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Expand an explicit list of files/directories/globs, keeping only files.
|
|
228
|
+
* Directories are scanned recursively.
|
|
229
|
+
* @param {string | string[]} patterns
|
|
230
|
+
* @param {string} root
|
|
231
|
+
*/
|
|
232
|
+
export async function expandFiles(patterns, root) {
|
|
233
|
+
return expandGlob(patterns, root, { includeDirectories: true });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* @param {string} directory
|
|
238
|
+
* @param {string} root
|
|
239
|
+
*/
|
|
240
|
+
export function relativeToRoot(directory, root) {
|
|
241
|
+
return toPosix(path.relative(root, directory));
|
|
242
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export { main } from "./cli.js";
|
|
2
|
+
export { parseArgs, HELP_TEXT } from "./args.js";
|
|
3
|
+
export {
|
|
4
|
+
loadConfig,
|
|
5
|
+
normalizeConfig,
|
|
6
|
+
selectScopes,
|
|
7
|
+
CONFIG_FILENAME,
|
|
8
|
+
CONFIG_VERSION,
|
|
9
|
+
METRICS,
|
|
10
|
+
PROFILES,
|
|
11
|
+
PROFILE_FLOORS
|
|
12
|
+
} from "./config.js";
|
|
13
|
+
export { loadCoverage } from "./coverage/index.js";
|
|
14
|
+
export { parseIstanbulReports } from "./coverage/istanbul.js";
|
|
15
|
+
export { parseLcovReports, parseLcovText } from "./coverage/lcov.js";
|
|
16
|
+
export { parseAnnotations } from "./annotations.js";
|
|
17
|
+
export { validateReason } from "./reason.js";
|
|
18
|
+
export { evaluateScope } from "./scope.js";
|
|
19
|
+
export { gateAll, gateScope } from "./gate.js";
|
|
20
|
+
export {
|
|
21
|
+
buildReport,
|
|
22
|
+
plannedArtifactPaths,
|
|
23
|
+
renderMarkdownReport,
|
|
24
|
+
renderTextReport,
|
|
25
|
+
writeReportArtifacts
|
|
26
|
+
} from "./report/index.js";
|
|
27
|
+
export { VERSION, TOOL_NAME } from "./version.js";
|
|
28
|
+
export { CovgateError, ConfigError, ExecutionError, CoverageDataError } from "./errors.js";
|
package/src/process.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { StringDecoder } from "node:string_decoder";
|
|
3
|
+
|
|
4
|
+
const MAX_CAPTURE = 4 * 1024 * 1024;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {import("node:child_process").ChildProcess} child
|
|
8
|
+
* @param {boolean} timedOut
|
|
9
|
+
*/
|
|
10
|
+
function killTree(child, timedOut) {
|
|
11
|
+
if (child.pid === undefined) return;
|
|
12
|
+
if (process.platform === "win32") {
|
|
13
|
+
const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
|
14
|
+
stdio: "ignore",
|
|
15
|
+
windowsHide: true
|
|
16
|
+
});
|
|
17
|
+
killer.on("error", () => {
|
|
18
|
+
try {
|
|
19
|
+
child.kill();
|
|
20
|
+
} catch {
|
|
21
|
+
// Ignore: process may already be gone.
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
process.kill(-child.pid, timedOut ? "SIGKILL" : "SIGTERM");
|
|
28
|
+
} catch {
|
|
29
|
+
try {
|
|
30
|
+
child.kill(timedOut ? "SIGKILL" : "SIGTERM");
|
|
31
|
+
} catch {
|
|
32
|
+
// Ignore: process may already be gone.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} current
|
|
39
|
+
* @param {string} chunk
|
|
40
|
+
* @param {number} limit
|
|
41
|
+
*/
|
|
42
|
+
function appendLimited(current, chunk, limit) {
|
|
43
|
+
if (chunk.length === 0) return { text: current, truncated: false };
|
|
44
|
+
const combined = current + chunk;
|
|
45
|
+
if (combined.length <= limit) return { text: combined, truncated: false };
|
|
46
|
+
return { text: combined.slice(-limit), truncated: true };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Run one shell command with bounded output capture and a timeout.
|
|
51
|
+
*
|
|
52
|
+
* @param {{
|
|
53
|
+
* command: string,
|
|
54
|
+
* cwd: string,
|
|
55
|
+
* env?: Record<string, string>,
|
|
56
|
+
* timeoutMs?: number,
|
|
57
|
+
* onStdout?: (chunk: string) => void,
|
|
58
|
+
* onStderr?: (chunk: string) => void
|
|
59
|
+
* }} options
|
|
60
|
+
*/
|
|
61
|
+
export function runShellCommand({
|
|
62
|
+
command,
|
|
63
|
+
cwd,
|
|
64
|
+
env = {},
|
|
65
|
+
timeoutMs = 900_000,
|
|
66
|
+
onStdout,
|
|
67
|
+
onStderr
|
|
68
|
+
}) {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const startedAt = Date.now();
|
|
71
|
+
const detached = process.platform !== "win32";
|
|
72
|
+
let child;
|
|
73
|
+
try {
|
|
74
|
+
child = spawn(command, {
|
|
75
|
+
cwd,
|
|
76
|
+
shell: true,
|
|
77
|
+
windowsHide: true,
|
|
78
|
+
detached,
|
|
79
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
80
|
+
env: { ...process.env, COVGATE: "1", ...env }
|
|
81
|
+
});
|
|
82
|
+
} catch (error) {
|
|
83
|
+
reject(error);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let stdout = "";
|
|
88
|
+
let stderr = "";
|
|
89
|
+
let stdoutTruncated = false;
|
|
90
|
+
let stderrTruncated = false;
|
|
91
|
+
let timedOut = false;
|
|
92
|
+
let settled = false;
|
|
93
|
+
let forceTimer = null;
|
|
94
|
+
const stdoutDecoder = new StringDecoder("utf8");
|
|
95
|
+
const stderrDecoder = new StringDecoder("utf8");
|
|
96
|
+
|
|
97
|
+
const finish = (/** @type {any} */ result) => {
|
|
98
|
+
if (settled) return;
|
|
99
|
+
settled = true;
|
|
100
|
+
if (timer) clearTimeout(timer);
|
|
101
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
102
|
+
resolve(result);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
timedOut = true;
|
|
107
|
+
killTree(child, false);
|
|
108
|
+
forceTimer = setTimeout(() => {
|
|
109
|
+
killTree(child, true);
|
|
110
|
+
finish({
|
|
111
|
+
code: null,
|
|
112
|
+
signal: "SIGKILL",
|
|
113
|
+
timedOut: true,
|
|
114
|
+
stdout,
|
|
115
|
+
stderr,
|
|
116
|
+
stdoutTruncated,
|
|
117
|
+
stderrTruncated,
|
|
118
|
+
durationMs: Date.now() - startedAt,
|
|
119
|
+
error: "timeout"
|
|
120
|
+
});
|
|
121
|
+
}, 8_000);
|
|
122
|
+
}, timeoutMs);
|
|
123
|
+
|
|
124
|
+
child.stdout?.on("data", (chunk) => {
|
|
125
|
+
try {
|
|
126
|
+
onStdout?.(chunk.toString());
|
|
127
|
+
} catch {
|
|
128
|
+
// Logging must not break the command.
|
|
129
|
+
}
|
|
130
|
+
const decoded = stdoutDecoder.write(chunk);
|
|
131
|
+
const appended = appendLimited(stdout, decoded, MAX_CAPTURE);
|
|
132
|
+
stdout = appended.text;
|
|
133
|
+
stdoutTruncated ||= appended.truncated;
|
|
134
|
+
});
|
|
135
|
+
child.stderr?.on("data", (chunk) => {
|
|
136
|
+
try {
|
|
137
|
+
onStderr?.(chunk.toString());
|
|
138
|
+
} catch {
|
|
139
|
+
// Logging must not break the command.
|
|
140
|
+
}
|
|
141
|
+
const decoded = stderrDecoder.write(chunk);
|
|
142
|
+
const appended = appendLimited(stderr, decoded, MAX_CAPTURE);
|
|
143
|
+
stderr = appended.text;
|
|
144
|
+
stderrTruncated ||= appended.truncated;
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
child.on("error", (error) => {
|
|
148
|
+
if (settled) return;
|
|
149
|
+
settled = true;
|
|
150
|
+
if (timer) clearTimeout(timer);
|
|
151
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
152
|
+
reject(error);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
child.on("close", (code, signal) => {
|
|
156
|
+
stdout += stdoutDecoder.end();
|
|
157
|
+
stderr += stderrDecoder.end();
|
|
158
|
+
finish({
|
|
159
|
+
code,
|
|
160
|
+
signal,
|
|
161
|
+
timedOut,
|
|
162
|
+
stdout,
|
|
163
|
+
stderr,
|
|
164
|
+
stdoutTruncated,
|
|
165
|
+
stderrTruncated,
|
|
166
|
+
durationMs: Date.now() - startedAt
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
}
|
package/src/reason.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { parseExpiryDate } from "./util.js";
|
|
2
|
+
|
|
3
|
+
const PLACEHOLDER_REASONS = new Set([
|
|
4
|
+
"todo",
|
|
5
|
+
"fixme",
|
|
6
|
+
"na",
|
|
7
|
+
"none",
|
|
8
|
+
"ignore",
|
|
9
|
+
"skip",
|
|
10
|
+
"skipped",
|
|
11
|
+
"unreachable",
|
|
12
|
+
"uncovered",
|
|
13
|
+
"notest",
|
|
14
|
+
"notestable",
|
|
15
|
+
"coverage",
|
|
16
|
+
"excluded",
|
|
17
|
+
"无",
|
|
18
|
+
"忽略",
|
|
19
|
+
"跳过",
|
|
20
|
+
"不可达",
|
|
21
|
+
"无法覆盖",
|
|
22
|
+
"测试不到",
|
|
23
|
+
"无测试",
|
|
24
|
+
"不需要",
|
|
25
|
+
"无需覆盖"
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Normalize a reason before checking it against the placeholder list.
|
|
30
|
+
* @param {string} reason
|
|
31
|
+
*/
|
|
32
|
+
function normalizeReason(reason) {
|
|
33
|
+
return reason
|
|
34
|
+
.trim()
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.replace(/[\s.,;:!?'"()\[\]{}<>/\\|_\-—–]+/g, "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Validate the human explanation attached to a suppression. This is
|
|
41
|
+
* deliberately strict: an audit trail with "TODO" is not an audit trail.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} reason
|
|
44
|
+
* @param {{ minReasonLength: number, requireOwner: boolean }} policy
|
|
45
|
+
* @param {{ owner?: string, expires?: string }} metadata
|
|
46
|
+
* @param {Date} [now]
|
|
47
|
+
* @returns {{ errors: string[], expired: boolean, expiresAt: Date | null }}
|
|
48
|
+
*/
|
|
49
|
+
export function validateReason(reason, policy, metadata = {}, now = new Date()) {
|
|
50
|
+
const errors = [];
|
|
51
|
+
const trimmed = reason.trim();
|
|
52
|
+
if (!trimmed) {
|
|
53
|
+
errors.push("缺少原因说明");
|
|
54
|
+
} else {
|
|
55
|
+
const length = [...trimmed].length;
|
|
56
|
+
if (length < policy.minReasonLength) {
|
|
57
|
+
errors.push(
|
|
58
|
+
`原因说明太短(${length} 个字符,至少需要 ${policy.minReasonLength} 个);请说明为什么这段代码无法被执行到`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (PLACEHOLDER_REASONS.has(normalizeReason(trimmed))) {
|
|
62
|
+
errors.push("原因说明是占位词,必须解释不可达的具体原因");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (policy.requireOwner && !metadata.owner?.trim()) {
|
|
66
|
+
errors.push("策略要求提供 owner 元数据");
|
|
67
|
+
}
|
|
68
|
+
let expiresAt = null;
|
|
69
|
+
if (metadata.expires !== undefined) {
|
|
70
|
+
expiresAt = parseExpiryDate(metadata.expires);
|
|
71
|
+
if (!expiresAt) {
|
|
72
|
+
errors.push(`expires 必须是合法的 YYYY-MM-DD 日期,收到 "${metadata.expires}"`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const expired = expiresAt !== null && expiresAt.getTime() < now.getTime();
|
|
76
|
+
return { errors, expired, expiresAt };
|
|
77
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { relativeDisplay, tail, toPosix } from "../util.js";
|
|
3
|
+
import { VERSION } from "../version.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {any} config
|
|
7
|
+
* @param {{ json?: string, markdown?: string }} paths
|
|
8
|
+
*/
|
|
9
|
+
function artifactDisplay(config, paths) {
|
|
10
|
+
const artifacts = {};
|
|
11
|
+
for (const [format, filePath] of Object.entries(paths)) {
|
|
12
|
+
if (!filePath) continue;
|
|
13
|
+
const relative = relativeDisplay(filePath, config.root);
|
|
14
|
+
artifacts[format] = relative.startsWith("..") ? toPosix(filePath) : relative;
|
|
15
|
+
}
|
|
16
|
+
return artifacts;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {any[]} scopes
|
|
21
|
+
*/
|
|
22
|
+
function countScopeState(scopes) {
|
|
23
|
+
let waivers = 0;
|
|
24
|
+
let exclusions = 0;
|
|
25
|
+
let errors = 0;
|
|
26
|
+
let warnings = 0;
|
|
27
|
+
let missing = 0;
|
|
28
|
+
for (const scope of scopes) {
|
|
29
|
+
waivers += scope.waivers.length;
|
|
30
|
+
exclusions += scope.exclusions.length;
|
|
31
|
+
errors += scope.issues.filter((issue) => issue.severity === "error").length;
|
|
32
|
+
warnings +=
|
|
33
|
+
scope.warnings.length +
|
|
34
|
+
scope.issues.filter((issue) => issue.severity === "warning").length;
|
|
35
|
+
missing += scope.missingSources.length;
|
|
36
|
+
}
|
|
37
|
+
return { waivers, exclusions, errors, warnings, missing };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {{
|
|
42
|
+
* config: any,
|
|
43
|
+
* mode: "run" | "check",
|
|
44
|
+
* scopes: any[],
|
|
45
|
+
* commands: any[],
|
|
46
|
+
* topLevelIssues: any[],
|
|
47
|
+
* durationMs: number,
|
|
48
|
+
* startedAt: string,
|
|
49
|
+
* artifactPaths: { json?: string, markdown?: string }
|
|
50
|
+
* }} options
|
|
51
|
+
*/
|
|
52
|
+
export function buildReport({
|
|
53
|
+
config,
|
|
54
|
+
mode,
|
|
55
|
+
scopes,
|
|
56
|
+
commands,
|
|
57
|
+
topLevelIssues,
|
|
58
|
+
durationMs,
|
|
59
|
+
startedAt,
|
|
60
|
+
artifactPaths
|
|
61
|
+
}) {
|
|
62
|
+
const errors = topLevelIssues.filter((issue) => issue.severity !== "warning");
|
|
63
|
+
const hasExecutionError = errors.length > 0;
|
|
64
|
+
const allPassed = scopes.length > 0 && scopes.every((scope) => scope.passed);
|
|
65
|
+
const status = hasExecutionError ? "error" : allPassed ? "pass" : "fail";
|
|
66
|
+
const exitCode = status === "error" ? 3 : status === "pass" ? 0 : 1;
|
|
67
|
+
const state = countScopeState(scopes);
|
|
68
|
+
const failedScopes = scopes.filter((scope) => !scope.passed).map((scope) => scope.name);
|
|
69
|
+
const artifacts = artifactDisplay(config, artifactPaths);
|
|
70
|
+
|
|
71
|
+
const sanitizedCommands = commands.map((command) => {
|
|
72
|
+
const record = {
|
|
73
|
+
scope: command.scope,
|
|
74
|
+
name: command.name,
|
|
75
|
+
command: command.command,
|
|
76
|
+
cwd: command.cwd,
|
|
77
|
+
exitCode: command.exitCode,
|
|
78
|
+
signal: command.signal,
|
|
79
|
+
timedOut: command.timedOut,
|
|
80
|
+
durationMs: command.durationMs
|
|
81
|
+
};
|
|
82
|
+
if (command.exitCode !== 0 || command.timedOut) {
|
|
83
|
+
record.outputTail = tail(
|
|
84
|
+
((command.stdout ?? "") + "\n" + (command.stderr ?? "")).trim(),
|
|
85
|
+
8000
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return record;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
schemaVersion: 1,
|
|
93
|
+
tool: { name: "covgate", version: VERSION },
|
|
94
|
+
status,
|
|
95
|
+
exitCode,
|
|
96
|
+
generatedAt: new Date().toISOString(),
|
|
97
|
+
startedAt,
|
|
98
|
+
durationMs,
|
|
99
|
+
mode,
|
|
100
|
+
config: {
|
|
101
|
+
path: relativeDisplay(config.configPath, config.root),
|
|
102
|
+
sha256: config.hash
|
|
103
|
+
},
|
|
104
|
+
summary: {
|
|
105
|
+
scopes: scopes.length,
|
|
106
|
+
passedScopes: scopes.filter((scope) => scope.passed).length,
|
|
107
|
+
failedScopes,
|
|
108
|
+
waivers: state.waivers,
|
|
109
|
+
exclusions: state.exclusions,
|
|
110
|
+
missingSources: state.missing,
|
|
111
|
+
errors: state.errors + errors.length,
|
|
112
|
+
warnings: state.warnings
|
|
113
|
+
},
|
|
114
|
+
issues: topLevelIssues,
|
|
115
|
+
commands: sanitizedCommands,
|
|
116
|
+
scopes,
|
|
117
|
+
artifacts
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {any} config
|
|
123
|
+
* @param {boolean} noFiles
|
|
124
|
+
*/
|
|
125
|
+
export function plannedArtifactPaths(config, noFiles = false) {
|
|
126
|
+
if (noFiles) return {};
|
|
127
|
+
const paths = {};
|
|
128
|
+
if (config.reports.formats.includes("json")) {
|
|
129
|
+
paths.json = path.join(config.reports.dir, "report.json");
|
|
130
|
+
}
|
|
131
|
+
if (config.reports.formats.includes("markdown")) {
|
|
132
|
+
paths.markdown = path.join(config.reports.dir, "report.md");
|
|
133
|
+
}
|
|
134
|
+
return paths;
|
|
135
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { writeTextFile } from "../util.js";
|
|
2
|
+
import { buildReport, plannedArtifactPaths } from "./build.js";
|
|
3
|
+
import { renderMarkdownReport } from "./markdown.js";
|
|
4
|
+
import { renderTextReport } from "./text.js";
|
|
5
|
+
|
|
6
|
+
export { buildReport, plannedArtifactPaths, renderMarkdownReport, renderTextReport };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {any} report
|
|
10
|
+
* @param {{ json?: string, markdown?: string }} paths
|
|
11
|
+
*/
|
|
12
|
+
export async function writeReportArtifacts(report, paths) {
|
|
13
|
+
const errors = [];
|
|
14
|
+
if (paths.json) {
|
|
15
|
+
try {
|
|
16
|
+
await writeTextFile(paths.json, JSON.stringify(report, null, 2) + "\n");
|
|
17
|
+
} catch (error) {
|
|
18
|
+
errors.push("无法写入 JSON 报告 " + paths.json + ": " + error.message);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (paths.markdown) {
|
|
22
|
+
try {
|
|
23
|
+
await writeTextFile(paths.markdown, renderMarkdownReport(report));
|
|
24
|
+
} catch (error) {
|
|
25
|
+
errors.push("无法写入 Markdown 报告 " + paths.markdown + ": " + error.message);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return errors;
|
|
29
|
+
}
|