covgate 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +502 -2
- package/bin/covgate-postinstall.js +13 -0
- package/bin/covgate.js +5 -0
- package/docs/ai-workflow.md +196 -0
- package/docs/design.md +327 -0
- package/docs/setup.md +163 -0
- package/package.json +48 -4
- package/schema/covgate.schema.json +295 -0
- package/src/annotations.js +224 -0
- package/src/args.js +189 -0
- package/src/cli.js +486 -0
- package/src/config.js +442 -0
- package/src/coverage/index.js +85 -0
- package/src/coverage/istanbul.js +215 -0
- package/src/coverage/lcov.js +234 -0
- package/src/coverage/model.js +316 -0
- package/src/coverage/paths.js +40 -0
- package/src/errors.js +34 -0
- package/src/gate.js +203 -0
- package/src/glob.js +242 -0
- package/src/index.js +28 -0
- package/src/process.js +170 -0
- package/src/reason.js +77 -0
- package/src/report/build.js +135 -0
- package/src/report/index.js +29 -0
- package/src/report/markdown.js +343 -0
- package/src/report/text.js +267 -0
- package/src/reports.js +113 -0
- package/src/runner.js +95 -0
- package/src/scope.js +440 -0
- package/src/setup/agent.js +327 -0
- package/src/setup/config.js +112 -0
- package/src/setup/index.js +242 -0
- package/src/setup/project.js +316 -0
- package/src/setup/prompt.js +43 -0
- package/src/util.js +163 -0
- package/src/version.js +8 -0
- package/templates/AGENTS.md.snippet.md +9 -0
- package/templates/agent-stop-hook.sh +7 -0
- package/templates/covgate.config.json +98 -0
- package/templates/github-actions-coverage.yml +41 -0
- package/templates/gitignore +5 -0
- package/templates/pre-commit +10 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { 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,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
|