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
@@ -0,0 +1,316 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+
6
+ const FRAMEWORK_LABELS = {
7
+ vitest: "Vitest",
8
+ jest: "Jest",
9
+ playwright: "Playwright Test",
10
+ mocha: "Mocha",
11
+ ava: "AVA",
12
+ tap: "node-tap",
13
+ "node-test": "Node.js 内置 test runner"
14
+ };
15
+
16
+ const BROWSER_DEPS = new Set([
17
+ "react",
18
+ "react-dom",
19
+ "vue",
20
+ "svelte",
21
+ "solid-js",
22
+ "next",
23
+ "nuxt",
24
+ "@angular/core",
25
+ "preact"
26
+ ]);
27
+
28
+ const SERVER_DEPS = new Set([
29
+ "express",
30
+ "fastify",
31
+ "koa",
32
+ "hapi",
33
+ "@hapi/hapi",
34
+ "@nestjs/core"
35
+ ]);
36
+
37
+ const TEST_DEPS = [
38
+ ["vitest", "vitest"],
39
+ ["jest", "jest"],
40
+ ["@playwright/test", "playwright"],
41
+ ["mocha", "mocha"],
42
+ ["ava", "ava"],
43
+ ["tap", "tap"]
44
+ ];
45
+
46
+ const SOURCE_EXT = "{js,jsx,ts,tsx,mjs,cjs}";
47
+
48
+ export class SetupError extends Error {
49
+ constructor(message) {
50
+ super(message);
51
+ this.name = "SetupError";
52
+ this.exitCode = 2;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * @param {string} root
58
+ */
59
+ export function packageJsonPath(root) {
60
+ return path.join(root, "package.json");
61
+ }
62
+
63
+ /**
64
+ * @param {string} root
65
+ */
66
+ export async function readPackage(root) {
67
+ const file = packageJsonPath(root);
68
+ let text;
69
+ try {
70
+ text = await readFile(file, "utf8");
71
+ } catch (error) {
72
+ if (error && error.code === "ENOENT") return null;
73
+ throw new SetupError("无法读取 package.json: " + error.message);
74
+ }
75
+ try {
76
+ return { path: file, text, data: JSON.parse(text.replace(/^\uFEFF/, "")) };
77
+ } catch (error) {
78
+ throw new SetupError("package.json 不是合法 JSON: " + error.message);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * @param {string} root
84
+ * @param {any} data
85
+ */
86
+ export async function writePackage(root, data) {
87
+ await writeFile(
88
+ packageJsonPath(root),
89
+ JSON.stringify(data, null, 2) + "\n",
90
+ "utf8"
91
+ );
92
+ }
93
+
94
+ /**
95
+ * @param {string} root
96
+ * @param {any} pkg
97
+ */
98
+ export function detectPackageManager(root, pkg) {
99
+ const declared = String(pkg?.packageManager || "").split("@")[0];
100
+ if (["npm", "pnpm", "yarn", "bun"].includes(declared)) return declared;
101
+ if (existsSync(path.join(root, "pnpm-lock.yaml"))) return "pnpm";
102
+ if (existsSync(path.join(root, "yarn.lock"))) return "yarn";
103
+ if (existsSync(path.join(root, "bun.lockb")) || existsSync(path.join(root, "bun.lock"))) return "bun";
104
+ return "npm";
105
+ }
106
+
107
+ /**
108
+ * @param {any} pkg
109
+ */
110
+ export function directDependencyNames(pkg) {
111
+ return new Set([
112
+ ...Object.keys(pkg?.dependencies || {}),
113
+ ...Object.keys(pkg?.devDependencies || {}),
114
+ ...Object.keys(pkg?.optionalDependencies || {})
115
+ ]);
116
+ }
117
+
118
+ /**
119
+ * @param {any} pkg
120
+ */
121
+ export function hasCovgateDependency(pkg) {
122
+ return directDependencyNames(pkg).has("covgate");
123
+ }
124
+
125
+ /**
126
+ * @param {any} pkg
127
+ * @returns {{ id: string | null, label: string, hasCoverageScript: boolean }}
128
+ */
129
+ export function detectFramework(pkg) {
130
+ const deps = directDependencyNames(pkg);
131
+ const scripts = pkg?.scripts || {};
132
+ for (const [dependency, id] of TEST_DEPS) {
133
+ if (deps.has(dependency)) {
134
+ return {
135
+ id,
136
+ label: FRAMEWORK_LABELS[id],
137
+ hasCoverageScript: Boolean(scripts["test:coverage"] || scripts.test)
138
+ };
139
+ }
140
+ }
141
+ const testScript = String(scripts["test:coverage"] || scripts.test || "");
142
+ if (/\bnode\s+--test\b/.test(testScript)) {
143
+ return {
144
+ id: "node-test",
145
+ label: FRAMEWORK_LABELS["node-test"],
146
+ hasCoverageScript: Boolean(scripts["test:coverage"])
147
+ };
148
+ }
149
+ return { id: null, label: "未检测到", hasCoverageScript: false };
150
+ }
151
+
152
+ /**
153
+ * @param {string} root
154
+ * @param {any} pkg
155
+ * @returns {"client" | "server"}
156
+ */
157
+ export function detectProfile(root, pkg) {
158
+ const deps = directDependencyNames(pkg);
159
+ if (existsSync(path.join(root, "index.html"))) return "client";
160
+ if (pkg?.browser) return "client";
161
+ if ([...BROWSER_DEPS].some((dependency) => deps.has(dependency))) return "client";
162
+ if ([...SERVER_DEPS].some((dependency) => deps.has(dependency))) return "server";
163
+ return "server";
164
+ }
165
+
166
+ /**
167
+ * @param {string} root
168
+ * @param {"client" | "server"} profile
169
+ * @returns {string | null}
170
+ */
171
+ export function detectSourceGlob(root, profile) {
172
+ if (existsSync(path.join(root, "src"))) {
173
+ return "src/**/*." + SOURCE_EXT;
174
+ }
175
+ if (profile === "client" && existsSync(path.join(root, "app.js"))) {
176
+ return "app.js";
177
+ }
178
+ const candidate = profile === "client" ? "client" : "server";
179
+ if (existsSync(path.join(root, candidate))) {
180
+ return candidate + "/**/*." + SOURCE_EXT;
181
+ }
182
+ if (existsSync(path.join(root, "app"))) {
183
+ return "app/**/*." + SOURCE_EXT;
184
+ }
185
+ if (existsSync(path.join(root, "lib"))) {
186
+ return "lib/**/*." + SOURCE_EXT;
187
+ }
188
+ return null;
189
+ }
190
+
191
+ /**
192
+ * @param {"vitest" | "jest" | string} frameworkId
193
+ */
194
+ export function coverageScriptFor(frameworkId) {
195
+ if (frameworkId === "vitest") {
196
+ return "vitest run --coverage --coverage.reporter=json --coverage.reporter=lcov --passWithNoTests";
197
+ }
198
+ if (frameworkId === "jest") {
199
+ return "jest --coverage --coverageReporters=json --coverageReporters=lcov --passWithNoTests";
200
+ }
201
+ return null;
202
+ }
203
+
204
+ /**
205
+ * @param {"vitest" | "jest" | string} frameworkId
206
+ */
207
+ export function testScriptFor(frameworkId) {
208
+ if (frameworkId === "vitest") return "vitest run";
209
+ if (frameworkId === "jest") return "jest";
210
+ return null;
211
+ }
212
+
213
+ /**
214
+ * Add idempotent package scripts. Existing user scripts are preserved unless
215
+ * force is true.
216
+ * @param {any} pkg
217
+ * @param {"vitest" | "jest" | string} frameworkId
218
+ * @param {{ force?: boolean, testCommand?: string }} [options]
219
+ */
220
+ export function addScripts(pkg, frameworkId, options = {}) {
221
+ pkg.scripts = pkg.scripts || {};
222
+ const changed = [];
223
+ if (!pkg.scripts["coverage:gate"] || options.force) {
224
+ pkg.scripts["coverage:gate"] = "covgate run";
225
+ changed.push("coverage:gate");
226
+ }
227
+ if (options.testCommand) {
228
+ if (!pkg.scripts["test:coverage"] || options.force) {
229
+ pkg.scripts["test:coverage"] = options.testCommand;
230
+ changed.push("test:coverage");
231
+ }
232
+ } else {
233
+ const coverageScript = coverageScriptFor(frameworkId);
234
+ if (coverageScript && (!pkg.scripts["test:coverage"] || options.force)) {
235
+ pkg.scripts["test:coverage"] = coverageScript;
236
+ changed.push("test:coverage");
237
+ }
238
+ const testScript = testScriptFor(frameworkId);
239
+ if (testScript && !pkg.scripts.test) {
240
+ pkg.scripts.test = testScript;
241
+ changed.push("test");
242
+ }
243
+ }
244
+ return changed;
245
+ }
246
+
247
+ /**
248
+ * @param {string} root
249
+ * @param {"npm" | "pnpm" | "yarn" | "bun"} packageManager
250
+ * @param {boolean} dryRun
251
+ * @param {{ stdout?: NodeJS.WritableStream, stderr?: NodeJS.WritableStream }} [io]
252
+ */
253
+ export async function installVitest(root, packageManager, dryRun = false, io = {}) {
254
+ const packages = ["vitest", "@vitest/coverage-v8"];
255
+ const command =
256
+ packageManager === "pnpm"
257
+ ? "pnpm add -D --ignore-scripts " + packages.join(" ")
258
+ : packageManager === "yarn"
259
+ ? "yarn add -D --ignore-scripts " + packages.join(" ")
260
+ : packageManager === "bun"
261
+ ? "bun add -d --ignore-scripts " + packages.join(" ")
262
+ : "npm install --save-dev --ignore-scripts --no-audit --no-fund " + packages.join(" ");
263
+ if (dryRun) {
264
+ (io.stdout || process.stdout).write("[dry-run] " + command + "\n");
265
+ return { ok: true, command, dryRun: true };
266
+ }
267
+ return new Promise((resolve) => {
268
+ const timeout = 180000;
269
+ let settled = false;
270
+ const finish = (result) => {
271
+ if (settled) return;
272
+ settled = true;
273
+ clearTimeout(timer);
274
+ resolve(result);
275
+ };
276
+ const child = spawn(command, {
277
+ cwd: root,
278
+ shell: true,
279
+ detached: process.platform !== "win32",
280
+ stdio: "inherit",
281
+ windowsHide: false
282
+ });
283
+ const timer = setTimeout(() => {
284
+ try {
285
+ if (process.platform === "win32" && child.pid) {
286
+ spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
287
+ stdio: "ignore",
288
+ windowsHide: true
289
+ });
290
+ } else if (child.pid) {
291
+ process.kill(-child.pid, "SIGKILL");
292
+ }
293
+ } catch {
294
+ try { child.kill(); } catch {}
295
+ }
296
+ (io.stderr || process.stderr).write("[covgate] 安装测试框架超时(180s),已终止。\n");
297
+ finish({ ok: false, command, timedOut: true });
298
+ }, timeout);
299
+ child.on("error", (error) => {
300
+ (io.stderr || process.stderr).write("[covgate] 无法启动测试框架安装:" + error.message + "\n");
301
+ finish({ ok: false, command });
302
+ });
303
+ child.on("exit", (code) => {
304
+ finish({ ok: code === 0, command, code });
305
+ });
306
+ });
307
+ }
308
+
309
+ /**
310
+ * @param {string} root
311
+ * @param {string} file
312
+ */
313
+ export function displayPath(root, file) {
314
+ const relative = path.relative(root, file).replaceAll("\\", "/");
315
+ return relative && !relative.startsWith("..") ? relative : file;
316
+ }
@@ -0,0 +1,43 @@
1
+ import readline from "node:readline/promises";
2
+
3
+ /**
4
+ * @param {{
5
+ * input?: NodeJS.ReadableStream & { isTTY?: boolean },
6
+ * output?: NodeJS.WritableStream & { isTTY?: boolean }
7
+ * }} [io]
8
+ */
9
+ export function canPrompt(io = {}) {
10
+ const input = io.input || process.stdin;
11
+ const output = io.output || process.stderr;
12
+ return Boolean(input.isTTY && output.isTTY && !process.env.CI);
13
+ }
14
+
15
+ /**
16
+ * @param {string} question
17
+ * @param {{
18
+ * defaultYes?: boolean,
19
+ * forceYes?: boolean,
20
+ * input?: any,
21
+ * output?: any
22
+ * }} [options]
23
+ */
24
+ export async function askYesNo(question, options = {}) {
25
+ const forceYes =
26
+ options.forceYes === true || process.env.COVGATE_SETUP_ASSUME_YES === "1";
27
+ if (forceYes) return true;
28
+ const input = options.input || process.stdin;
29
+ const output = options.output || process.stderr;
30
+ if (!input.isTTY || !output.isTTY) return false;
31
+
32
+ const rl = readline.createInterface({ input, output });
33
+ try {
34
+ const suffix = options.defaultYes === false ? " [y/N] " : " [Y/n] ";
35
+ const answer = (await rl.question(question + suffix)).trim().toLowerCase();
36
+ if (!answer) return options.defaultYes !== false;
37
+ return answer === "y" || answer === "yes" || answer === "是";
38
+ } catch {
39
+ return false;
40
+ } finally {
41
+ rl.close();
42
+ }
43
+ }
package/src/util.js ADDED
@@ -0,0 +1,163 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ export const PACKAGE_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
7
+ export const IS_WINDOWS = process.platform === "win32";
8
+
9
+ /** @param {string} value */
10
+ export function toPosix(value) {
11
+ return value.replaceAll("\\", "/");
12
+ }
13
+
14
+ /** @param {unknown} value */
15
+ export function isPlainObject(value) {
16
+ return typeof value === "object" && value !== null && !Array.isArray(value);
17
+ }
18
+
19
+ /** @param {string} absPath */
20
+ export function pathKey(absPath) {
21
+ const normalized = path.normalize(absPath);
22
+ return IS_WINDOWS ? normalized.toLowerCase() : normalized;
23
+ }
24
+
25
+ /**
26
+ * Canonical identity for matching files across coverage tools, source globs,
27
+ * Windows drive-letter casing and mixed slash styles.
28
+ * @param {string} absPath
29
+ */
30
+ export function canonicalPath(absPath) {
31
+ return pathKey(path.resolve(absPath));
32
+ }
33
+
34
+ /**
35
+ * @param {string} absPath
36
+ * @param {string} root
37
+ */
38
+ export function relativeDisplay(absPath, root) {
39
+ return toPosix(path.relative(root, absPath));
40
+ }
41
+
42
+ /** @param {number} ms */
43
+ export function formatDuration(ms) {
44
+ if (ms < 1_000) return `${Math.max(0, Math.round(ms))}ms`;
45
+ if (ms < 60_000) return `${(ms / 1_000).toFixed(1)}s`;
46
+ const minutes = Math.floor(ms / 60_000);
47
+ const seconds = Math.round((ms % 60_000) / 1_000);
48
+ return `${minutes}m ${seconds}s`;
49
+ }
50
+
51
+ /**
52
+ * @param {number} covered
53
+ * @param {number} total
54
+ * @param {number} [fractionDigits]
55
+ */
56
+ export function percentOf(covered, total, fractionDigits = 2) {
57
+ if (total === 0) return null;
58
+ return Number(((covered / total) * 100).toFixed(fractionDigits));
59
+ }
60
+
61
+ /** @param {number | null} value */
62
+ export function displayPercent(value) {
63
+ return value === null || value === undefined ? "N/A" : `${value.toFixed(2)}%`;
64
+ }
65
+
66
+ /** @param {number} value */
67
+ export function formatThreshold(value) {
68
+ return Number.isInteger(value) ? String(value) : String(value);
69
+ }
70
+
71
+ /** @param {number} value @param {number} min @param {number} max */
72
+ export function clamp(value, min, max) {
73
+ return Math.min(max, Math.max(min, value));
74
+ }
75
+
76
+ /** @param {string} text @param {number} max */
77
+ export function tail(text, max = 20_000) {
78
+ if (text.length <= max) return text;
79
+ return `… 已截断 ${text.length - max} 个字符 …\n${text.slice(-max)}`;
80
+ }
81
+
82
+ /** @param {string} text */
83
+ export function sha256(text) {
84
+ return createHash("sha256").update(text).digest("hex");
85
+ }
86
+
87
+ /** @param {string} text */
88
+ export function escapeMarkdown(text) {
89
+ return text.replaceAll("|", "\\|").replaceAll("\n", "<br>");
90
+ }
91
+
92
+ /**
93
+ * Parse YYYY-MM-DD as an inclusive local end-of-day date.
94
+ * @param {string} value
95
+ * @returns {Date | null}
96
+ */
97
+ export function parseExpiryDate(value) {
98
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
99
+ const [year, month, day] = value.split("-").map(Number);
100
+ const date = new Date(year, month - 1, day, 23, 59, 59, 999);
101
+ if (
102
+ date.getFullYear() !== year ||
103
+ date.getMonth() !== month - 1 ||
104
+ date.getDate() !== day
105
+ ) {
106
+ return null;
107
+ }
108
+ return date;
109
+ }
110
+
111
+ /** @param {Date} [now] */
112
+ export function todayIso(now = new Date()) {
113
+ const year = now.getFullYear();
114
+ const month = String(now.getMonth() + 1).padStart(2, "0");
115
+ const day = String(now.getDate()).padStart(2, "0");
116
+ return `${year}-${month}-${day}`;
117
+ }
118
+
119
+ /** @param {string} filePath */
120
+ export async function readJsonFile(filePath) {
121
+ const text = await readFile(filePath, "utf8");
122
+ try {
123
+ return JSON.parse(text);
124
+ } catch (error) {
125
+ throw new SyntaxError(`无法解析 JSON 文件 ${filePath}: ${error.message}`);
126
+ }
127
+ }
128
+
129
+ /** @param {string} filePath @param {string} content */
130
+ export async function writeTextFile(filePath, content) {
131
+ await mkdir(path.dirname(filePath), { recursive: true });
132
+ await writeFile(filePath, content, "utf8");
133
+ }
134
+
135
+ /** @param {string} text @param {number} max */
136
+ export function firstLine(text, max = 240) {
137
+ const line = text.split(/\r?\n/, 1)[0] ?? "";
138
+ return line.length > max ? `${line.slice(0, max)}…` : line;
139
+ }
140
+
141
+ /** @param {PropertyKey} key */
142
+ export function keyToString(key) {
143
+ return typeof key === "symbol" ? key.description ?? String(key) : String(key);
144
+ }
145
+
146
+ /**
147
+ * @template T
148
+ * @param {T[]} values
149
+ * @param {(value: T) => string} key
150
+ * @returns {T[]}
151
+ */
152
+ export function uniqueBy(values, key) {
153
+ const seen = new Set();
154
+ const result = [];
155
+ for (const value of values) {
156
+ const id = key(value);
157
+ if (!seen.has(id)) {
158
+ seen.add(id);
159
+ result.push(value);
160
+ }
161
+ }
162
+ return result;
163
+ }
package/src/version.js ADDED
@@ -0,0 +1,8 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ const pkg = JSON.parse(
4
+ readFileSync(new URL("../package.json", import.meta.url), "utf8")
5
+ );
6
+
7
+ export const VERSION = pkg.version;
8
+ export const TOOL_NAME = "covgate";
@@ -0,0 +1,9 @@
1
+ ## 覆盖率门禁(完成定义)
2
+
3
+ - 任何代码修改完成后,必须运行 `npx --no-install covgate run --json`;**退出码 0 才能结束任务**。
4
+ - 退出码 `1`:补测试或修实现;`2`:修配置;`3`:修测试命令/覆盖率报告。不要绕过。
5
+ - 禁止降低 `covgate.config.json` 中的门槛、扩大 exclude、删除豁免检查来让门禁通过。任何配置改动都必须由人类评审。
6
+ - 禁止 `istanbul ignore`、`c8 ignore`、`v8 ignore`、`node:coverage ignore`。
7
+ - 确实不可达的代码使用 `covgate-ignore-next-line`、`covgate-ignore-start/end` 或 `covgate-ignore-file`,并写具体原因、owner 和 expires。
8
+ - 新增或修改的源码必须出现在覆盖率报告中;`sources.include` 是范围事实来源。
9
+ - 交付时附上 `covgate run` 的结果摘要;不要只说“已写测试”。
@@ -0,0 +1,7 @@
1
+ #!/bin/sh
2
+ # Generic agent stop hook: runs the gate and blocks completion on failure.
3
+ # Put it in scripts/ and call it from your agent's Stop hook configuration.
4
+ set -eu
5
+ root="$(git rev-parse --show-toplevel)"
6
+ cd "$root"
7
+ exec npx --no-install covgate run --json
@@ -0,0 +1,98 @@
1
+ {
2
+ "$schema": "./node_modules/covgate/schema/covgate.schema.json",
3
+ "version": 1,
4
+ "scopes": {
5
+ "client": {
6
+ "profile": "client",
7
+ "label": "客户端",
8
+ "commands": [
9
+ {
10
+ "name": "客户端测试与覆盖率",
11
+ "run": "npm --prefix web run test -- --coverage",
12
+ "timeoutSeconds": 900
13
+ }
14
+ ],
15
+ "coverage": {
16
+ "format": "istanbul",
17
+ "paths": [
18
+ "web/coverage/coverage-final.json"
19
+ ]
20
+ },
21
+ "sources": {
22
+ "include": [
23
+ "web/src/**/*.{ts,tsx,js,jsx}"
24
+ ]
25
+ },
26
+ "exclude": [
27
+ {
28
+ "pattern": "web/src/**/*.test.{ts,tsx,js,jsx}",
29
+ "reason": "测试文件,不是产品运行时代码",
30
+ "owner": "web-platform"
31
+ },
32
+ {
33
+ "pattern": "web/src/**/__tests__/**",
34
+ "reason": "测试目录,不是产品运行时代码",
35
+ "owner": "web-platform"
36
+ },
37
+ {
38
+ "pattern": "web/src/**/*.d.ts",
39
+ "reason": "类型声明文件,没有运行时代码",
40
+ "owner": "web-platform"
41
+ },
42
+ {
43
+ "pattern": "web/src/generated/**",
44
+ "reason": "由 OpenAPI 生成,仓库不手工维护,生成器有独立测试",
45
+ "owner": "api-platform",
46
+ "expires": "2027-03-31"
47
+ }
48
+ ]
49
+ },
50
+ "server": {
51
+ "profile": "server",
52
+ "label": "服务端",
53
+ "commands": [
54
+ {
55
+ "name": "服务端测试与覆盖率",
56
+ "run": "npm --prefix api run test -- --coverage",
57
+ "timeoutSeconds": 1200
58
+ }
59
+ ],
60
+ "coverage": {
61
+ "format": "lcov",
62
+ "paths": [
63
+ "api/coverage/lcov.info"
64
+ ]
65
+ },
66
+ "sources": {
67
+ "include": [
68
+ "api/src/**/*.{ts,js}"
69
+ ]
70
+ },
71
+ "exclude": [
72
+ {
73
+ "pattern": "api/src/**/*.test.{ts,js}",
74
+ "reason": "测试文件,不是产品运行时代码",
75
+ "owner": "api-platform"
76
+ },
77
+ {
78
+ "pattern": "api/src/**/*.d.ts",
79
+ "reason": "类型声明文件,没有运行时代码",
80
+ "owner": "api-platform"
81
+ }
82
+ ]
83
+ }
84
+ },
85
+ "waivers": {
86
+ "minReasonLength": 8,
87
+ "requireOwner": true,
88
+ "stale": "fail",
89
+ "expiry": "fail"
90
+ },
91
+ "reports": {
92
+ "dir": ".covgate",
93
+ "formats": [
94
+ "json",
95
+ "markdown"
96
+ ]
97
+ }
98
+ }
@@ -0,0 +1,41 @@
1
+ name: coverage-gate
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main]
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ concurrency:
12
+ group: coverage-${{ github.workflow }}-${{ github.ref }}
13
+ cancel-in-progress: true
14
+
15
+ jobs:
16
+ coverage:
17
+ runs-on: ubuntu-latest
18
+ timeout-minutes: 30
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+
22
+ - uses: actions/setup-node@v4
23
+ with:
24
+ node-version: 22
25
+ cache: npm
26
+
27
+ - name: Install dependencies
28
+ run: npm ci
29
+
30
+ # covgate 需要通过 file:、workspace 或私有 registry 安装。
31
+ # 如果使用 npm link / 本地路径,请改成 CI 可复现的安装方式。
32
+ - name: Run coverage gate
33
+ run: npx --no-install covgate run --json
34
+
35
+ - name: Upload audit report
36
+ if: always()
37
+ uses: actions/upload-artifact@v4
38
+ with:
39
+ name: covgate-report
40
+ path: .covgate/
41
+ if-no-files-found: ignore
@@ -0,0 +1,5 @@
1
+ # CovGate report artifacts
2
+ /.covgate/
3
+
4
+ # Typical local coverage output (root only; do not ignore src/coverage)
5
+ /coverage/