proof-pr 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +43 -17
  2. package/dist/index.js +621 -5
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,41 +1,67 @@
1
1
  # proof-pr
2
2
 
3
- ProofPR 的命令行工具。
3
+ ProofPR 是给开源维护者和工程团队使用的 PR 证据门禁。它在投入深度 review 之前,先检查 PR 是否提供了足够证据:测试、复现、截图、changelog、权限理由,以及是否触碰敏感路径、依赖、workflow、MCP 或 secret 风险。
4
4
 
5
- ProofPR 帮助维护者在投入深入 review 之前,先检查 PR 的证据、范围和安全风险。报告会输出风险等级、0-100 证据评分,以及 Review 门禁建议。
5
+ 它不依赖大模型,不上传代码,只基于 diff、PR 描述和配置做确定性判断。
6
6
 
7
- ## 它什么时候运行?
7
+ ## 快速使用
8
8
 
9
- 作为 GitHub Action 使用时,ProofPR 默认在 PR 打开、PR 分支更新、PR 重新打开时运行。普通分支 push 不会单独生成报告。
9
+ 初始化配置和 GitHub Action
10
10
 
11
- 报告会出现在 PR 评论区、GitHub Actions job summary 和 PR checks 状态里。
12
- `v0.1.5` 起还可以输出 GitHub annotations,并通过 `sarif-output` 写出 SARIF 文件。当前版本还会识别依赖大版本升级、包生命周期脚本和 `pull_request_target` workflow 触发器。
13
-
14
- ## 使用
11
+ ```bash
12
+ npx proof-pr@latest init --preset open-source-maintainer
13
+ ```
15
14
 
16
- 可以直接通过 npm 使用:
15
+ 本地扫描当前分支:
17
16
 
18
17
  ```bash
19
- npx proof-pr@latest init
20
- npx proof-pr@latest init --preset security-strict
21
- npx proof-pr@latest scan --base origin/main --head HEAD
22
18
  npx proof-pr@latest scan --base origin/main --head HEAD --locale zh-CN
23
- npx proof-pr@latest scan --base origin/main --pr-body-file pr-body.md --format json
24
- npx proof-pr@latest benchmark --cases benchmarks/cases
25
19
  ```
26
20
 
27
- 可用预设:`balanced`、`open-source-maintainer`、`security-strict`、`ai-generated-pr`、`mcp-security`、`dependency-careful`。
21
+ 扫描内置案例:
22
+
23
+ ```bash
24
+ npx proof-pr@latest scan --diff-file examples/cases/workflow-untrusted-checkout.diff --locale zh-CN
25
+ ```
26
+
27
+ 生成独立 HTML 可视化报告:
28
+
29
+ ```bash
30
+ npx proof-pr@latest scan --base origin/main --head HEAD --locale zh-CN --format html > proofpr-report.html
31
+ ```
32
+
33
+ 运行 benchmark:
34
+
35
+ ```bash
36
+ npx proof-pr@latest benchmark --cases benchmarks/cases
37
+ ```
28
38
 
29
39
  ## GitHub Action
30
40
 
31
41
  ```yaml
32
- - uses: linsk27/proof-pr@v0.1.5
42
+ - uses: linsk27/proof-pr@v0.1.9
33
43
  with:
34
44
  fail-on: high
35
45
  comment: "true"
36
46
  annotations: "true"
37
47
  ```
38
48
 
39
- 完整文档见仓库 README:
49
+ ## 输出什么
50
+
51
+ - 风险等级:`low`、`medium`、`high`。
52
+ - 证据评分:0-100 分。
53
+ - Review 门禁:正常 review、重点 review、先补证据、风险处理前不要合并。
54
+ - Review 行动清单:维护者可直接执行的 checklist。
55
+ - 可选输出:GitHub annotations、SARIF、benchmark report、独立 HTML 可视化报告。
56
+
57
+ ## 常用预设
58
+
59
+ - `open-source-maintainer`:开源仓库推荐。
60
+ - `security-strict`:安全敏感项目。
61
+ - `ai-generated-pr`:AI 生成 PR 较多的仓库。
62
+ - `mcp-security`:关注 MCP / agent 配置。
63
+ - `dependency-careful`:关注依赖和锁文件变化。
64
+
65
+ 完整中文文档、截图和从 0 到 1 教程见仓库 README:
40
66
 
