covgate 0.0.0-stage → 0.2.1

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 +504 -2
  3. package/bin/covgate-postinstall.js +13 -0
  4. package/bin/covgate.js +5 -0
  5. package/docs/ai-workflow.md +197 -0
  6. package/docs/design.md +327 -0
  7. package/docs/setup.md +165 -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 +329 -0
  33. package/src/setup/config.js +129 -0
  34. package/src/setup/index.js +270 -0
  35. package/src/setup/project.js +391 -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 +10 -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,329 @@
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
+ "- 行为变更必须新增或更新覆盖该行为的真实测试;不能把“门禁失败”当作完成理由,也不得用空报告、`--passWithNoTests`、ignore 或降低门槛绕过。",
18
+ "- 失败详情在 `.covgate/report.json` 的 scopes/issues/waivers 中;修复后重跑。",
19
+ "",
20
+ "也可运行 `npm run coverage:gate -- --json`。Agent Stop hook 可调用 `scripts/covgate-agent-gate.ps1`(Windows)或 `scripts/covgate-agent-gate.sh`。",
21
+ "<!-- 由 covgate setup 生成;保留标记以便幂等更新。 -->",
22
+ END
23
+ ].join("\n");
24
+
25
+ const CURSOR_RULE = [
26
+ "---",
27
+ "description: CovGate coverage gate",
28
+ "alwaysApply: true",
29
+ "---",
30
+ "",
31
+ "# CovGate 覆盖率门禁",
32
+ "",
33
+ "任何代码修改后运行 `npx --no-install covgate run --json`,退出码 0 才能结束。",
34
+ "客户端必须 100%,服务端至少 95%。行为变更必须补真实测试;门禁失败不能作为完成理由。",
35
+ "禁止原生 ignore;豁免必须写原因、owner、expires。",
36
+ ""
37
+ ].join("\n");
38
+
39
+ const SH_SCRIPT = [
40
+ "#!/bin/sh",
41
+ "# Generated by covgate setup. Use as an agent Stop hook or pre-commit step.",
42
+ "set -eu",
43
+ 'root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"',
44
+ 'cd "$root"',
45
+ "exec npx --no-install covgate run --json"
46
+ ].join("\n") + "\n";
47
+
48
+ const PS1_SCRIPT = [
49
+ "# Generated by covgate setup. Use as an agent Stop hook or pre-commit step.",
50
+ "$ErrorActionPreference = 'Stop'",
51
+ "$root = git rev-parse --show-toplevel 2>$null",
52
+ "if (-not $root) { $root = (Get-Location).Path }",
53
+ "Set-Location $root",
54
+ "npx --no-install covgate run --json",
55
+ "exit $LASTEXITCODE"
56
+ ].join("\n") + "\n";
57
+
58
+ function hasManagedBlock(text) {
59
+ return text.includes(START) && text.includes(END);
60
+ }
61
+
62
+ /**
63
+ * @param {string} text
64
+ * @param {string} block
65
+ */
66
+ function replaceManagedBlock(text, block) {
67
+ const start = text.indexOf(START);
68
+ const end = text.indexOf(END);
69
+ if (start === -1 || end === -1 || end < start) return text + "\n\n" + block + "\n";
70
+ return text.slice(0, start) + block + text.slice(end + END.length);
71
+ }
72
+
73
+ /**
74
+ * @param {{
75
+ * root: string,
76
+ * file: string,
77
+ * block: string,
78
+ * force?: boolean,
79
+ * dryRun?: boolean,
80
+ * log: (message: string) => void,
81
+ * changes: string[]
82
+ * }} options
83
+ */
84
+ async function ensureManagedFile({ root, file, block, force, dryRun, log, changes }) {
85
+ const absolute = path.join(root, file);
86
+ let current = null;
87
+ try {
88
+ current = await readFile(absolute, "utf8");
89
+ } catch (error) {
90
+ if (!error || error.code !== "ENOENT") throw error;
91
+ }
92
+ if (current !== null && hasManagedBlock(current) && !force) {
93
+ log(" = " + file + " 已有 CovGate 区块,跳过");
94
+ return;
95
+ }
96
+ const next =
97
+ current === null
98
+ ? block + "\n"
99
+ : current.endsWith("\n")
100
+ ? current + "\n" + block + "\n"
101
+ : current + "\n\n" + block + "\n";
102
+ const finalContent =
103
+ current !== null && force && hasManagedBlock(current)
104
+ ? replaceManagedBlock(current, block)
105
+ : next;
106
+ if (!dryRun) {
107
+ await mkdir(path.dirname(absolute), { recursive: true });
108
+ await writeFile(absolute, finalContent, "utf8");
109
+ }
110
+ changes.push(file);
111
+ log(" + " + file);
112
+ }
113
+
114
+ /**
115
+ * @param {{
116
+ * root: string,
117
+ * file: string,
118
+ * content: string,
119
+ * force?: boolean,
120
+ * dryRun?: boolean,
121
+ * executable?: boolean,
122
+ * log: (message: string) => void,
123
+ * changes: string[]
124
+ * }} options
125
+ */
126
+ async function ensureNewFile({ root, file, content, force, dryRun, executable, log, changes }) {
127
+ const absolute = path.join(root, file);
128
+ if (existsSync(absolute) && !force) {
129
+ log(" = " + file + " 已存在,保留");
130
+ return;
131
+ }
132
+ if (!dryRun) {
133
+ await mkdir(path.dirname(absolute), { recursive: true });
134
+ await writeFile(absolute, content, "utf8");
135
+ if (executable && process.platform !== "win32") {
136
+ await chmod(absolute, 0o755).catch(() => {});
137
+ }
138
+ }
139
+ changes.push(file);
140
+ log(" + " + file);
141
+ }
142
+
143
+ /**
144
+ * @param {string} root
145
+ * @param {boolean} dryRun
146
+ * @param {(message: string) => void} log
147
+ * @param {string[]} changes
148
+ */
149
+ async function ensureGitignore(root, dryRun, log, changes) {
150
+ const file = path.join(root, ".gitignore");
151
+ let current = "";
152
+ try {
153
+ current = await readFile(file, "utf8");
154
+ } catch (error) {
155
+ if (!error || error.code !== "ENOENT") throw error;
156
+ }
157
+ const lines = current.split(/\n/);
158
+ const additions = ["/.covgate/", "/coverage/"].filter((entry) => !lines.includes(entry));
159
+ if (additions.length === 0) {
160
+ log(" = .gitignore 已包含覆盖率产物");
161
+ return;
162
+ }
163
+ const next =
164
+ current +
165
+ (current && !current.endsWith("\n") ? "\n" : "") +
166
+ additions.join("\n") +
167
+ "\n";
168
+ if (!dryRun) await writeFile(file, next, "utf8");
169
+ changes.push(".gitignore");
170
+ log(" + .gitignore(" + additions.join(", ") + ")");
171
+ }
172
+
173
+ /**
174
+ * @param {{
175
+ * root: string,
176
+ * packageManager: string,
177
+ * force?: boolean,
178
+ * dryRun?: boolean,
179
+ * log: (message:string) => void
180
+ * }} options
181
+ */
182
+ export async function scaffoldAgent({ root, packageManager, force = false, dryRun = false, log }) {
183
+ const changes = [];
184
+ const exists = (file) => existsSync(path.join(root, file));
185
+ await ensureManagedFile({
186
+ root,
187
+ file: "AGENTS.md",
188
+ block: AGENT_BLOCK,
189
+ force,
190
+ dryRun,
191
+ log,
192
+ changes
193
+ });
194
+
195
+ if (exists("CLAUDE.md") || exists(".claude")) {
196
+ await ensureManagedFile({
197
+ root,
198
+ file: "CLAUDE.md",
199
+ block: AGENT_BLOCK,
200
+ force,
201
+ dryRun,
202
+ log,
203
+ changes
204
+ });
205
+ }
206
+ if (exists(".cursor")) {
207
+ await ensureNewFile({
208
+ root,
209
+ file: ".cursor/rules/covgate.mdc",
210
+ content: CURSOR_RULE,
211
+ force,
212
+ dryRun,
213
+ log,
214
+ changes
215
+ });
216
+ }
217
+ if (exists(".github/copilot-instructions.md")) {
218
+ await ensureManagedFile({
219
+ root,
220
+ file: ".github/copilot-instructions.md",
221
+ block: AGENT_BLOCK,
222
+ force,
223
+ dryRun,
224
+ log,
225
+ changes
226
+ });
227
+ }
228
+
229
+ await ensureNewFile({
230
+ root,
231
+ file: "scripts/covgate-agent-gate.sh",
232
+ content: SH_SCRIPT,
233
+ force,
234
+ dryRun,
235
+ executable: true,
236
+ log,
237
+ changes
238
+ });
239
+ await ensureNewFile({
240
+ root,
241
+ file: "scripts/covgate-agent-gate.ps1",
242
+ content: PS1_SCRIPT,
243
+ force,
244
+ dryRun,
245
+ log,
246
+ changes
247
+ });
248
+
249
+ if (exists(".github")) {
250
+ await ensureNewFile({
251
+ root,
252
+ file: ".github/workflows/covgate.yml",
253
+ content: buildWorkflow(packageManager),
254
+ force,
255
+ dryRun,
256
+ log,
257
+ changes
258
+ });
259
+ }
260
+ await ensureGitignore(root, dryRun, log, changes);
261
+ return changes;
262
+ }
263
+
264
+ /**
265
+ * @param {string} packageManager
266
+ */
267
+ export function buildWorkflow(packageManager) {
268
+ const setup = [" - uses: actions/checkout@v4"];
269
+ let install = " - run: npm ci";
270
+ if (packageManager === "pnpm") {
271
+ setup.push(
272
+ " - uses: pnpm/action-setup@v4",
273
+ " with:",
274
+ " version: 9",
275
+ " - uses: actions/setup-node@v4",
276
+ " with:",
277
+ " node-version: 22",
278
+ " cache: pnpm"
279
+ );
280
+ install = " - run: pnpm install --frozen-lockfile";
281
+ } else if (packageManager === "yarn") {
282
+ setup.push(
283
+ " - uses: actions/setup-node@v4",
284
+ " with:",
285
+ " node-version: 22",
286
+ " cache: yarn"
287
+ );
288
+ install = " - run: corepack enable\n - run: yarn install --immutable";
289
+ } else if (packageManager === "bun") {
290
+ setup.push(" - uses: oven-sh/setup-bun@v2");
291
+ install = " - run: bun install --frozen-lockfile";
292
+ } else {
293
+ setup.push(
294
+ " - uses: actions/setup-node@v4",
295
+ " with:",
296
+ " node-version: 22",
297
+ " cache: npm"
298
+ );
299
+ }
300
+ return [
301
+ "name: coverage-gate",
302
+ "",
303
+ "on:",
304
+ " pull_request:",
305
+ " push:",
306
+ " branches: [main]",
307
+ "",
308
+ "permissions:",
309
+ " contents: read",
310
+ "",
311
+ "jobs:",
312
+ " coverage:",
313
+ " runs-on: ubuntu-latest",
314
+ " timeout-minutes: 30",
315
+ " steps:",
316
+ ...setup,
317
+ install,
318
+ " - name: Run CovGate",
319
+ " run: npx --no-install covgate run --json",
320
+ " - name: Upload audit report",
321
+ " if: always()",
322
+ " uses: actions/upload-artifact@v4",
323
+ " with:",
324
+ " name: covgate-report",
325
+ " path: .covgate/",
326
+ " if-no-files-found: ignore",
327
+ ""
328
+ ].join("\n");
329
+ }
@@ -0,0 +1,129 @@
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
+ * scopes?: Array<{ name: string, profile: "client" | "server", label?: string, sourceGlob: string }>,
48
+ * profile?: "client" | "server",
49
+ * sourceGlob?: string,
50
+ * command: string,
51
+ * coveragePath: string
52
+ * }} options
53
+ */
54
+ export async function buildStarterConfig({
55
+ root,
56
+ scopes,
57
+ profile,
58
+ sourceGlob,
59
+ command,
60
+ coveragePath
61
+ }) {
62
+ const specs =
63
+ scopes && scopes.length > 0
64
+ ? scopes
65
+ : [
66
+ {
67
+ name: profile,
68
+ profile,
69
+ label: profile === "client" ? "客户端" : "服务端",
70
+ sourceGlob
71
+ }
72
+ ];
73
+ const scopeConfigs = {};
74
+ for (const spec of specs) {
75
+ if (!spec.profile || !spec.sourceGlob || !spec.name) continue;
76
+ const label =
77
+ spec.label || (spec.profile === "client" ? "客户端" : "服务端");
78
+ const exclude = await pickExcludes(root, spec.sourceGlob);
79
+ scopeConfigs[spec.name] = {
80
+ profile: spec.profile,
81
+ label,
82
+ commands: [
83
+ {
84
+ name: label + "测试与覆盖率",
85
+ run: command,
86
+ timeoutSeconds: 900
87
+ }
88
+ ],
89
+ coverage: {
90
+ format: "auto",
91
+ paths: [coveragePath]
92
+ },
93
+ sources: {
94
+ include: [spec.sourceGlob]
95
+ },
96
+ exclude
97
+ };
98
+ }
99
+ return {
100
+ $schema: "./node_modules/covgate/schema/covgate.schema.json",
101
+ version: 1,
102
+ scopes: scopeConfigs,
103
+ waivers: {
104
+ minReasonLength: 8,
105
+ requireOwner: false,
106
+ stale: "fail",
107
+ expiry: "fail"
108
+ },
109
+ reports: {
110
+ dir: ".covgate",
111
+ formats: ["json", "markdown"]
112
+ }
113
+ };
114
+ }
115
+
116
+ /**
117
+ * @param {any} config
118
+ */
119
+ export function configSummary(config) {
120
+ const scopes = Object.entries(config.scopes || {}).map(([name, scope]) => ({
121
+ name,
122
+ profile: scope.profile,
123
+ command: scope.commands?.[0]?.run,
124
+ source: scope.sources?.include?.[0],
125
+ coverage: scope.coverage?.paths?.[0],
126
+ exclusions: scope.exclude?.length || 0
127
+ }));
128
+ return scopes;
129
+ }