covgate 0.2.0 → 0.2.2

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.
@@ -46,7 +46,8 @@ function countScopeState(scopes) {
46
46
  * topLevelIssues: any[],
47
47
  * durationMs: number,
48
48
  * startedAt: string,
49
- * artifactPaths: { json?: string, markdown?: string }
49
+ * artifactPaths: { json?: string, markdown?: string, html?: string },
50
+ * changed?: { base: string, relativeFiles: string[] } | null
50
51
  * }} options
51
52
  */
52
53
  export function buildReport({
@@ -57,7 +58,8 @@ export function buildReport({
57
58
  topLevelIssues,
58
59
  durationMs,
59
60
  startedAt,
60
- artifactPaths
61
+ artifactPaths,
62
+ changed
61
63
  }) {
62
64
  const errors = topLevelIssues.filter((issue) => issue.severity !== "warning");
63
65
  const hasExecutionError = errors.length > 0;
@@ -67,6 +69,9 @@ export function buildReport({
67
69
  const state = countScopeState(scopes);
68
70
  const failedScopes = scopes.filter((scope) => !scope.passed).map((scope) => scope.name);
69
71
  const artifacts = artifactDisplay(config, artifactPaths);
72
+ const changedSummary = changed
73
+ ? { enabled: true, base: changed.base, files: changed.relativeFiles }
74
+ : null;
70
75
 
71
76
  const sanitizedCommands = commands.map((command) => {
72
77
  const record = {
@@ -103,7 +108,8 @@ export function buildReport({
103
108
  },
104
109
  summary: {
105
110
  scopes: scopes.length,
106
- passedScopes: scopes.filter((scope) => scope.passed).length,
111
+ passedScopes: scopes.filter((scope) => scope.passed && !scope.skipped).length,
112
+ skippedScopes: scopes.filter((scope) => scope.skipped).map((scope) => scope.name),
107
113
  failedScopes,
108
114
  waivers: state.waivers,
109
115
  exclusions: state.exclusions,
@@ -111,6 +117,7 @@ export function buildReport({
111
117
  errors: state.errors + errors.length,
112
118
  warnings: state.warnings
113
119
  },
120
+ changed: changedSummary,
114
121
  issues: topLevelIssues,
115
122
  commands: sanitizedCommands,
116
123
  scopes,
@@ -131,5 +138,8 @@ export function plannedArtifactPaths(config, noFiles = false) {
131
138
  if (config.reports.formats.includes("markdown")) {
132
139
  paths.markdown = path.join(config.reports.dir, "report.md");
133
140
  }
141
+ if (config.reports.formats.includes("html")) {
142
+ paths.html = path.join(config.reports.dir, "report.html");
143
+ }
134
144
  return paths;
135
145
  }
@@ -0,0 +1,257 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const ESCAPE_MAP = {
5
+ "&": "&",
6
+ "<": "&lt;",
7
+ ">": "&gt;",
8
+ '"': "&quot;",
9
+ "'": "&#39;"
10
+ };
11
+
12
+ function escapeHtml(value) {
13
+ return String(value).replace(/[&<>"']/g, (character) => ESCAPE_MAP[character]);
14
+ }
15
+
16
+ function percent(value) {
17
+ return value === null || value === undefined ? "N/A" : value.toFixed(2) + "%";
18
+ }
19
+
20
+ function statusLabel(status) {
21
+ if (status === "pass") return "通过";
22
+ if (status === "fail") return "未通过";
23
+ return "运行失败";
24
+ }
25
+
26
+ async function readSourceLines(root, relativePath) {
27
+ try {
28
+ const text = await readFile(path.resolve(root, relativePath), "utf8");
29
+ return text.replace(/^\uFEFF/, "").split(/\r?\n/);
30
+ } catch (error) {
31
+ return ["// 无法读取源码:" + error.message];
32
+ }
33
+ }
34
+
35
+ function metricCard(label, value) {
36
+ if (!value || !value.applicable) {
37
+ return '<div class="metric"><span>' + escapeHtml(label) + '</span><strong>N/A</strong></div>';
38
+ }
39
+ const klass = value.passed ? "good" : "bad";
40
+ return (
41
+ '<div class="metric ' +
42
+ klass +
43
+ '"><span>' +
44
+ escapeHtml(label) +
45
+ "</span><strong>" +
46
+ percent(value.percent) +
47
+ "</strong><small>" +
48
+ value.covered +
49
+ "/" +
50
+ value.total +
51
+ " · 目标 " +
52
+ value.threshold +
53
+ "%</small></div>"
54
+ );
55
+ }
56
+
57
+ function lineHtml(number, line, hits) {
58
+ const hasHit = hits && Object.prototype.hasOwnProperty.call(hits, String(number));
59
+ const count = hasHit ? Number(hits[String(number)]) : null;
60
+ const state = !hasHit ? "neutral" : count > 0 ? "covered" : "uncovered";
61
+ const hitLabel = hasHit ? (count > 0 ? String(count) : "0") : "";
62
+ return (
63
+ '<div class="line ' +
64
+ state +
65
+ '"><span class="ln">' +
66
+ number +
67
+ '</span><span class="hit" title="命中次数">' +
68
+ escapeHtml(hitLabel) +
69
+ '</span><span class="code">' +
70
+ (line ? escapeHtml(line) : "&nbsp;") +
71
+ "</span></div>"
72
+ );
73
+ }
74
+
75
+ const CSS = [
76
+ ":root{color-scheme:light;--ink:#17202a;--muted:#65707c;--line:#e5e9ed;--bg:#f4f6f8;--panel:#fff;--green:#d9f7e2;--green-strong:#198754;--red:#e5484d;--neutral:#f5f6f7;--accent:#1f6feb}",
77
+ "*{box-sizing:border-box}",
78
+ "html,body{margin:0;height:100%}",
79
+ "body{font-family:ui-sans-serif,-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--ink)}",
80
+ ".app{display:flex;height:100vh;overflow:hidden}",
81
+ ".sidebar{width:340px;min-width:280px;border-right:1px solid var(--line);background:#fbfcfd;display:flex;flex-direction:column}",
82
+ ".brand{padding:18px 18px 12px;border-bottom:1px solid var(--line)}",
83
+ ".brand h1{font-size:17px;margin:0 0 6px;letter-spacing:.2px}",
84
+ ".brand p{margin:0;color:var(--muted);font-size:12px;line-height:1.5}",
85
+ ".summary{padding:12px 18px;border-bottom:1px solid var(--line);font-size:12px;color:var(--muted);display:flex;flex-wrap:wrap;gap:8px}",
86
+ ".pill{padding:3px 8px;border-radius:99px;background:#eef1f4;color:#46505b}",
87
+ ".pill.good{background:var(--green);color:#116a3e}.pill.bad{background:#ffe3e4;color:#a51d22}",
88
+ ".search{padding:12px 14px 8px}",
89
+ ".search input{width:100%;padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:#fff;font:inherit;outline:none}",
90
+ ".search input:focus{border-color:var(--accent);box-shadow:0 0 0 3px #1f6feb1a}",
91
+ ".file-list{overflow:auto;padding:4px 10px 24px;flex:1}",
92
+ ".scope-title{font-size:11px;text-transform:uppercase;letter-spacing:.8px;color:#8a94a0;padding:14px 8px 7px;display:flex;justify-content:space-between;gap:8px}",
93
+ ".scope-title span{font-weight:600;color:#56616d}",
94
+ ".file-link{display:flex;align-items:center;gap:8px;padding:8px 9px;border-radius:8px;color:var(--ink);text-decoration:none;font-size:13px;line-height:1.3}",
95
+ ".file-link:hover{background:#eef3f8}",
96
+ ".file-link.active{background:#e7f0ff;box-shadow:inset 2px 0 var(--accent)}",
97
+ ".file-link .name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;direction:rtl;text-align:left}",
98
+ ".file-link .pct{font-variant-numeric:tabular-nums;font-size:11px;color:var(--muted)}",
99
+ ".file-link .pct.ok{color:var(--green-strong)}.file-link .pct.bad{color:var(--red)}",
100
+ ".empty{padding:14px;font-size:13px;color:var(--muted);line-height:1.6}",
101
+ ".main{flex:1;min-width:0;display:flex;flex-direction:column;overflow:hidden}",
102
+ ".topbar{padding:16px 22px;border-bottom:1px solid var(--line);background:var(--panel);display:flex;align-items:center;gap:16px;flex-wrap:wrap}",
103
+ ".topbar h2{font-size:15px;margin:0;flex:1;min-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
104
+ ".changed-badge{font-size:11px;background:#fff3cd;color:#7a5b00;border-radius:99px;padding:3px 8px}",
105
+ ".metrics{display:flex;gap:8px;flex-wrap:wrap}",
106
+ ".metric{border:1px solid var(--line);background:#fff;border-radius:8px;padding:6px 10px;min-width:104px;display:flex;flex-direction:column;gap:1px}",
107
+ ".metric span{font-size:10px;color:var(--muted)}.metric strong{font-size:13px}.metric small{font-size:10px;color:var(--muted)}",
108
+ ".metric.good strong{color:var(--green-strong)}.metric.bad strong{color:var(--red)}",
109
+ ".content{overflow:auto;flex:1;padding:22px}",
110
+ ".file-view{max-width:1500px;margin:0 auto}",
111
+ ".file-meta{display:flex;gap:12px;flex-wrap:wrap;align-items:center;margin-bottom:12px;color:var(--muted);font-size:12px}",
112
+ ".file-meta .path{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--ink);font-size:13px}",
113
+ ".legend{display:flex;gap:14px;font-size:11px;color:var(--muted);margin-bottom:12px;flex-wrap:wrap}",
114
+ ".legend i{display:inline-block;width:18px;height:10px;border:1px solid var(--line);border-radius:2px;vertical-align:-1px;margin-right:4px}",
115
+ ".legend .covered-bg{background:var(--green)}.legend .uncovered-bg{background:#fff}.legend .neutral-bg{background:var(--neutral)}",
116
+ ".code-pane{background:#fff;border:1px solid var(--line);border-radius:10px;overflow:auto;font-family:ui-monospace,SFMono-Regular,Consolas,'Liberation Mono',monospace;font-size:12.5px;line-height:1.55;padding:6px 0}",
117
+ ".line{display:flex;min-width:max-content;white-space:pre;border-bottom:1px solid #f1f3f5}",
118
+ ".line.covered{background:var(--green)}.line.uncovered{background:#fff;box-shadow:inset 3px 0 var(--red)}.line.neutral{background:var(--neutral);color:#8e99a4}",
119
+ ".ln{width:60px;flex:0 0 60px;text-align:right;padding-right:12px;color:#8a94a0;user-select:none;border-right:1px solid #0000000d}",
120
+ ".hit{width:42px;flex:0 0 42px;text-align:right;padding-right:12px;color:#8a94a0;user-select:none;border-right:1px solid #0000000d}",
121
+ ".line.covered .hit{color:var(--green-strong)}.line.uncovered .hit{color:var(--red)}",
122
+ ".code-text{padding:0 14px 0 10px;padding:0 14px 0 10px}",
123
+ ".line .code{padding:0 14px 0 10px}",
124
+ ".source-note{font-size:12px;color:var(--muted);margin:8px 0 12px}",
125
+ "@media(max-width:900px){.app{display:block;height:auto}.sidebar{width:auto;height:46vh}.main{height:54vh}.content{padding:12px}}",
126
+ ""
127
+ ].join("\n");
128
+
129
+ const JS = [
130
+ "(function(){",
131
+ " var links = Array.prototype.slice.call(document.querySelectorAll('.file-link'));",
132
+ " var views = Array.prototype.slice.call(document.querySelectorAll('.file-view'));",
133
+ " function show(id) {",
134
+ " views.forEach(function(view){ view.hidden = view.id !== id; });",
135
+ " links.forEach(function(link){ link.classList.toggle('active', link.getAttribute('data-file') === id); });",
136
+ " if (id) { try { history.replaceState(null, '', '#' + id); } catch (_) {} }",
137
+ " }",
138
+ " links.forEach(function(link){",
139
+ " link.addEventListener('click', function(event){ event.preventDefault(); show(link.getAttribute('data-file')); });",
140
+ " });",
141
+ " var search = document.getElementById('fileSearch');",
142
+ " if (search) search.addEventListener('input', function(){",
143
+ " var term = search.value.trim().toLowerCase();",
144
+ " links.forEach(function(link){ link.style.display = !term || link.textContent.toLowerCase().indexOf(term) !== -1 ? '' : 'none'; });",
145
+ " });",
146
+ " var initial = location.hash ? location.hash.slice(1) : (links[0] && links[0].getAttribute('data-file'));",
147
+ " if (initial) show(initial);",
148
+ "})();"
149
+ ].join("\n");
150
+
151
+ function sidebarScope(scope, files) {
152
+ const title = escapeHtml(scope.label) + " · " + escapeHtml(scope.name);
153
+ const metrics = scope.metrics && scope.metrics.lines
154
+ ? (scope.metrics.lines.applicable ? percent(scope.metrics.lines.percent) : "N/A")
155
+ : "N/A";
156
+ const threshold = scope.metrics?.lines?.threshold ?? 100;
157
+ const rows = files.map((file) => {
158
+ const line = file.metrics && file.metrics.lines;
159
+ const value = line && line.percent !== null ? percent(line.percent) : "N/A";
160
+ const ok = line && line.percent !== null && line.percent >= threshold ? "ok" : "bad";
161
+ return (
162
+ '<a class="file-link" data-file="' + file.id + '" href="#' + file.id + '">' +
163
+ '<span class="name" title="' + escapeHtml(file.path) + '">' + escapeHtml(file.path) + "</span>" +
164
+ '<span class="pct ' + ok + '">' + value + "</span></a>"
165
+ );
166
+ });
167
+ return (
168
+ '<div class="scope-title"><span>' + title + "</span><span>" + metrics + "</span></div>" +
169
+ (rows.length > 0 ? rows.join("") : '<div class="empty">本次变更未涉及该 scope。</div>')
170
+ );
171
+ }
172
+
173
+ function fileView(scope, file, index) {
174
+ const source = file.source || [];
175
+ const lines = [];
176
+ for (let lineNumber = 1; lineNumber <= source.length; lineNumber += 1) {
177
+ lines.push(lineHtml(lineNumber, source[lineNumber - 1], file.lineHits));
178
+ }
179
+ const lineMetric = file.metrics && file.metrics.lines;
180
+ const statLine = lineMetric ? percent(lineMetric.percent) + " (" + lineMetric.covered + "/" + lineMetric.total + ")" : "N/A";
181
+ const hidden = index === 0 ? "" : " hidden";
182
+ return (
183
+ '<section class="file-view" id="' + file.id + '"' + hidden + ">" +
184
+ '<div class="file-meta"><span class="path">' + escapeHtml(file.path) + "</span>" +
185
+ "<span>行覆盖 " + statLine + "</span>" +
186
+ "<span>" + (file.changed ? "本次 Git 变更文件" : "") + "</span></div>" +
187
+ '<div class="legend"><span><i class="covered-bg"></i>已覆盖</span><span><i class="uncovered-bg"></i>未覆盖</span><span><i class="neutral-bg"></i>非可执行代码</span></div>' +
188
+ '<div class="code-pane">' + lines.join("") + "</div>" +
189
+ "</section>"
190
+ );
191
+ }
192
+
193
+ /**
194
+ * @param {any} report
195
+ * @param {{ root: string }} options
196
+ */
197
+ export async function renderHtmlReport(report, options) {
198
+ const root = options.root;
199
+ const scopeViews = [];
200
+ const sidebar = [];
201
+ let fileIndex = 0;
202
+ for (let scopeIndex = 0; scopeIndex < report.scopes.length; scopeIndex += 1) {
203
+ const scope = report.scopes[scopeIndex];
204
+ const files = [];
205
+ for (const file of scope.files || []) {
206
+ const source = await readSourceLines(root, file.path);
207
+ files.push({
208
+ ...file,
209
+ id: "file-" + scopeIndex + "-" + fileIndex,
210
+ source,
211
+ changed: Boolean(report.changed && report.changed.enabled)
212
+ });
213
+ fileIndex += 1;
214
+ }
215
+ sidebar.push(sidebarScope(scope, files));
216
+ scopeViews.push(files);
217
+ }
218
+
219
+ const views = [];
220
+ for (let scopeIndex = 0; scopeIndex < report.scopes.length; scopeIndex += 1) {
221
+ for (const file of scopeViews[scopeIndex]) views.push(fileView(report.scopes[scopeIndex], file, views.length));
222
+ }
223
+
224
+ const changed = report.changed && report.changed.enabled
225
+ ? '<span class="changed-badge">只显示 Git 变更文件 · base ' + escapeHtml(report.changed.base) + "</span>"
226
+ : "";
227
+ const status = statusLabel(report.status);
228
+ const statusClass = report.status === "pass" ? "good" : "bad";
229
+ const summary = report.scopes.map((scope) => {
230
+ const lines = scope.metrics && scope.metrics.lines;
231
+ return metricCard(scope.label, lines);
232
+ }).join("");
233
+ const scopeCount = report.scopes.length;
234
+ const fileCount = report.scopes.reduce((sum, scope) => sum + (scope.files || []).length, 0);
235
+ const body = views.length > 0
236
+ ? views.join("")
237
+ : '<div class="file-view"><div class="empty">没有可展示的源码文件。请检查 sources.include 或本次 Git 变更。</div></div>';
238
+
239
+ return [
240
+ "<!doctype html>",
241
+ '<html lang="zh-CN"><head><meta charset="utf-8">',
242
+ '<meta name="viewport" content="width=device-width,initial-scale=1">',
243
+ "<title>CovGate 覆盖率报告</title>",
244
+ "<style>" + CSS + "</style></head><body>",
245
+ '<div class="app">',
246
+ '<aside class="sidebar"><div class="brand"><h1>CovGate 覆盖率</h1><p>' +
247
+ escapeHtml(report.generatedAt) + " · " + escapeHtml(report.mode === "check" ? "已有报告" : "本次运行") +
248
+ "</p></div>" +
249
+ '<div class="summary"><span class="pill ' + statusClass + '">' + status + "</span>" +
250
+ '<span class="pill">' + scopeCount + ' scopes</span><span class="pill">' + fileCount + ' files</span>' + changed + "</div>" +
251
+ '<div class="search"><input id="fileSearch" type="search" placeholder="搜索文件…" aria-label="搜索文件"></div>',
252
+ '<nav class="file-list">' + sidebar.join("") + "</nav></aside>",
253
+ '<main class="main"><header class="topbar"><h2>源码覆盖</h2><div class="metrics">' + summary + "</div></header>",
254
+ '<div class="content">' + body + "</div></main></div>",
255
+ "<script>" + JS + "</script></body></html>"
256
+ ].join("\n");
257
+ }
@@ -1,15 +1,23 @@
1
1
  import { writeTextFile } from "../util.js";
2
2
  import { buildReport, plannedArtifactPaths } from "./build.js";
3
+ import { renderHtmlReport } from "./html.js";
3
4
  import { renderMarkdownReport } from "./markdown.js";
4
5
  import { renderTextReport } from "./text.js";
5
6
 
6
- export { buildReport, plannedArtifactPaths, renderMarkdownReport, renderTextReport };
7
+ export {
8
+ buildReport,
9
+ plannedArtifactPaths,
10
+ renderHtmlReport,
11
+ renderMarkdownReport,
12
+ renderTextReport
13
+ };
7
14
 
8
15
  /**
9
16
  * @param {any} report
10
- * @param {{ json?: string, markdown?: string }} paths
17
+ * @param {{ json?: string, markdown?: string, html?: string }} paths
18
+ * @param {any} [config]
11
19
  */
12
- export async function writeReportArtifacts(report, paths) {
20
+ export async function writeReportArtifacts(report, paths, config) {
13
21
  const errors = [];
14
22
  if (paths.json) {
15
23
  try {
@@ -25,5 +33,15 @@ export async function writeReportArtifacts(report, paths) {
25
33
  errors.push("无法写入 Markdown 报告 " + paths.markdown + ": " + error.message);
26
34
  }
27
35
  }
36
+ if (paths.html) {
37
+ try {
38
+ const html = await renderHtmlReport(report, {
39
+ root: config?.root || process.cwd()
40
+ });
41
+ await writeTextFile(paths.html, html);
42
+ } catch (error) {
43
+ errors.push("无法写入 HTML 报告 " + paths.html + ": " + error.message);
44
+ }
45
+ }
28
46
  return errors;
29
47
  }
@@ -57,7 +57,7 @@ function scopeTableRow(scope) {
57
57
  (value.passed ? "✓" : "✗")
58
58
  );
59
59
  }
60
- cells.push(scope.passed ? "✓ 通过" : "✗ 未通过");
60
+ cells.push(scope.skipped ? "— 未涉及变更" : scope.passed ? "✓ 通过" : "✗ 未通过");
61
61
  return "| " + cells.join(" | ") + " |";
62
62
  }
63
63
 
@@ -111,6 +111,8 @@ export function renderMarkdownReport(report) {
111
111
  report.summary.scopes +
112
112
  " 个,通过 " +
113
113
  report.summary.passedScopes +
114
+ " 个;跳过 " +
115
+ report.summary.skippedScopes.length +
114
116
  " 个;豁免 " +
115
117
  report.summary.waivers +
116
118
  " 条;配置排除 " +
@@ -54,7 +54,7 @@ export function renderTextReport(report, options = {}) {
54
54
  if (report.issues.length > 0) lines.push("");
55
55
 
56
56
  for (const scope of report.scopes) {
57
- const symbol = scope.passed ? symbols.pass : symbols.fail;
57
+ const symbol = scope.skipped ? "○" : scope.passed ? symbols.pass : symbols.fail;
58
58
  lines.push(
59
59
  symbol +
60
60
  " " +
@@ -62,7 +62,7 @@ export function renderTextReport(report, options = {}) {
62
62
  " " +
63
63
  dim("[" + scope.name + "/" + scope.profile + "]") +
64
64
  " " +
65
- (scope.passed ? green("通过") : red("未通过"))
65
+ (scope.skipped ? dim("未涉及本次变更") : scope.passed ? green("通过") : red("未通过"))
66
66
  );
67
67
 
68
68
  const metricParts = [];
@@ -260,6 +260,9 @@ export function renderTextReport(report, options = {}) {
260
260
  if (report.artifacts.markdown) {
261
261
  lines.push(dim("Markdown 报告: " + report.artifacts.markdown));
262
262
  }
263
+ if (report.artifacts.html) {
264
+ lines.push(dim("HTML 报告: " + report.artifacts.html));
265
+ }
263
266
  if (report.status !== "pass" && report.summary.failedScopes.length > 0) {
264
267
  lines.push(dim("失败 scope: " + report.summary.failedScopes.join(", ")));
265
268
  }
package/src/scope.js CHANGED
@@ -64,10 +64,11 @@ function findOverlaps(ranges) {
64
64
  * root: string,
65
65
  * coverage: { format: string, files: Map<string, any> },
66
66
  * waiverPolicy: any,
67
+ * changedKeys?: Set<string> | null,
67
68
  * now?: Date
68
69
  * }} options
69
70
  */
70
- export async function evaluateScope({ scope, root, coverage, waiverPolicy, now = new Date() }) {
71
+ export async function evaluateScope({ scope, root, coverage, waiverPolicy, changedKeys = null, now = new Date() }) {
71
72
  /** @type {Array<any>} */
72
73
  const issues = [];
73
74
  /** @type {Array<any>} */
@@ -161,6 +162,33 @@ export async function evaluateScope({ scope, root, coverage, waiverPolicy, now =
161
162
  });
162
163
  }
163
164
 
165
+ const universeKeys = new Set(expected.keys());
166
+ if (changedKeys) {
167
+ for (const key of [...expected.keys()]) {
168
+ if (!changedKeys.has(key)) expected.delete(key);
169
+ }
170
+ if (universeKeys.size > 0 && expected.size === 0) {
171
+ return {
172
+ name: scope.name,
173
+ label: scope.label,
174
+ profile: scope.profile,
175
+ thresholds: scope.thresholds,
176
+ format: coverage.format,
177
+ files: [],
178
+ expectedSourceCount: 0,
179
+ reportedFileCount: coverage.files.size,
180
+ missingSources: [],
181
+ extras: [],
182
+ waivers: [],
183
+ exclusions,
184
+ issues,
185
+ warnings,
186
+ skipped: true,
187
+ changedOnly: true
188
+ };
189
+ }
190
+ }
191
+
164
192
  // Read and scan every remaining expected source file. We need this before
165
193
  // deciding missing coverage so file-level waivers can suppress that check
166
194
  // with a documented reason.
@@ -391,7 +419,7 @@ export async function evaluateScope({ scope, root, coverage, waiverPolicy, now =
391
419
  // it helps catch an accidentally narrow include pattern.
392
420
  const extras = [];
393
421
  for (const [key, file] of coverage.files) {
394
- if (expected.has(key) || excludedKeys.has(key) || fileIgnoreKeys.has(key)) continue;
422
+ if (universeKeys.has(key) || excludedKeys.has(key) || fileIgnoreKeys.has(key)) continue;
395
423
  extras.push(file.relPath);
396
424
  }
397
425
  if (extras.length > 0) {
@@ -435,6 +463,8 @@ export async function evaluateScope({ scope, root, coverage, waiverPolicy, now =
435
463
  waivers,
436
464
  exclusions,
437
465
  issues,
438
- warnings
466
+ warnings,
467
+ skipped: false,
468
+ changedOnly: Boolean(changedKeys)
439
469
  };
440
470
  }
@@ -14,6 +14,7 @@ const AGENT_BLOCK = [
14
14
  "- 禁止 `istanbul ignore`、`c8 ignore`、`v8 ignore`、`node:coverage ignore`。",
15
15
  "- 确实不可达的代码使用 `covgate-ignore-next-line`、`covgate-ignore-start/end` 或 `covgate-ignore-file`,写具体原因、owner 和 expires。",
16
16
  "- 禁止通过扩大 exclude、缩小 sources.include 或删除测试来让门禁通过;这类改动必须由人类评审。",
17
+ "- 行为变更必须新增或更新覆盖该行为的真实测试;不能把“门禁失败”当作完成理由,也不得用空报告、`--passWithNoTests`、ignore 或降低门槛绕过。",
17
18
  "- 失败详情在 `.covgate/report.json` 的 scopes/issues/waivers 中;修复后重跑。",
18
19
  "",
19
20
  "也可运行 `npm run coverage:gate -- --json`。Agent Stop hook 可调用 `scripts/covgate-agent-gate.ps1`(Windows)或 `scripts/covgate-agent-gate.sh`。",
@@ -30,7 +31,8 @@ const CURSOR_RULE = [
30
31
  "# CovGate 覆盖率门禁",
31
32
  "",
32
33
  "任何代码修改后运行 `npx --no-install covgate run --json`,退出码 0 才能结束。",
33
- "客户端必须 100%,服务端至少 95%。禁止原生 ignore;豁免必须写原因、owner、expires。",
34
+ "客户端必须 100%,服务端至少 95%。行为变更必须补真实测试;门禁失败不能作为完成理由。",
35
+ "禁止原生 ignore;豁免必须写原因、owner、expires。",
34
36
  ""
35
37
  ].join("\n");
36
38
 
@@ -44,45 +44,62 @@ async function pickExcludes(root, sourceGlob) {
44
44
  /**
45
45
  * @param {{
46
46
  * root: string,
47
- * profile: "client" | "server",
48
- * sourceGlob: string,
47
+ * scopes?: Array<{ name: string, profile: "client" | "server", label?: string, sourceGlob: string }>,
48
+ * profile?: "client" | "server",
49
+ * sourceGlob?: string,
49
50
  * command: string,
50
51
  * coveragePath: string
51
52
  * }} options
52
53
  */
53
54
  export async function buildStarterConfig({
54
55
  root,
56
+ scopes,
55
57
  profile,
56
58
  sourceGlob,
57
59
  command,
58
60
  coveragePath
59
61
  }) {
60
- const exclude = await pickExcludes(root, sourceGlob);
61
- const label = profile === "client" ? "客户端" : "服务端";
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
+ }
62
99
  return {
63
100
  $schema: "./node_modules/covgate/schema/covgate.schema.json",
64
101
  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
- },
102
+ scopes: scopeConfigs,
86
103
  waivers: {
87
104
  minReasonLength: 8,
88
105
  requireOwner: false,
@@ -91,7 +108,7 @@ export async function buildStarterConfig({
91
108
  },
92
109
  reports: {
93
110
  dir: ".covgate",
94
- formats: ["json", "markdown"]
111
+ formats: ["json", "markdown", "html"]
95
112
  }
96
113
  };
97
114
  }