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/config.js ADDED
@@ -0,0 +1,442 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { ConfigError } from "./errors.js";
4
+ import { isPlainObject, sha256 } from "./util.js";
5
+
6
+ export const CONFIG_FILENAME = "covgate.config.json";
7
+ export const CONFIG_VERSION = 1;
8
+ export const METRICS = ["lines", "statements", "functions", "branches"];
9
+ export const PROFILES = ["client", "server"];
10
+
11
+ /**
12
+ * These floors are part of the product contract and are enforced in addition
13
+ * to the thresholds in the config. A project can raise a threshold, never lower
14
+ * it. Client code must be 100% on every metric; server code at least 95%.
15
+ */
16
+ export const PROFILE_FLOORS = {
17
+ client: { lines: 100, statements: 100, functions: 100, branches: 100 },
18
+ server: { lines: 95, statements: 95, functions: 95, branches: 95 }
19
+ };
20
+
21
+ const CONFIG_KEYS = new Set(["$schema", "version", "scopes", "waivers", "reports"]);
22
+ const SCOPE_KEYS = new Set([
23
+ "profile",
24
+ "label",
25
+ "commands",
26
+ "coverage",
27
+ "thresholds",
28
+ "sources",
29
+ "exclude"
30
+ ]);
31
+ const COMMAND_KEYS = new Set(["name", "run", "cwd", "timeoutSeconds", "env"]);
32
+ const COVERAGE_KEYS = new Set(["format", "paths"]);
33
+ const SOURCE_KEYS = new Set(["include"]);
34
+ const WAIVER_KEYS = new Set([
35
+ "minReasonLength",
36
+ "requireOwner",
37
+ "stale",
38
+ "expiry"
39
+ ]);
40
+ const REPORT_KEYS = new Set(["dir", "formats"]);
41
+ const EXCLUDE_KEYS = new Set(["pattern", "reason", "owner", "expires", "ticket"]);
42
+ const COVERAGE_FORMATS = new Set(["auto", "istanbul", "lcov"]);
43
+ const REPORT_FORMATS = new Set(["json", "markdown"]);
44
+ const POLICIES = new Set(["fail", "warn"]);
45
+
46
+ /**
47
+ * @param {string} field
48
+ * @param {string} message
49
+ * @returns {never}
50
+ */
51
+ function fail(field, message) {
52
+ throw new ConfigError(`配置错误 ${field}: ${message}`);
53
+ }
54
+
55
+ /**
56
+ * @param {unknown} value
57
+ * @param {string} field
58
+ * @param {string[]} [allowed]
59
+ */
60
+ function assertObject(value, field, allowed) {
61
+ if (!isPlainObject(value)) fail(field, "必须是 JSON 对象");
62
+ if (allowed) {
63
+ for (const key of Object.keys(value)) {
64
+ if (!allowed.includes(key)) fail(`${field}.${key}`, "未知字段(检查拼写)");
65
+ }
66
+ }
67
+ }
68
+
69
+ /**
70
+ * @param {unknown} value
71
+ * @param {string} field
72
+ * @param {{ allowEmpty?: boolean }} [options]
73
+ */
74
+ function assertString(value, field, options = {}) {
75
+ if (typeof value !== "string" || (!options.allowEmpty && value.trim() === "")) {
76
+ fail(field, "必须是非空字符串");
77
+ }
78
+ return value;
79
+ }
80
+
81
+ /**
82
+ * @param {unknown} value
83
+ * @param {string} field
84
+ * @returns {string[]}
85
+ */
86
+ function stringList(value, field) {
87
+ const list = typeof value === "string" ? [value] : value;
88
+ if (!Array.isArray(list) || list.length === 0) {
89
+ fail(field, "必须是非空字符串或字符串数组");
90
+ }
91
+ for (const [index, item] of list.entries()) {
92
+ if (typeof item !== "string" || item.trim() === "") {
93
+ fail(`${field}[${index}]`, "必须是非空字符串");
94
+ }
95
+ }
96
+ return list.map((item) => item.trim());
97
+ }
98
+
99
+ /**
100
+ * @param {unknown} value
101
+ * @param {string} field
102
+ * @param {number} floor
103
+ * @param {number} ceiling
104
+ * @returns {{ value: number, basisPoints: number }}
105
+ */
106
+ function thresholdValue(value, field, floor, ceiling) {
107
+ if (typeof value !== "number" || !Number.isFinite(value)) {
108
+ fail(field, "必须是数字");
109
+ }
110
+ if (value < floor) {
111
+ fail(
112
+ field,
113
+ `不得低于产品底线 ${floor}(本项目配置试图使用 ${value})`
114
+ );
115
+ }
116
+ if (value > ceiling) fail(field, `不得大于 ${ceiling}`);
117
+ const scaled = value * 100;
118
+ if (Math.abs(scaled - Math.round(scaled)) > 1e-8) {
119
+ fail(field, "最多允许两位小数");
120
+ }
121
+ return { value, basisPoints: Math.round(scaled) };
122
+ }
123
+
124
+ /**
125
+ * @param {any} raw
126
+ * @param {number} index
127
+ * @param {string} scopeName
128
+ * @param {string} root
129
+ */
130
+ function normalizeCommand(raw, index, scopeName, root) {
131
+ const field = `scopes.${scopeName}.commands[${index}]`;
132
+ let value;
133
+ if (typeof raw === "string") {
134
+ value = { run: raw };
135
+ } else {
136
+ assertObject(raw, field);
137
+ for (const key of Object.keys(raw)) {
138
+ if (!COMMAND_KEYS.has(key)) fail(`${field}.${key}`, "未知字段(检查拼写)");
139
+ }
140
+ value = raw;
141
+ }
142
+ const run = assertString(value.run, `${field}.run`);
143
+ const name = value.name === undefined
144
+ ? `命令 ${index + 1}`
145
+ : assertString(value.name, `${field}.name`);
146
+ const cwd = value.cwd === undefined
147
+ ? root
148
+ : path.resolve(root, assertString(value.cwd, `${field}.cwd`));
149
+ let timeoutSeconds = 900;
150
+ if (value.timeoutSeconds !== undefined) {
151
+ if (
152
+ typeof value.timeoutSeconds !== "number" ||
153
+ !Number.isFinite(value.timeoutSeconds) ||
154
+ value.timeoutSeconds <= 0 ||
155
+ value.timeoutSeconds > 86_400
156
+ ) {
157
+ fail(`${field}.timeoutSeconds`, "必须是 0 到 86400 之间的秒数");
158
+ }
159
+ timeoutSeconds = value.timeoutSeconds;
160
+ }
161
+ /** @type {Record<string, string>} */
162
+ const env = {};
163
+ if (value.env !== undefined) {
164
+ assertObject(value.env, `${field}.env`);
165
+ for (const [key, envValue] of Object.entries(value.env)) {
166
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
167
+ fail(`${field}.env.${key}`, "不是合法的环境变量名");
168
+ }
169
+ if (
170
+ typeof envValue !== "string" &&
171
+ typeof envValue !== "number" &&
172
+ typeof envValue !== "boolean"
173
+ ) {
174
+ fail(`${field}.env.${key}`, "必须是字符串、数字或布尔值");
175
+ }
176
+ env[key] = String(envValue);
177
+ }
178
+ }
179
+ return {
180
+ name,
181
+ run,
182
+ cwd,
183
+ timeoutSeconds,
184
+ timeoutMs: Math.round(timeoutSeconds * 1_000),
185
+ env
186
+ };
187
+ }
188
+
189
+ /**
190
+ * @param {any} raw
191
+ * @param {string} scopeName
192
+ */
193
+ function normalizeScope(raw, scopeName) {
194
+ const field = `scopes.${scopeName}`;
195
+ assertObject(raw, field);
196
+ for (const key of Object.keys(raw)) {
197
+ if (!SCOPE_KEYS.has(key)) fail(`${field}.${key}`, "未知字段(检查拼写)");
198
+ }
199
+ const profile = raw.profile ?? (scopeName === "client" || scopeName === "server" ? scopeName : null);
200
+ if (!profile) {
201
+ fail(`${field}.profile`, "必须显式设置为 \"client\" 或 \"server\"");
202
+ }
203
+ if (!PROFILES.includes(profile)) {
204
+ fail(`${field}.profile`, `必须是 ${PROFILES.join(" 或 ")}`);
205
+ }
206
+ const label = raw.label === undefined
207
+ ? scopeName
208
+ : assertString(raw.label, `${field}.label`);
209
+
210
+ if (!Array.isArray(raw.commands) || raw.commands.length === 0) {
211
+ fail(`${field}.commands`, "必须包含至少一个测试命令(脚本模式可用 check 跳过执行)");
212
+ }
213
+
214
+ const coverage = raw.coverage;
215
+ assertObject(coverage, `${field}.coverage`);
216
+ for (const key of Object.keys(coverage)) {
217
+ if (!COVERAGE_KEYS.has(key)) fail(`${field}.coverage.${key}`, "未知字段(检查拼写)");
218
+ }
219
+ const format = coverage.format ?? "auto";
220
+ if (!COVERAGE_FORMATS.has(format)) {
221
+ fail(`${field}.coverage.format`, `必须是 ${[...COVERAGE_FORMATS].join(", ")}`);
222
+ }
223
+ const coveragePaths = stringList(coverage.paths, `${field}.coverage.paths`);
224
+
225
+ const sources = raw.sources;
226
+ assertObject(sources, `${field}.sources`);
227
+ for (const key of Object.keys(sources)) {
228
+ if (!SOURCE_KEYS.has(key)) fail(`${field}.sources.${key}`, "未知字段(检查拼写)");
229
+ }
230
+ const include = stringList(sources.include, `${field}.sources.include`);
231
+
232
+ const floors = PROFILE_FLOORS[profile];
233
+ /** @type {Record<string, { value: number, basisPoints: number }>} */
234
+ const thresholds = {};
235
+ if (raw.thresholds !== undefined) {
236
+ assertObject(raw.thresholds, `${field}.thresholds`);
237
+ for (const key of Object.keys(raw.thresholds)) {
238
+ if (!METRICS.includes(key)) {
239
+ fail(`${field}.thresholds.${key}`, `未知指标,可用指标:${METRICS.join(", ")}`);
240
+ }
241
+ }
242
+ }
243
+ for (const metric of METRICS) {
244
+ const value = raw.thresholds?.[metric] ?? floors[metric];
245
+ thresholds[metric] = thresholdValue(
246
+ value,
247
+ `${field}.thresholds.${metric}`,
248
+ floors[metric],
249
+ 100
250
+ );
251
+ }
252
+
253
+ // Commands need the config root to resolve cwd, so they are normalized in
254
+ // normalizeConfig after the root is known.
255
+ const excludes = raw.exclude ?? [];
256
+ if (!Array.isArray(excludes)) fail(`${field}.exclude`, "必须是数组");
257
+ const normalizedExcludes = excludes.map((entry, index) => {
258
+ const excludeField = `${field}.exclude[${index}]`;
259
+ if (typeof entry === "string") {
260
+ fail(
261
+ excludeField,
262
+ "不能使用裸路径;必须写成 {\"pattern\": \"...\", \"reason\": \"为什么可以排除\"}"
263
+ );
264
+ }
265
+ assertObject(entry, excludeField, [...EXCLUDE_KEYS]);
266
+ const pattern = assertString(entry.pattern, `${excludeField}.pattern`);
267
+ const reason = assertString(entry.reason, `${excludeField}.reason`);
268
+ return {
269
+ pattern,
270
+ reason,
271
+ owner: entry.owner === undefined ? undefined : assertString(entry.owner, `${excludeField}.owner`),
272
+ expires: entry.expires === undefined ? undefined : assertString(entry.expires, `${excludeField}.expires`),
273
+ ticket: entry.ticket === undefined ? undefined : assertString(entry.ticket, `${excludeField}.ticket`)
274
+ };
275
+ });
276
+
277
+ return {
278
+ name: scopeName,
279
+ label,
280
+ profile,
281
+ commands: raw.commands,
282
+ coverage: { format, paths: coveragePaths },
283
+ thresholds,
284
+ floors,
285
+ sources: { include },
286
+ exclude: normalizedExcludes
287
+ };
288
+ }
289
+
290
+ /**
291
+ * Validate and normalize a parsed config object.
292
+ * @param {any} raw
293
+ * @param {{ configPath: string, root: string, text?: string }} context
294
+ */
295
+ export function normalizeConfig(raw, context) {
296
+ assertObject(raw, "config");
297
+ for (const key of Object.keys(raw)) {
298
+ if (!CONFIG_KEYS.has(key)) fail(key, "未知字段(检查拼写)");
299
+ }
300
+ if (raw.version !== undefined && raw.version !== CONFIG_VERSION) {
301
+ fail("version", `只支持 ${CONFIG_VERSION}`);
302
+ }
303
+ if (!isPlainObject(raw.scopes) || Object.keys(raw.scopes).length === 0) {
304
+ fail("scopes", "至少需要配置一个 scope");
305
+ }
306
+
307
+ const scopes = [];
308
+ for (const [name, scope] of Object.entries(raw.scopes)) {
309
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) {
310
+ fail(`scopes.${name}`, "scope 名称只能包含字母、数字、下划线或连字符");
311
+ }
312
+ const normalized = normalizeScope(scope, name);
313
+ normalized.commands = normalized.commands.map((command, index) =>
314
+ normalizeCommand(command, index, name, context.root)
315
+ );
316
+ scopes.push(normalized);
317
+ }
318
+
319
+ const waivers = {
320
+ minReasonLength: 8,
321
+ requireOwner: false,
322
+ stale: "fail",
323
+ expiry: "fail"
324
+ };
325
+ if (raw.waivers !== undefined) {
326
+ assertObject(raw.waivers, "waivers", [...WAIVER_KEYS]);
327
+ if (raw.waivers.minReasonLength !== undefined) {
328
+ if (
329
+ !Number.isInteger(raw.waivers.minReasonLength) ||
330
+ raw.waivers.minReasonLength < 1 ||
331
+ raw.waivers.minReasonLength > 500
332
+ ) {
333
+ fail("waivers.minReasonLength", "必须是 1 到 500 之间的整数");
334
+ }
335
+ waivers.minReasonLength = raw.waivers.minReasonLength;
336
+ }
337
+ if (raw.waivers.requireOwner !== undefined) {
338
+ if (typeof raw.waivers.requireOwner !== "boolean") {
339
+ fail("waivers.requireOwner", "必须是布尔值");
340
+ }
341
+ waivers.requireOwner = raw.waivers.requireOwner;
342
+ }
343
+ for (const key of ["stale", "expiry"]) {
344
+ if (raw.waivers[key] !== undefined) {
345
+ if (!POLICIES.has(raw.waivers[key])) {
346
+ fail(`waivers.${key}`, `必须是 ${[...POLICIES].join(" 或 ")}`);
347
+ }
348
+ waivers[key] = raw.waivers[key];
349
+ }
350
+ }
351
+ }
352
+
353
+ const reports = { dir: ".covgate", formats: ["json", "markdown"] };
354
+ if (raw.reports !== undefined) {
355
+ assertObject(raw.reports, "reports", [...REPORT_KEYS]);
356
+ if (raw.reports.dir !== undefined) {
357
+ reports.dir = assertString(raw.reports.dir, "reports.dir");
358
+ }
359
+ if (raw.reports.formats !== undefined) {
360
+ if (!Array.isArray(raw.reports.formats)) {
361
+ fail("reports.formats", "必须是字符串数组");
362
+ }
363
+ for (const format of raw.reports.formats) {
364
+ if (!REPORT_FORMATS.has(format)) {
365
+ fail("reports.formats", `不支持的格式 ${format},可用:${[...REPORT_FORMATS].join(", ")}`);
366
+ }
367
+ }
368
+ reports.formats = [...new Set(raw.reports.formats)];
369
+ }
370
+ }
371
+
372
+ return {
373
+ version: CONFIG_VERSION,
374
+ configPath: context.configPath,
375
+ root: context.root,
376
+ scopes,
377
+ waivers,
378
+ reports: {
379
+ dir: path.resolve(context.root, reports.dir),
380
+ formats: reports.formats
381
+ },
382
+ hash: context.text === undefined ? undefined : sha256(context.text)
383
+ };
384
+ }
385
+
386
+ /**
387
+ * @param {string} configPath
388
+ */
389
+ export async function loadConfig(configPath) {
390
+ const absolute = path.resolve(configPath);
391
+ let text;
392
+ try {
393
+ text = await readFile(absolute, "utf8");
394
+ } catch (error) {
395
+ throw new ConfigError(
396
+ `无法读取配置文件 ${absolute}: ${error.message}\n提示:在项目根目录创建 covgate.config.json,或使用 --config 指定路径。`,
397
+ { cause: error }
398
+ );
399
+ }
400
+ let raw;
401
+ try {
402
+ raw = JSON.parse(text.replace(/^\uFEFF/, ""));
403
+ } catch (error) {
404
+ throw new ConfigError(`配置文件不是合法 JSON(${absolute}):${error.message}`, {
405
+ cause: error
406
+ });
407
+ }
408
+ return normalizeConfig(raw, {
409
+ configPath: absolute,
410
+ root: path.dirname(absolute),
411
+ text
412
+ });
413
+ }
414
+
415
+ /**
416
+ * @param {string} cwd
417
+ * @param {string} [explicitPath]
418
+ */
419
+ export function resolveConfigPath(cwd, explicitPath) {
420
+ if (explicitPath) return path.resolve(cwd, explicitPath);
421
+ return path.resolve(cwd, CONFIG_FILENAME);
422
+ }
423
+
424
+ /**
425
+ * @param {any} config
426
+ * @param {string[]} names
427
+ */
428
+ export function selectScopes(config, names) {
429
+ if (!names || names.length === 0) return config.scopes;
430
+ const byName = new Map(config.scopes.map((scope) => [scope.name, scope]));
431
+ const selected = [];
432
+ for (const name of names) {
433
+ const scope = byName.get(name);
434
+ if (!scope) {
435
+ throw new ConfigError(
436
+ `未知 scope "${name}"。可用 scope:${config.scopes.map((item) => item.name).join(", ")}`
437
+ );
438
+ }
439
+ selected.push(scope);
440
+ }
441
+ return selected;
442
+ }
@@ -0,0 +1,85 @@
1
+ import { open, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { CoverageDataError } from "../errors.js";
4
+ import { parseIstanbulReports } from "./istanbul.js";
5
+ import { parseLcovReports } from "./lcov.js";
6
+
7
+ /**
8
+ * @param {string} reportFile
9
+ * @returns {Promise<"istanbul" | "lcov">}
10
+ */
11
+ async function inferFormat(reportFile) {
12
+ const extension = path.extname(reportFile).toLowerCase();
13
+ if (extension === ".json") return "istanbul";
14
+ if (extension === ".info" || extension === ".lcov") return "lcov";
15
+ let handle;
16
+ try {
17
+ handle = await open(reportFile, "r");
18
+ const buffer = Buffer.alloc(2048);
19
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
20
+ const head = buffer.subarray(0, bytesRead).toString("utf8").trimStart();
21
+ if (head.startsWith("{")) return "istanbul";
22
+ if (/(^|\n)SF:/.test(head)) return "lcov";
23
+ } catch {
24
+ // The caller will produce a precise read error when parsing.
25
+ } finally {
26
+ if (handle) await handle.close().catch(() => {});
27
+ }
28
+ throw new CoverageDataError(
29
+ `无法从文件名推断覆盖率报告格式:${reportFile};请在配置中显式设置 coverage.format。`
30
+ );
31
+ }
32
+
33
+ /**
34
+ * @param {string[]} reportFiles
35
+ * @param {string} format
36
+ * @returns {Promise<"istanbul" | "lcov">}
37
+ */
38
+ async function resolveFormat(reportFiles, format) {
39
+ if (format === "istanbul" || format === "lcov") return format;
40
+ if (format !== "auto") {
41
+ throw new CoverageDataError(`不支持的覆盖率格式:${format}`);
42
+ }
43
+ const formats = new Set();
44
+ for (const reportFile of reportFiles) formats.add(await inferFormat(reportFile));
45
+ if (formats.size === 0) {
46
+ throw new CoverageDataError("没有可读取的覆盖率报告");
47
+ }
48
+ if (formats.size > 1) {
49
+ throw new CoverageDataError(
50
+ `同一 scope 内混用了多种覆盖率格式(${[...formats].join(", ")});请拆分为不同 scope,或统一报告格式。`
51
+ );
52
+ }
53
+ return [...formats][0];
54
+ }
55
+
56
+ /**
57
+ * @param {{ reportFiles: string[], format: string, root: string }} options
58
+ */
59
+ export async function loadCoverage({ reportFiles, format, root }) {
60
+ if (!Array.isArray(reportFiles) || reportFiles.length === 0) {
61
+ throw new CoverageDataError(
62
+ "没有找到覆盖率报告。请检查 coverage.paths,并确认测试命令确实生成了报告。"
63
+ );
64
+ }
65
+ const resolvedFormat = await resolveFormat(reportFiles, format);
66
+ const files =
67
+ resolvedFormat === "istanbul"
68
+ ? await parseIstanbulReports(reportFiles, root)
69
+ : await parseLcovReports(reportFiles, root);
70
+ if (files.size === 0) {
71
+ throw new CoverageDataError(
72
+ "覆盖率报告存在,但其中没有任何源码记录。请检查测试命令和覆盖率采集配置。"
73
+ );
74
+ }
75
+ return { format: resolvedFormat, files };
76
+ }
77
+
78
+ /**
79
+ * Read a report for diagnostics only.
80
+ * @param {string} reportFile
81
+ */
82
+ export async function readReportHead(reportFile) {
83
+ const text = await readFile(reportFile, "utf8");
84
+ return text.slice(0, 4000);
85
+ }