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