41
67
  https://github.com/linsk27/proof-pr
package/dist/index.js CHANGED
@@ -23371,6 +23371,434 @@ function renderMarkdownReport(result, locale = "en") {
23371
23371
  }
23372
23372
  return renderEnglishMarkdownReport(result);
23373
23373
  }
23374
+ function renderHtmlReport(result, locale = "en") {
23375
+ const labels = htmlLabels(locale);
23376
+ const risk = locale === "zh-CN" ? translateRisk(result.risk) : result.risk;
23377
+ const decision = formatReviewDecision(result.reviewDecision, locale);
23378
+ const scoreGrade = formatEvidenceGrade(result.evidenceScore.grade, locale);
23379
+ const findingsBySeverity = countFindingsBySeverity(result.findings);
23380
+ const ruleCounts = countFindingsByRule(result.findings);
23381
+ const evidenceSignals = [
23382
+ [labels.prDescription, locale === "zh-CN" ? translateDescriptionState(result.summary.pullRequestDescription) : result.summary.pullRequestDescription, result.summary.pullRequestDescription === "present"],
23383
+ [labels.verification, yesNo(result.summary.verificationEvidence, locale), result.summary.verificationEvidence],
23384
+ [labels.reproduction, yesNo(result.summary.reproductionEvidence, locale), result.summary.reproductionEvidence],
23385
+ [labels.screenshot, yesNo(result.summary.screenshotEvidence, locale), result.summary.screenshotEvidence],
23386
+ [labels.changelog, yesNo(result.summary.changelogEvidence, locale), result.summary.changelogEvidence],
23387
+ [labels.permissionRationale, yesNo(result.summary.permissionRationaleEvidence, locale), result.summary.permissionRationaleEvidence]
23388
+ ];
23389
+ return `<!doctype html>
23390
+ <html lang="${locale === "zh-CN" ? "zh-CN" : "en"}">
23391
+ <head>
23392
+ <meta charset="utf-8">
23393
+ <meta name="viewport" content="width=device-width, initial-scale=1">
23394
+ <title>ProofPR ${labels.report}</title>
23395
+ <style>
23396
+ :root {
23397
+ color-scheme: light;
23398
+ --bg: #f6f7f9;
23399
+ --panel: #ffffff;
23400
+ --ink: #17202a;
23401
+ --muted: #667085;
23402
+ --line: #d9dee7;
23403
+ --green: #138a5e;
23404
+ --amber: #b7791f;
23405
+ --red: #c24135;
23406
+ --blue: #2563a9;
23407
+ --soft-green: #e8f6ef;
23408
+ --soft-amber: #fff3d6;
23409
+ --soft-red: #fdebea;
23410
+ --soft-blue: #eaf2fb;
23411
+ }
23412
+
23413
+ * { box-sizing: border-box; }
23414
+
23415
+ body {
23416
+ margin: 0;
23417
+ background: var(--bg);
23418
+ color: var(--ink);
23419
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
23420
+ line-height: 1.5;
23421
+ }
23422
+
23423
+ main {
23424
+ width: min(1180px, calc(100vw - 32px));
23425
+ margin: 0 auto;
23426
+ padding: 32px 0 48px;
23427
+ }
23428
+
23429
+ .topbar {
23430
+ display: flex;
23431
+ justify-content: space-between;
23432
+ gap: 18px;
23433
+ align-items: flex-start;
23434
+ margin-bottom: 20px;
23435
+ }
23436
+
23437
+ h1, h2, h3, p { margin: 0; }
23438
+
23439
+ h1 {
23440
+ font-size: 28px;
23441
+ line-height: 1.2;
23442
+ }
23443
+
23444
+ h2 {
23445
+ font-size: 17px;
23446
+ margin-bottom: 14px;
23447
+ }
23448
+
23449
+ h3 {
23450
+ font-size: 15px;
23451
+ margin-bottom: 8px;
23452
+ }
23453
+
23454
+ .subtitle {
23455
+ color: var(--muted);
23456
+ margin-top: 8px;
23457
+ max-width: 760px;
23458
+ }
23459
+
23460
+ .pill {
23461
+ display: inline-flex;
23462
+ align-items: center;
23463
+ border: 1px solid var(--line);
23464
+ border-radius: 999px;
23465
+ padding: 5px 10px;
23466
+ background: var(--panel);
23467
+ color: var(--muted);
23468
+ font-size: 13px;
23469
+ white-space: nowrap;
23470
+ }
23471
+
23472
+ .grid {
23473
+ display: grid;
23474
+ grid-template-columns: repeat(12, 1fr);
23475
+ gap: 14px;
23476
+ }
23477
+
23478
+ .card {
23479
+ background: var(--panel);
23480
+ border: 1px solid var(--line);
23481
+ border-radius: 8px;
23482
+ padding: 18px;
23483
+ box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
23484
+ }
23485
+
23486
+ .metric { grid-column: span 3; }
23487
+ .wide { grid-column: span 8; }
23488
+ .side { grid-column: span 4; }
23489
+ .full { grid-column: 1 / -1; }
23490
+
23491
+ .metric-label {
23492
+ color: var(--muted);
23493
+ font-size: 13px;
23494
+ margin-bottom: 8px;
23495
+ }
23496
+
23497
+ .metric-value {
23498
+ font-size: 27px;
23499
+ font-weight: 720;
23500
+ line-height: 1.1;
23501
+ }
23502
+
23503
+ .tone-low { color: var(--green); background: var(--soft-green); border-color: #b8e5cf; }
23504
+ .tone-medium { color: var(--amber); background: var(--soft-amber); border-color: #f1d28a; }
23505
+ .tone-high { color: var(--red); background: var(--soft-red); border-color: #f3b6b1; }
23506
+
23507
+ .scorebar {
23508
+ width: 100%;
23509
+ height: 16px;
23510
+ border: 1px solid var(--line);
23511
+ border-radius: 999px;
23512
+ overflow: hidden;
23513
+ margin: 14px 0 10px;
23514
+ background: #eef1f5;
23515
+ }
23516
+
23517
+ .scorefill {
23518
+ height: 100%;
23519
+ width: ${result.evidenceScore.value}%;
23520
+ background: ${scoreColor(result.evidenceScore.value)};
23521
+ }
23522
+
23523
+ .summary-grid {
23524
+ display: grid;
23525
+ grid-template-columns: repeat(4, minmax(0, 1fr));
23526
+ gap: 10px;
23527
+ }
23528
+
23529
+ .summary-item {
23530
+ border: 1px solid var(--line);
23531
+ border-radius: 8px;
23532
+ padding: 10px 12px;
23533
+ background: #fbfcfd;
23534
+ }
23535
+
23536
+ .summary-item strong {
23537
+ display: block;
23538
+ font-size: 20px;
23539
+ margin-bottom: 2px;
23540
+ }
23541
+
23542
+ .summary-item span {
23543
+ color: var(--muted);
23544
+ font-size: 12px;
23545
+ }
23546
+
23547
+ .signal-list, .action-list, .finding-list, .focus-list, .deduction-list, .rule-list {
23548
+ display: grid;
23549
+ gap: 10px;
23550
+ }
23551
+
23552
+ .signal, .action, .focus, .deduction, .rule-row {
23553
+ border: 1px solid var(--line);
23554
+ border-radius: 8px;
23555
+ padding: 10px 12px;
23556
+ background: #fbfcfd;
23557
+ }
23558
+
23559
+ .signal {
23560
+ display: flex;
23561
+ justify-content: space-between;
23562
+ gap: 12px;
23563
+ align-items: center;
23564
+ }
23565
+
23566
+ .signal-name, .action-title, .finding-title {
23567
+ font-weight: 680;
23568
+ }
23569
+
23570
+ .signal-state {
23571
+ font-size: 12px;
23572
+ border-radius: 999px;
23573
+ padding: 3px 8px;
23574
+ border: 1px solid var(--line);
23575
+ white-space: nowrap;
23576
+ }
23577
+
23578
+ .severity-grid {
23579
+ display: grid;
23580
+ grid-template-columns: repeat(4, minmax(0, 1fr));
23581
+ gap: 8px;
23582
+ }
23583
+
23584
+ .severity {
23585
+ border: 1px solid var(--line);
23586
+ border-radius: 8px;
23587
+ padding: 10px;
23588
+ background: #fbfcfd;
23589
+ }
23590
+
23591
+ .severity strong {
23592
+ display: block;
23593
+ font-size: 22px;
23594
+ }
23595
+
23596
+ .muted {
23597
+ color: var(--muted);
23598
+ font-size: 13px;
23599
+ }
23600
+
23601
+ .action {
23602
+ display: grid;
23603
+ grid-template-columns: auto 1fr;
23604
+ gap: 10px;
23605
+ }
23606
+
23607
+ .box {
23608
+ width: 18px;
23609
+ height: 18px;
23610
+ border: 2px solid var(--blue);
23611
+ border-radius: 4px;
23612
+ margin-top: 2px;
23613
+ }
23614
+
23615
+ .priority {
23616
+ display: inline-flex;
23617
+ margin-left: 6px;
23618
+ color: var(--muted);
23619
+ font-size: 12px;
23620
+ font-weight: 560;
23621
+ }
23622
+
23623
+ .finding {
23624
+ border: 1px solid var(--line);
23625
+ border-radius: 8px;
23626
+ padding: 14px;
23627
+ background: #fff;
23628
+ }
23629
+
23630
+ .finding-head {
23631
+ display: flex;
23632
+ justify-content: space-between;
23633
+ gap: 12px;
23634
+ align-items: flex-start;
23635
+ margin-bottom: 8px;
23636
+ }
23637
+
23638
+ code {
23639
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
23640
+ font-size: 12px;
23641
+ background: #f0f3f7;
23642
+ border: 1px solid var(--line);
23643
+ border-radius: 6px;
23644
+ padding: 2px 5px;
23645
+ word-break: break-word;
23646
+ }
23647
+
23648
+ .evidence-list {
23649
+ margin: 10px 0 0;
23650
+ padding-left: 18px;
23651
+ color: var(--muted);
23652
+ }
23653
+
23654
+ .footer {
23655
+ color: var(--muted);
23656
+ font-size: 12px;
23657
+ margin-top: 18px;
23658
+ text-align: center;
23659
+ }
23660
+
23661
+ @media (max-width: 860px) {
23662
+ main { width: min(100vw - 20px, 1180px); padding-top: 20px; }
23663
+ .topbar { display: block; }
23664
+ .pill { margin-top: 12px; }
23665
+ .metric, .wide, .side { grid-column: 1 / -1; }
23666
+ .summary-grid, .severity-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
23667
+ }
23668
+ </style>
23669
+ </head>
23670
+ <body>
23671
+ <main>
23672
+ <section class="topbar">
23673
+ <div>
23674
+ <h1>ProofPR ${labels.report}</h1>
23675
+ <p class="subtitle">${labels.subtitle}</p>
23676
+ </div>
23677
+ <span class="pill">${labels.generated}</span>
23678
+ </section>
23679
+
23680
+ <section class="grid">
23681
+ <article class="card metric">
23682
+ <div class="metric-label">${labels.risk}</div>
23683
+ <div class="metric-value">${escapeHtml(risk)}</div>
23684
+ <span class="pill tone-${result.risk}">${escapeHtml(result.risk)}</span>
23685
+ </article>
23686
+ <article class="card metric">
23687
+ <div class="metric-label">${labels.evidenceScore}</div>
23688
+ <div class="metric-value">${result.evidenceScore.value}/100</div>
23689
+ <div class="scorebar" aria-label="${labels.evidenceScore}">
23690
+ <div class="scorefill"></div>
23691
+ </div>
23692
+ <div class="muted">${escapeHtml(scoreGrade)}</div>
23693
+ </article>
23694
+ <article class="card metric">
23695
+ <div class="metric-label">${labels.reviewGate}</div>
23696
+ <div class="metric-value" style="font-size: 20px;">${escapeHtml(decision)}</div>
23697
+ </article>
23698
+ <article class="card metric">
23699
+ <div class="metric-label">${labels.findings}</div>
23700
+ <div class="metric-value">${result.findings.length}</div>
23701
+ <div class="muted">${labels.findingsHint}</div>
23702
+ </article>
23703
+
23704
+ <article class="card wide">
23705
+ <h2>${labels.changeSummary}</h2>
23706
+ <div class="summary-grid">
23707
+ ${summaryItem(labels.filesChanged, result.summary.filesChanged)}
23708
+ ${summaryItem(labels.additions, result.summary.additions)}
23709
+ ${summaryItem(labels.deletions, result.summary.deletions)}
23710
+ ${summaryItem(labels.sensitiveFiles, result.summary.sensitiveFilesChanged)}
23711
+ ${summaryItem(labels.testFiles, result.summary.testFilesChanged)}
23712
+ ${summaryItem(labels.highFindings, findingsBySeverity.high)}
23713
+ ${summaryItem(labels.mediumFindings, findingsBySeverity.medium)}
23714
+ ${summaryItem(labels.lowFindings, findingsBySeverity.low)}
23715
+ </div>
23716
+ </article>
23717
+
23718
+ <article class="card side">
23719
+ <h2>${labels.evidenceSignals}</h2>
23720
+ <div class="signal-list">
23721
+ ${evidenceSignals.map(([name, state, ok]) => signalItem(name, state, ok)).join("\n")}
23722
+ </div>
23723
+ </article>
23724
+
23725
+ <article class="card wide">
23726
+ <h2>${labels.reviewPlan}</h2>
23727
+ <div class="action-list">
23728
+ ${result.reviewPlan.actionItems.length > 0
23729
+ ? result.reviewPlan.actionItems.map((action) => `
23730
+ <div class="action">
23731
+ <span class="box"></span>
23732
+ <div>
23733
+ <div class="action-title">${escapeHtml(localizeActionTitle(action.actionId, action.title, locale))}<span class="priority">${escapeHtml(formatPriority(action.priority, locale))}</span></div>
23734
+ <div class="muted">${escapeHtml(localizeActionDetail(action.actionId, action.detail, locale))}</div>
23735
+ </div>
23736
+ </div>`).join("\n")
23737
+ : `<div class="muted">${labels.noActions}</div>`}
23738
+ </div>
23739
+ </article>
23740
+
23741
+ <article class="card side">
23742
+ <h2>${labels.findingDistribution}</h2>
23743
+ <div class="severity-grid">
23744
+ ${severityItem("high", findingsBySeverity.high, labels.high)}
23745
+ ${severityItem("medium", findingsBySeverity.medium, labels.medium)}
23746
+ ${severityItem("low", findingsBySeverity.low, labels.low)}
23747
+ ${severityItem("info", findingsBySeverity.info, labels.info)}
23748
+ </div>
23749
+ </article>
23750
+
23751
+ <article class="card side">
23752
+ <h2>${labels.focusFiles}</h2>
23753
+ <div class="focus-list">
23754
+ ${result.reviewPlan.focusFiles.length > 0
23755
+ ? result.reviewPlan.focusFiles.map((file) => `
23756
+ <div class="focus">
23757
+ <div><code>${escapeHtml(file.path)}</code></div>
23758
+ <div class="muted">${escapeHtml(localizeFocusReason(file.reasonId, file.reason, locale))}</div>
23759
+ </div>`).join("\n")
23760
+ : `<div class="muted">${labels.noFocusFiles}</div>`}
23761
+ </div>
23762
+ </article>
23763
+
23764
+ <article class="card side">
23765
+ <h2>${labels.scoreDetails}</h2>
23766
+ <div class="deduction-list">
23767
+ ${result.evidenceScore.deductions.length > 0
23768
+ ? result.evidenceScore.deductions.map((deduction) => `
23769
+ <div class="deduction">
23770
+ <strong>-${deduction.points}</strong>
23771
+ <div class="muted">${escapeHtml(localizeDeduction(deduction.reasonId, deduction.message, locale))}</div>
23772
+ </div>`).join("\n")
23773
+ : `<div class="muted">${labels.noDeductions}</div>`}
23774
+ </div>
23775
+ </article>
23776
+
23777
+ <article class="card full">
23778
+ <h2>${labels.rulesCovered}</h2>
23779
+ <div class="rule-list">
23780
+ ${ruleCounts.length > 0
23781
+ ? ruleCounts.map((item) => `<div class="rule-row"><code>${escapeHtml(item.ruleId)}</code> <span class="muted">${item.count}</span></div>`).join("\n")
23782
+ : `<div class="muted">${labels.noRules}</div>`}
23783
+ </div>
23784
+ </article>
23785
+
23786
+ <article class="card full">
23787
+ <h2>${labels.findings}</h2>
23788
+ <div class="finding-list">
23789
+ ${result.findings.length > 0
23790
+ ? result.findings.map((finding) => htmlFinding(finding, locale)).join("\n")
23791
+ : `<div class="muted">${labels.noFindings}</div>`}
23792
+ </div>
23793
+ </article>
23794
+ </section>
23795
+
23796
+ <p class="footer">${labels.footer}</p>
23797
+ </main>
23798
+ </body>
23799
+ </html>
23800
+ `;
23801
+ }
23374
23802
  function getReportMarker() {
23375
23803
  return REPORT_MARKER;
23376
23804
  }
