covgate 0.2.1 → 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 +39 -6
- package/docs/coverage-viewer.png +0 -0
- package/docs/design.md +1 -1
- package/docs/setup.md +2 -2
- 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/config.js +1 -1
- package/templates/covgate.config.json +2 -1
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ covgate run
|
|
|
48
48
|
|
|
49
49
|
### 从 npm registry 安装(推荐)
|
|
50
50
|
|
|
51
|
-
包已发布到 npm:[covgate](https://www.npmjs.com/package/covgate),当前 latest 为 **0.2.
|
|
51
|
+
包已发布到 npm:[covgate](https://www.npmjs.com/package/covgate),当前 latest 为 **0.2.2**。
|
|
52
52
|
|
|
53
53
|
```bash
|
|
54
54
|
npm install --save-dev covgate
|
|
@@ -185,7 +185,7 @@ node bin/covgate.js --help
|
|
|
185
185
|
},
|
|
186
186
|
"reports": {
|
|
187
187
|
"dir": ".covgate",
|
|
188
|
-
"formats": ["json", "markdown"]
|
|
188
|
+
"formats": ["json", "markdown", "html"]
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
191
|
```
|
|
@@ -248,7 +248,9 @@ npx --no-install covgate validate
|
|
|
248
248
|
| `--config <path>` | 指定配置文件。 |
|
|
249
249
|
| `--scope <name>` | 只检查指定 scope,可重复或逗号分隔。 |
|
|
250
250
|
| `--json` | stdout 只输出 JSON;过程日志走 stderr。 |
|
|
251
|
-
| `--
|
|
251
|
+
| `--changed` | 只门禁/展示本次 Git 变更文件;默认对比 `HEAD`。 |
|
|
252
|
+
| `--base <rev>` | 配合 `--changed` 指定 Git 对比版本,例如 `HEAD^`。 |
|
|
253
|
+
| `--no-files` | 不写 `.covgate/report.json` / `report.md` / `report.html`。 |
|
|
252
254
|
| `--report-dir <path>` | 覆盖报告目录(相对当前目录)。 |
|
|
253
255
|
| `--verbose` | 实时转发测试命令输出。 |
|
|
254
256
|
| `--color` / `--no-color` | 控制彩色输出。 |
|
|
@@ -266,6 +268,25 @@ setup 的完整选项见 [安装与自动配置](docs/setup.md)。
|
|
|
266
268
|
|
|
267
269
|
CI 只需要判断退出码是否为 0。
|
|
268
270
|
|
|
271
|
+
### 只检查 Git 变更文件
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
# 工作区相对 HEAD 的新增/修改(包含未跟踪的新文件)
|
|
275
|
+
npx --no-install covgate run --changed
|
|
276
|
+
|
|
277
|
+
# 检查最新一次提交相对父提交的变更
|
|
278
|
+
npx --no-install covgate run --changed --base HEAD^
|
|
279
|
+
|
|
280
|
+
# PR / CI:相对目标分支
|
|
281
|
+
npx --no-install covgate run --changed --base origin/main
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`--changed` 会把每个 scope 的源码范围收窄到变更文件,并只对这些文件计算覆盖率、展示文件和执行 100%/95% 门槛;未变更文件不会因为本次门禁被误判。HTML 报告也只列出变更文件。
|
|
285
|
+
|
|
286
|
+
它**不会自动筛选测试用例**。测试命令仍是项目在 `commands` 中配置的命令(通常是全量测试),CovGate 只负责覆盖率范围。测试命令自己决定跑哪些测试,才能避免漏掉依赖变更。
|
|
287
|
+
|
|
288
|
+
如果变更文件全部不在任何 scope 的 `sources.include` 内,CovGate 会失败而不是虚假通过。
|
|
289
|
+
|
|
269
290
|
## 配置参考
|
|
270
291
|
|
|
271
292
|
### profile
|
|
@@ -328,7 +349,7 @@ CI 只需要判断退出码是否为 0。
|
|
|
328
349
|
### reports
|
|
329
350
|
|
|
330
351
|
- `dir`:输出目录,默认 `.covgate`。
|
|
331
|
-
- `formats`:`json`、`markdown`。
|
|
352
|
+
- `formats`:`json`、`markdown`、`html`。
|
|
332
353
|
|
|
333
354
|
## 写豁免:不可达代码的唯一方式
|
|
334
355
|
|
|
@@ -435,13 +456,24 @@ stdout 是纯 JSON,代理可以读取 `status`、`scopes[].failingFiles`、`is
|
|
|
435
456
|
|
|
436
457
|
每次运行默认写:
|
|
437
458
|
|
|
438
|
-
- `.covgate/report.json`:机器可读,包含 scope
|
|
459
|
+
- `.covgate/report.json`:机器可读,包含 scope 指标、失败文件、逐行未覆盖、行命中、全部豁免、排除、命令、退出码。
|
|
439
460
|
- `.covgate/report.md`:人类/评审可读的审计报告。
|
|
461
|
+
- `.covgate/report.html`:自包含网页查看器;左侧文件列表,中间源码,已覆盖行绿色背景、未覆盖行白色背景、非可执行行灰色。
|
|
462
|
+
|
|
463
|
+
打开网页:
|
|
464
|
+
|
|
465
|
+
```powershell
|
|
466
|
+
start .covgate\report.html
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
macOS 用 `open .covgate/report.html`,Linux 用 `xdg-open .covgate/report.html`。
|
|
470
|
+
|
|
471
|
+

|
|
440
472
|
|
|
441
473
|
文本输出示例:
|
|
442
474
|
|
|
443
475
|
```text
|
|
444
|
-
CovGate · 覆盖率门禁 v0.2.
|
|
476
|
+
CovGate · 覆盖率门禁 v0.2.2
|
|
445
477
|
模式 执行测试并检查 · 配置 covgate.config.json · 用时 12.4s
|
|
446
478
|
|
|
447
479
|
✗ 客户端 [client/client] 未通过
|
|
@@ -451,6 +483,7 @@ CovGate · 覆盖率门禁 v0.2.1
|
|
|
451
483
|
|
|
452
484
|
结果: 未通过
|
|
453
485
|
JSON 报告: .covgate/report.json
|
|
486
|
+
HTML 报告: .covgate/report.html
|
|
454
487
|
```
|
|
455
488
|
|
|
456
489
|
建议把 `.covgate/` 加入 `.gitignore`。报告是本次运行的产物,不是源码。
|
|
Binary file
|
package/docs/design.md
CHANGED
package/docs/setup.md
CHANGED
|
@@ -9,7 +9,7 @@ npm install --save-dev covgate
|
|
|
9
9
|
npx --no-install covgate setup
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
包页面:[covgate](https://www.npmjs.com/package/covgate)。当前 latest 是 **0.2.
|
|
12
|
+
包页面:[covgate](https://www.npmjs.com/package/covgate)。当前 latest 是 **0.2.2**。
|
|
13
13
|
|
|
14
14
|
> `0.0.0-stage` 是 npm 暂存发布产生的占位版本,不会被 `npm install covgate` 选中,可以忽略。
|
|
15
15
|
|
|
@@ -38,7 +38,7 @@ npm 11.19+ 默认不执行依赖的 `postinstall`,会显示类似:
|
|
|
38
38
|
|
|
39
39
|
```text
|
|
40
40
|
npm warn install-scripts 1 package has install scripts not yet covered by allowScripts
|
|
41
|
-
npm warn install-scripts covgate@0.2.
|
|
41
|
+
npm warn install-scripts covgate@0.2.2 (postinstall: node bin/covgate-postinstall.js)
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
因此:
|
package/package.json
CHANGED
package/src/args.js
CHANGED
|
@@ -16,6 +16,8 @@ export const HELP_TEXT = [
|
|
|
16
16
|
" --config <path> 配置文件路径(默认 ./covgate.config.json)",
|
|
17
17
|
" --scope <name> 只检查指定 scope,可重复或用逗号分隔",
|
|
18
18
|
" --no-run 等同 check:不执行测试命令",
|
|
19
|
+
" --changed 只门禁/展示 Git 变更文件(默认对比 HEAD)",
|
|
20
|
+
" --base <rev> 配合 --changed,指定 Git 对比版本(如 HEAD^)",
|
|
19
21
|
" --json 向 stdout 输出纯 JSON(日志走 stderr)",
|
|
20
22
|
" --no-files 不写 .covgate/report.json 和 report.md",
|
|
21
23
|
" --report-dir <path> 覆盖报告输出目录(相对当前目录)",
|
|
@@ -61,6 +63,8 @@ export function parseArgs(argv) {
|
|
|
61
63
|
noRun: false,
|
|
62
64
|
noFiles: false,
|
|
63
65
|
reportDir: undefined,
|
|
66
|
+
changed: false,
|
|
67
|
+
base: undefined,
|
|
64
68
|
verbose: false,
|
|
65
69
|
color: undefined,
|
|
66
70
|
help: false,
|
|
@@ -137,6 +141,10 @@ export function parseArgs(argv) {
|
|
|
137
141
|
options.noRun = true;
|
|
138
142
|
} else if (key === "--no-files") {
|
|
139
143
|
options.noFiles = true;
|
|
144
|
+
} else if (key === "--changed") {
|
|
145
|
+
options.changed = true;
|
|
146
|
+
} else if (key === "--base") {
|
|
147
|
+
options.base = value();
|
|
140
148
|
} else if (key === "--report-dir") {
|
|
141
149
|
options.reportDir = value();
|
|
142
150
|
} else if (key === "--verbose") {
|
|
@@ -178,6 +186,12 @@ export function parseArgs(argv) {
|
|
|
178
186
|
if (options.command !== "setup" && (options.yes || options.profile || options.source || options.coverage || options.testCommand || options.noAgent || options.noConfig || options.force || options.dryRun || !options.installFramework)) {
|
|
179
187
|
throw new ConfigError("setup 相关选项只能和 covgate setup 一起使用");
|
|
180
188
|
}
|
|
189
|
+
if (options.base && !options.changed) {
|
|
190
|
+
throw new ConfigError("--base 必须和 --changed 一起使用");
|
|
191
|
+
}
|
|
192
|
+
if (options.changed && !["run", "check"].includes(options.command)) {
|
|
193
|
+
throw new ConfigError("--changed 只能用于 covgate run 或 covgate check");
|
|
194
|
+
}
|
|
181
195
|
return options;
|
|
182
196
|
}
|
|
183
197
|
|
package/src/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import { CovgateError, ExecutionError } from "./errors.js";
|
|
10
10
|
import { loadCoverage } from "./coverage/index.js";
|
|
11
11
|
import { gateScope } from "./gate.js";
|
|
12
|
+
import { changedFileKeys, collectChangedFiles } from "./git.js";
|
|
12
13
|
import {
|
|
13
14
|
expandCoverageReportFiles,
|
|
14
15
|
findStaleReports,
|
|
@@ -249,8 +250,8 @@ function printValidate(config, scopes, options) {
|
|
|
249
250
|
* @param {any} options
|
|
250
251
|
* @param {{ json?: string, markdown?: string }} paths
|
|
251
252
|
*/
|
|
252
|
-
async function emitReport(report, options, paths) {
|
|
253
|
-
const writeErrors = await writeReportArtifacts(report, paths);
|
|
253
|
+
async function emitReport(report, options, paths, config) {
|
|
254
|
+
const writeErrors = await writeReportArtifacts(report, paths, config);
|
|
254
255
|
for (const message of writeErrors) {
|
|
255
256
|
process.stderr.write("警告: " + message + "\n");
|
|
256
257
|
}
|
|
@@ -297,6 +298,8 @@ async function executeGate(context) {
|
|
|
297
298
|
const scopeResults = [];
|
|
298
299
|
const topLevelIssues = [];
|
|
299
300
|
const paths = plannedArtifactPaths(config, options.noFiles);
|
|
301
|
+
let changed = null;
|
|
302
|
+
let changedKeys = null;
|
|
300
303
|
|
|
301
304
|
const finish = async () => {
|
|
302
305
|
const report = buildReport({
|
|
@@ -307,9 +310,10 @@ async function executeGate(context) {
|
|
|
307
310
|
topLevelIssues,
|
|
308
311
|
durationMs: Date.now() - startedMs,
|
|
309
312
|
startedAt,
|
|
310
|
-
artifactPaths: paths
|
|
313
|
+
artifactPaths: paths,
|
|
314
|
+
changed
|
|
311
315
|
});
|
|
312
|
-
return emitReport(report, options, paths);
|
|
316
|
+
return emitReport(report, options, paths, config);
|
|
313
317
|
};
|
|
314
318
|
|
|
315
319
|
try {
|
|
@@ -321,6 +325,27 @@ async function executeGate(context) {
|
|
|
321
325
|
" · " +
|
|
322
326
|
relativeDisplay(config.configPath, config.root)
|
|
323
327
|
);
|
|
328
|
+
if (options.changed) {
|
|
329
|
+
changed = await collectChangedFiles({
|
|
330
|
+
root: config.root,
|
|
331
|
+
base: options.base || "HEAD"
|
|
332
|
+
});
|
|
333
|
+
if (changed.files.length === 0) {
|
|
334
|
+
throw new ExecutionError(
|
|
335
|
+
"没有检测到相对 " +
|
|
336
|
+
changed.base +
|
|
337
|
+
" 的新增/修改文件。请确认 Git 基线,或用 --base 指定版本。"
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
changedKeys = changedFileKeys(changed.files);
|
|
341
|
+
log.info(
|
|
342
|
+
"变更文件: " +
|
|
343
|
+
changed.relativeFiles.join(", ") +
|
|
344
|
+
"(base " +
|
|
345
|
+
changed.base +
|
|
346
|
+
")"
|
|
347
|
+
);
|
|
348
|
+
}
|
|
324
349
|
for (const scope of scopes) {
|
|
325
350
|
log.info("\n[" + scope.name + "] " + scope.label + " (" + scope.profile + ")");
|
|
326
351
|
/** @type {Map<string, { path: string, mtimeMs: number, size: number }>} */
|
|
@@ -376,13 +401,14 @@ async function executeGate(context) {
|
|
|
376
401
|
scope,
|
|
377
402
|
root: config.root,
|
|
378
403
|
coverage,
|
|
379
|
-
waiverPolicy: config.waivers
|
|
404
|
+
waiverPolicy: config.waivers,
|
|
405
|
+
changedKeys
|
|
380
406
|
});
|
|
381
407
|
const gated = gateScope(evaluated);
|
|
382
408
|
scopeResults.push(gated);
|
|
383
409
|
log.info(
|
|
384
410
|
" " +
|
|
385
|
-
(gated.passed ? "✓" : "✗") +
|
|
411
|
+
(gated.skipped ? "○" : gated.passed ? "✓" : "✗") +
|
|
386
412
|
" " +
|
|
387
413
|
METRICS.map((metric) => {
|
|
388
414
|
const value = gated.metrics[metric];
|
|
@@ -396,6 +422,15 @@ async function executeGate(context) {
|
|
|
396
422
|
}).join(" · ")
|
|
397
423
|
);
|
|
398
424
|
}
|
|
425
|
+
if (
|
|
426
|
+
options.changed &&
|
|
427
|
+
scopeResults.length > 0 &&
|
|
428
|
+
scopeResults.every((scope) => scope.skipped)
|
|
429
|
+
) {
|
|
430
|
+
throw new ExecutionError(
|
|
431
|
+
"变更文件不在任何 scope 的 sources.include 中,无法执行覆盖率门禁。"
|
|
432
|
+
);
|
|
433
|
+
}
|
|
399
434
|
return await finish();
|
|
400
435
|
} catch (error) {
|
|
401
436
|
const record = commandRecordFromError(error);
|
package/src/config.js
CHANGED
|
@@ -40,7 +40,7 @@ const WAIVER_KEYS = new Set([
|
|
|
40
40
|
const REPORT_KEYS = new Set(["dir", "formats"]);
|
|
41
41
|
const EXCLUDE_KEYS = new Set(["pattern", "reason", "owner", "expires", "ticket"]);
|
|
42
42
|
const COVERAGE_FORMATS = new Set(["auto", "istanbul", "lcov"]);
|
|
43
|
-
const REPORT_FORMATS = new Set(["json", "markdown"]);
|
|
43
|
+
const REPORT_FORMATS = new Set(["json", "markdown", "html"]);
|
|
44
44
|
const POLICIES = new Set(["fail", "warn"]);
|
|
45
45
|
|
|
46
46
|
/**
|
|
@@ -350,7 +350,7 @@ export function normalizeConfig(raw, context) {
|
|
|
350
350
|
}
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
-
const reports = { dir: ".covgate", formats: ["json", "markdown"] };
|
|
353
|
+
const reports = { dir: ".covgate", formats: ["json", "markdown", "html"] };
|
|
354
354
|
if (raw.reports !== undefined) {
|
|
355
355
|
assertObject(raw.reports, "reports", [...REPORT_KEYS]);
|
|
356
356
|
if (raw.reports.dir !== undefined) {
|
package/src/coverage/model.js
CHANGED
|
@@ -277,6 +277,9 @@ export function fileReport(file, limit = Number.POSITIVE_INFINITY) {
|
|
|
277
277
|
return {
|
|
278
278
|
path: file.relPath,
|
|
279
279
|
metrics: metricsForFile(file),
|
|
280
|
+
lineHits: Object.fromEntries(
|
|
281
|
+
[...file.lines].sort((left, right) => left[0] - right[0])
|
|
282
|
+
),
|
|
280
283
|
uncovered: {
|
|
281
284
|
lines: uncoveredLines.values,
|
|
282
285
|
linesTotal: uncoveredLines.total,
|
package/src/gate.js
CHANGED
|
@@ -41,6 +41,52 @@ function aggregate(files, metric) {
|
|
|
41
41
|
export function gateScope(scopeData) {
|
|
42
42
|
const issues = [...(scopeData.issues ?? [])];
|
|
43
43
|
const warnings = [...(scopeData.warnings ?? [])];
|
|
44
|
+
const initialErrorIssues = issues.filter((issue) => issue.severity === "error");
|
|
45
|
+
const thresholds = Object.fromEntries(
|
|
46
|
+
METRICS.map((metric) => [metric, scopeData.thresholds[metric].value])
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
if (scopeData.skipped) {
|
|
50
|
+
const metrics = Object.fromEntries(
|
|
51
|
+
METRICS.map((metric) => [
|
|
52
|
+
metric,
|
|
53
|
+
{
|
|
54
|
+
metric,
|
|
55
|
+
applicable: false,
|
|
56
|
+
covered: 0,
|
|
57
|
+
total: 0,
|
|
58
|
+
percent: null,
|
|
59
|
+
threshold: thresholds[metric],
|
|
60
|
+
thresholdBasisPoints: scopeData.thresholds[metric].basisPoints,
|
|
61
|
+
passed: true,
|
|
62
|
+
note: "本次 Git 变更未涉及该 scope"
|
|
63
|
+
}
|
|
64
|
+
])
|
|
65
|
+
);
|
|
66
|
+
return {
|
|
67
|
+
name: scopeData.name,
|
|
68
|
+
label: scopeData.label,
|
|
69
|
+
profile: scopeData.profile,
|
|
70
|
+
format: scopeData.format,
|
|
71
|
+
passed: initialErrorIssues.length === 0,
|
|
72
|
+
skipped: true,
|
|
73
|
+
changedOnly: Boolean(scopeData.changedOnly),
|
|
74
|
+
thresholds,
|
|
75
|
+
metrics,
|
|
76
|
+
files: [],
|
|
77
|
+
failingFiles: [],
|
|
78
|
+
expectedSourceCount: 0,
|
|
79
|
+
reportedFileCount: scopeData.reportedFileCount,
|
|
80
|
+
missingSources: [],
|
|
81
|
+
extras: [],
|
|
82
|
+
waivers: scopeData.waivers,
|
|
83
|
+
exclusions: scopeData.exclusions,
|
|
84
|
+
issues,
|
|
85
|
+
warnings,
|
|
86
|
+
reasons: errorIssues.map((issue) => issue.message)
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
44
90
|
const fileReports = scopeData.files.map((file) => fileReport(file));
|
|
45
91
|
const metrics = {};
|
|
46
92
|
|
|
@@ -112,16 +158,14 @@ export function gateScope(scopeData) {
|
|
|
112
158
|
return leftPercent - rightPercent || left.path.localeCompare(right.path, "en");
|
|
113
159
|
});
|
|
114
160
|
|
|
115
|
-
const thresholds = Object.fromEntries(
|
|
116
|
-
METRICS.map((metric) => [metric, scopeData.thresholds[metric].value])
|
|
117
|
-
);
|
|
118
|
-
|
|
119
161
|
const result = {
|
|
120
162
|
name: scopeData.name,
|
|
121
163
|
label: scopeData.label,
|
|
122
164
|
profile: scopeData.profile,
|
|
123
165
|
format: scopeData.format,
|
|
124
166
|
passed,
|
|
167
|
+
skipped: false,
|
|
168
|
+
changedOnly: Boolean(scopeData.changedOnly),
|
|
125
169
|
thresholds,
|
|
126
170
|
metrics,
|
|
127
171
|
files: fileReports,
|
package/src/git.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { ConfigError } from "./errors.js";
|
|
6
|
+
import { canonicalPath, relativeDisplay } from "./util.js";
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} cwd
|
|
12
|
+
* @param {string[]} args
|
|
13
|
+
*/
|
|
14
|
+
async function git(cwd, args) {
|
|
15
|
+
try {
|
|
16
|
+
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
|
|
17
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
18
|
+
windowsHide: true
|
|
19
|
+
});
|
|
20
|
+
return stdout;
|
|
21
|
+
} catch (error) {
|
|
22
|
+
throw new ConfigError(
|
|
23
|
+
"Git 命令失败:" + (error.stderr?.toString().trim() || error.message),
|
|
24
|
+
{ cause: error }
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Collect added/copied/modified/renamed files between a base revision and the
|
|
31
|
+
* current working tree. Untracked files are included because AI agents often
|
|
32
|
+
* add new files before staging them.
|
|
33
|
+
*
|
|
34
|
+
* @param {{ root: string, base?: string }} options
|
|
35
|
+
*/
|
|
36
|
+
export async function collectChangedFiles({ root, base = "HEAD" }) {
|
|
37
|
+
const projectRoot = path.resolve(root);
|
|
38
|
+
const gitRoot = (await git(projectRoot, ["rev-parse", "--show-toplevel"]))
|
|
39
|
+
.trim()
|
|
40
|
+
.replaceAll("\\", "/");
|
|
41
|
+
if (!gitRoot) throw new ConfigError("当前目录不在 Git 仓库中,不能使用 --changed。");
|
|
42
|
+
try {
|
|
43
|
+
await git(projectRoot, ["rev-parse", "--verify", base + "^{commit}"]);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw new ConfigError('找不到 Git base 版本 "' + base + '"。', { cause: error });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const tracked = (
|
|
49
|
+
await git(projectRoot, [
|
|
50
|
+
"diff",
|
|
51
|
+
"--name-only",
|
|
52
|
+
"--diff-filter=ACMR",
|
|
53
|
+
"-M",
|
|
54
|
+
base,
|
|
55
|
+
"--"
|
|
56
|
+
])
|
|
57
|
+
)
|
|
58
|
+
.split(/\r?\n/)
|
|
59
|
+
.map((line) => line.trim())
|
|
60
|
+
.filter(Boolean);
|
|
61
|
+
const untracked = (
|
|
62
|
+
await git(projectRoot, ["ls-files", "--others", "--exclude-standard"])
|
|
63
|
+
)
|
|
64
|
+
.split(/\r?\n/)
|
|
65
|
+
.map((line) => line.trim())
|
|
66
|
+
.filter(Boolean);
|
|
67
|
+
|
|
68
|
+
const all = [...new Set([...tracked, ...untracked])]
|
|
69
|
+
.map((relative) => path.resolve(gitRoot, relative))
|
|
70
|
+
.filter((absolute) => existsSync(absolute))
|
|
71
|
+
.sort((left, right) => left.localeCompare(right, "en"));
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
base,
|
|
75
|
+
gitRoot,
|
|
76
|
+
files: all,
|
|
77
|
+
relativeFiles: all.map((file) => relativeDisplay(file, projectRoot))
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {string[]} files
|
|
83
|
+
*/
|
|
84
|
+
export function changedFileKeys(files) {
|
|
85
|
+
return new Set(files.map((file) => canonicalPath(file)));
|
|
86
|
+
}
|
package/src/index.js
CHANGED
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/config.js
CHANGED