preflight-check-cli 1.0.1__py3-none-any.whl

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 (126) hide show
  1. preflight_check/__init__.py +5 -0
  2. preflight_check/cli.py +1315 -0
  3. preflight_check/core/__init__.py +1 -0
  4. preflight_check/core/claims.py +468 -0
  5. preflight_check/core/interact.py +212 -0
  6. preflight_check/core/models.py +530 -0
  7. preflight_check/core/registry.py +698 -0
  8. preflight_check/core/release.py +328 -0
  9. preflight_check/core/report.py +284 -0
  10. preflight_check/core/specs.py +483 -0
  11. preflight_check/demo/__init__.py +77 -0
  12. preflight_check/demo/broken-agent/.gitignore +3 -0
  13. preflight_check/demo/broken-agent/ROLLBACK.md +9 -0
  14. preflight_check/demo/broken-agent/RUNBOOK.md +13 -0
  15. preflight_check/demo/broken-agent/agent.py +27 -0
  16. preflight_check/demo/broken-agent/config.py +1 -0
  17. preflight_check/demo/broken-agent/llm.py +7 -0
  18. preflight_check/demo/broken-agent/models.py +13 -0
  19. preflight_check/demo/broken-agent/tests/test_agent.py +2 -0
  20. preflight_check/demo/broken-agent/tests/test_e2e_demo.py +2 -0
  21. preflight_check/demo/broken-agent/tests/test_integration_flow.py +2 -0
  22. preflight_check/demo/broken-agent/tests/test_smoke.py +2 -0
  23. preflight_check/demo/broken-agent/tools.py +3 -0
  24. preflight_check/demo/clean-agent/.gitignore +3 -0
  25. preflight_check/demo/clean-agent/ROLLBACK.md +9 -0
  26. preflight_check/demo/clean-agent/RUNBOOK.md +13 -0
  27. preflight_check/demo/clean-agent/agent.py +65 -0
  28. preflight_check/demo/clean-agent/config.py +3 -0
  29. preflight_check/demo/clean-agent/llm.py +19 -0
  30. preflight_check/demo/clean-agent/models.py +23 -0
  31. preflight_check/demo/clean-agent/tests/test_agent.py +2 -0
  32. preflight_check/demo/clean-agent/tests/test_e2e_demo.py +2 -0
  33. preflight_check/demo/clean-agent/tests/test_integration_flow.py +2 -0
  34. preflight_check/demo/clean-agent/tests/test_smoke.py +2 -0
  35. preflight_check/demo/clean-agent/tools.py +7 -0
  36. preflight_check/detect/__init__.py +12 -0
  37. preflight_check/detect/adapters/__init__.py +30 -0
  38. preflight_check/detect/adapters/base.py +395 -0
  39. preflight_check/detect/adapters/gitleaks.py +135 -0
  40. preflight_check/detect/adapters/pip_audit.py +136 -0
  41. preflight_check/detect/adapters/ruff_tool.py +147 -0
  42. preflight_check/detect/agent_analysis.py +614 -0
  43. preflight_check/detect/agent_checks.py +997 -0
  44. preflight_check/detect/ast_checks.py +397 -0
  45. preflight_check/detect/base.py +193 -0
  46. preflight_check/detect/cross_checks.py +506 -0
  47. preflight_check/detect/derive.py +182 -0
  48. preflight_check/detect/engine.py +137 -0
  49. preflight_check/detect/generic.py +224 -0
  50. preflight_check/detect/ignore.py +104 -0
  51. preflight_check/detect/probe.py +323 -0
  52. preflight_check/detect/registry.py +96 -0
  53. preflight_check/detect/repo.py +338 -0
  54. preflight_check/interact/__init__.py +61 -0
  55. preflight_check/interact/questions.py +159 -0
  56. preflight_check/interact/store.py +109 -0
  57. preflight_check/interact/waiver.py +190 -0
  58. preflight_check/knowledge/claims/verification.yaml +119 -0
  59. preflight_check/knowledge/docs/roles.yaml +312 -0
  60. preflight_check/knowledge/invariants/no-duplicate.yaml +6 -0
  61. preflight_check/knowledge/invariants/no-leak.yaml +6 -0
  62. preflight_check/knowledge/invariants/no-loss.yaml +6 -0
  63. preflight_check/knowledge/invariants/no-overrun.yaml +8 -0
  64. preflight_check/knowledge/invariants/no-unauthorized.yaml +7 -0
  65. preflight_check/knowledge/invariants/ordering.yaml +7 -0
  66. preflight_check/knowledge/mechanisms/cache.yaml +43 -0
  67. preflight_check/knowledge/mechanisms/external-api.yaml +50 -0
  68. preflight_check/knowledge/mechanisms/llm-call.yaml +81 -0
  69. preflight_check/knowledge/mechanisms/long-connection.yaml +52 -0
  70. preflight_check/knowledge/mechanisms/message-queue.yaml +56 -0
  71. preflight_check/knowledge/mechanisms/multi-region.yaml +46 -0
  72. preflight_check/knowledge/mechanisms/object-storage.yaml +50 -0
  73. preflight_check/knowledge/mechanisms/relational-db.yaml +67 -0
  74. preflight_check/knowledge/mechanisms/scheduled-job.yaml +42 -0
  75. preflight_check/knowledge/mechanisms/search-index.yaml +37 -0
  76. preflight_check/knowledge/profiles/agent-enterprise.yaml +11 -0
  77. preflight_check/knowledge/profiles/cli-oss.yaml +9 -0
  78. preflight_check/knowledge/profiles/saas-consumer.yaml +29 -0
  79. preflight_check/knowledge/profiles/saas-enterprise.yaml +25 -0
  80. preflight_check/knowledge/questions/questions.yaml +113 -0
  81. preflight_check/knowledge/rules/config-name-drift.yaml +42 -0
  82. preflight_check/knowledge/rules/cost-from-price-table.yaml +43 -0
  83. preflight_check/knowledge/rules/env-not-ignored.yaml +40 -0
  84. preflight_check/knowledge/rules/exception-swallowed.yaml +36 -0
  85. preflight_check/knowledge/rules/hardcoded-secret.yaml +35 -0
  86. preflight_check/knowledge/rules/leaked-secret.yaml +34 -0
  87. preflight_check/knowledge/rules/model-name-scattered.yaml +33 -0
  88. preflight_check/knowledge/rules/no-budget-breaker.yaml +40 -0
  89. preflight_check/knowledge/rules/no-context-guard.yaml +40 -0
  90. preflight_check/knowledge/rules/no-eval-fingerprint.yaml +42 -0
  91. preflight_check/knowledge/rules/no-evidence-check.yaml +41 -0
  92. preflight_check/knowledge/rules/no-loss-backlog-recovery.yaml +42 -0
  93. preflight_check/knowledge/rules/no-output-limit.yaml +36 -0
  94. preflight_check/knowledge/rules/no-request-timeout.yaml +49 -0
  95. preflight_check/knowledge/rules/no-rollback-plan.yaml +39 -0
  96. preflight_check/knowledge/rules/no-runbook.yaml +31 -0
  97. preflight_check/knowledge/rules/no-test-layer.yaml +53 -0
  98. preflight_check/knowledge/rules/output-field-has-default.yaml +43 -0
  99. preflight_check/knowledge/rules/plain-text-ends-session.yaml +39 -0
  100. preflight_check/knowledge/rules/static-defect.yaml +34 -0
  101. preflight_check/knowledge/rules/tests-hit-network.yaml +48 -0
  102. preflight_check/knowledge/rules/tool-exception-uncaught.yaml +36 -0
  103. preflight_check/knowledge/rules/tool-output-unbounded.yaml +34 -0
  104. preflight_check/knowledge/rules/truncation-without-notice.yaml +44 -0
  105. preflight_check/knowledge/rules/unbounded-loop.yaml +47 -0
  106. preflight_check/knowledge/rules/unbounded-parallel-tools.yaml +31 -0
  107. preflight_check/knowledge/rules/unpinned-dependency-upper-bound.yaml +40 -0
  108. preflight_check/knowledge/rules/unvalidated-model-output.yaml +42 -0
  109. preflight_check/knowledge/rules/vulnerable-dependency.yaml +29 -0
  110. preflight_check/runtime/__init__.py +56 -0
  111. preflight_check/runtime/budget.py +133 -0
  112. preflight_check/runtime/cache.py +165 -0
  113. preflight_check/runtime/llm.py +484 -0
  114. preflight_check/runtime/paths.py +66 -0
  115. preflight_check/runtime/trace.py +118 -0
  116. preflight_check/understand/__init__.py +25 -0
  117. preflight_check/understand/claims.py +891 -0
  118. preflight_check/understand/discover.py +209 -0
  119. preflight_check/understand/guard.py +389 -0
  120. preflight_check/understand/mapping.py +193 -0
  121. preflight_check/understand/verify.py +528 -0
  122. preflight_check_cli-1.0.1.dist-info/METADATA +470 -0
  123. preflight_check_cli-1.0.1.dist-info/RECORD +126 -0
  124. preflight_check_cli-1.0.1.dist-info/WHEEL +4 -0
  125. preflight_check_cli-1.0.1.dist-info/entry_points.txt +2 -0
  126. preflight_check_cli-1.0.1.dist-info/licenses/LICENSE +21 -0