@@ -23906,6 +24334,191 @@ function translateDeduction(reasonId, fallback) {
23906
24334
  "missing-tests": "代码发生变更,但缺少测试变更或验证说明。"
23907
24335
  }[reasonId] ?? fallback;
23908
24336
  }
24337
+ function htmlLabels(locale) {
24338
+ if (locale === "zh-CN") {
24339
+ return {
24340
+ report: "可视化报告",
24341
+ subtitle: "把 PR 风险、证据质量、Review 门禁和维护者行动清单整理成一个可分享的静态页面。",
24342
+ generated: "Generated by ProofPR",
24343
+ risk: "风险等级",
24344
+ evidenceScore: "证据评分",
24345
+ reviewGate: "Review 门禁",
24346
+ findings: "风险发现",
24347
+ findingsHint: "需要维护者优先关注的信号",
24348
+ changeSummary: "改动概览",
24349
+ filesChanged: "改动文件",
24350
+ additions: "新增行",
24351
+ deletions: "删除行",
24352
+ sensitiveFiles: "敏感文件",
24353
+ testFiles: "测试文件",
24354
+ highFindings: "高风险",
24355
+ mediumFindings: "中风险",
24356
+ lowFindings: "低风险",
24357
+ evidenceSignals: "证据信号",
24358
+ prDescription: "PR 描述",
24359
+ verification: "验证证据",
24360
+ reproduction: "复现上下文",
24361
+ screenshot: "截图证据",
24362
+ changelog: "Changelog",
24363
+ permissionRationale: "权限理由",
24364
+ reviewPlan: "Review 行动清单",
24365
+ noActions: "没有额外行动项。",
24366
+ findingDistribution: "Finding 分布",
24367
+ high: "高",
24368
+ medium: "中",
24369
+ low: "低",
24370
+ info: "信息",
24371
+ focusFiles: "重点文件",
24372
+ noFocusFiles: "没有重点文件。",
24373
+ scoreDetails: "证据扣分",
24374
+ noDeductions: "没有扣分项。",
24375
+ rulesCovered: "命中规则",
24376
+ noRules: "没有规则命中。",
24377
+ noFindings: "启用的规则没有发现需要优先关注的 review 风险。",
24378
+ rule: "规则",
24379
+ severity: "严重程度",
24380
+ path: "路径",
24381
+ detail: "详情",
24382
+ evidence: "证据",
24383
+ recommendation: "建议",
24384
+ footer: "ProofPR 不替代人工 review,它帮助维护者先判断证据是否足够、风险边界是否清楚。"
24385
+ };
24386
+ }
24387
+ return {
24388
+ report: "Visual Report",
24389
+ subtitle: "A shareable static view of PR risk, evidence quality, review gate, and maintainer actions.",
24390
+ generated: "Generated by ProofPR",
24391
+ risk: "Risk",
24392
+ evidenceScore: "Evidence score",
24393
+ reviewGate: "Review gate",
24394
+ findings: "Findings",
24395
+ findingsHint: "Signals that deserve maintainer attention",
24396
+ changeSummary: "Change summary",
24397
+ filesChanged: "Files changed",
24398
+ additions: "Additions",
24399
+ deletions: "Deletions",
24400
+ sensitiveFiles: "Sensitive files",
24401
+ testFiles: "Test files",
24402
+ highFindings: "High findings",
24403
+ mediumFindings: "Medium findings",
24404
+ lowFindings: "Low findings",
24405
+ evidenceSignals: "Evidence signals",
24406
+ prDescription: "PR description",
24407
+ verification: "Verification",
24408
+ reproduction: "Reproduction",
24409
+ screenshot: "Screenshot",
24410
+ changelog: "Changelog",
24411
+ permissionRationale: "Permission rationale",
24412
+ reviewPlan: "Review plan",
24413
+ noActions: "No additional action items.",
24414
+ findingDistribution: "Finding distribution",
24415
+ high: "High",
24416
+ medium: "Medium",
24417
+ low: "Low",
24418
+ info: "Info",
24419
+ focusFiles: "Focus files",
24420
+ noFocusFiles: "No focus files.",
24421
+ scoreDetails: "Evidence deductions",
24422
+ noDeductions: "No deductions.",
24423
+ rulesCovered: "Rules covered",
24424
+ noRules: "No rule hits.",
24425
+ noFindings: "No review-risk findings detected by the enabled rules.",
24426
+ rule: "Rule",
24427
+ severity: "Severity",
24428
+ path: "Path",
24429
+ detail: "Detail",
24430
+ evidence: "Evidence",
24431
+ recommendation: "Recommendation",
24432
+ footer: "ProofPR does not replace human review. It helps maintainers decide whether evidence is enough and risk boundaries are clear."
24433
+ };
24434
+ }
24435
+ function summaryItem(label, value) {
24436
+ return `<div class="summary-item"><strong>${value}</strong><span>${escapeHtml(label)}</span></div>`;
24437
+ }
24438
+ function signalItem(name, state, ok) {
24439
+ return `<div class="signal"><span class="signal-name">${escapeHtml(name)}</span><span class="signal-state ${ok ? "tone-low" : "tone-medium"}">${escapeHtml(state)}</span></div>`;
24440
+ }
24441
+ function severityItem(severity, value, label) {
24442
+ return `<div class="severity ${severity === "high" ? "tone-high" : severity === "medium" ? "tone-medium" : severity === "low" ? "tone-low" : ""}"><strong>${value}</strong><span>${escapeHtml(label)}</span></div>`;
24443
+ }
24444
+ function htmlFinding(finding, locale) {
24445
+ const labels = htmlLabels(locale);
24446
+ const translated = locale === "zh-CN" ? translateFinding(finding) : finding;
24447
+ const evidence = finding.evidence && finding.evidence.length > 0
24448
+ ? `<ul class="evidence-list">${finding.evidence
24449
+ .map((item) => `<li><code>${escapeHtml(locale === "zh-CN" ? translateEvidence(item) : item)}</code></li>`)
24450
+ .join("")}</ul>`
24451
+ : "";
24452
+ const path = finding.path
24453
+ ? `<div class="muted">${labels.path}: <code>${escapeHtml(finding.path)}</code></div>`
24454
+ : "";
24455
+ const recommendation = translated.recommendation
24456
+ ? `<div class="muted">${labels.recommendation}: ${escapeHtml(translated.recommendation)}</div>`
24457
+ : "";
24458
+ return `<div class="finding">
24459
+ <div class="finding-head">
24460
+ <div>
24461
+ <div class="finding-title">${escapeHtml(translated.title)}</div>
24462
+ <div class="muted">${labels.rule}: <code>${escapeHtml(finding.ruleId)}</code></div>
24463
+ </div>
24464
+ <span class="pill ${finding.severity === "high" ? "tone-high" : finding.severity === "medium" ? "tone-medium" : "tone-low"}">${escapeHtml(locale === "zh-CN" ? translateSeverity(finding.severity) : finding.severity)}</span>
24465
+ </div>
24466
+ ${path}
24467
+ <div class="muted">${labels.detail}: ${escapeHtml(translated.message)}</div>
24468
+ ${evidence}
24469
+ ${recommendation}
24470
+ </div>`;
24471
+ }
24472
+ function countFindingsBySeverity(findings) {
24473
+ return findings.reduce((counts, finding) => {
24474
+ counts[finding.severity] += 1;
24475
+ return counts;
24476
+ }, { info: 0, low: 0, medium: 0, high: 0 });
24477
+ }
24478
+ function countFindingsByRule(findings) {
24479
+ const counts = new Map();
24480
+ for (const finding of findings) {
24481
+ counts.set(finding.ruleId, (counts.get(finding.ruleId) ?? 0) + 1);
24482
+ }
24483
+ return [...counts.entries()]
24484
+ .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
24485
+ .map(([ruleId, count]) => ({ ruleId, count }));
24486
+ }
24487
+ function scoreColor(value) {
24488
+ if (value >= 85) {
24489
+ return "var(--green)";
24490
+ }
24491
+ if (value >= 70) {
24492
+ return "var(--blue)";
24493
+ }
24494
+ if (value >= 50) {
24495
+ return "var(--amber)";
24496
+ }
24497
+ return "var(--red)";
24498
+ }
24499
+ function yesNo(value, locale) {
24500
+ return locale === "zh-CN" ? formatChineseBoolean(value) : formatBoolean(value);
24501
+ }
24502
+ function localizeActionTitle(actionId, fallback, locale) {
24503
+ return locale === "zh-CN" ? translateReviewActionTitle(actionId, fallback) : fallback;
24504
+ }
24505
+ function localizeActionDetail(actionId, fallback, locale) {
24506
+ return locale === "zh-CN" ? translateReviewActionDetail(actionId, fallback) : fallback;
24507
+ }
24508
+ function localizeFocusReason(reasonId, fallback, locale) {
24509
+ return locale === "zh-CN" ? translateFocusReason(reasonId, fallback) : fallback;
24510
+ }
24511
+ function localizeDeduction(reasonId, fallback, locale) {
24512
+ return locale === "zh-CN" ? translateDeduction(reasonId, fallback) : fallback;
24513
+ }
24514
+ function escapeHtml(value) {
24515
+ return value
24516
+ .replace(/&/g, "&amp;")
24517
+ .replace(/</g, "&lt;")
24518
+ .replace(/>/g, "&gt;")
24519
+ .replace(/"/g, "&quot;")
24520
+ .replace(/'/g, "&#39;");
24521
+ }
23909
24522
  function formatBoolean(value) {
23910
24523
  return value ? "yes" : "no";
23911
24524
  }
@@ -25122,7 +25735,7 @@ const build_program = new Command();
25122
25735
  build_program
25123
25736
  .name("proof-pr")
25124
25737
  .description("Review pull request evidence, scope, and safety before maintainers spend time on it.")
25125
- .version("0.1.7");
25738
+ .version("0.1.9");
25126
25739
  build_program
25127
25740
  .command("scan", { isDefault: true })
25128
25741
  .description("Scan a git diff and print a ProofPR report.")
@@ -25133,7 +25746,7 @@ build_program
25133
25746
  .option("--pr-body <body>", "Pull request body used for evidence checks.")
25134
25747
  .option("--pr-body-file <path>", "Read a pull request body from a Markdown file.")
25135
25748
  .option("--config <path>", "Path to .proofpr.yml.", ".proofpr.yml")
25136
- .option("--format <format>", "Output format: markdown, json, or sarif.", parseFormat, "markdown")
25749
+ .option("--format <format>", "Output format: markdown, json, sarif, or html.", parseFormat, "markdown")
25137
25750
  .option("--locale <locale>", "Report language: en or zh-CN.")
25138
25751
  .option("--fail-on <level>", "Exit with code 1 on risk level: low, medium, high, or never.", parseFailLevel, "never")
25139
25752
  .action(async (options) => {
@@ -25314,7 +25927,7 @@ jobs:
25314
25927
  runs-on: ubuntu-latest
25315
25928
  steps:
25316
25929
  - uses: actions/checkout@v4
25317
- - uses: linsk27/proof-pr@v0.1.7
25930
+ - uses: linsk27/proof-pr@v0.1.9
25318
25931
  with:
25319
25932
  fail-on: ${failOn}
25320
25933
  comment: "true"
@@ -25328,6 +25941,9 @@ function renderOutput(result, format, locale) {
25328
25941
  if (format === "sarif") {
25329
25942
  return renderSarifReport(result);
25330
25943
  }
25944
+ if (format === "html") {
25945
+ return renderHtmlReport(result, locale);
25946
+ }
25331
25947
  return renderMarkdownReport(result, locale);
25332
25948
  }
25333
25949
  async function runBenchmarks(casesDir) {
@@ -25481,10 +26097,10 @@ function matchesFindingExpectation(actualFindings, expected) {
25481
26097
  return actualFindings.includes(expected);
25482
26098
  }
25483
26099
  function parseFormat(value) {
25484
- if (value === "json" || value === "markdown" || value === "sarif") {
26100
+ if (value === "json" || value === "markdown" || value === "sarif" || value === "html") {
25485
26101
  return value;
25486
26102
  }
25487
- throw new InvalidArgumentError("format must be one of: markdown, json, sarif");
26103
+ throw new InvalidArgumentError("format must be one of: markdown, json, sarif, html");
25488
26104
  }
25489
26105
  function parseBenchmarkFormat(value) {
25490
26106
  if (value === "text" || value === "json" || value === "markdown") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proof-pr",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "CLI for ProofPR, a maintainer-focused pull request evidence scanner.",
5
5
  "license": "MIT",
6
6
  "type": "module",