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,327 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const START = "<!-- covgate:start -->";
|
|
6
|
+
const END = "<!-- covgate:end -->";
|
|
7
|
+
|
|
8
|
+
const AGENT_BLOCK = [
|
|
9
|
+
START,
|
|
10
|
+
"## CovGate 覆盖率门禁(完成定义)",
|
|
11
|
+
"",
|
|
12
|
+
"- 修改代码后必须运行 `npx --no-install covgate run --json`;退出码 0 才能结束任务。",
|
|
13
|
+
"- 客户端覆盖率必须 100%,服务端不低于 95%;不得降低 `covgate.config.json` 中的门槛。",
|
|
14
|
+
"- 禁止 `istanbul ignore`、`c8 ignore`、`v8 ignore`、`node:coverage ignore`。",
|
|
15
|
+
"- 确实不可达的代码使用 `covgate-ignore-next-line`、`covgate-ignore-start/end` 或 `covgate-ignore-file`,写具体原因、owner 和 expires。",
|
|
16
|
+
"- 禁止通过扩大 exclude、缩小 sources.include 或删除测试来让门禁通过;这类改动必须由人类评审。",
|
|
17
|
+
"- 失败详情在 `.covgate/report.json` 的 scopes/issues/waivers 中;修复后重跑。",
|
|
18
|
+
"",
|
|
19
|
+
"也可运行 `npm run coverage:gate -- --json`。Agent Stop hook 可调用 `scripts/covgate-agent-gate.ps1`(Windows)或 `scripts/covgate-agent-gate.sh`。",
|
|
20
|
+
"<!-- 由 covgate setup 生成;保留标记以便幂等更新。 -->",
|
|
21
|
+
END
|
|
22
|
+
].join("\n");
|
|
23
|
+
|
|
24
|
+
const CURSOR_RULE = [
|
|
25
|
+
"---",
|
|
26
|
+
"description: CovGate coverage gate",
|
|
27
|
+
"alwaysApply: true",
|
|
28
|
+
"---",
|
|
29
|
+
"",
|
|
30
|
+
"# CovGate 覆盖率门禁",
|
|
31
|
+
"",
|
|
32
|
+
"任何代码修改后运行 `npx --no-install covgate run --json`,退出码 0 才能结束。",
|
|
33
|
+
"客户端必须 100%,服务端至少 95%。禁止原生 ignore;豁免必须写原因、owner、expires。",
|
|
34
|
+
""
|
|
35
|
+
].join("\n");
|
|
36
|
+
|
|
37
|
+
const SH_SCRIPT = [
|
|
38
|
+
"#!/bin/sh",
|
|
39
|
+
"# Generated by covgate setup. Use as an agent Stop hook or pre-commit step.",
|
|
40
|
+
"set -eu",
|
|
41
|
+
'root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"',
|
|
42
|
+
'cd "$root"',
|
|
43
|
+
"exec npx --no-install covgate run --json"
|
|
44
|
+
].join("\n") + "\n";
|
|
45
|
+
|
|
46
|
+
const PS1_SCRIPT = [
|
|
47
|
+
"# Generated by covgate setup. Use as an agent Stop hook or pre-commit step.",
|
|
48
|
+
"$ErrorActionPreference = 'Stop'",
|
|
49
|
+
"$root = git rev-parse --show-toplevel 2>$null",
|
|
50
|
+
"if (-not $root) { $root = (Get-Location).Path }",
|
|
51
|
+
"Set-Location $root",
|
|
52
|
+
"npx --no-install covgate run --json",
|
|
53
|
+
"exit $LASTEXITCODE"
|
|
54
|
+
].join("\n") + "\n";
|
|
55
|
+
|
|
56
|
+
function hasManagedBlock(text) {
|
|
57
|
+
return text.includes(START) && text.includes(END);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {string} text
|
|
62
|
+
* @param {string} block
|
|
63
|
+
*/
|
|
64
|
+
function replaceManagedBlock(text, block) {
|
|
65
|
+
const start = text.indexOf(START);
|
|
66
|
+
const end = text.indexOf(END);
|
|
67
|
+
if (start === -1 || end === -1 || end < start) return text + "\n\n" + block + "\n";
|
|
68
|
+
return text.slice(0, start) + block + text.slice(end + END.length);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {{
|
|
73
|
+
* root: string,
|
|
74
|
+
* file: string,
|
|
75
|
+
* block: string,
|
|
76
|
+
* force?: boolean,
|
|
77
|
+
* dryRun?: boolean,
|
|
78
|
+
* log: (message: string) => void,
|
|
79
|
+
* changes: string[]
|
|
80
|
+
* }} options
|
|
81
|
+
*/
|
|
82
|
+
async function ensureManagedFile({ root, file, block, force, dryRun, log, changes }) {
|
|
83
|
+
const absolute = path.join(root, file);
|
|
84
|
+
let current = null;
|
|
85
|
+
try {
|
|
86
|
+
current = await readFile(absolute, "utf8");
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (!error || error.code !== "ENOENT") throw error;
|
|
89
|
+
}
|
|
90
|
+
if (current !== null && hasManagedBlock(current) && !force) {
|
|
91
|
+
log(" = " + file + " 已有 CovGate 区块,跳过");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const next =
|
|
95
|
+
current === null
|
|
96
|
+
? block + "\n"
|
|
97
|
+
: current.endsWith("\n")
|
|
98
|
+
? current + "\n" + block + "\n"
|
|
99
|
+
: current + "\n\n" + block + "\n";
|
|
100
|
+
const finalContent =
|
|
101
|
+
current !== null && force && hasManagedBlock(current)
|
|
102
|
+
? replaceManagedBlock(current, block)
|
|
103
|
+
: next;
|
|
104
|
+
if (!dryRun) {
|
|
105
|
+
await mkdir(path.dirname(absolute), { recursive: true });
|
|
106
|
+
await writeFile(absolute, finalContent, "utf8");
|
|
107
|
+
}
|
|
108
|
+
changes.push(file);
|
|
109
|
+
log(" + " + file);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @param {{
|
|
114
|
+
* root: string,
|
|
115
|
+
* file: string,
|
|
116
|
+
* content: string,
|
|
117
|
+
* force?: boolean,
|
|
118
|
+
* dryRun?: boolean,
|
|
119
|
+
* executable?: boolean,
|
|
120
|
+
* log: (message: string) => void,
|
|
121
|
+
* changes: string[]
|
|
122
|
+
* }} options
|
|
123
|
+
*/
|
|
124
|
+
async function ensureNewFile({ root, file, content, force, dryRun, executable, log, changes }) {
|
|
125
|
+
const absolute = path.join(root, file);
|
|
126
|
+
if (existsSync(absolute) && !force) {
|
|
127
|
+
log(" = " + file + " 已存在,保留");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!dryRun) {
|
|
131
|
+
await mkdir(path.dirname(absolute), { recursive: true });
|
|
132
|
+
await writeFile(absolute, content, "utf8");
|
|
133
|
+
if (executable && process.platform !== "win32") {
|
|
134
|
+
await chmod(absolute, 0o755).catch(() => {});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
changes.push(file);
|
|
138
|
+
log(" + " + file);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {string} root
|
|
143
|
+
* @param {boolean} dryRun
|
|
144
|
+
* @param {(message: string) => void} log
|
|
145
|
+
* @param {string[]} changes
|
|
146
|
+
*/
|
|
147
|
+
async function ensureGitignore(root, dryRun, log, changes) {
|
|
148
|
+
const file = path.join(root, ".gitignore");
|
|
149
|
+
let current = "";
|
|
150
|
+
try {
|
|
151
|
+
current = await readFile(file, "utf8");
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (!error || error.code !== "ENOENT") throw error;
|
|
154
|
+
}
|
|
155
|
+
const lines = current.split(/\n/);
|
|
156
|
+
const additions = ["/.covgate/", "/coverage/"].filter((entry) => !lines.includes(entry));
|
|
157
|
+
if (additions.length === 0) {
|
|
158
|
+
log(" = .gitignore 已包含覆盖率产物");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const next =
|
|
162
|
+
current +
|
|
163
|
+
(current && !current.endsWith("\n") ? "\n" : "") +
|
|
164
|
+
additions.join("\n") +
|
|
165
|
+
"\n";
|
|
166
|
+
if (!dryRun) await writeFile(file, next, "utf8");
|
|
167
|
+
changes.push(".gitignore");
|
|
168
|
+
log(" + .gitignore(" + additions.join(", ") + ")");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @param {{
|
|
173
|
+
* root: string,
|
|
174
|
+
* packageManager: string,
|
|
175
|
+
* force?: boolean,
|
|
176
|
+
* dryRun?: boolean,
|
|
177
|
+
* log: (message:string) => void
|
|
178
|
+
* }} options
|
|
179
|
+
*/
|
|
180
|
+
export async function scaffoldAgent({ root, packageManager, force = false, dryRun = false, log }) {
|
|
181
|
+
const changes = [];
|
|
182
|
+
const exists = (file) => existsSync(path.join(root, file));
|
|
183
|
+
await ensureManagedFile({
|
|
184
|
+
root,
|
|
185
|
+
file: "AGENTS.md",
|
|
186
|
+
block: AGENT_BLOCK,
|
|
187
|
+
force,
|
|
188
|
+
dryRun,
|
|
189
|
+
log,
|
|
190
|
+
changes
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
if (exists("CLAUDE.md") || exists(".claude")) {
|
|
194
|
+
await ensureManagedFile({
|
|
195
|
+
root,
|
|
196
|
+
file: "CLAUDE.md",
|
|
197
|
+
block: AGENT_BLOCK,
|
|
198
|
+
force,
|
|
199
|
+
dryRun,
|
|
200
|
+
log,
|
|
201
|
+
changes
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
if (exists(".cursor")) {
|
|
205
|
+
await ensureNewFile({
|
|
206
|
+
root,
|
|
207
|
+
file: ".cursor/rules/covgate.mdc",
|
|
208
|
+
content: CURSOR_RULE,
|
|
209
|
+
force,
|
|
210
|
+
dryRun,
|
|
211
|
+
log,
|
|
212
|
+
changes
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (exists(".github/copilot-instructions.md")) {
|
|
216
|
+
await ensureManagedFile({
|
|
217
|
+
root,
|
|
218
|
+
file: ".github/copilot-instructions.md",
|
|
219
|
+
block: AGENT_BLOCK,
|
|
220
|
+
force,
|
|
221
|
+
dryRun,
|
|
222
|
+
log,
|
|
223
|
+
changes
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
await ensureNewFile({
|
|
228
|
+
root,
|
|
229
|
+
file: "scripts/covgate-agent-gate.sh",
|
|
230
|
+
content: SH_SCRIPT,
|
|
231
|
+
force,
|
|
232
|
+
dryRun,
|
|
233
|
+
executable: true,
|
|
234
|
+
log,
|
|
235
|
+
changes
|
|
236
|
+
});
|
|
237
|
+
await ensureNewFile({
|
|
238
|
+
root,
|
|
239
|
+
file: "scripts/covgate-agent-gate.ps1",
|
|
240
|
+
content: PS1_SCRIPT,
|
|
241
|
+
force,
|
|
242
|
+
dryRun,
|
|
243
|
+
log,
|
|
244
|
+
changes
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
if (exists(".github")) {
|
|
248
|
+
await ensureNewFile({
|
|
249
|
+
root,
|
|
250
|
+
file: ".github/workflows/covgate.yml",
|
|
251
|
+
content: buildWorkflow(packageManager),
|
|
252
|
+
force,
|
|
253
|
+
dryRun,
|
|
254
|
+
log,
|
|
255
|
+
changes
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
await ensureGitignore(root, dryRun, log, changes);
|
|
259
|
+
return changes;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* @param {string} packageManager
|
|
264
|
+
*/
|
|
265
|
+
export function buildWorkflow(packageManager) {
|
|
266
|
+
const setup = [" - uses: actions/checkout@v4"];
|
|
267
|
+
let install = " - run: npm ci";
|
|
268
|
+
if (packageManager === "pnpm") {
|
|
269
|
+
setup.push(
|
|
270
|
+
" - uses: pnpm/action-setup@v4",
|
|
271
|
+
" with:",
|
|
272
|
+
" version: 9",
|
|
273
|
+
" - uses: actions/setup-node@v4",
|
|
274
|
+
" with:",
|
|
275
|
+
" node-version: 22",
|
|
276
|
+
" cache: pnpm"
|
|
277
|
+
);
|
|
278
|
+
install = " - run: pnpm install --frozen-lockfile";
|
|
279
|
+
} else if (packageManager === "yarn") {
|
|
280
|
+
setup.push(
|
|
281
|
+
" - uses: actions/setup-node@v4",
|
|
282
|
+
" with:",
|
|
283
|
+
" node-version: 22",
|
|
284
|
+
" cache: yarn"
|
|
285
|
+
);
|
|
286
|
+
install = " - run: corepack enable\n - run: yarn install --immutable";
|
|
287
|
+
} else if (packageManager === "bun") {
|
|
288
|
+
setup.push(" - uses: oven-sh/setup-bun@v2");
|
|
289
|
+
install = " - run: bun install --frozen-lockfile";
|
|
290
|
+
} else {
|
|
291
|
+
setup.push(
|
|
292
|
+
" - uses: actions/setup-node@v4",
|
|
293
|
+
" with:",
|
|
294
|
+
" node-version: 22",
|
|
295
|
+
" cache: npm"
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
return [
|
|
299
|
+
"name: coverage-gate",
|
|
300
|
+
"",
|
|
301
|
+
"on:",
|
|
302
|
+
" pull_request:",
|
|
303
|
+
" push:",
|
|
304
|
+
" branches: [main]",
|
|
305
|
+
"",
|
|
306
|
+
"permissions:",
|
|
307
|
+
" contents: read",
|
|
308
|
+
"",
|
|
309
|
+
"jobs:",
|
|
310
|
+
" coverage:",
|
|
311
|
+
" runs-on: ubuntu-latest",
|
|
312
|
+
" timeout-minutes: 30",
|
|
313
|
+
" steps:",
|
|
314
|
+
...setup,
|
|
315
|
+
install,
|
|
316
|
+
" - name: Run CovGate",
|
|
317
|
+
" run: npx --no-install covgate run --json",
|
|
318
|
+
" - name: Upload audit report",
|
|
319
|
+
" if: always()",
|
|
320
|
+
" uses: actions/upload-artifact@v4",
|
|
321
|
+
" with:",
|
|
322
|
+
" name: covgate-report",
|
|
323
|
+
" path: .covgate/",
|
|
324
|
+
" if-no-files-found: ignore",
|
|
325
|
+
""
|
|
326
|
+
].join("\n");
|
|
327
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { expandFiles, matchGlob } from "../glob.js";
|
|
2
|
+
import { relativeDisplay } from "../util.js";
|
|
3
|
+
|
|
4
|
+
const TEST_EXCLUDE_CANDIDATES = [
|
|
5
|
+
{
|
|
6
|
+
pattern: "**/*.test.*",
|
|
7
|
+
reason: "测试文件,不是产品运行时代码"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
pattern: "**/*.spec.*",
|
|
11
|
+
reason: "测试文件,不是产品运行时代码"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
pattern: "**/__tests__/**",
|
|
15
|
+
reason: "测试目录,不是产品运行时代码"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
pattern: "**/*.d.ts",
|
|
19
|
+
reason: "类型声明文件,没有运行时代码"
|
|
20
|
+
}
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Only add exclusions that currently match something. CovGate intentionally
|
|
25
|
+
* fails stale exclusions, so a starter config must not contain guesses that
|
|
26
|
+
* match zero files.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} root
|
|
29
|
+
* @param {string} sourceGlob
|
|
30
|
+
*/
|
|
31
|
+
async function pickExcludes(root, sourceGlob) {
|
|
32
|
+
let files = [];
|
|
33
|
+
try {
|
|
34
|
+
files = await expandFiles([sourceGlob], root);
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
const relative = files.map((file) => relativeDisplay(file, root));
|
|
39
|
+
return TEST_EXCLUDE_CANDIDATES.filter((entry) =>
|
|
40
|
+
relative.some((file) => matchGlob(file, entry.pattern))
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {{
|
|
46
|
+
* root: string,
|
|
47
|
+
* profile: "client" | "server",
|
|
48
|
+
* sourceGlob: string,
|
|
49
|
+
* command: string,
|
|
50
|
+
* coveragePath: string
|
|
51
|
+
* }} options
|
|
52
|
+
*/
|
|
53
|
+
export async function buildStarterConfig({
|
|
54
|
+
root,
|
|
55
|
+
profile,
|
|
56
|
+
sourceGlob,
|
|
57
|
+
command,
|
|
58
|
+
coveragePath
|
|
59
|
+
}) {
|
|
60
|
+
const exclude = await pickExcludes(root, sourceGlob);
|
|
61
|
+
const label = profile === "client" ? "客户端" : "服务端";
|
|
62
|
+
return {
|
|
63
|
+
$schema: "./node_modules/covgate/schema/covgate.schema.json",
|
|
64
|
+
version: 1,
|
|
65
|
+
scopes: {
|
|
66
|
+
[profile]: {
|
|
67
|
+
profile,
|
|
68
|
+
label,
|
|
69
|
+
commands: [
|
|
70
|
+
{
|
|
71
|
+
name: label + "测试与覆盖率",
|
|
72
|
+
run: command,
|
|
73
|
+
timeoutSeconds: 900
|
|
74
|
+
}
|
|
75
|
+
],
|
|
76
|
+
coverage: {
|
|
77
|
+
format: "auto",
|
|
78
|
+
paths: [coveragePath]
|
|
79
|
+
},
|
|
80
|
+
sources: {
|
|
81
|
+
include: [sourceGlob]
|
|
82
|
+
},
|
|
83
|
+
exclude
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
waivers: {
|
|
87
|
+
minReasonLength: 8,
|
|
88
|
+
requireOwner: false,
|
|
89
|
+
stale: "fail",
|
|
90
|
+
expiry: "fail"
|
|
91
|
+
},
|
|
92
|
+
reports: {
|
|
93
|
+
dir: ".covgate",
|
|
94
|
+
formats: ["json", "markdown"]
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {any} config
|
|
101
|
+
*/
|
|
102
|
+
export function configSummary(config) {
|
|
103
|
+
const scopes = Object.entries(config.scopes || {}).map(([name, scope]) => ({
|
|
104
|
+
name,
|
|
105
|
+
profile: scope.profile,
|
|
106
|
+
command: scope.commands?.[0]?.run,
|
|
107
|
+
source: scope.sources?.include?.[0],
|
|
108
|
+
coverage: scope.coverage?.paths?.[0],
|
|
109
|
+
exclusions: scope.exclude?.length || 0
|
|
110
|
+
}));
|
|
111
|
+
return scopes;
|
|
112
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
SetupError,
|
|
6
|
+
addScripts,
|
|
7
|
+
coverageScriptFor,
|
|
8
|
+
detectFramework,
|
|
9
|
+
detectPackageManager,
|
|
10
|
+
detectProfile,
|
|
11
|
+
detectSourceGlob,
|
|
12
|
+
displayPath,
|
|
13
|
+
installVitest,
|
|
14
|
+
readPackage,
|
|
15
|
+
writePackage
|
|
16
|
+
} from "./project.js";
|
|
17
|
+
import { buildStarterConfig } from "./config.js";
|
|
18
|
+
import { scaffoldAgent } from "./agent.js";
|
|
19
|
+
import { askYesNo, canPrompt } from "./prompt.js";
|
|
20
|
+
|
|
21
|
+
const DEFAULT_COVERAGE_PATH = "coverage/coverage-final.json";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} root
|
|
25
|
+
* @param {string} value
|
|
26
|
+
*/
|
|
27
|
+
function normalizeGlob(root, value) {
|
|
28
|
+
return String(value || "")
|
|
29
|
+
.trim()
|
|
30
|
+
.replaceAll("\\", "/")
|
|
31
|
+
.replace(/^\.\//, "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {{
|
|
36
|
+
* root?: string,
|
|
37
|
+
* yes?: boolean,
|
|
38
|
+
* installFramework?: boolean,
|
|
39
|
+
* profile?: "client" | "server",
|
|
40
|
+
* source?: string,
|
|
41
|
+
* coverage?: string,
|
|
42
|
+
* testCommand?: string,
|
|
43
|
+
* noAgent?: boolean,
|
|
44
|
+
* noConfig?: boolean,
|
|
45
|
+
* force?: boolean,
|
|
46
|
+
* dryRun?: boolean,
|
|
47
|
+
* postinstall?: boolean,
|
|
48
|
+
* log?: (message: string) => void,
|
|
49
|
+
* stderr?: NodeJS.WritableStream
|
|
50
|
+
* }} options
|
|
51
|
+
*/
|
|
52
|
+
export async function runSetup(options = {}) {
|
|
53
|
+
const root = path.resolve(options.root || process.cwd());
|
|
54
|
+
const log = options.log || ((message) => process.stdout.write(message + String.fromCharCode(10)));
|
|
55
|
+
const writeError = options.stderr || process.stderr;
|
|
56
|
+
const project = await readPackage(root);
|
|
57
|
+
if (!project) {
|
|
58
|
+
throw new SetupError("当前目录没有 package.json。CovGate 的自动配置目前面向 Node.js 项目。");
|
|
59
|
+
}
|
|
60
|
+
let pkg = project.data;
|
|
61
|
+
const packageManager = detectPackageManager(root, pkg);
|
|
62
|
+
log("[covgate] 项目: " + root);
|
|
63
|
+
log("[covgate] 包管理器: " + packageManager);
|
|
64
|
+
|
|
65
|
+
let framework = detectFramework(pkg);
|
|
66
|
+
let installedFramework = false;
|
|
67
|
+
|
|
68
|
+
if (!framework.id && options.installFramework !== false) {
|
|
69
|
+
const approved =
|
|
70
|
+
options.yes === true
|
|
71
|
+
? true
|
|
72
|
+
: await askYesNo(
|
|
73
|
+
"[covgate] 未检测到测试框架。是否安装 Vitest + @vitest/coverage-v8?",
|
|
74
|
+
{ defaultYes: true, output: options.stderr || process.stderr }
|
|
75
|
+
);
|
|
76
|
+
if (approved) {
|
|
77
|
+
log("[covgate] 安装 Vitest 与 V8 覆盖率插件……");
|
|
78
|
+
const result = await installVitest(root, packageManager, options.dryRun === true, {
|
|
79
|
+
stdout: process.stdout,
|
|
80
|
+
stderr: writeError
|
|
81
|
+
});
|
|
82
|
+
if (result.ok) {
|
|
83
|
+
installedFramework = true;
|
|
84
|
+
framework = { id: "vitest", label: "Vitest", hasCoverageScript: true };
|
|
85
|
+
const refreshed = await readPackage(root);
|
|
86
|
+
if (refreshed) pkg = refreshed.data;
|
|
87
|
+
} else {
|
|
88
|
+
log("[covgate] 自动安装失败。可手动运行: " + result.command);
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
log("[covgate] 跳过测试框架安装。");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let packageChanged = false;
|
|
96
|
+
const scriptChanges = addScripts(pkg, framework.id, {
|
|
97
|
+
testCommand: options.testCommand,
|
|
98
|
+
force: options.force === true
|
|
99
|
+
});
|
|
100
|
+
if (scriptChanges.length > 0) {
|
|
101
|
+
packageChanged = true;
|
|
102
|
+
if (!options.dryRun) await writePackage(root, pkg);
|
|
103
|
+
log("[covgate] package.json 脚本: " + scriptChanges.join(", "));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let testCommand = options.testCommand;
|
|
107
|
+
if (!testCommand && pkg.scripts && pkg.scripts["test:coverage"]) {
|
|
108
|
+
testCommand = "npm run test:coverage";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const profile = options.profile || detectProfile(root, pkg);
|
|
112
|
+
if (!["client", "server"].includes(profile)) {
|
|
113
|
+
throw new SetupError("profile 必须是 client 或 server");
|
|
114
|
+
}
|
|
115
|
+
const sourceGlob = normalizeGlob(root, options.source || detectSourceGlob(root, profile) || "");
|
|
116
|
+
const coveragePath = normalizeGlob(root, options.coverage || DEFAULT_COVERAGE_PATH);
|
|
117
|
+
const configPath = path.join(root, "covgate.config.json");
|
|
118
|
+
let configWritten = false;
|
|
119
|
+
|
|
120
|
+
if (options.noConfig !== true && testCommand && sourceGlob) {
|
|
121
|
+
if (existsSync(configPath) && options.force !== true) {
|
|
122
|
+
log("[covgate] " + displayPath(root, configPath) + " 已存在,保留现有配置");
|
|
123
|
+
} else {
|
|
124
|
+
const config = await buildStarterConfig({
|
|
125
|
+
root,
|
|
126
|
+
profile,
|
|
127
|
+
sourceGlob,
|
|
128
|
+
command: testCommand,
|
|
129
|
+
coveragePath
|
|
130
|
+
});
|
|
131
|
+
if (!options.dryRun) {
|
|
132
|
+
await mkdir(path.dirname(configPath), { recursive: true });
|
|
133
|
+
await writeFile(configPath, JSON.stringify(config, null, 2) + String.fromCharCode(10), "utf8");
|
|
134
|
+
}
|
|
135
|
+
configWritten = true;
|
|
136
|
+
log(
|
|
137
|
+
"[covgate] 生成 " +
|
|
138
|
+
displayPath(root, configPath) +
|
|
139
|
+
"(" +
|
|
140
|
+
profile +
|
|
141
|
+
",源码 " +
|
|
142
|
+
sourceGlob +
|
|
143
|
+
")"
|
|
144
|
+
);
|
|
145
|
+
if (framework.id && !coverageScriptFor(framework.id) && !options.testCommand) {
|
|
146
|
+
log("[covgate] 注意:该测试框架的覆盖率命令需要你确认报告路径。");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} else if (options.noConfig !== true) {
|
|
150
|
+
if (!testCommand) {
|
|
151
|
+
log("[covgate] 未找到可用的覆盖率测试命令,跳过 covgate.config.json。");
|
|
152
|
+
}
|
|
153
|
+
if (!sourceGlob) {
|
|
154
|
+
log("[covgate] 未找到源码目录,跳过 covgate.config.json;请用 --source 指定。");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let agentChanges = [];
|
|
159
|
+
if (options.noAgent !== true) {
|
|
160
|
+
agentChanges = await scaffoldAgent({
|
|
161
|
+
root,
|
|
162
|
+
packageManager,
|
|
163
|
+
force: options.force === true,
|
|
164
|
+
dryRun: options.dryRun === true,
|
|
165
|
+
log
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const summary = {
|
|
170
|
+
root,
|
|
171
|
+
packageManager,
|
|
172
|
+
framework: framework.id || null,
|
|
173
|
+
installedFramework,
|
|
174
|
+
profile,
|
|
175
|
+
sourceGlob: sourceGlob || null,
|
|
176
|
+
testCommand: testCommand || null,
|
|
177
|
+
coveragePath,
|
|
178
|
+
configPath: configWritten ? configPath : existsSync(configPath) ? configPath : null,
|
|
179
|
+
packageChanged,
|
|
180
|
+
packageScripts: scriptChanges,
|
|
181
|
+
agentChanges,
|
|
182
|
+
dryRun: options.dryRun === true,
|
|
183
|
+
postinstall: options.postinstall === true
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
log("");
|
|
187
|
+
log("[covgate] 完成。");
|
|
188
|
+
if (summary.configPath) {
|
|
189
|
+
log("[covgate] 配置: " + displayPath(root, summary.configPath));
|
|
190
|
+
}
|
|
191
|
+
if (summary.framework) {
|
|
192
|
+
log("[covgate] 测试框架: " + (framework.label || summary.framework));
|
|
193
|
+
}
|
|
194
|
+
log("[covgate] 运行门禁: " + (summary.dryRun ? "npx covgate run" : "npx --no-install covgate run"));
|
|
195
|
+
if (!options.noAgent) {
|
|
196
|
+
log("[covgate] AI 接入: AGENTS.md 与 scripts/covgate-agent-gate.*");
|
|
197
|
+
}
|
|
198
|
+
return summary;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {{
|
|
203
|
+
* root: string,
|
|
204
|
+
* stderr?: NodeJS.WritableStream
|
|
205
|
+
* }} options
|
|
206
|
+
*/
|
|
207
|
+
export async function runPostinstall(options) {
|
|
208
|
+
const stderr = options.stderr || process.stderr;
|
|
209
|
+
const root = path.resolve(options.root || process.cwd());
|
|
210
|
+
const project = await readPackage(root).catch(() => null);
|
|
211
|
+
if (!project) return 0;
|
|
212
|
+
if (project.data?.name === "covgate") return 0;
|
|
213
|
+
if (process.env.COVGATE_SKIP_SETUP === "1") return 0;
|
|
214
|
+
if (process.env.CI) return 0;
|
|
215
|
+
if (process.env.npm_config_global === "true") return 0;
|
|
216
|
+
const hasDirectDependency =
|
|
217
|
+
project.data?.dependencies?.covgate ||
|
|
218
|
+
project.data?.devDependencies?.covgate ||
|
|
219
|
+
project.data?.optionalDependencies?.covgate;
|
|
220
|
+
if (!hasDirectDependency) return 0;
|
|
221
|
+
|
|
222
|
+
if (!canPrompt({ output: stderr }) && process.env.COVGATE_SETUP_ASSUME_YES !== "1") {
|
|
223
|
+
stderr.write(
|
|
224
|
+
"[covgate] 已安装。未检测到交互终端,未自动配置;可运行 npx covgate setup。\n"
|
|
225
|
+
);
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
await runSetup({
|
|
231
|
+
root,
|
|
232
|
+
postinstall: true,
|
|
233
|
+
installFramework: true,
|
|
234
|
+
stderr,
|
|
235
|
+
log: (message) => console.log(message)
|
|
236
|
+
});
|
|
237
|
+
} catch (error) {
|
|
238
|
+
stderr.write("[covgate] 安装后自动配置失败:" + (error?.message || error) + "\n");
|
|
239
|
+
stderr.write("[covgate] 可稍后运行 npx covgate setup。\n");
|
|
240
|
+
}
|
|
241
|
+
return 0;
|
|
242
|
+
}
|