preflight_check/cli.py ADDED
@@ -0,0 +1,1315 @@
1
+ """preflight 命令行入口。
2
+
3
+ S0 阶段只有四条命令:``version`` / ``list-rules`` / ``explain`` / ``knowledge-check``。
4
+ 它们是**给语料贡献者用的**:S0 的产出是"知识的格式与校验",不是"能查代码"。
5
+
6
+ 真正的检查命令(``preflight run``)在 S1 出现。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import shutil
13
+ import sys
14
+ import tempfile
15
+ from collections.abc import Sequence
16
+ from datetime import UTC, date, datetime
17
+ from pathlib import Path
18
+
19
+ import typer
20
+ from rich.console import Console
21
+ from rich.markup import escape
22
+ from rich.panel import Panel
23
+ from rich.table import Table
24
+
25
+ from preflight_check import __version__
26
+ from preflight_check.core.claims import Claim, Verdict, Verification
27
+ from preflight_check.core.interact import Answer, Waiver
28
+ from preflight_check.core.models import Review, Rule, Severity
29
+ from preflight_check.core.registry import (
30
+ Knowledge,
31
+ KnowledgeLoadError,
32
+ load_knowledge,
33
+ )
34
+ from preflight_check.core.release import WaiverRecord, build_release_review
35
+ from preflight_check.core.report import (
36
+ render_json,
37
+ render_markdown,
38
+ render_terminal,
39
+ write_report,
40
+ )
41
+ from preflight_check.demo import DEMOS, demo_files
42
+ from preflight_check.detect.derive import derive_checks
43
+ from preflight_check.detect.engine import run_path
44
+ from preflight_check.detect.probe import probe_mechanisms
45
+ from preflight_check.detect.repo import Repo
46
+ from preflight_check.interact.questions import build_questionnaire
47
+ from preflight_check.interact.store import load_answers, load_waivers, save_answers, save_waivers
48
+ from preflight_check.interact.waiver import (
49
+ apply_waivers,
50
+ apply_waivers_to_verifications,
51
+ expired_audit,
52
+ waiver_audit,
53
+ )
54
+ from preflight_check.runtime.budget import Budget
55
+ from preflight_check.runtime.cache import ResponseCache
56
+ from preflight_check.runtime.llm import (
57
+ DEFAULT_MODEL,
58
+ LiteLLMClient,
59
+ LLMBudgetExceeded,
60
+ OpenAICompatClient,
61
+ )
62
+ from preflight_check.runtime.trace import Tracer
63
+ from preflight_check.understand.claims import extract_claims
64
+ from preflight_check.understand.discover import discover_documents
65
+ from preflight_check.understand.verify import verify_claims
66
+
67
+ app = typer.Typer(
68
+ name="preflight",
69
+ help="上线前的自动评审。跑一次,告诉你哪些问题能带着上线。",
70
+ no_args_is_help=True,
71
+ add_completion=False,
72
+ )
73
+
74
+ console = Console()
75
+
76
+ # 由主回调设置。typer 没有更好的全局状态传递方式。
77
+ KNOWLEDGE_DIR: Path | None = None
78
+
79
+ _SEVERITY_STYLE: dict[Severity, str] = {
80
+ Severity.BLOCKER: "bold red",
81
+ Severity.HIGH: "red",
82
+ Severity.MEDIUM: "yellow",
83
+ Severity.INFO: "dim",
84
+ }
85
+
86
+ _SEVERITY_LABEL: dict[Severity, str] = {
87
+ Severity.BLOCKER: "阻断",
88
+ Severity.HIGH: "高危",
89
+ Severity.MEDIUM: "中危",
90
+ Severity.INFO: "提示",
91
+ }
92
+
93
+
94
+ @app.callback()
95
+ def main_callback(
96
+ knowledge: Path | None = typer.Option( # noqa: B008 —— typer 的惯用写法
97
+ None,
98
+ "--knowledge",
99
+ "-k",
100
+ help="自定义语料目录(覆盖内置语料)。见 docs/KNOWLEDGE.md",
101
+ ),
102
+ ) -> None:
103
+ """preflight —— 上线前的自动评审。"""
104
+ global KNOWLEDGE_DIR
105
+ KNOWLEDGE_DIR = knowledge
106
+
107
+
108
+ # --------------------------------------------------------------------------
109
+ # 共用的加载
110
+ # --------------------------------------------------------------------------
111
+
112
+
113
+ def _load() -> Knowledge:
114
+ """加载语料;出错时**打印全部错误**再退出。
115
+
116
+ 为什么不是抛异常:语料错误是"人写错了 YAML",不是程序 bug。
117
+ 给它一段 traceback 是帮倒忙;它要的是"哪个文件、哪个字段、错在哪"。
118
+ """
119
+ try:
120
+ return load_knowledge(KNOWLEDGE_DIR)
121
+ except KnowledgeLoadError as exc:
122
+ console.print(f"[red]语料加载失败:{len(exc.errors)} 处问题[/red]")
123
+ console.print()
124
+ for error in exc.errors:
125
+ # ⚠️ `soft_wrap=True` 不是装饰。
126
+ #
127
+ # 实测:在 Linux 的 80 列终端里,rich 把长路径**折成了两行** ——
128
+ # `/tmp/.../rules/a-r` + `ule.yaml`。于是
129
+ # 1. 用户复制那个路径会拿到垃圾
130
+ # 2. 一条断言 `"a-rule.yaml" in stdout` 的测试红了
131
+ #
132
+ # 而它在 Windows 上一直是绿的(终端宽度不同)——
133
+ # **这正是"从没在 Linux 上跑过"掩盖的那一类问题。**
134
+ #
135
+ # 路径是我们自己产出的、有精确含义的东西,不能让渲染层去折它。
136
+ console.print(
137
+ f" [red]✗[/red] [bold]{_display_path(error.path)}[/bold]", soft_wrap=True
138
+ )
139
+ console.print(f" [cyan]{error.field_path or '<root>'}[/cyan]", soft_wrap=True)
140
+ console.print(f" {error.message}")
141
+ console.print()
142
+ console.print("[dim]语料格式见 docs/RULES.md §1[/dim]")
143
+ raise typer.Exit(code=1) from exc
144
+
145
+
146
+ def _severity_text(severity: Severity) -> str:
147
+ style = _SEVERITY_STYLE[severity]
148
+ return f"[{style}]{severity.value}[/{style}]"
149
+
150
+
151
+ # --------------------------------------------------------------------------
152
+ # 命令
153
+ # --------------------------------------------------------------------------
154
+
155
+
156
+ @app.command()
157
+ def version() -> None:
158
+ """打印版本号。"""
159
+ console.print(f"preflight [bold cyan]{__version__}[/bold cyan]")
160
+
161
+
162
+ @app.command("run")
163
+ def run_cmd(
164
+ path: Path = typer.Argument( # noqa: B008
165
+ Path("."),
166
+ help="要检查的项目目录",
167
+ ),
168
+ profile: str = typer.Option(
169
+ "cli-oss",
170
+ "--profile",
171
+ "-p",
172
+ help="场景档案,决定跑哪些规则、门禁多严",
173
+ ),
174
+ fmt: str = typer.Option(
175
+ "terminal",
176
+ "--format",
177
+ "-f",
178
+ help="输出格式:terminal / markdown / json",
179
+ ),
180
+ out: Path | None = typer.Option( # noqa: B008
181
+ None,
182
+ "--out",
183
+ "-o",
184
+ help="把报告写到文件(不写就只打印)",
185
+ ),
186
+ fail_on: str | None = typer.Option(
187
+ None,
188
+ "--fail-on",
189
+ help="覆盖档案的门禁级别:blocker / high / medium / info",
190
+ ),
191
+ full: bool = typer.Option(False, "--full", help="连修复建议和血案一起打印"),
192
+ ) -> None:
193
+ """跑一次上线前评审。
194
+
195
+ 退出码:``NO-GO`` → 1,其余 → 0。
196
+
197
+ ⚠️ ``INCOMPLETE``(有规则没跑成)返回 **0** —— 检查器没实现完是**这个工具**
198
+ 的状态,不是被检查项目的问题,拿它拦住别人的 CI 是耍流氓。
199
+ 但它会被显著地打印出来,不会和通过混在一起。
200
+ """
201
+ knowledge = _load()
202
+
203
+ if profile not in knowledge.profiles:
204
+ console.print(f"[red]没有这个档案:{profile}[/red]")
205
+ console.print(f"[dim]可用档案:{', '.join(sorted(knowledge.profiles))}[/dim]")
206
+ raise typer.Exit(code=1)
207
+
208
+ scene = knowledge.profiles[profile]
209
+ gate = scene.fail_on
210
+ if fail_on is not None:
211
+ try:
212
+ gate = Severity(fail_on)
213
+ except ValueError:
214
+ console.print(f"[red]没有这个级别:{fail_on}[/red]")
215
+ console.print(f"[dim]可用:{', '.join(s.value for s in Severity)}[/dim]")
216
+ raise typer.Exit(code=1) from None
217
+
218
+ if not path.is_dir():
219
+ console.print(f"[red]不是一个目录:{path}[/red]")
220
+ raise typer.Exit(code=1)
221
+
222
+ rules = knowledge.select(profile)
223
+ review = run_path(
224
+ path,
225
+ rules=rules,
226
+ profile_id=profile,
227
+ fail_on=gate,
228
+ knowledge_digest=knowledge.digest(),
229
+ )
230
+
231
+ if fmt == "terminal":
232
+ render_terminal(review, console, full=full)
233
+ elif fmt == "markdown":
234
+ _emit_raw(render_markdown(review))
235
+ elif fmt == "json":
236
+ _emit_raw(render_json(review))
237
+ else:
238
+ console.print(f"[red]不认识的格式:{fmt}[/red](可用:terminal / markdown / json)")
239
+ raise typer.Exit(code=1)
240
+
241
+ if out is not None:
242
+ content = render_markdown(review) if fmt == "terminal" else _render_for(fmt, review)
243
+ write_report(out, content)
244
+ console.print(f"\n[dim]报告已写入 {_display_path(out)}[/dim]")
245
+
246
+ raise typer.Exit(code=review.exit_code)
247
+
248
+
249
+ def _emit_raw(content: str) -> None:
250
+ """把机器可读的输出**原样**写到 stdout。
251
+
252
+ ⚠️ 不能用 ``console.print``:rich 会按控制台宽度软折行,
253
+ 于是 ``--format json`` 输出一个**行内断掉的 JSON** ——
254
+ ``json.loads`` 直接报 "Invalid control character"。
255
+
256
+ 这个 bug 是 CLI 测试抓到的:单元测试里 ``render_json`` 完全正确,
257
+ 错的是它经过控制台那一段路。**机器可读的输出不该被装饰**,
258
+ 所以这里绕过 rich 直接写。
259
+
260
+ ``sys.stdout`` 而不是 click 的流:CliRunner 替换的就是它,
261
+ 两边写的其实是同一个被捕获的流。
262
+ """
263
+ sys.stdout.write(content)
264
+ if not content.endswith("\n"):
265
+ sys.stdout.write("\n")
266
+ sys.stdout.flush()
267
+
268
+
269
+ def _render_for(fmt: str, review: object) -> str:
270
+ from preflight_check.core.models import Review
271
+
272
+ assert isinstance(review, Review)
273
+ return render_json(review) if fmt == "json" else render_markdown(review)
274
+
275
+
276
+ @app.command("list-rules")
277
+ def list_rules(
278
+ profile: str | None = typer.Option(
279
+ None,
280
+ "--profile",
281
+ "-p",
282
+ help="按场景档案过滤。不传则列出全部规则",
283
+ ),
284
+ show_matrix: bool = typer.Option(
285
+ False,
286
+ "--matrix",
287
+ help="额外打印矩阵覆盖情况(哪些格子还没人覆盖)",
288
+ ),
289
+ ) -> None:
290
+ """列出规则。
291
+
292
+ ``--profile`` 是重点:**同一个仓库、不同档案,跑的规则集不一样**
293
+ (例如 ``cli-oss`` 不跑 agent 规则,``agent-enterprise`` 不跑消息队列规则)。
294
+ """
295
+ knowledge = _load()
296
+
297
+ if profile is not None:
298
+ if profile not in knowledge.profiles:
299
+ console.print(f"[red]没有这个档案:{profile}[/red]")
300
+ console.print(f"[dim]可用档案:{', '.join(sorted(knowledge.profiles))}[/dim]")
301
+ raise typer.Exit(code=1)
302
+ rules = knowledge.select(profile)
303
+ scene = knowledge.profiles[profile]
304
+ console.print(
305
+ f"档案 [bold]{profile}[/bold]({scene.name})"
306
+ f" 门禁:{_severity_text(scene.fail_on)} 以上"
307
+ )
308
+ else:
309
+ rules = sorted(knowledge.rules.values(), key=lambda r: (r.severity.rank, r.id))
310
+ console.print(f"全部规则({len(rules)} 条)")
311
+
312
+ console.print()
313
+ console.print(_rules_table(rules))
314
+
315
+ if show_matrix:
316
+ console.print()
317
+ console.print(_matrix_table(knowledge))
318
+
319
+
320
+ def _rules_table(rules: list[Rule]) -> Table:
321
+ """规则表格。
322
+
323
+ ⚠️ **列宽分配是个坑,改这个函数之前先读完这段。**
324
+
325
+ 这个表格改过两版,两版都栽在同一个坑上:**80 列放不下四列宽内容。**
326
+
327
+ 第一版:四列(级别/规则/说明/适用),其中「规则」和「适用」都是
328
+ ``no_wrap``。两者都可能 37 字符,总需求超标,于是 Rich 把**唯一可伸缩的**
329
+ 那列(说明,``overflow="fold"``)压成了零宽度。表现是表头莫名少一列:
330
+
331
+ │ 级别 │ 规则 ││ 适用 │ ← 说明 列没了
332
+
333
+ 第二版:让「规则」用 ``ellipsis``。零宽问题解决了,但**规则 id 被截断成
334
+ ``message-queue/no-loss-backlog-re…``** —— 而 id 是这个命令最该看清的东西
335
+ (你要拿它去 ``explain``)。
336
+
337
+ 最终版:**减到三列,把规则名挪到 id 下面一行。** 宽度账算下来:
338
+ 80 − 4 条竖线 − 6 个内边距 = 70;级别 7 + 适用 24 之后,规则还有 39 列,
339
+ 37 字符的 id 完整放得下。
340
+
341
+ 教训:**宽度不够时该重构布局,而不是让某一列去挨饿。**
342
+ """
343
+ table = Table(header_style="bold")
344
+ table.add_column("级别", no_wrap=True)
345
+ table.add_column("规则", style="cyan", overflow="fold", min_width=28)
346
+ table.add_column("适用", overflow="fold", max_width=26)
347
+
348
+ for rule in rules:
349
+ # 名字放 id 下面一行:一行放不下两个都是中文的长字段,
350
+ # 而 id 必须完整(要拿它去 explain),名字可以折行。
351
+ rule_cell = f"{rule.id}\n[dim]{rule.name}[/dim]"
352
+
353
+ applies_cell = rule.applies_to.value
354
+ if rule.matrix is not None:
355
+ matrix = f"{rule.matrix.mechanism}×{rule.matrix.invariant}"
356
+ applies_cell = f"{rule.applies_to.value}\n[dim]{matrix}[/dim]"
357
+
358
+ table.add_row(_severity_text(rule.severity), rule_cell, applies_cell)
359
+
360
+ return table
361
+
362
+
363
+ def _matrix_table(knowledge: Knowledge) -> Table:
364
+ """矩阵覆盖表。
365
+
366
+ **空格子 = 没人覆盖的风险区。** 如果只列有规则的格子,你永远看不出
367
+ "这个组合没人管" —— 而这正是矩阵存在的意义。
368
+ """
369
+ cells = knowledge.matrix_view()
370
+ covered = [cell for cell in cells if cell.covered]
371
+ table = Table(
372
+ title=f"矩阵覆盖:{len(covered)}/{len(cells)} 格有规则",
373
+ header_style="bold",
374
+ )
375
+ # 同样的列宽教训:机制/不变量名字都不长,可以 no_wrap;
376
+ # 「规则」那一列可能很长,做成可伸缩的。
377
+ table.add_column("机制", style="cyan", no_wrap=True)
378
+ table.add_column("不变量", no_wrap=True)
379
+ table.add_column("规则", overflow="ellipsis")
380
+
381
+ for cell in cells:
382
+ body = "、".join(cell.rules) if cell.covered else "[dim]—(没人覆盖)[/dim]"
383
+ table.add_row(cell.mechanism, cell.invariant, body)
384
+
385
+ return table
386
+
387
+
388
+ @app.command()
389
+ def explain(
390
+ rule_id: str = typer.Argument(..., help="规则 id,如 agent/no-request-timeout"),
391
+ ) -> None:
392
+ """讲清一条规则:怎么检测、报出什么、怎么修、**以及它对应哪次血案**。
393
+
394
+ ``incident``(血案)是这个项目对"什么算一条规则"的态度:
395
+ 说不出出过什么事的,不是规则,是风格偏好。
396
+ """
397
+ knowledge = _load()
398
+
399
+ rule = knowledge.rules.get(rule_id)
400
+ if rule is None:
401
+ console.print(f"[red]没有这条规则:{rule_id}[/red]")
402
+ close = [rid for rid in sorted(knowledge.rules) if rule_id.split("/")[-1] in rid]
403
+ if close:
404
+ console.print(f"[dim]你是不是想找:{', '.join(close)}[/dim]")
405
+ else:
406
+ console.print(
407
+ f"[dim]全部规则:{', '.join(sorted(knowledge.rules)) or '(一条都没有)'}[/dim]"
408
+ )
409
+ raise typer.Exit(code=1)
410
+
411
+ source = knowledge.rule_source(rule.id)
412
+ body = [
413
+ f"[bold]{rule.name}[/bold]",
414
+ "",
415
+ f"级别 {_severity_text(rule.severity)}",
416
+ f"适用 {rule.applies_to.value}"
417
+ + (f" 面向 {rule.audience.value}" if rule.audience.value != "both" else ""),
418
+ ]
419
+ if rule.matrix is not None:
420
+ body.append(f"矩阵 {rule.matrix.mechanism} × {rule.matrix.invariant}")
421
+ if rule.stage:
422
+ body.append(f"阶段 {rule.stage}")
423
+ if source:
424
+ body.append(f"来源 {_display_path(source)}")
425
+
426
+ body += [
427
+ "",
428
+ "[bold]怎么检测[/bold]",
429
+ f" kind = {rule.detect.kind.value}",
430
+ ]
431
+ for key, value in sorted(rule.detect.params.items()):
432
+ body.append(f" {key} = {value!r}")
433
+
434
+ body += [
435
+ "",
436
+ "[bold]报出什么[/bold]",
437
+ _indent(rule.report),
438
+ "",
439
+ "[bold]怎么修[/bold]",
440
+ _indent(rule.fix),
441
+ "",
442
+ "[bold red]血案[/bold red]",
443
+ _indent(rule.incident),
444
+ ]
445
+
446
+ if rule.refs:
447
+ body += ["", f"[dim]引用:{', '.join(rule.refs)}[/dim]"]
448
+
449
+ console.print(Panel("\n".join(body), title=rule.id, title_align="left", expand=False))
450
+
451
+
452
+ def _indent(text: str, prefix: str = " ") -> str:
453
+ return "\n".join(prefix + line if line.strip() else "" for line in text.splitlines())
454
+
455
+
456
+ def _display_path(path: Path) -> str:
457
+ """能相对就相对。
458
+
459
+ 绝对路径在这台机器上很长(``D:\\deepseekharness\\preflight\\src\\preflight\\
460
+ knowledge\\rules\\no-request-timeout.yaml``),塞进面板会折成三行,
461
+ 而其中九成是前缀。相对路径一样能定位,而且不会把版面撑坏。
462
+ """
463
+ try:
464
+ return str(path.relative_to(Path.cwd()))
465
+ except ValueError:
466
+ # 不在当前目录下(装成包时在 site-packages 里)—— 那就照原样给
467
+ return str(path)
468
+
469
+
470
+ @app.command("knowledge-check")
471
+ def knowledge_check() -> None:
472
+ """校验语料是否合法,并报告覆盖情况。
473
+
474
+ **语料贡献者的入口。** 出任何问题都以退出码 1 结束,可以直接进 CI。
475
+ """
476
+ knowledge = _load()
477
+
478
+ counts = knowledge.counts
479
+ table = Table(header_style="bold")
480
+ table.add_column("语料", style="cyan", no_wrap=True)
481
+ table.add_column("数量", justify="right")
482
+ for name, count in counts.items():
483
+ table.add_row(name, str(count))
484
+ table.add_row("指纹", knowledge.digest())
485
+
486
+ console.print(table)
487
+
488
+ cells = knowledge.matrix_view()
489
+ empty = [cell for cell in cells if not cell.covered]
490
+ console.print()
491
+ console.print(f"矩阵覆盖:{len(cells) - len(empty)}/{len(cells)} 格有规则")
492
+ if empty:
493
+ # 空格子不是错误,是**风险提示**。所以只提示,不影响退出码。
494
+ console.print(f"[yellow]有 {len(empty)} 个格子还没有规则覆盖:[/yellow]")
495
+ for cell in empty[:10]:
496
+ console.print(f" [dim]{cell.mechanism} × {cell.invariant}[/dim]")
497
+ if len(empty) > 10:
498
+ console.print(f" [dim]…还有 {len(empty) - 10} 个[/dim]")
499
+
500
+ console.print()
501
+ console.print("[green]语料合法[/green]")
502
+
503
+
504
+ @app.command("docs")
505
+ def docs_cmd(
506
+ path: Path = typer.Argument(Path("."), help="要扫的项目目录"), # noqa: B008
507
+ fmt: str = typer.Option("terminal", "--format", "-f", help="terminal / json"),
508
+ ) -> None:
509
+ """列出项目里的文档,以及它们被判成了什么类型。
510
+
511
+ **这一步不需要模型,也不花钱。** 它决定"待会儿要给模型看什么",
512
+ 所以先让它单独可跑、可检查 —— 分类错了要在这里就看得出来,
513
+ 而不是等花完钱发现抽取结果不对。
514
+ """
515
+ knowledge = _load()
516
+ repo = Repo.scan(path)
517
+ docs = discover_documents(repo, knowledge.doc_roles)
518
+
519
+ if fmt == "json":
520
+ _emit_raw(
521
+ json.dumps([doc.model_dump(mode="json") for doc in docs], ensure_ascii=False, indent=2)
522
+ )
523
+ return
524
+
525
+ if not docs:
526
+ console.print("[yellow]没有找到任何文档。[/yellow]")
527
+ return
528
+
529
+ table = Table(header_style="bold")
530
+ table.add_column("类型", style="cyan", no_wrap=True)
531
+ table.add_column("文档")
532
+ table.add_column("行", justify="right")
533
+ table.add_column("抽取", no_wrap=True)
534
+ table.add_column("依据")
535
+
536
+ for doc in docs:
537
+ # `confident=False` 的是"按内容猜的" —— 要能一眼看出来,
538
+ # 因为按内容猜的准确率明显低一档
539
+ role = doc.role.value if doc.confident else f"{doc.role.value}?"
540
+ table.add_row(
541
+ role,
542
+ doc.path,
543
+ str(doc.lines),
544
+ "抽" if doc.worth_extracting else "[dim]跳过[/dim]",
545
+ f"[dim]{doc.why}[/dim]",
546
+ )
547
+
548
+ console.print(table)
549
+ console.print()
550
+ by_role: dict[str, int] = {}
551
+ for doc in docs:
552
+ by_role[doc.role.value] = by_role.get(doc.role.value, 0) + 1
553
+ summary = "、".join(f"{role} {count}" for role, count in sorted(by_role.items()))
554
+ console.print(f"{len(docs)} 份文档:{summary}")
555
+ unknown = [doc.path for doc in docs if doc.role.value == "unknown"]
556
+ if unknown:
557
+ # 认不出来**不是错误** —— 猜错才是。所以只提示,不影响退出码。
558
+ console.print(
559
+ f"[yellow]{len(unknown)} 份没认出来(标成 unknown,而不是硬猜一个):[/yellow]"
560
+ )
561
+ for name in unknown:
562
+ console.print(f" [dim]{name}[/dim]")
563
+
564
+
565
+ @app.command("claims")
566
+ def claims_cmd(
567
+ path: Path = typer.Argument(Path("."), help="要抽声明的项目目录"), # noqa: B008
568
+ profile: str = typer.Option("cli-oss", "--profile", "-p", help="场景档案"),
569
+ fmt: str = typer.Option("terminal", "--format", "-f", help="terminal / json"),
570
+ out: Path | None = typer.Option(None, "--out", "-o", help="把结果写到文件"), # noqa: B008
571
+ model: str = typer.Option(DEFAULT_MODEL, "--model", help="模型名"),
572
+ max_tokens: int = typer.Option(200_000, "--max-tokens", help="本次运行的 token 上限"),
573
+ max_cost: float = typer.Option(0.50, "--max-cost", help="本次运行的成本上限(美元)"),
574
+ max_calls: int = typer.Option(50, "--max-calls", help="本次运行最多调用几次"),
575
+ no_cache: bool = typer.Option(False, "--no-cache", help="不用缓存(会重新花钱)"),
576
+ trace: bool = typer.Option(False, "--trace", help="把每一步写到轨迹文件"),
577
+ api_key: str | None = typer.Option(
578
+ None,
579
+ "--api-key",
580
+ envvar="PREFLIGHT_API_KEY",
581
+ help="不传就读环境变量 PREFLIGHT_API_KEY",
582
+ ),
583
+ ) -> None:
584
+ """从文档里抽出可验证的声明。
585
+
586
+ ⚠️ **这一步会调用模型,会花钱。** 所以三道闸门默认都是开的:
587
+ ``--max-tokens`` / ``--max-cost`` / ``--max-calls``,任何一个撞到就停,
588
+ 并说明停在哪一步。
589
+
590
+ 模型只会"提议" —— 每一条引用都会拿去和原文**逐字核对**,
591
+ 对不上的进 ``rejected``,不进结论。所以这一步的输出**不会编造**,
592
+ 但它可能**漏抽**(宁可少抽,不要错抽)。
593
+ """
594
+ knowledge = _load()
595
+ repo = Repo.scan(path)
596
+ docs = discover_documents(repo, knowledge.doc_roles)
597
+
598
+ budget = Budget(max_tokens=max_tokens, max_cost_usd=max_cost, max_calls=max_calls)
599
+ cache = ResponseCache(enabled=not no_cache)
600
+ tracer = Tracer.to_file() if trace else Tracer()
601
+ client = LiteLLMClient(api_key=api_key)
602
+
603
+ console.print(f"[dim]模型 {model};预算 {budget.summary_line()}[/dim]")
604
+ console.print()
605
+
606
+ try:
607
+ result = extract_claims(
608
+ docs,
609
+ repo,
610
+ client,
611
+ markers=knowledge.doc_roles.claim_markers,
612
+ budget=budget,
613
+ cache=cache,
614
+ tracer=tracer,
615
+ )
616
+ except LLMBudgetExceeded as exc:
617
+ # 撞闸门**不是崩溃** —— 它是一个明确的"我停下来是因为预算"。
618
+ # 已经抽到的部分照样给出来。
619
+ console.print(f"[yellow]预算到顶,提前停止:{exc}[/yellow]")
620
+ raise typer.Exit(code=0) from exc
621
+ finally:
622
+ tracer.close()
623
+
624
+ result.model = result.model or model
625
+ if out is not None:
626
+ write_report(out, result.model_dump_json(indent=2))
627
+ console.print(f"[green]已写入 {_display_path(out)}[/green]")
628
+
629
+ if fmt == "json":
630
+ _emit_raw(result.model_dump_json(indent=2))
631
+ return
632
+
633
+ console.print(result.summary_line())
634
+ console.print(f"[dim]模型:{result.model or model}({budget.summary_line()})[/dim]")
635
+ console.print(f"[dim]{cache.summary_line()}[/dim]")
636
+
637
+ if result.claims:
638
+ table = Table(header_style="bold")
639
+ table.add_column("类型", style="cyan", no_wrap=True)
640
+ table.add_column("位置", no_wrap=True)
641
+ table.add_column("对象")
642
+ table.add_column("断言")
643
+ for claim in result.claims:
644
+ loose = "[dim](宽松命中)[/dim]" if claim.loose_match else ""
645
+ table.add_row(
646
+ claim.kind.value,
647
+ f"{claim.doc}:{claim.line}",
648
+ claim.subject + loose,
649
+ claim.assertion,
650
+ )
651
+ console.print()
652
+ console.print(table)
653
+
654
+ if result.not_commitments:
655
+ console.print()
656
+ console.print(
657
+ f"[dim]{len(result.not_commitments)} 条不是承诺(计划 / 背景)—— "
658
+ "**它们不会被拿去验证**,列在这里是为了能证明我们没把它们当承诺:[/dim]"
659
+ )
660
+ for claim in result.not_commitments[:8]:
661
+ console.print(f" [dim]{claim.modality.value:10s} {claim.doc}:{claim.line}[/dim]")
662
+
663
+ if result.rejected:
664
+ console.print()
665
+ console.print(
666
+ f"[yellow]{len(result.rejected)} 条引用没通过原文核对(模型改写或编造):[/yellow]"
667
+ )
668
+ for claim in result.rejected[:5]:
669
+ console.print(f" [dim]{claim.quote[:60]!r} —— {claim.rejected_reason}[/dim]")
670
+
671
+
672
+ @app.command("verify")
673
+ def verify_cmd(
674
+ path: Path = typer.Argument(Path("."), help="要核对的项目目录"), # noqa: B008
675
+ claims: Path | None = typer.Option( # noqa: B008
676
+ None,
677
+ "--claims",
678
+ help="复用之前抽好的声明(JSON)。**给了它就不会再调用模型、不再花钱**",
679
+ ),
680
+ profile: str = typer.Option("cli-oss", "--profile", "-p", help="场景档案"),
681
+ fmt: str = typer.Option("terminal", "--format", "-f", help="terminal / json"),
682
+ out: Path | None = typer.Option(None, "--out", "-o", help="把结果写到文件"), # noqa: B008
683
+ model: str = typer.Option(DEFAULT_MODEL, "--model", help="模型名(--claims 时不用)"),
684
+ max_cost: float = typer.Option(0.50, "--max-cost", help="抽取阶段的成本上限(美元)"),
685
+ no_cache: bool = typer.Option(False, "--no-cache", help="不用缓存"),
686
+ api_key: str | None = typer.Option(
687
+ None, "--api-key", envvar="PREFLIGHT_API_KEY", help="不传就读环境变量"
688
+ ),
689
+ ) -> None:
690
+ """把文档声明和代码对上,算出**文档兑现率**。
691
+
692
+ ## 三条规矩
693
+
694
+ **① 查不了就说查不了。** 结果是三态:兑现 / 漂移 / **查不了**。
695
+ 把"查不了"算成兑现会让兑现率虚高,算成漂移会让你去改一份没问题的文档 ——
696
+ 两个都错。所以兑现率的分母**只含真的能查的**那些。
697
+
698
+ **② 结论必须带证据,而证据会被逐个字段核对。**
699
+ 路径存在吗、行号在范围内吗、片段对得上吗、符号在 AST 里找得到吗 ——
700
+ 任何一项对不上,结论降级。**这是防幻觉的全部意义。**
701
+
702
+ **③ 确定性优先。** 能对到一条已有规则的,直接跑它,不问模型。
703
+ 对不上的(尤其是**否定性**断言)诚实地说「无法证明否定」。
704
+ """
705
+ knowledge = _load()
706
+ repo = Repo.scan(path)
707
+ docs = discover_documents(repo, knowledge.doc_roles)
708
+
709
+ if claims is not None:
710
+ # 复用已有结果:**这一步不花钱**,所以可以反复调规则、反复核对
711
+ payload = json.loads(claims.read_text(encoding="utf-8"))
712
+ extracted = [Claim.model_validate(item) for item in payload["claims"]]
713
+ console.print(f"[dim]复用 {_display_path(claims)} 里的 {len(extracted)} 条声明[/dim]")
714
+ else:
715
+ if not api_key:
716
+ console.print(
717
+ "[yellow]要抽取声明就得有 API key(--api-key 或 PREFLIGHT_API_KEY);"
718
+ "或者用 --claims 复用之前抽好的结果(不花钱)。[/yellow]"
719
+ )
720
+ raise typer.Exit(code=2)
721
+ budget = Budget(max_cost_usd=max_cost)
722
+ extracted_set = extract_claims(
723
+ docs,
724
+ repo,
725
+ OpenAICompatClient(api_key=api_key),
726
+ markers=knowledge.doc_roles.claim_markers,
727
+ budget=budget,
728
+ cache=ResponseCache(enabled=not no_cache),
729
+ )
730
+ extracted = extracted_set.claims
731
+ console.print(f"[dim]{extracted_set.summary_line()};{budget.summary_line()}[/dim]")
732
+
733
+ report = verify_claims(extracted, repo, knowledge, docs=len(docs), profile=profile)
734
+
735
+ if out is not None:
736
+ write_report(out, report.model_dump_json(indent=2))
737
+ console.print(f"[green]已写入 {_display_path(out)}[/green]")
738
+
739
+ if fmt == "json":
740
+ _emit_raw(report.model_dump_json(indent=2))
741
+ return
742
+
743
+ console.print()
744
+ console.print(report.summary_line())
745
+
746
+ if report.unverified_evidence:
747
+ console.print(
748
+ f"[red]⚠ {report.unverified_evidence} 条的证据**没通过核对**"
749
+ "(路径/行号/片段/符号有一项对不上)—— 那些结论已经被降级[/red]"
750
+ )
751
+
752
+ for title, verdict, style in (
753
+ ("漂移(文档这么说,代码不是这样)", Verdict.VIOLATED, "red"),
754
+ ("兑现", Verdict.HOLDS, "green"),
755
+ ("查不了(这不是失败,是我没有能力判断)", Verdict.UNVERIFIABLE, "yellow"),
756
+ ):
757
+ items = report.by_verdict(verdict)
758
+ if not items:
759
+ continue
760
+ console.print()
761
+ console.print(f"[{style}]{title} {len(items)} 条[/{style}]")
762
+ for item in items[:12]:
763
+ console.print(f" [dim]{item.doc}[/dim] {escape(item.quote[:50])}")
764
+ console.print(f" {escape(item.reason[:150])}")
765
+ for piece in item.evidence[:2]:
766
+ location = piece.path + (f":{piece.line}" if piece.line else "")
767
+ console.print(f" [dim]证据 {escape(location)} {escape(piece.note[:60])}[/dim]")
768
+ if len(items) > 12:
769
+ console.print(f" [dim]…还有 {len(items) - 12} 条[/dim]")
770
+
771
+
772
+ @app.command("mechanisms")
773
+ def mechanisms_cmd(
774
+ path: Path = typer.Argument(Path("."), help="要探测的项目目录"), # noqa: B008
775
+ fmt: str = typer.Option("terminal", "--format", "-f", help="terminal / json"),
776
+ full: bool = typer.Option(False, "--full", help="把每一条的完整推理链都打出来"),
777
+ ) -> None:
778
+ """探测项目里有哪些**机制**,并推导出该查什么。
779
+
780
+ ## 为什么是"机制"而不是"项目类别"
781
+
782
+ 项目类别是无穷的:聊天、电商、支付、IoT、游戏、编辑器、爬虫、编译器……
783
+ 按类别写检查表永远覆盖不全。
784
+
785
+ 而**会坏的东西就那么几种**:队列、数据库、缓存、长连接、定时任务、
786
+ 对象存储、外部 API、搜索索引、多区域、LLM 调用。十个。
787
+
788
+ 一个聊天系统和一个支付系统看起来完全不同,但它们坏起来是同一批地方。
789
+
790
+ ## 每条结论都带推理链
791
+
792
+ 因为**推理链是这个产品的核心价值** —— 用户要能判断我们判断错了。
793
+ 一个"检查项列表"没有这个价值:用户不知道那些项从哪来。
794
+ """
795
+ knowledge = _load()
796
+ repo = Repo.scan(path)
797
+ outcome = probe_mechanisms(repo, knowledge)
798
+ derivation = derive_checks(outcome.hits, knowledge)
799
+
800
+ if fmt == "json":
801
+ _emit_raw(
802
+ json.dumps(
803
+ {
804
+ "detected": outcome.detected,
805
+ "uncertain": [entry.mechanism.id for entry in outcome.uncertain],
806
+ "questionnaire": outcome.questionnaire,
807
+ "checks": [
808
+ {
809
+ "mechanism": check.mechanism,
810
+ "invariant": check.invariant,
811
+ "checks": check.checks,
812
+ "rule": check.rule,
813
+ "chain": check.chain,
814
+ }
815
+ for check in derivation.checks
816
+ ],
817
+ },
818
+ ensure_ascii=False,
819
+ indent=2,
820
+ )
821
+ )
822
+ return
823
+
824
+ if outcome.nothing_detected:
825
+ # **探测不到不是失败** —— 它意味着"我们不知道这个项目有什么",
826
+ # 而那时唯一诚实的做法是问人,不是猜一个机制。
827
+ console.print("[yellow]一个机制都没探测到。[/yellow]")
828
+ console.print("[dim]这不是失败 —— 是「我们不知道这个项目有什么」。[/dim]")
829
+ console.print()
830
+ console.print("按**不变量**问几个问题(不是按行业问):")
831
+ for question in outcome.questionnaire:
832
+ console.print(f" [cyan]·[/cyan] {escape(question)}")
833
+ return
834
+
835
+ table = Table(header_style="bold")
836
+ table.add_column("机制", style="cyan", no_wrap=True)
837
+ table.add_column("可信度", no_wrap=True)
838
+ table.add_column("最强的线索")
839
+ for hit in outcome.hits:
840
+ table.add_row(hit.mechanism.name, hit.confidence, escape(hit.hits[0].describe()))
841
+ console.print(table)
842
+
843
+ if outcome.uncertain:
844
+ console.print()
845
+ console.print(
846
+ f"[yellow]{len(outcome.uncertain)} 个机制只有弱线索 —— **不算探测到**:[/yellow]"
847
+ )
848
+ for entry in outcome.uncertain:
849
+ console.print(
850
+ f" [dim]{entry.mechanism.name}:{escape(entry.hits[0].describe())}[/dim]"
851
+ )
852
+ console.print("[dim]弱线索不算探测到:符号名会撞车,文档提到一个机制也不等于用了它。[/dim]")
853
+
854
+ console.print()
855
+ console.print(derivation.summary_line())
856
+
857
+ console.print()
858
+ console.print("[bold]推导出来的检查项[/bold](带推理链)")
859
+ shown = derivation.checks if full else derivation.checks[:6]
860
+ for check in shown:
861
+ console.print()
862
+ console.print(f"[cyan]{escape(check.render(' '))}[/cyan]")
863
+ if not full and len(derivation.checks) > len(shown):
864
+ console.print()
865
+ console.print(
866
+ f"[dim]…还有 {len(derivation.checks) - len(shown)} 条,用 --full 看全部[/dim]"
867
+ )
868
+
869
+ if derivation.uncovered:
870
+ console.print()
871
+ console.print(
872
+ f"[yellow]{len(derivation.uncovered)} 条检查项**还没有规则覆盖** —— "
873
+ "那是没人管的风险区[/yellow]"
874
+ )
875
+ for check in derivation.uncovered[:8]:
876
+ console.print(
877
+ f" [dim]{check.mechanism_name} × {check.invariant_name}:"
878
+ f"{escape('、'.join(check.checks))}[/dim]"
879
+ )
880
+
881
+
882
+ @app.command("review")
883
+ def review_cmd(
884
+ path: Path = typer.Argument(Path("."), help="要评审的项目目录"), # noqa: B008
885
+ profile: str = typer.Option("cli-oss", "--profile", "-p", help="场景档案"),
886
+ claims: Path | None = typer.Option( # noqa: B008
887
+ None, "--claims", help="复用抽好的声明(JSON)—— 给了它就不花钱"
888
+ ),
889
+ out: Path | None = typer.Option( # noqa: B008
890
+ None, "--out", "-o", help="写到文件;不给就打到终端"
891
+ ),
892
+ fail_on: str | None = typer.Option(None, "--fail-on", help="覆盖档案的门禁级别"),
893
+ today: str | None = typer.Option(
894
+ None, "--today", help="把「今天」当成这一天(YYYY-MM-DD)—— 用于复现"
895
+ ),
896
+ fmt: str = typer.Option("markdown", "--format", "-f", help="markdown / json"),
897
+ ) -> None:
898
+ """**一次完整的评审** —— 确定性 → 文档 → 问卷 → 报告 → 退出码。
899
+
900
+ 这是这个工具最终的样子:一条命令跑完三个阶段。
901
+
902
+ ## 退出码
903
+
904
+ - ``NO-GO`` → 1
905
+ - ``GO`` / ``INCOMPLETE`` → 0
906
+
907
+ ``INCOMPLETE`` 返回 0 是**故意**的:它是「我还没查完」,不是「你项目有问题」。
908
+ 拿它拦住别人的 CI 是耍流氓 —— 但它会被显著地打印出来。
909
+
910
+ ## 为什么要有 ``--today``
911
+
912
+ 报告里的日期、豁免的到期判断都依赖「今天」。不给这个参数的话,
913
+ **同一份输入在不同日期会渲染出不同的报告** —— 那就没法复核了。
914
+ CI 里固定一个日期,报告才是可重建的。
915
+ """
916
+ knowledge = _load()
917
+ repo = Repo.scan(path)
918
+ moment = _parse_day(today)
919
+ chosen = knowledge.profiles.get(profile) or knowledge.profiles["cli-oss"]
920
+ gate = Severity(fail_on) if fail_on else chosen.fail_on
921
+
922
+ # ---- ① 确定性检查 ----
923
+ review = run_path(
924
+ repo.root,
925
+ rules=knowledge.select(chosen.id),
926
+ profile_id=chosen.id,
927
+ fail_on=gate,
928
+ knowledge_digest=knowledge.digest(),
929
+ )
930
+
931
+ # ---- ② 文档层(有 --claims 才做,因为那一步花钱)----
932
+ verifications: list[Verification] = []
933
+ docs = 0
934
+ if claims is not None:
935
+ payload = json.loads(claims.read_text(encoding="utf-8"))
936
+ extracted = [Claim.model_validate(item) for item in payload["claims"]]
937
+ docs = len(payload.get("docs", []))
938
+ report = verify_claims(extracted, repo, knowledge, docs=docs, profile=chosen.id)
939
+ verifications = report.claims
940
+
941
+ # ---- ③ 机制探测(决定该查什么)----
942
+ outcome = probe_mechanisms(repo, knowledge)
943
+
944
+ # ---- ④ 问卷 ----
945
+ waivers = load_waivers(repo.root)
946
+ questionnaire = build_questionnaire(
947
+ knowledge.questions,
948
+ profile=chosen.id,
949
+ detected=set(outcome.detected),
950
+ answers=load_answers(repo.root),
951
+ )
952
+
953
+ # ---- ⑤ 应用豁免 ----
954
+ waiver_result = apply_waivers(review, waivers, today=moment)
955
+ kept_verifications, waived_verifications = apply_waivers_to_verifications(
956
+ verifications, waivers, today=moment
957
+ )
958
+
959
+ active = waivers.active(moment)
960
+ records = [
961
+ WaiverRecord(
962
+ rule_id=waiver.rule_id,
963
+ path=waiver.path,
964
+ who=waiver.who,
965
+ why=waiver.why,
966
+ created=waiver.created.date().isoformat(),
967
+ expires=waiver.expires.isoformat(),
968
+ waived=[
969
+ item.what
970
+ for item in [*waiver_result.waived_findings, *waived_verifications]
971
+ if item.waiver is waiver
972
+ ],
973
+ )
974
+ for waiver in active
975
+ ]
976
+
977
+ release = build_release_review(
978
+ review,
979
+ today=moment,
980
+ profile=chosen.id,
981
+ tool_version=__version__,
982
+ verifications=kept_verifications,
983
+ docs=docs,
984
+ pending_questions=[
985
+ f"{question.text} (为什么问:{question.why})"
986
+ for question in questionnaire.required_pending
987
+ ],
988
+ answers=[
989
+ f"{answer.question_id} = {answer.value}"
990
+ + (" (从文档读到的)" if answer.source == "doc" else "")
991
+ for answer in questionnaire.answered
992
+ ]
993
+ + [entry.describe() for entry in questionnaire.from_docs],
994
+ waivers=records,
995
+ expired_waivers=expired_audit(waivers, today=moment),
996
+ mechanisms=outcome.detected,
997
+ )
998
+
999
+ if fmt == "json":
1000
+ _emit_raw(release.model_dump_json(indent=2))
1001
+ else:
1002
+ text = release.render()
1003
+ if out is not None:
1004
+ write_report(out, text)
1005
+ console.print(f"[green]已写入 {_display_path(out)}[/green]")
1006
+ else:
1007
+ _emit_raw(text)
1008
+
1009
+ waived_total = len(waiver_result.waived_findings) + len(waived_verifications)
1010
+ if waived_total:
1011
+ console.print(f"[yellow]{waived_total} 条被豁免吃掉(记录在报告里)[/yellow]")
1012
+
1013
+ # 判定只由**没被豁免**的发现决定 —— 这正是豁免的用处。
1014
+ blocking = [f for f in waiver_result.kept_findings if f.severity.rank <= gate.rank]
1015
+ if blocking:
1016
+ raise typer.Exit(code=1)
1017
+
1018
+
1019
+ @app.command("answer")
1020
+ def answer_cmd(
1021
+ question_id: str = typer.Argument(..., help="问题 id,见 `preflight ask`"),
1022
+ value: str = typer.Argument(..., help="答案"),
1023
+ path: Path = typer.Option(Path("."), "--path"), # noqa: B008
1024
+ who: str = typer.Option("", "--who", help="谁答的(进审计记录)"),
1025
+ ) -> None:
1026
+ """回答一条必答问题。**答案落盘,第二次运行不再问。**"""
1027
+ knowledge = _load()
1028
+ known = {question.id for question in knowledge.questions.required} | {
1029
+ question.id for question in knowledge.questions.extra
1030
+ }
1031
+ if question_id not in known:
1032
+ console.print(f"[red]没有这个问题:{question_id}[/red]")
1033
+ console.print(f"[dim]可选:{', '.join(sorted(known))}[/dim]")
1034
+ raise typer.Exit(code=2)
1035
+
1036
+ answers = [item for item in load_answers(path) if item.question_id != question_id]
1037
+ answers.append(
1038
+ Answer(
1039
+ question_id=question_id,
1040
+ value=value,
1041
+ who=who or None,
1042
+ when=datetime.now(UTC),
1043
+ source="human",
1044
+ )
1045
+ )
1046
+ saved = save_answers(path, sorted(answers, key=lambda item: item.question_id))
1047
+ console.print(f"[green]已记下 {question_id} = {value}[/green] {_display_path(saved)}")
1048
+
1049
+
1050
+ @app.command("ask")
1051
+ def ask_cmd(
1052
+ path: Path = typer.Argument(Path(".")), # noqa: B008
1053
+ profile: str = typer.Option("cli-oss", "--profile", "-p"),
1054
+ ) -> None:
1055
+ """看还有哪些必答问题没回答。"""
1056
+ knowledge = _load()
1057
+ repo = Repo.scan(path)
1058
+ outcome = probe_mechanisms(repo, knowledge)
1059
+ questionnaire = build_questionnaire(
1060
+ knowledge.questions,
1061
+ profile=profile,
1062
+ detected=set(outcome.detected),
1063
+ answers=load_answers(path),
1064
+ )
1065
+
1066
+ console.print(questionnaire.summary_line())
1067
+ console.print()
1068
+
1069
+ if questionnaire.required_pending:
1070
+ console.print(f"[yellow]必答({len(questionnaire.required_pending)} 条)[/yellow]")
1071
+ for question in questionnaire.required_pending:
1072
+ console.print(f" [cyan]{question.id}[/cyan] {escape(question.text)}")
1073
+ for option in question.options:
1074
+ console.print(f" [dim]· {escape(option)}[/dim]")
1075
+ console.print(f" [dim]为什么问:{escape(question.why)}[/dim]")
1076
+ console.print(f" [dim]会改变什么:{escape(question.effect)}[/dim]")
1077
+ else:
1078
+ console.print("[green]必答的都答完了。[/green]")
1079
+
1080
+ optional = [item for item in questionnaire.pending if not item.required]
1081
+ if optional:
1082
+ console.print()
1083
+ console.print(f"[dim]按档案展开的({len(optional)} 条,可选)[/dim]")
1084
+ for question in optional:
1085
+ console.print(f" [dim]{question.id} {escape(question.text)}[/dim]")
1086
+
1087
+ if questionnaire.from_docs:
1088
+ console.print()
1089
+ console.print(f"[dim]{len(questionnaire.from_docs)} 条文档里已经说了,不用问:[/dim]")
1090
+ for entry in questionnaire.from_docs:
1091
+ console.print(f" [dim]{escape(entry.describe())}[/dim]")
1092
+
1093
+
1094
+ @app.command("waive")
1095
+ def waive_cmd(
1096
+ rule_id: str = typer.Argument(..., help="规则 id"),
1097
+ path: Path = typer.Option(Path("."), "--path"), # noqa: B008
1098
+ who: str = typer.Option(..., "--who", help="谁批的(必填 —— 审计记录)"),
1099
+ why: str = typer.Option(..., "--why", help="为什么(必填 —— 审计记录)"),
1100
+ expires: str = typer.Option(..., "--expires", help="到期日 YYYY-MM-DD(必填)"),
1101
+ scope: str | None = typer.Option(None, "--scope", help="只在某个文件上豁免;不给就是全仓库"),
1102
+ ) -> None:
1103
+ """豁免一条规则。
1104
+
1105
+ ## 四样缺一不可:谁 / 何时 / 为什么 / 到期
1106
+
1107
+ 因为没有这四样的豁免,就是一个**「这条我们不管了」的口头决定** ——
1108
+ 它会一直生效到某天有人想起来为止,而那时已经没人记得为什么。
1109
+
1110
+ 到期之后**自动失效**,问题原样报回来。
1111
+ """
1112
+ knowledge = _load()
1113
+ if rule_id not in knowledge.rules:
1114
+ console.print(f"[red]没有这条规则:{rule_id}[/red]")
1115
+ raise typer.Exit(code=2)
1116
+
1117
+ try:
1118
+ deadline = date.fromisoformat(expires)
1119
+ except ValueError:
1120
+ console.print(f"[red]到期日格式不对:{expires}(要 YYYY-MM-DD)[/red]")
1121
+ raise typer.Exit(code=2) from None
1122
+
1123
+ if deadline <= date.today():
1124
+ console.print("[red]到期日不能是过去或今天 —— 那等于没豁免。[/red]")
1125
+ raise typer.Exit(code=2)
1126
+
1127
+ waivers = load_waivers(path)
1128
+ waivers.waivers.append(
1129
+ Waiver(
1130
+ rule_id=rule_id,
1131
+ path=scope,
1132
+ who=who,
1133
+ why=why,
1134
+ expires=deadline,
1135
+ created=datetime.now(UTC),
1136
+ )
1137
+ )
1138
+ saved = save_waivers(path, waivers)
1139
+ console.print(
1140
+ f"[green]已豁免 {rule_id}({scope or '全仓库'})到 {deadline}[/green]"
1141
+ f" {_display_path(saved)}"
1142
+ )
1143
+
1144
+
1145
+ @app.command("waivers")
1146
+ def waivers_cmd(
1147
+ path: Path = typer.Argument(Path(".")), # noqa: B008
1148
+ ) -> None:
1149
+ """**豁免审计** —— 列出所有未过期的豁免。
1150
+
1151
+ 豁免是一个**权力**,而任何权力都需要能被审计 ——
1152
+ 否则它会变成「有些人可以不走流程」。
1153
+ """
1154
+ waivers = load_waivers(path)
1155
+ active = waiver_audit(waivers)
1156
+ expired = expired_audit(waivers)
1157
+
1158
+ if active:
1159
+ console.print(f"[cyan]生效中的豁免({len(active)} 条)[/cyan]")
1160
+ for line in active:
1161
+ console.print(f" · {escape(line)}")
1162
+ else:
1163
+ console.print("[dim]没有生效中的豁免。[/dim]")
1164
+
1165
+ if expired:
1166
+ console.print()
1167
+ console.print(f"[yellow]已过期({len(expired)} 条)—— 问题已经回到报告里[/yellow]")
1168
+ for line in expired:
1169
+ console.print(f" · {escape(line)}")
1170
+
1171
+
1172
+ def _parse_day(text: str | None) -> date:
1173
+ if text is None:
1174
+ return date.today()
1175
+ try:
1176
+ return date.fromisoformat(text)
1177
+ except ValueError:
1178
+ console.print(f"[red]日期格式不对:{text}(要 YYYY-MM-DD)[/red]")
1179
+ raise typer.Exit(code=2) from None
1180
+
1181
+
1182
+ @app.command("demo")
1183
+ def demo_cmd(
1184
+ which: str = typer.Option("both", "--which", help="broken-agent / clean-agent / both"),
1185
+ keep: Path | None = typer.Option( # noqa: B008
1186
+ None, "--keep", help="把 demo 仓库留在这个目录(不给就写临时目录,用完删)"
1187
+ ),
1188
+ profile: str = typer.Option("agent-enterprise", "--profile", "-p", help="场景档案"),
1189
+ ) -> None:
1190
+ """**离线 demo** —— 不配 API Key、不联网就能看到结果。
1191
+
1192
+ ## 为什么这个命令必须存在
1193
+
1194
+ 「陌生人 5 分钟能跑通」的最低要求是:**他不需要先搞到一个 API key。**
1195
+
1196
+ 而这个工具最有说服力的东西(14 条 agent 规则)恰好全都在确定性层 ——
1197
+ 零 LLM、零联网、零配置。所以 demo 就该让这一点直接可见。
1198
+
1199
+ ## 它演示什么
1200
+
1201
+ 两份 agent,**同样的功能、两种写法**:
1202
+
1203
+ - ``broken-agent``:能跑、能查出点东西,而该有的保护一个都没有
1204
+ - ``clean-agent``:差别不是"多了很多代码",而是一处一处的 ``try`` /
1205
+ ``Semaphore`` / 一句告诉模型"被截断了"的话
1206
+
1207
+ **agent 的可靠性不在算法里,在这些细节里。**
1208
+ """
1209
+ knowledge = _load()
1210
+ chosen = knowledge.profiles.get(profile) or knowledge.profiles["agent-enterprise"]
1211
+
1212
+ names = ["broken-agent", "clean-agent"] if which == "both" else [which]
1213
+ for name in names:
1214
+ if name not in DEMOS:
1215
+ console.print(f"[red]没有这个 demo:{name}[/red]")
1216
+ console.print(f"[dim]可选:{', '.join(DEMOS)}[/dim]")
1217
+ raise typer.Exit(code=2)
1218
+
1219
+ results: list[tuple[str, Path, Review]] = []
1220
+ temporary: Path | None = None
1221
+
1222
+ try:
1223
+ for name in names:
1224
+ if keep is not None:
1225
+ root = _write_demo(keep / name, demo_files(name))
1226
+ else:
1227
+ if temporary is None:
1228
+ temporary = Path(tempfile.mkdtemp(prefix="preflight-demo-"))
1229
+ root = _write_demo(temporary / name, demo_files(name))
1230
+
1231
+ review = run_path(
1232
+ root,
1233
+ rules=knowledge.select(chosen.id),
1234
+ profile_id=chosen.id,
1235
+ fail_on=Severity.INFO,
1236
+ knowledge_digest=knowledge.digest(),
1237
+ )
1238
+ results.append((name, root, review))
1239
+
1240
+ _render_demo(results, chosen.id, knowledge)
1241
+
1242
+ finally:
1243
+ if temporary is not None:
1244
+ shutil.rmtree(temporary, ignore_errors=True)
1245
+
1246
+
1247
+ def _write_demo(root: Path, files: dict[str, str]) -> Path:
1248
+ """把 demo 素材写到磁盘上。
1249
+
1250
+ ⚠️ 显式 ``newline="\\n"`` —— 少了它,Windows 上写的 ``\\n`` 会变成
1251
+ ``\\r\\n``,于是 demo 在 Windows 和 Linux 上**字节不同**。
1252
+ 而这个 demo 是给人看的对照物,两边不一样会让"同一份代码跑两次结果一样"
1253
+ 这句话在跨平台时失效。
1254
+ """
1255
+ for relative, content in files.items():
1256
+ target = root / relative
1257
+ target.parent.mkdir(parents=True, exist_ok=True)
1258
+ target.write_text(content, encoding="utf-8", newline="\n")
1259
+ return root
1260
+
1261
+
1262
+ def _render_demo(
1263
+ results: Sequence[tuple[str, Path, Review]], profile: str, knowledge: Knowledge
1264
+ ) -> None:
1265
+ """把两份 demo 的结果**并排**打出来 —— 那一眼就够看懂。"""
1266
+ console.print()
1267
+ console.print(f"[bold]preflight demo[/bold] 档案 {profile} [dim](零 LLM、零联网)[/dim]")
1268
+ console.print()
1269
+
1270
+ for name, _root, review in results:
1271
+ triggered = sorted({finding.rule_id for finding in review.findings})
1272
+ agent_rules = [rule for rule in triggered if rule.startswith("agent/")]
1273
+ mark = "🔴" if review.findings else "🟢"
1274
+ note = ""
1275
+ if review.verdict == "INCOMPLETE":
1276
+ note = f" [dim]({len(review.skipped)} 条规则依赖外部工具,这台机器上没装)[/dim]"
1277
+ console.print(
1278
+ f"{mark} [bold]{name}[/bold] {len(review.findings)} 条发现"
1279
+ f"(agent 专项 {len(agent_rules)} 条) 判定 {review.verdict}{note}"
1280
+ )
1281
+
1282
+ table = Table(header_style="bold")
1283
+ table.add_column("规则", style="cyan", no_wrap=True)
1284
+ table.add_column("broken-agent", justify="center")
1285
+ table.add_column("clean-agent", justify="center")
1286
+ table.add_column("为什么重要", style="dim")
1287
+
1288
+ fired: dict[str, set[str]] = {}
1289
+ for name, _root, review in results:
1290
+ for finding in review.findings:
1291
+ fired.setdefault(finding.rule_id, set()).add(name)
1292
+
1293
+ for rule_id in sorted(fired, key=lambda item: (not item.startswith("agent/"), item)):
1294
+ rule = knowledge.rules.get(rule_id)
1295
+ table.add_row(
1296
+ rule_id,
1297
+ "❌" if "broken-agent" in fired[rule_id] else "—",
1298
+ "❌" if "clean-agent" in fired[rule_id] else "—",
1299
+ (rule.name if rule else "")[:40],
1300
+ )
1301
+ console.print(table)
1302
+
1303
+ console.print()
1304
+ console.print("[dim]两份 demo 的文件结构完全相同 —— 差别只在几行保护代码上。[/dim]")
1305
+ console.print("[dim]这就是 agent 可靠性的样子:不在算法里,在这些细节里。[/dim]")
1306
+ console.print()
1307
+ console.print("[dim]下一步:preflight run <你的项目> 看看你自己缺哪些。[/dim]")
1308
+
1309
+
1310
+ def main() -> None:
1311
+ app()
1312
+
1313
+
1314
+ if __name__ == "__main__": # pragma: no cover
1315
+ main()