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 path from "node:path";
2
+ import { canonicalPath, percentOf, relativeDisplay } from "../util.js";
3
+
4
+ /**
5
+ * @typedef {Object} StatementUnit
6
+ * @property {string} key
7
+ * @property {number} line
8
+ * @property {number} column
9
+ * @property {number} endLine
10
+ * @property {number} count
11
+ *
12
+ * @typedef {Object} FunctionUnit
13
+ * @property {string} key
14
+ * @property {string} name
15
+ * @property {number} line
16
+ * @property {number} column
17
+ * @property {number} endLine
18
+ * @property {number} count
19
+ *
20
+ * @typedef {Object} BranchUnit
21
+ * @property {string} key
22
+ * @property {number} line
23
+ * @property {number} column
24
+ * @property {string} type
25
+ * @property {number} groupLine
26
+ * @property {number} index
27
+ * @property {number} count
28
+ *
29
+ * @typedef {Object} FileCoverage
30
+ * @property {string} absPath
31
+ * @property {string} relPath
32
+ * @property {"istanbul" | "lcov"} format
33
+ * @property {Map<string, StatementUnit> | null} statements
34
+ * @property {Map<string, FunctionUnit>} functions
35
+ * @property {Map<string, BranchUnit>} branches
36
+ * @property {Map<number, number>} lines
37
+ */
38
+
39
+ /**
40
+ * @param {string} absPath
41
+ * @param {"istanbul" | "lcov"} format
42
+ * @param {string} root
43
+ * @returns {FileCoverage}
44
+ */
45
+ export function createFileCoverage(absPath, format, root) {
46
+ return {
47
+ absPath: path.resolve(absPath),
48
+ relPath: relativeDisplay(absPath, root),
49
+ format,
50
+ statements: format === "istanbul" ? new Map() : null,
51
+ functions: new Map(),
52
+ branches: new Map(),
53
+ lines: new Map()
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Istanbul's line coverage convention: the hit count for a line is the maximum
59
+ * hit count among statements that start on that line.
60
+ * @param {Map<string, StatementUnit> | null} statements
61
+ * @returns {Map<number, number>}
62
+ */
63
+ export function deriveLinesFromStatements(statements) {
64
+ /** @type {Map<number, number>} */
65
+ const lines = new Map();
66
+ if (!statements) return lines;
67
+ for (const statement of statements.values()) {
68
+ const current = lines.get(statement.line);
69
+ if (current === undefined || statement.count > current) {
70
+ lines.set(statement.line, statement.count);
71
+ }
72
+ }
73
+ return lines;
74
+ }
75
+
76
+ /**
77
+ * Merge another report for the same file into a base record. Counts are summed
78
+ * so independently-sharded test runs can be combined.
79
+ * @param {FileCoverage} base
80
+ * @param {FileCoverage} incoming
81
+ */
82
+ export function mergeFileCoverage(base, incoming) {
83
+ if (base.format !== incoming.format) {
84
+ throw new Error(
85
+ `同一文件收到了不同格式的覆盖率数据:${base.relPath}(${base.format} / ${incoming.format})`
86
+ );
87
+ }
88
+ if ((base.statements === null) !== (incoming.statements === null)) {
89
+ throw new Error(`同一文件收到了不兼容的语句覆盖率数据:${base.relPath}`);
90
+ }
91
+ if (base.statements && incoming.statements) {
92
+ mergeUnits(base.statements, incoming.statements, "语句", base.relPath);
93
+ }
94
+ mergeUnits(base.functions, incoming.functions, "函数", base.relPath);
95
+ mergeUnits(base.branches, incoming.branches, "分支", base.relPath);
96
+ for (const [line, count] of incoming.lines) {
97
+ base.lines.set(line, (base.lines.get(line) ?? 0) + count);
98
+ }
99
+ if (base.statements) {
100
+ base.lines = deriveLinesFromStatements(base.statements);
101
+ }
102
+ return base;
103
+ }
104
+
105
+ /**
106
+ * @template {{ key: string, line: number, count: number }} T
107
+ * @param {Map<string, T>} target
108
+ * @param {Map<string, T>} incoming
109
+ * @param {string} label
110
+ * @param {string} relPath
111
+ */
112
+ function mergeUnits(target, incoming, label, relPath) {
113
+ for (const [key, unit] of incoming) {
114
+ const existing = target.get(key);
115
+ if (!existing) {
116
+ target.set(key, { ...unit });
117
+ continue;
118
+ }
119
+ if (existing.line !== unit.line) {
120
+ throw new Error(
121
+ `${relPath} 的${label}覆盖率数据不一致(键 ${key} 对应不同行号)`
122
+ );
123
+ }
124
+ existing.count += unit.count;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * @param {FileCoverage} file
130
+ */
131
+ export function metricsForFile(file) {
132
+ const withPercent = (value) => {
133
+ if (!value) return null;
134
+ return { ...value, percent: percentOf(value.covered, value.total) };
135
+ };
136
+ return {
137
+ lines: withPercent(countMap(file.lines)),
138
+ statements: file.statements ? withPercent(countMap(file.statements)) : null,
139
+ functions: withPercent(countMap(file.functions)),
140
+ branches: withPercent(countMap(file.branches))
141
+ };
142
+ }
143
+
144
+ /**
145
+ * @template {number | { count: number }} T
146
+ * @param {Map<string, T>} map
147
+ */
148
+ function countMap(map) {
149
+ let covered = 0;
150
+ for (const value of map.values()) {
151
+ const count = typeof value === "number" ? value : value.count;
152
+ if (count > 0) covered += 1;
153
+ }
154
+ return { covered, total: map.size };
155
+ }
156
+
157
+ /**
158
+ * Remove rows in [startLine, endLine] from a file's normalized coverage.
159
+ * Statements/functions/branches are removed when their declared start line is
160
+ * in the range, mirroring what a human would call "this code segment".
161
+ * @param {FileCoverage} file
162
+ * @param {Array<{ startLine: number, endLine: number }>} ranges
163
+ * @returns {{ lines: number, statements: number, functions: number, branches: number }}
164
+ */
165
+ export function removeRanges(file, ranges) {
166
+ const inside = (/** @type {number} */ line) =>
167
+ ranges.some((range) => line >= range.startLine && line <= range.endLine);
168
+ let removed = { lines: 0, statements: 0, functions: 0, branches: 0 };
169
+ for (const line of [...file.lines.keys()]) {
170
+ if (inside(line)) {
171
+ file.lines.delete(line);
172
+ removed.lines += 1;
173
+ }
174
+ }
175
+ if (file.statements) {
176
+ for (const [key, unit] of [...file.statements]) {
177
+ if (inside(unit.line)) {
178
+ file.statements.delete(key);
179
+ removed.statements += 1;
180
+ }
181
+ }
182
+ }
183
+ for (const [key, unit] of [...file.functions]) {
184
+ if (inside(unit.line)) {
185
+ file.functions.delete(key);
186
+ removed.functions += 1;
187
+ }
188
+ }
189
+ for (const [key, unit] of [...file.branches]) {
190
+ if (inside(unit.line)) {
191
+ file.branches.delete(key);
192
+ removed.branches += 1;
193
+ }
194
+ }
195
+ return removed;
196
+ }
197
+
198
+ /**
199
+ * Count uncovered rows that a set of ranges would remove, without mutating.
200
+ * Used to detect stale suppressions.
201
+ * @param {FileCoverage} file
202
+ * @param {Array<{ startLine: number, endLine: number }>} ranges
203
+ */
204
+ export function countUncoveredInRanges(file, ranges) {
205
+ const inside = (/** @type {number} */ line) =>
206
+ ranges.some((range) => line >= range.startLine && line <= range.endLine);
207
+ let lines = 0;
208
+ let statements = 0;
209
+ let functions = 0;
210
+ let branches = 0;
211
+ for (const [line, count] of file.lines) {
212
+ if (count === 0 && inside(line)) lines += 1;
213
+ }
214
+ if (file.statements) {
215
+ for (const unit of file.statements.values()) {
216
+ if (unit.count === 0 && inside(unit.line)) statements += 1;
217
+ }
218
+ }
219
+ for (const unit of file.functions.values()) {
220
+ if (unit.count === 0 && inside(unit.line)) functions += 1;
221
+ }
222
+ for (const unit of file.branches.values()) {
223
+ if (unit.count === 0 && inside(unit.line)) branches += 1;
224
+ }
225
+ return { lines, statements, functions, branches };
226
+ }
227
+
228
+ /**
229
+ * @param {Iterable<number>} lines
230
+ * @param {number} limit
231
+ */
232
+ export function summarizeLines(lines, limit = Number.POSITIVE_INFINITY) {
233
+ const sorted = [...new Set(lines)].sort((left, right) => left - right);
234
+ return {
235
+ values: sorted.slice(0, limit),
236
+ total: sorted.length,
237
+ truncated: sorted.length > limit
238
+ };
239
+ }
240
+
241
+ /**
242
+ * @param {Iterable<{ line: number }>} units
243
+ * @param {number} limit
244
+ */
245
+ export function summarizeUnits(units, limit = Number.POSITIVE_INFINITY) {
246
+ const sorted = [...units].sort((left, right) => left.line - right.line);
247
+ return {
248
+ values: sorted.slice(0, limit),
249
+ total: sorted.length,
250
+ truncated: sorted.length > limit
251
+ };
252
+ }
253
+
254
+ /**
255
+ * @param {FileCoverage} file
256
+ * @param {number} [limit]
257
+ */
258
+ export function fileReport(file, limit = Number.POSITIVE_INFINITY) {
259
+ const uncoveredLines = summarizeLines(
260
+ [...file.lines].filter(([, count]) => count === 0).map(([line]) => line),
261
+ limit
262
+ );
263
+ const uncoveredFunctions = summarizeUnits(
264
+ [...file.functions.values()].filter((unit) => unit.count === 0),
265
+ limit
266
+ );
267
+ const uncoveredBranches = summarizeUnits(
268
+ [...file.branches.values()].filter((unit) => unit.count === 0),
269
+ limit
270
+ );
271
+ const uncoveredStatements = file.statements
272
+ ? summarizeUnits(
273
+ [...file.statements.values()].filter((unit) => unit.count === 0),
274
+ limit
275
+ )
276
+ : null;
277
+ return {
278
+ path: file.relPath,
279
+ metrics: metricsForFile(file),
280
+ uncovered: {
281
+ lines: uncoveredLines.values,
282
+ linesTotal: uncoveredLines.total,
283
+ statements: uncoveredStatements?.values.map((unit) => ({
284
+ line: unit.line,
285
+ column: unit.column
286
+ })) ?? null,
287
+ statementsTotal: uncoveredStatements?.total ?? null,
288
+ functions: uncoveredFunctions.values.map((unit) => ({
289
+ name: unit.name,
290
+ line: unit.line
291
+ })),
292
+ functionsTotal: uncoveredFunctions.total,
293
+ branches: uncoveredBranches.values.map((unit) => ({
294
+ line: unit.line,
295
+ type: unit.type,
296
+ index: unit.index
297
+ })),
298
+ branchesTotal: uncoveredBranches.total
299
+ }
300
+ };
301
+ }
302
+
303
+ /**
304
+ * @param {Map<string, FileCoverage>} files
305
+ */
306
+ export function mergeCoverageFiles(files) {
307
+ /** @type {Map<string, FileCoverage>} */
308
+ const merged = new Map();
309
+ for (const file of files.values()) {
310
+ const key = canonicalPath(file.absPath);
311
+ const existing = merged.get(key);
312
+ if (existing) mergeFileCoverage(existing, file);
313
+ else merged.set(key, file);
314
+ }
315
+ return merged;
316
+ }
@@ -0,0 +1,40 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { IS_WINDOWS, toPosix } from "../util.js";
4
+
5
+ /**
6
+ * Coverage tools disagree about path syntax. This normalizes the common cases:
7
+ * file:// URLs, Windows paths with a leading slash, mixed separators, and
8
+ * project-relative paths.
9
+ *
10
+ * @param {string} rawPath
11
+ * @param {string} root
12
+ * @returns {string}
13
+ */
14
+ export function resolveCoverageSourcePath(rawPath, root) {
15
+ if (typeof rawPath !== "string" || rawPath.trim() === "") {
16
+ throw new Error("覆盖率报告包含空的源码路径");
17
+ }
18
+ let value = rawPath.trim();
19
+ if (value.startsWith("file://")) {
20
+ try {
21
+ value = fileURLToPath(value);
22
+ } catch {
23
+ // Keep the original value and let path handling produce a useful error.
24
+ }
25
+ }
26
+ if (IS_WINDOWS) {
27
+ if (/^\/[A-Za-z]:\//.test(value)) value = value.slice(1);
28
+ value = value.replaceAll("/", "\\");
29
+ }
30
+ return path.normalize(path.isAbsolute(value) ? value : path.resolve(root, value));
31
+ }
32
+
33
+ /**
34
+ * Paths in lcov SF records are often already relative to the project root.
35
+ * @param {string} rawPath
36
+ * @param {string} root
37
+ */
38
+ export function displayCoveragePath(rawPath, root) {
39
+ return toPosix(path.relative(root, resolveCoverageSourcePath(rawPath, root)));
40
+ }
package/src/errors.js ADDED
@@ -0,0 +1,34 @@
1
+ export class CovgateError extends Error {
2
+ /**
3
+ * @param {string} message
4
+ * @param {{ kind?: string, exitCode?: number, details?: unknown, cause?: unknown }} [options]
5
+ */
6
+ constructor(message, options = {}) {
7
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
8
+ this.name = "CovgateError";
9
+ this.kind = options.kind ?? "error";
10
+ this.exitCode = options.exitCode ?? 2;
11
+ this.details = options.details;
12
+ }
13
+ }
14
+
15
+ export class ConfigError extends CovgateError {
16
+ constructor(message, options = {}) {
17
+ super(message, { ...options, kind: "config", exitCode: 2 });
18
+ this.name = "ConfigError";
19
+ }
20
+ }
21
+
22
+ export class ExecutionError extends CovgateError {
23
+ constructor(message, options = {}) {
24
+ super(message, { ...options, kind: "execution", exitCode: 3 });
25
+ this.name = "ExecutionError";
26
+ }
27
+ }
28
+
29
+ export class CoverageDataError extends CovgateError {
30
+ constructor(message, options = {}) {
31
+ super(message, { ...options, kind: "coverage-data", exitCode: 3 });
32
+ this.name = "CoverageDataError";
33
+ }
34
+ }
package/src/gate.js ADDED
@@ -0,0 +1,203 @@
1
+ import { fileReport, metricsForFile } from "./coverage/model.js";
2
+ import { METRICS } from "./config.js";
3
+ import { percentOf } from "./util.js";
4
+
5
+ /**
6
+ * @param {number} covered
7
+ * @param {number} total
8
+ * @param {number} basisPoints
9
+ */
10
+ function meetsThreshold(covered, total, basisPoints) {
11
+ if (total === 0) return true;
12
+ return covered * 10_000 >= basisPoints * total;
13
+ }
14
+
15
+ /**
16
+ * @param {Array<{ metrics: Record<string, { covered: number, total: number } | null> }>} files
17
+ * @param {string} metric
18
+ */
19
+ function aggregate(files, metric) {
20
+ let covered = 0;
21
+ let total = 0;
22
+ let applicable = true;
23
+ for (const file of files) {
24
+ const value = file.metrics[metric];
25
+ if (!value) {
26
+ applicable = false;
27
+ continue;
28
+ }
29
+ covered += value.covered;
30
+ total += value.total;
31
+ }
32
+ return { covered, total, applicable };
33
+ }
34
+
35
+ /**
36
+ * Apply thresholds and issue state to an evaluated scope.
37
+ * Percentages are for display only; the gate compares exact integer counts.
38
+ *
39
+ * @param {any} scopeData
40
+ */
41
+ export function gateScope(scopeData) {
42
+ const issues = [...(scopeData.issues ?? [])];
43
+ const warnings = [...(scopeData.warnings ?? [])];
44
+ const fileReports = scopeData.files.map((file) => fileReport(file));
45
+ const metrics = {};
46
+
47
+ for (const metric of METRICS) {
48
+ const counts = aggregate(fileReports, metric);
49
+ const threshold = scopeData.thresholds[metric];
50
+ if (!counts.applicable) {
51
+ metrics[metric] = {
52
+ metric,
53
+ applicable: false,
54
+ covered: 0,
55
+ total: 0,
56
+ percent: null,
57
+ threshold: threshold.value,
58
+ thresholdBasisPoints: threshold.basisPoints,
59
+ passed: true,
60
+ note: "当前覆盖率格式不提供该指标"
61
+ };
62
+ warnings.push({
63
+ code: "metric-not-reported",
64
+ severity: "warning",
65
+ message: `${metric} 覆盖率未由 ${scopeData.format} 报告提供,该指标不纳入本次门禁`
66
+ });
67
+ continue;
68
+ }
69
+ const passed = meetsThreshold(counts.covered, counts.total, threshold.basisPoints);
70
+ metrics[metric] = {
71
+ metric,
72
+ applicable: true,
73
+ covered: counts.covered,
74
+ total: counts.total,
75
+ percent: percentOf(counts.covered, counts.total),
76
+ threshold: threshold.value,
77
+ thresholdBasisPoints: threshold.basisPoints,
78
+ passed
79
+ };
80
+ }
81
+
82
+ const anyUnits = Object.values(metrics).some(
83
+ (metric) => metric.applicable && metric.total > 0
84
+ );
85
+ if (!anyUnits) {
86
+ issues.push({
87
+ code: "no-executable-coverage",
88
+ severity: "error",
89
+ message:
90
+ "scope 中没有任何可统计的代码单元。检查 sources.include、覆盖率报告路径和测试采集配置。"
91
+ });
92
+ }
93
+
94
+ const errorIssues = issues.filter((issue) => issue.severity === "error");
95
+ const failedMetrics = Object.values(metrics).filter(
96
+ (metric) => metric.applicable && !metric.passed
97
+ );
98
+ const passed = errorIssues.length === 0 && failedMetrics.length === 0;
99
+
100
+ const failingFiles = fileReports
101
+ .filter((file) => {
102
+ const uncovered =
103
+ file.uncovered.linesTotal +
104
+ (file.uncovered.statementsTotal ?? 0) +
105
+ file.uncovered.functionsTotal +
106
+ file.uncovered.branchesTotal;
107
+ return uncovered > 0;
108
+ })
109
+ .sort((left, right) => {
110
+ const leftPercent = left.metrics.lines?.percent ?? 100;
111
+ const rightPercent = right.metrics.lines?.percent ?? 100;
112
+ return leftPercent - rightPercent || left.path.localeCompare(right.path, "en");
113
+ });
114
+
115
+ const thresholds = Object.fromEntries(
116
+ METRICS.map((metric) => [metric, scopeData.thresholds[metric].value])
117
+ );
118
+
119
+ const result = {
120
+ name: scopeData.name,
121
+ label: scopeData.label,
122
+ profile: scopeData.profile,
123
+ format: scopeData.format,
124
+ passed,
125
+ thresholds,
126
+ metrics,
127
+ files: fileReports,
128
+ failingFiles,
129
+ expectedSourceCount: scopeData.expectedSourceCount,
130
+ reportedFileCount: scopeData.reportedFileCount,
131
+ missingSources: scopeData.missingSources,
132
+ extras: scopeData.extras,
133
+ waivers: scopeData.waivers,
134
+ exclusions: scopeData.exclusions,
135
+ issues,
136
+ warnings,
137
+ reasons: [
138
+ ...failedMetrics.map(
139
+ (metric) =>
140
+ `${metric.metric} 覆盖率 ${metric.percent.toFixed(2)}%,低于 ${metric.threshold}%(${metric.covered}/${metric.total})`
141
+ ),
142
+ ...errorIssues.map((issue) => issue.message)
143
+ ]
144
+ };
145
+ return result;
146
+ }
147
+
148
+ /**
149
+ * @param {any[]} scopes
150
+ */
151
+ export function gateAll(scopes) {
152
+ const results = scopes.map(gateScope);
153
+ return {
154
+ passed: results.every((scope) => scope.passed),
155
+ scopes: results
156
+ };
157
+ }
158
+
159
+ /**
160
+ * @param {any} scopeResult
161
+ */
162
+ export function scopeMetricSummary(scopeResult) {
163
+ return METRICS.map((metric) => {
164
+ const value = scopeResult.metrics[metric];
165
+ return {
166
+ metric,
167
+ covered: value.covered,
168
+ total: value.total,
169
+ percent: value.percent,
170
+ threshold: value.threshold,
171
+ passed: value.passed
172
+ };
173
+ });
174
+ }
175
+
176
+ /**
177
+ * @param {any} scopeResult
178
+ */
179
+ export function scopeCoveredUnits(scopeResult) {
180
+ const totals = { covered: 0, total: 0 };
181
+ for (const metric of METRICS) {
182
+ const value = scopeResult.metrics[metric];
183
+ if (!value.applicable) continue;
184
+ totals.covered += value.covered;
185
+ totals.total += value.total;
186
+ }
187
+ return totals;
188
+ }
189
+
190
+ /**
191
+ * @param {any} scopeResult
192
+ */
193
+ export function scopeUncoveredFileCount(scopeResult) {
194
+ return scopeResult.failingFiles.length;
195
+ }
196
+
197
+ /**
198
+ * @param {any} file
199
+ */
200
+ export function fileHasAnyCoverage(file) {
201
+ const metrics = metricsForFile(file);
202
+ return Object.values(metrics).some((metric) => metric && metric.total > 0);
203
+ }