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 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.1**。
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
- | `--no-files` | 不写 `.covgate/report.json` / `report.md`。 |
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
+ ![CovGate 覆盖率查看页:左侧文件列表,中间源码,绿色已覆盖、白色未覆盖](docs/coverage-viewer.png)
440
472
 
441
473
  文本输出示例:
442
474
 
443
475
  ```text
444
- CovGate · 覆盖率门禁 v0.2.1
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
@@ -69,7 +69,7 @@ covgate.config.json
69
69
  └─ 不通过 ─▶ 1
70
70
  │
71
71
  ▼
72
- [写 report.json / report.md]
72
+ [写 report.json / report.md / report.html]
73
73
  ```
74
74
 
75
75
  ## 4. 配置与信任边界
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.1**。
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.1 (postinstall: node bin/covgate-postinstall.js)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "covgate",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "覆盖率门禁:客户端 100%,服务端至少 95%,安装时自动检测/配置测试框架与 AI 编码代理接入。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -281,12 +281,14 @@
281
281
  "items": {
282
282
  "enum": [
283
283
  "json",
284
- "markdown"
284
+ "markdown",
285
+ "html"
285
286
  ]
286
287
  },
287
288
  "default": [
288
289
  "json",
289
- "markdown"
290
+ "markdown",
291
+ "html"
290
292
  ]
291
293
  }
292
294
  }
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) {
@@ -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
@@ -20,6 +20,7 @@ export { gateAll, gateScope } from "./gate.js";
20
20
  export {
21
21
  buildReport,
22
22
  plannedArtifactPaths,
23
+ renderHtmlReport,
23
24
  renderMarkdownReport,
24
25
  renderTextReport,
25
26
  writeReportArtifacts
@@ -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
+ "&": "&amp;",
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
  }
@@ -108,7 +108,7 @@ export async function buildStarterConfig({
108
108
  },
109
109
  reports: {
110
110
  dir: ".covgate",
111
- formats: ["json", "markdown"]
111
+ formats: ["json", "markdown", "html"]
112
112
  }
113
113
  };
114
114
  }
@@ -92,7 +92,8 @@
92
92
  "dir": ".covgate",
93
93
  "formats": [
94
94
  "json",
95
- "markdown"
95
+ "markdown",
96
+ "html"
96
97
  ]
97
98
  }
98
99
  }