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.
- package/README.md +52 -17
- package/docs/ai-workflow.md +1 -0
- package/docs/coverage-viewer.png +0 -0
- package/docs/design.md +1 -1
- package/docs/setup.md +30 -28
- package/package.json +1 -1
- package/schema/covgate.schema.json +4 -2
- package/src/args.js +14 -0
- package/src/cli.js +41 -6
- package/src/config.js +2 -2
- package/src/coverage/model.js +3 -0
- package/src/gate.js +48 -4
- package/src/git.js +86 -0
- package/src/index.js +1 -0
- package/src/report/build.js +13 -3
- package/src/report/html.js +257 -0
- package/src/report/index.js +21 -3
- package/src/report/markdown.js +3 -1
- package/src/report/text.js +5 -2
- package/src/scope.js +33 -3
- package/src/setup/agent.js +3 -1
- package/src/setup/config.js +43 -26
- package/src/setup/index.js +41 -13
- package/src/setup/project.js +82 -7
- package/templates/AGENTS.md.snippet.md +1 -0
- package/templates/covgate.config.json +2 -1
package/src/report/build.js
CHANGED
|
@@ -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
|
+
"<": "<",
|
|
7
|
+
">": ">",
|
|
8
|
+
'"': """,
|
|
9
|
+
"'": "'"
|
|
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) : " ") +
|
|
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
|
+
}
|
package/src/report/index.js
CHANGED
|
@@ -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 {
|
|
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
|
}
|
package/src/report/markdown.js
CHANGED
|
@@ -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
|
" 条;配置排除 " +
|
package/src/report/text.js
CHANGED
|
@@ -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 (
|
|
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
|
}
|
package/src/setup/agent.js
CHANGED
|
@@ -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
|
|
34
|
+
"客户端必须 100%,服务端至少 95%。行为变更必须补真实测试;门禁失败不能作为完成理由。",
|
|
35
|
+
"禁止原生 ignore;豁免必须写原因、owner、expires。",
|
|
34
36
|
""
|
|
35
37
|
].join("\n");
|
|
36
38
|
|
package/src/setup/config.js
CHANGED
|
@@ -44,45 +44,62 @@ async function pickExcludes(root, sourceGlob) {
|
|
|
44
44
|
/**
|
|
45
45
|
* @param {{
|
|
46
46
|
* root: string,
|
|
47
|
-
* profile: "client" | "server",
|
|
48
|
-
*
|
|
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
|
|
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
|
+
}
|
|
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
|
}
|