@chinhae/atw-cli 0.6.0 → 0.7.0

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 (241) hide show
  1. package/README.md +128 -451
  2. package/dist/cli/index.d.ts.map +1 -1
  3. package/dist/cli/index.js +24 -1
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/commands/channel/adapters/claude.d.ts +44 -2
  6. package/dist/commands/channel/adapters/claude.d.ts.map +1 -1
  7. package/dist/commands/channel/adapters/claude.js +124 -23
  8. package/dist/commands/channel/adapters/claude.js.map +1 -1
  9. package/dist/commands/channel/adapters/codex.d.ts +36 -6
  10. package/dist/commands/channel/adapters/codex.d.ts.map +1 -1
  11. package/dist/commands/channel/adapters/codex.js +150 -10
  12. package/dist/commands/channel/adapters/codex.js.map +1 -1
  13. package/dist/commands/channel/adapters/index.d.ts +33 -4
  14. package/dist/commands/channel/adapters/index.d.ts.map +1 -1
  15. package/dist/commands/channel/adapters/index.js +159 -6
  16. package/dist/commands/channel/adapters/index.js.map +1 -1
  17. package/dist/commands/channel/adapters/omp.d.ts +106 -0
  18. package/dist/commands/channel/adapters/omp.d.ts.map +1 -0
  19. package/dist/commands/channel/adapters/omp.js +405 -0
  20. package/dist/commands/channel/adapters/omp.js.map +1 -0
  21. package/dist/commands/channel/adapters/pi.d.ts +123 -0
  22. package/dist/commands/channel/adapters/pi.d.ts.map +1 -0
  23. package/dist/commands/channel/adapters/pi.js +387 -0
  24. package/dist/commands/channel/adapters/pi.js.map +1 -0
  25. package/dist/commands/channel/adapters/types.d.ts +16 -0
  26. package/dist/commands/channel/adapters/types.d.ts.map +1 -1
  27. package/dist/commands/channel/agent-loader.d.ts +1 -0
  28. package/dist/commands/channel/agent-loader.d.ts.map +1 -1
  29. package/dist/commands/channel/agent-loader.js +45 -5
  30. package/dist/commands/channel/agent-loader.js.map +1 -1
  31. package/dist/commands/channel/dev-parse-trace.d.ts +1 -1
  32. package/dist/commands/channel/dev-parse-trace.d.ts.map +1 -1
  33. package/dist/commands/channel/dev-parse-trace.js +25 -2
  34. package/dist/commands/channel/dev-parse-trace.js.map +1 -1
  35. package/dist/commands/channel/index.d.ts.map +1 -1
  36. package/dist/commands/channel/index.js +44 -12
  37. package/dist/commands/channel/index.js.map +1 -1
  38. package/dist/commands/channel/run.d.ts +39 -2
  39. package/dist/commands/channel/run.d.ts.map +1 -1
  40. package/dist/commands/channel/run.js +227 -68
  41. package/dist/commands/channel/run.js.map +1 -1
  42. package/dist/commands/channel/spawn.d.ts +7 -2
  43. package/dist/commands/channel/spawn.d.ts.map +1 -1
  44. package/dist/commands/channel/spawn.js +35 -1
  45. package/dist/commands/channel/spawn.js.map +1 -1
  46. package/dist/commands/channel/store/events.d.ts +2 -2
  47. package/dist/commands/channel/store/events.d.ts.map +1 -1
  48. package/dist/commands/channel/store/events.js +1 -1
  49. package/dist/commands/channel/store/events.js.map +1 -1
  50. package/dist/commands/channel/supervisor/shutdown.d.ts +2 -0
  51. package/dist/commands/channel/supervisor/shutdown.d.ts.map +1 -1
  52. package/dist/commands/channel/supervisor/shutdown.js +6 -1
  53. package/dist/commands/channel/supervisor/shutdown.js.map +1 -1
  54. package/dist/commands/channel/supervisor.d.ts +2 -2
  55. package/dist/commands/channel/supervisor.d.ts.map +1 -1
  56. package/dist/commands/channel/supervisor.js +26 -3
  57. package/dist/commands/channel/supervisor.js.map +1 -1
  58. package/dist/commands/init.d.ts.map +1 -1
  59. package/dist/commands/init.js +6 -1
  60. package/dist/commands/init.js.map +1 -1
  61. package/dist/commands/mem.d.ts +2 -1
  62. package/dist/commands/mem.d.ts.map +1 -1
  63. package/dist/commands/mem.js +162 -13
  64. package/dist/commands/mem.js.map +1 -1
  65. package/dist/commands/mode.d.ts.map +1 -1
  66. package/dist/commands/mode.js +5 -3
  67. package/dist/commands/mode.js.map +1 -1
  68. package/dist/commands/uninstall.d.ts +5 -3
  69. package/dist/commands/uninstall.d.ts.map +1 -1
  70. package/dist/commands/uninstall.js +40 -16
  71. package/dist/commands/uninstall.js.map +1 -1
  72. package/dist/commands/update.d.ts +34 -0
  73. package/dist/commands/update.d.ts.map +1 -1
  74. package/dist/commands/update.js +209 -11
  75. package/dist/commands/update.js.map +1 -1
  76. package/dist/commands/verify.d.ts +18 -0
  77. package/dist/commands/verify.d.ts.map +1 -0
  78. package/dist/commands/verify.js +75 -0
  79. package/dist/commands/verify.js.map +1 -0
  80. package/dist/configurators/index.d.ts.map +1 -1
  81. package/dist/configurators/index.js +3 -2
  82. package/dist/configurators/index.js.map +1 -1
  83. package/dist/configurators/omp.d.ts +22 -0
  84. package/dist/configurators/omp.d.ts.map +1 -1
  85. package/dist/configurators/omp.js +101 -2
  86. package/dist/configurators/omp.js.map +1 -1
  87. package/dist/configurators/shared.d.ts.map +1 -1
  88. package/dist/configurators/shared.js +39 -7
  89. package/dist/configurators/shared.js.map +1 -1
  90. package/dist/configurators/workflow.d.ts.map +1 -1
  91. package/dist/configurators/workflow.js +5 -1
  92. package/dist/configurators/workflow.js.map +1 -1
  93. package/dist/constants/paths.d.ts +4 -0
  94. package/dist/constants/paths.d.ts.map +1 -1
  95. package/dist/constants/paths.js +4 -0
  96. package/dist/constants/paths.js.map +1 -1
  97. package/dist/migrations/manifests/0.7.0.json +1378 -0
  98. package/dist/templates/atw/definition-of-done.md +65 -0
  99. package/dist/templates/atw/gitignore.txt +7 -0
  100. package/dist/templates/atw/index.d.ts +11 -0
  101. package/dist/templates/atw/index.d.ts.map +1 -1
  102. package/dist/templates/atw/index.js +22 -0
  103. package/dist/templates/atw/index.js.map +1 -1
  104. package/dist/templates/atw/scripts/common/active_task.py +2 -2
  105. package/dist/templates/atw/scripts/common/workflow_phase.py +33 -0
  106. package/dist/templates/atw/scripts/cross_model_review.py +3312 -0
  107. package/dist/templates/atw/scripts/decision_log.py +2002 -0
  108. package/dist/templates/atw/scripts/floor_guard.py +642 -0
  109. package/dist/templates/atw/scripts/task.py +25 -6
  110. package/dist/templates/atw/scripts/tickets.py +480 -10
  111. package/dist/templates/atw/workflow-general.md +6 -1
  112. package/dist/templates/atw/workflow.md +346 -26
  113. package/dist/templates/claude/settings.json +12 -0
  114. package/dist/templates/codex/hooks/session-start.py +2 -2
  115. package/dist/templates/common/bundled-skills/atw-askme/SKILL.md +1 -1
  116. package/dist/templates/common/bundled-skills/atw-askme-with-docs/SKILL.md +1 -1
  117. package/dist/templates/common/bundled-skills/atw-channel/SKILL.md +5 -2
  118. package/dist/templates/common/bundled-skills/atw-channel/references/command-reference.md +44 -5
  119. package/dist/templates/common/bundled-skills/atw-channel/references/progress-debugging.md +9 -0
  120. package/dist/templates/common/bundled-skills/atw-channel/references/workers.md +48 -4
  121. package/dist/templates/common/bundled-skills/atw-code-review/SKILL.md +78 -3
  122. package/dist/templates/common/bundled-skills/atw-codebase-design/SKILL.md +2 -2
  123. package/dist/templates/common/bundled-skills/atw-codebase-design/{DESIGN-IT-TWICE.md → design-it-twice.md} +3 -3
  124. package/dist/templates/common/bundled-skills/atw-cross-model-review/SKILL.md +361 -0
  125. package/dist/templates/common/bundled-skills/atw-cross-model-review/agents/openai.yaml +5 -0
  126. package/dist/templates/common/bundled-skills/atw-cross-model-review/references/four-tiers.md +42 -0
  127. package/dist/templates/common/bundled-skills/atw-cross-model-review/references/log-review-questions.md +38 -0
  128. package/dist/templates/common/bundled-skills/atw-cross-model-review/references/questions.md +54 -0
  129. package/dist/templates/common/bundled-skills/atw-cross-model-review/references/recheck-questions.md +42 -0
  130. package/dist/templates/common/bundled-skills/atw-cross-model-review/references/report-format.md +22 -0
  131. package/dist/templates/common/bundled-skills/atw-diagnosing-bugs/SKILL.md +13 -2
  132. package/dist/templates/common/bundled-skills/atw-domain-modeling/SKILL.md +2 -2
  133. package/dist/templates/common/bundled-skills/atw-handoff/SKILL.md +1 -1
  134. package/dist/templates/common/bundled-skills/atw-implement/SKILL.md +37 -1
  135. package/dist/templates/common/bundled-skills/atw-implement-all/SKILL.md +219 -24
  136. package/dist/templates/common/bundled-skills/atw-implement-spec/SKILL.md +237 -18
  137. package/dist/templates/common/bundled-skills/atw-improve-codebase-architecture/SKILL.md +2 -2
  138. package/dist/templates/common/bundled-skills/atw-init-repo/SKILL.md +1 -1
  139. package/dist/templates/common/bundled-skills/atw-init-repo/references/issue-tracker-atw-local.md +7 -1
  140. package/dist/templates/common/bundled-skills/atw-init-repo/references/issue-tracker-atw.md +7 -1
  141. package/dist/templates/common/bundled-skills/atw-map/SKILL.md +1 -1
  142. package/dist/templates/common/bundled-skills/atw-meta/SKILL.md +1 -1
  143. package/dist/templates/common/bundled-skills/atw-meta/references/customize-local/add-project-local-conventions.md +4 -3
  144. package/dist/templates/common/bundled-skills/atw-meta/references/customize-local/change-skills-or-commands.md +21 -16
  145. package/dist/templates/common/bundled-skills/atw-meta/references/customize-local/change-task-lifecycle.md +1 -1
  146. package/dist/templates/common/bundled-skills/atw-meta/references/customize-local/overview.md +1 -0
  147. package/dist/templates/common/bundled-skills/atw-meta/references/local-architecture/task-system.md +2 -1
  148. package/dist/templates/common/bundled-skills/atw-meta/references/platform-files/hooks-and-settings.md +1 -0
  149. package/dist/templates/common/bundled-skills/atw-noob-mode/SKILL.md +1 -1
  150. package/dist/templates/common/bundled-skills/atw-performance/SKILL.md +134 -0
  151. package/dist/templates/common/bundled-skills/atw-performance/agents/openai.yaml +3 -0
  152. package/dist/templates/common/bundled-skills/atw-performance/references/optimization-patterns.md +250 -0
  153. package/dist/templates/common/bundled-skills/atw-performance/references/performance-checklist.md +238 -0
  154. package/dist/templates/common/bundled-skills/atw-performance/references/performance-optimization.md +264 -0
  155. package/dist/templates/common/bundled-skills/atw-prototype/SKILL.md +3 -3
  156. package/dist/templates/common/bundled-skills/atw-prototype/{LOGIC.md → logic.md} +1 -1
  157. package/dist/templates/common/bundled-skills/atw-prototype/references/design-direction.md +1 -1
  158. package/dist/templates/common/bundled-skills/atw-prototype/references/style-explorer.md +1 -1
  159. package/dist/templates/common/bundled-skills/atw-prototype/{UI.md → ui.md} +4 -4
  160. package/dist/templates/common/bundled-skills/atw-reflect/SKILL.md +77 -0
  161. package/dist/templates/common/bundled-skills/atw-reflect/agents/openai.yaml +5 -0
  162. package/dist/templates/common/bundled-skills/atw-reflect/references/divergent-reviewer.md +38 -0
  163. package/dist/templates/common/bundled-skills/atw-reflect/references/judgment-reviewer.md +37 -0
  164. package/dist/templates/common/bundled-skills/atw-reflect/references/synthesizer.md +63 -0
  165. package/dist/templates/common/bundled-skills/atw-reflect/references/tooling-reviewer.md +48 -0
  166. package/dist/templates/common/bundled-skills/atw-security/SKILL.md +248 -0
  167. package/dist/templates/common/bundled-skills/atw-security/agents/openai.yaml +3 -0
  168. package/dist/templates/common/bundled-skills/atw-security/references/hardening-patterns.md +324 -0
  169. package/dist/templates/common/bundled-skills/atw-security/references/security-checklist.md +248 -0
  170. package/dist/templates/common/bundled-skills/atw-session-insight/SKILL.md +1 -1
  171. package/dist/templates/common/bundled-skills/atw-session-insight/references/cli-quick-reference.md +11 -2
  172. package/dist/templates/common/bundled-skills/atw-spec/SKILL.md +1 -1
  173. package/dist/templates/common/bundled-skills/atw-tdd/SKILL.md +25 -0
  174. package/dist/templates/common/bundled-skills/atw-tickets/SKILL.md +1 -1
  175. package/dist/templates/common/bundled-skills/atw-triage/SKILL.md +5 -5
  176. package/dist/templates/common/bundled-skills/atw-ui/SKILL.md +54 -1
  177. package/dist/templates/common/bundled-skills/atw-ui/references/accessibility-checklist.md +160 -0
  178. package/dist/templates/common/bundled-skills/atw-verify-skill/SKILL.md +58 -0
  179. package/dist/templates/common/bundled-skills/atw-verify-skill/agents/openai.yaml +5 -0
  180. package/dist/templates/common/bundled-skills/atw-verify-skill/references/feature-map-example/README.md +47 -0
  181. package/dist/templates/common/bundled-skills/atw-verify-skill/references/feature-map-example/create-note.md +39 -0
  182. package/dist/templates/common/bundled-skills/atw-verify-skill/references/feature-map-example/search.md +45 -0
  183. package/dist/templates/common/bundled-skills/atw-verify-skill/references/generate.md +107 -0
  184. package/dist/templates/common/bundled-skills/atw-verify-skill/references/health-check.md +100 -0
  185. package/dist/templates/common/bundled-skills/atw-verify-skill/references/verify-skill-template.md +99 -0
  186. package/dist/templates/common/bundled-skills/atw-what-happened/SKILL.md +49 -0
  187. package/dist/templates/common/bundled-skills/atw-what-happened/agents/openai.yaml +5 -0
  188. package/dist/templates/common/commands/finish-work.md +10 -1
  189. package/dist/templates/common/commands/start.md +3 -1
  190. package/dist/templates/common/skills/update-spec.md +40 -4
  191. package/dist/templates/copilot/hooks/session-start.py +2 -2
  192. package/dist/templates/dsh/DSH.md +2 -2
  193. package/dist/templates/markdown/spec/backend/quality-guidelines.md.txt +10 -0
  194. package/dist/templates/markdown/spec/frontend/quality-guidelines.md.txt +10 -0
  195. package/dist/templates/omp/config.yml +15 -0
  196. package/dist/templates/omp/extensions/atw/index.ts.txt +135 -18
  197. package/dist/templates/omp/index.d.ts +1 -0
  198. package/dist/templates/omp/index.d.ts.map +1 -1
  199. package/dist/templates/omp/index.js +3 -0
  200. package/dist/templates/omp/index.js.map +1 -1
  201. package/dist/templates/opencode/lib/session-utils.js +4 -2
  202. package/dist/templates/opencode/plugins/inject-workflow-state.js +5 -1
  203. package/dist/templates/pi/extensions/atw/index.ts.txt +80 -7
  204. package/dist/templates/shared-hooks/index.d.ts +8 -1
  205. package/dist/templates/shared-hooks/index.d.ts.map +1 -1
  206. package/dist/templates/shared-hooks/index.js +8 -0
  207. package/dist/templates/shared-hooks/index.js.map +1 -1
  208. package/dist/templates/shared-hooks/inject-workflow-state.py +131 -14
  209. package/dist/templates/shared-hooks/session-start.py +4 -2
  210. package/dist/templates/shared-hooks/show-notice.py +135 -0
  211. package/dist/templates/snow/SNOW.md +1 -1
  212. package/dist/utils/initialized.d.ts +9 -0
  213. package/dist/utils/initialized.d.ts.map +1 -0
  214. package/dist/utils/initialized.js +24 -0
  215. package/dist/utils/initialized.js.map +1 -0
  216. package/dist/utils/managed-removal.d.ts +2 -0
  217. package/dist/utils/managed-removal.d.ts.map +1 -1
  218. package/dist/utils/managed-removal.js +23 -3
  219. package/dist/utils/managed-removal.js.map +1 -1
  220. package/dist/utils/optional-skills.d.ts +2 -0
  221. package/dist/utils/optional-skills.d.ts.map +1 -1
  222. package/dist/utils/optional-skills.js +1 -1
  223. package/dist/utils/optional-skills.js.map +1 -1
  224. package/dist/utils/template-hash.d.ts.map +1 -1
  225. package/dist/utils/template-hash.js +3 -0
  226. package/dist/utils/template-hash.js.map +1 -1
  227. package/dist/utils/uninstall-scrubbers.d.ts +10 -0
  228. package/dist/utils/uninstall-scrubbers.d.ts.map +1 -1
  229. package/dist/utils/uninstall-scrubbers.js +50 -0
  230. package/dist/utils/uninstall-scrubbers.js.map +1 -1
  231. package/dist/utils/verify-entries.d.ts +51 -0
  232. package/dist/utils/verify-entries.d.ts.map +1 -0
  233. package/dist/utils/verify-entries.js +399 -0
  234. package/dist/utils/verify-entries.js.map +1 -0
  235. package/package.json +2 -2
  236. /package/dist/templates/common/bundled-skills/atw-codebase-design/{DEEPENING.md → deepening.md} +0 -0
  237. /package/dist/templates/common/bundled-skills/atw-domain-modeling/{ADR-FORMAT.md → adr-format.md} +0 -0
  238. /package/dist/templates/common/bundled-skills/atw-domain-modeling/{GLOSSARY-FORMAT.md → glossary-format.md} +0 -0
  239. /package/dist/templates/common/bundled-skills/atw-improve-codebase-architecture/{HTML-REPORT.md → html-report.md} +0 -0
  240. /package/dist/templates/common/bundled-skills/atw-triage/{AGENT-BRIEF.md → agent-brief.md} +0 -0
  241. /package/dist/templates/common/bundled-skills/atw-triage/{OUT-OF-SCOPE.md → out-of-scope.md} +0 -0
@@ -0,0 +1,642 @@
1
+ #!/usr/bin/env python3
2
+ # floor-guard: opt-out — 这个文件的工作就是定义标记表,表里每一行都会被当成标记
3
+ """防降级守卫 —— 安装到 .atw/scripts/floor_guard.py。
4
+
5
+ floor_guard.py <patch> 扫一份冻结好的补丁文件,报出「放低门槛」的动作
6
+ floor_guard.py <patch> --waiting-on-you <清单> --decision-log <日志>
7
+ 同上,另外放行「等你处理」清单里按三条写明的占位
8
+
9
+ 输入是审查技能第 1 步冻结的补丁(`git diff --binary` 加 `git diff --no-index`
10
+ 拼出来的统一格式),本脚本不自己跑 git:审查子代理禁止跑 git,而且三方看的
11
+ 必须是同一份改动。
12
+
13
+ 只看新增行、删除行与被删文件,报七类规则(规则名即输出首列;标记类的三条
14
+ 不看 Markdown 等纯文本文件,文档里提到标记名只是字面提及):
15
+ skip-marker 新增的无条件跳过标记(it.skip / xit / it.todo / @pytest.mark.skip …);
16
+ 带条件的跳过不报:.skipIf(、pytest.mark.skipif、@unittest.skipIf,
17
+ 以及写在 `if …:` 同一行或紧随其后的 pytest.skip(
18
+ suppress-comment 新增的压制注释(ts-ignore / eslint-disable / noqa / type: ignore …)
19
+ test-file-deleted 被整个删掉的测试文件
20
+ assertion-removed 保留的测试文件里被删掉的断言——从 expect / assert / should 起到
21
+ 行尾的表达式在同文件新增行里找不到才算(只是被改动的不报)
22
+ ignore-entry 忽略文件与配置里新增的 ignore / allowlist / exclude 条目
23
+ placeholder 新增的桩(not implemented、单行 def: pass / ...)、
24
+ 体里只有注释的 catch、except: pass / ...、TODO
25
+ guard-opt-out 文件开头声明了「floor-guard: opt-out — 原因」的文件:只报这一行
26
+ (文件、原因),不逐行报;没有原因的声明不认。本脚本和它的测试
27
+ 样例就靠它——它们的工作是定义和测试标记。这是唯一不只看补丁的
28
+ 地方:工作区里有这个文件就读它的文件头(改动没碰文件头时补丁里
29
+ 看不到声明),没有才看补丁新侧的文件头
30
+
31
+ 放行(两个参数要一起给,传任务目录的 waiting-on-you.md 与 decision-log.md,都由
32
+ decision_log.py 的读法读):只看 placeholder 这一类。清单里有一条同时满足三条才放行——
33
+ 类别是「外部账号·密钥·付费」;条目还没勾掉,点名这个占位的 `文件:行`,写了缺什么
34
+ (「要你做什么」)和补上后怎么接上(「回来后可以输入」的命令);决定日志里条目记的时间上
35
+ 正是记进这一条的那一行,且没被顶替。放行的照样列出,行尾加「放行:等你处理第 N 条」。清单在守卫跑的那一刻读,
36
+ 所以审查第 1 步就跑,审查开始后才写的条目放行不了。
37
+
38
+ 退出码:
39
+ 0 干净,或报出的每一条都已放行
40
+ 1 有没放行的发现
41
+ 2 没跑起来(文件不存在、读不出来、不是补丁格式、hunk 行数对不上、
42
+ 清单或日志认不出、两个参数只给了一个、脚本自身异常)—— 2 绝不等于 0,审查技能把 2 当硬违规而不是当干净
43
+
44
+ 输出每条一行:规则名、文件:行号、标记名或片段开头(截到一行、不超过
45
+ SNIPPET_MAX 个字符,不完整回显——报告要可读,也不把行里的敏感值带出去)。
46
+ 行号:新增行取新文件的行号,删除行取旧文件的行号。
47
+ 文件名按 git 引号转义还原后显示,其中的控制字符写成 \\t、\\n、\\r、\\xNN,保持一条一行。
48
+
49
+ 第一版覆盖 JS/TS 与 Python 写法;标记都在下面的常量里,加新语言往列表里追加。
50
+ """
51
+ from __future__ import annotations
52
+
53
+ import argparse
54
+ import re
55
+ import sys
56
+ from dataclasses import dataclass, field
57
+ from pathlib import Path
58
+
59
+ from common import configure_encoding
60
+ from decision_log import LANGS, WAIT_LANGS, LogError, WaitItem, read_log, read_waiting, superseded_times
61
+
62
+ EXIT_CLEAN = 0
63
+ EXIT_FOUND = 1
64
+ EXIT_ERROR = 2
65
+
66
+ SNIPPET_MAX = 60
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # 可扩的标记表。每项 (显示名, 正则):显示名进报告,正则在一行内匹配
70
+ # ---------------------------------------------------------------------------
71
+
72
+ # 带条件的跳过不算:`.skipIf(`、`pytest.mark.skipif`、`@unittest.skipIf/skipUnless`
73
+ # 自带条件;`pytest.skip(` 本身无条件,写在 `if …:` 同一行、或所在块的块头是 `if …:`
74
+ # (上方最近一行缩进更浅的非空行)才算带条件
75
+ SKIP_MARKERS: list[tuple[str, re.Pattern[str]]] = [
76
+ # JS/TS:vitest / jest / mocha(`\.skip\s*\(` 不匹配 `.skipIf(`)
77
+ ("it.skip", re.compile(r"\b(?:it|test|describe|suite|context)\.skip\s*\(")),
78
+ ("it.only", re.compile(r"\b(?:it|test|describe|suite|context)\.only\s*\(")),
79
+ ("it.todo", re.compile(r"\b(?:it|test)\.todo\s*\(")),
80
+ ("xit", re.compile(r"\bx(?:it|test|describe|context|specify)\s*\(")),
81
+ # Python:pytest / unittest(`skip\b` 不匹配 `skipif` / `skipIf` / `skipUnless`)
82
+ ("pytest.mark.skip", re.compile(r"pytest\.mark\.(?:skip|xfail)\b")),
83
+ ("pytest.skip", re.compile(r"\bpytest\.(?:skip|xfail)\s*\(")),
84
+ ("unittest.skip", re.compile(r"@(?:unittest\.)?(?:skip|expectedFailure)\b")),
85
+ ]
86
+ RE_IF_LINE = re.compile(r"^\s*(?:if|elif)\b")
87
+ RE_IF_HEADER = re.compile(r"^\s*(?:if|elif)\b.*:\s*(?:#.*)?$")
88
+
89
+ SUPPRESS_MARKERS: list[tuple[str, re.Pattern[str]]] = [
90
+ ("ts-ignore", re.compile(r"@ts-ignore\b")),
91
+ ("ts-nocheck", re.compile(r"@ts-nocheck\b")),
92
+ ("eslint-disable", re.compile(r"\beslint-disable\b")),
93
+ ("biome-ignore", re.compile(r"\bbiome-ignore\b")),
94
+ ("istanbul ignore", re.compile(r"\bistanbul ignore\b")),
95
+ ("nosemgrep", re.compile(r"\bnosemgrep\b")),
96
+ ("gitleaks:allow", re.compile(r"\bgitleaks:allow\b")),
97
+ ("Stryker disable", re.compile(r"\bStryker disable\b")),
98
+ ("noqa", re.compile(r"#\s*noqa\b")),
99
+ ("type: ignore", re.compile(r"#\s*type:\s*ignore\b")),
100
+ ("pyright: ignore", re.compile(r"#\s*pyright:\s*ignore\b")),
101
+ ("pylint: disable", re.compile(r"#\s*pylint:\s*disable\b")),
102
+ ]
103
+
104
+ PLACEHOLDER_MARKERS: list[tuple[str, re.Pattern[str]]] = [
105
+ ("TODO", re.compile(r"\b(?:TODO|FIXME)\b")),
106
+ ("not implemented", re.compile(r"not implemented|NotImplementedError", re.IGNORECASE)),
107
+ # 单行空 catch / except / def:体里只有空白、注释、pass 或 ...
108
+ ("empty catch", re.compile(r"\bcatch\b[^{]*\{\s*(?:/\*.*?\*/\s*)*\}")),
109
+ ("except: pass", re.compile(r"^\s*except\b[^:]*:\s*(?:pass|\.\.\.)\s*(?:#.*)?$")),
110
+ ("def: pass", re.compile(r"^\s*(?:async\s+)?def\s+\w+\s*\(.*\)\s*(?:->\s*[^:]+)?:\s*(?:pass|\.\.\.)\s*(?:#.*)?$")),
111
+ ]
112
+ # 多行空 catch / except:这一行开了块,跳过只有注释的行,紧接着就收。
113
+ # 只认 catch / except,`() => {}` 这类空回调合法用法太多,不算
114
+ RE_CATCH_OPEN = re.compile(r"\bcatch\b[^{]*\{\s*$")
115
+ RE_BLOCK_CLOSE = re.compile(r"^\s*\}")
116
+ RE_JS_COMMENT = re.compile(r"^\s*(?://|/\*|\*)")
117
+ RE_EXCEPT_OPEN = re.compile(r"^\s*except\b[^:]*:\s*(?:#.*)?$")
118
+ RE_BARE_PASS = re.compile(r"^\s*(?:pass|\.\.\.)\s*(?:#.*)?$")
119
+ RE_PY_COMMENT = re.compile(r"^\s*#")
120
+
121
+ # 断言:删掉这些行就是在放低测试的门槛
122
+ ASSERTION_PATTERNS: list[re.Pattern[str]] = [
123
+ re.compile(r"\bexpect\s*\("),
124
+ re.compile(r"\bassert\b"),
125
+ re.compile(r"\bself\.assert\w*\s*\("),
126
+ re.compile(r"\bpytest\.raises\s*\("),
127
+ re.compile(r"\.should\b"),
128
+ ]
129
+
130
+ # 文件级退出声明:文件开头 OPT_OUT_HEAD_LINES 行内一行「floor-guard: opt-out — 原因」,
131
+ # 注释形式随语言(`# …`、`// …`、JSDoc 里的 ` * …` 都行)。有效声明的文件只报一行
132
+ # guard-opt-out(文件、原因),不逐行报;破折号后没有原因就无效,照常逐行报。
133
+ # 工作区里有这个文件就以它的文件头为准(审查在仓库根目录跑,补丁新侧就是工作区;
134
+ # 改动没碰文件头时补丁里看不到声明),没有(在别处重放补丁)才看补丁新侧的文件头
135
+ OPT_OUT_HEAD_LINES = 10
136
+ RE_OPT_OUT = re.compile(r"floor-guard:\s*opt-out\b\s*(?:[—–-]+\s*(?P<reason>.*))?$")
137
+
138
+ # 纯文本文件里提到 TODO、eslint-disable 只是字面提及,不是桩也不是压制——
139
+ # 标记类规则(skip / suppress / placeholder)跳过这些扩展名
140
+ PROSE_EXTENSIONS = (".md", ".markdown", ".txt", ".rst") # .mdx 能嵌真代码,不在此列
141
+
142
+ # 哪些路径算测试文件
143
+ TEST_FILE_PATTERNS: list[re.Pattern[str]] = [
144
+ re.compile(r"\.(?:test|spec)\.[cm]?[jt]sx?$"),
145
+ re.compile(r"(?:^|/)(?:test|tests|__tests__|spec)/"),
146
+ re.compile(r"(?:^|/)test_[^/]*\.py$"),
147
+ re.compile(r"_test\.py$"),
148
+ re.compile(r"(?:^|/)conftest\.py$"),
149
+ ]
150
+
151
+ # 忽略文件:每一条非空、非注释的新增行都是一个放行条目
152
+ RE_IGNORE_FILE = re.compile(r"(?:^|/)\.[\w.-]*ignore$")
153
+ # 配置文件:只在这些文件里认 ignore / allowlist / exclude 键
154
+ CONFIG_FILE_PATTERNS: list[re.Pattern[str]] = [
155
+ re.compile(r"\.config\.[cm]?[jt]s$"),
156
+ re.compile(r"\.(?:json|jsonc|json5|ya?ml|toml|ini|cfg)$"),
157
+ re.compile(r"(?:^|/)\.(?:eslintrc|babelrc|pylintrc|flake8|coveragerc|prettierrc|stylelintrc)(?:\.\w+)?$"),
158
+ re.compile(r"(?:^|/)(?:setup|tox|pytest)\.(?:cfg|ini)$"),
159
+ ]
160
+ # 键名按族认:含这些词的键都算(ignores、ignorePatterns、exclude_lines、
161
+ # exclude_also…)。裸 `allow` / `skip` 不算——`.claude/settings.json` 的
162
+ # `permissions.allow` 每加一条都会报,接进审查后就是常态噪音
163
+ IGNORE_KEY_FAMILIES = ("ignore", "exclude", "allowlist", "allow_list", "allow-list")
164
+ _IGNORE_KEY_NAME = r"[\w.-]*(?:%s)[\w.-]*" % "|".join(re.escape(k) for k in IGNORE_KEY_FAMILIES)
165
+ RE_IGNORE_KEY = re.compile(
166
+ r"""^\s*(?:"%s"|'%s'|%s)\s*[:=]\s*(?P<value>.*)$""" % ((_IGNORE_KEY_NAME,) * 3),
167
+ re.IGNORECASE,
168
+ )
169
+ # 任何「键 = / 键 :」形状的行:遇到别的键就说明离开了 ignore 列表块。
170
+ # 带引号的键必须整对闭合,否则列表项 `"pragma: no cover",` 会被当成键
171
+ RE_ANY_KEY = re.compile(r"""^\s*(?:"[^"]+"|'[^']+'|[\w.$-]+)\s*[:=]""")
172
+ RE_LIST_CLOSE = re.compile(r"^\s*[\]\})]")
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # 补丁解析
177
+ # ---------------------------------------------------------------------------
178
+
179
+ @dataclass
180
+ class Line:
181
+ kind: str # "+" / "-" / " "
182
+ text: str # 去掉前缀后的内容
183
+ old_no: int # 旧文件行号(新增行为 0)
184
+ new_no: int # 新文件行号(删除行为 0)
185
+
186
+
187
+ @dataclass
188
+ class FileDiff:
189
+ path: str
190
+ deleted: bool = False
191
+ lines: list[Line] = field(default_factory=list)
192
+
193
+
194
+ class PatchError(Exception):
195
+ """文件不存在、读不出来、不是补丁 —— main 统一转成退出码 2。"""
196
+
197
+
198
+ RE_HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
199
+
200
+ # git 给含中文、引号、制表符、反斜杠等的路径加双引号,按 C 风格转义;
201
+ # 非 ASCII 字节写成三位八进制(中文即 UTF-8 字节的 \ooo)
202
+ RE_GIT_ESCAPE = re.compile(r'\\([0-7]{3}|[abtnvfr"\\])')
203
+ GIT_ESCAPES = {"a": 7, "b": 8, "t": 9, "n": 10, "v": 11, "f": 12, "r": 13, '"': 34, "\\": 92}
204
+
205
+
206
+ def _unquote_git_path(body: str) -> str:
207
+ """还原 git 加了引号的路径;没加引号的原样返回。"""
208
+ if len(body) < 2 or not (body.startswith('"') and body.endswith('"')):
209
+ return body
210
+ out = bytearray()
211
+ for i, part in enumerate(RE_GIT_ESCAPE.split(body[1:-1])):
212
+ if i % 2 == 0:
213
+ out += part.encode("utf-8")
214
+ elif len(part) == 3:
215
+ out.append(int(part, 8))
216
+ else:
217
+ out.append(GIT_ESCAPES[part])
218
+ return out.decode("utf-8", errors="replace")
219
+
220
+
221
+ def _header_path(line: str) -> str | None:
222
+ """从 `--- a/x` / `+++ b/x` 取路径(先还原引号转义);/dev/null 返回 None。"""
223
+ body = line[4:].rstrip("\n")
224
+ # 路径里有空格时 git 在末尾补一个制表符
225
+ body = _unquote_git_path(body.rstrip("\t"))
226
+ if body == "/dev/null":
227
+ return None
228
+ if body.startswith(("a/", "b/")):
229
+ body = body[2:]
230
+ return body
231
+
232
+
233
+ def parse_patch(text: str) -> list[FileDiff]:
234
+ files: list[FileDiff] = []
235
+ current: FileDiff | None = None
236
+ old_path: str | None = None
237
+ new_path: str | None = None
238
+ old_left = new_left = 0
239
+ old_no = new_no = 0
240
+ in_binary = False
241
+
242
+ def start_file() -> None:
243
+ nonlocal current
244
+ current = FileDiff(path="")
245
+ files.append(current)
246
+
247
+ def check_hunk_closed(where: str) -> None:
248
+ # 行数对不上就是补丁被截断或改过——漏看后面的文件比报错糟得多
249
+ if old_left > 0 or new_left > 0:
250
+ raise PatchError(f"补丁格式有误:{where},hunk 比头部声明的行数少({current.path if current else '?'})")
251
+
252
+ for lineno, raw in enumerate(text.splitlines(), 1):
253
+ in_hunk = old_left > 0 or new_left > 0
254
+ if in_hunk and current is not None:
255
+ if raw.startswith("\\"):
256
+ continue # ""
257
+ kind = raw[:1] or " "
258
+ body = raw[1:]
259
+ if kind not in "+- ":
260
+ raise PatchError(f"补丁格式有误:第 {lineno} 行不是 hunk 内容行({current.path})")
261
+ if kind == "+":
262
+ new_no += 1
263
+ new_left -= 1
264
+ current.lines.append(Line("+", body, 0, new_no))
265
+ elif kind == "-":
266
+ old_no += 1
267
+ old_left -= 1
268
+ current.lines.append(Line("-", body, old_no, 0))
269
+ else:
270
+ old_no += 1
271
+ new_no += 1
272
+ old_left -= 1
273
+ new_left -= 1
274
+ current.lines.append(Line(" ", body, old_no, new_no))
275
+ continue
276
+
277
+ if raw.startswith("diff --git "):
278
+ check_hunk_closed(f"第 {lineno} 行开始下一个文件时")
279
+ start_file()
280
+ old_path = new_path = None
281
+ in_binary = False
282
+ continue
283
+ if current is None:
284
+ continue
285
+ if in_binary:
286
+ continue
287
+ if raw.startswith("GIT binary patch") or raw.startswith("Binary files "):
288
+ in_binary = True
289
+ continue
290
+ if raw.startswith("deleted file mode"):
291
+ current.deleted = True
292
+ continue
293
+ if raw.startswith("--- "):
294
+ old_path = _header_path(raw)
295
+ continue
296
+ if raw.startswith("+++ "):
297
+ new_path = _header_path(raw)
298
+ # 新文件没有旧路径、删除的文件没有新路径——取有的那一侧
299
+ current.path = new_path or old_path or current.path
300
+ if new_path is None and old_path is not None:
301
+ current.deleted = True
302
+ continue
303
+ m = RE_HUNK.match(raw)
304
+ if m:
305
+ old_no = int(m.group(1)) - 1
306
+ old_left = int(m.group(2)) if m.group(2) is not None else 1
307
+ new_no = int(m.group(3)) - 1
308
+ new_left = int(m.group(4)) if m.group(4) is not None else 1
309
+ continue
310
+ if raw[:1] in ("+", "-") and not raw.startswith(("+++ ", "--- ")):
311
+ raise PatchError(f"补丁格式有误:第 {lineno} 行在 hunk 之外出现 {raw[:1]} 行,hunk 头部声明的行数偏少({current.path})")
312
+
313
+ check_hunk_closed("到文件末尾时")
314
+ files = [f for f in files if f.path]
315
+ if not files:
316
+ raise PatchError("不是补丁格式:没有找到任何 `diff --git` 文件头")
317
+ return files
318
+
319
+
320
+ # ---------------------------------------------------------------------------
321
+ # 规则
322
+ # ---------------------------------------------------------------------------
323
+
324
+ @dataclass
325
+ class Finding:
326
+ rule: str
327
+ path: str
328
+ line_no: int
329
+ detail: str
330
+ passed_by: int | None = None # 放行它的「等你处理」条目号
331
+
332
+
333
+ def is_test_file(path: str) -> bool:
334
+ return any(p.search(path) for p in TEST_FILE_PATTERNS)
335
+
336
+
337
+ def is_config_file(path: str) -> bool:
338
+ return any(p.search(path) for p in CONFIG_FILE_PATTERNS)
339
+
340
+
341
+ RE_COMMENT_LINE = re.compile(r"^\s*(?:#|//|/\*|\*)")
342
+
343
+
344
+ def assertion_key(text: str) -> str | None:
345
+ """断言表达式:从 expect / assert / should 起到行尾;不是断言行返回 None。
346
+
347
+ 删除行与新增行按它比对:一行断言只是被改动(前面加了跳过条件)时表达式还在,
348
+ 不另报「断言被删」;表达式本身变了或真删了才报。
349
+ """
350
+ # 注释里的「assert」不是断言,删掉它不放低任何门槛
351
+ if RE_COMMENT_LINE.match(text):
352
+ return None
353
+ starts = [m.start() for m in (p.search(text) for p in ASSERTION_PATTERNS) if m]
354
+ return text[min(starts):].strip() if starts else None
355
+
356
+
357
+ def snippet(text: str) -> str:
358
+ """片段开头:去首尾空白、截到 SNIPPET_MAX,超出加省略号。"""
359
+ s = text.strip()
360
+ return s if len(s) <= SNIPPET_MAX else s[:SNIPPET_MAX] + "…"
361
+
362
+
363
+ def match_markers(text: str, markers: list[tuple[str, re.Pattern[str]]]) -> str | None:
364
+ for name, pat in markers:
365
+ if pat.search(text):
366
+ return name
367
+ return None
368
+
369
+
370
+ def _opt_out_in(head: list[tuple[int, str]]) -> tuple[int, str] | None:
371
+ for no, text in head:
372
+ m = RE_OPT_OUT.search(text)
373
+ if not m:
374
+ continue
375
+ reason = (m.group("reason") or "").strip().removesuffix("*/").strip()
376
+ if reason:
377
+ return no, reason
378
+ return None
379
+
380
+
381
+ def opt_out(f: FileDiff) -> tuple[int, str] | None:
382
+ """有效退出声明的 (行号, 原因);没有返回 None。"""
383
+ disk = Path(f.path)
384
+ if disk.is_file():
385
+ try:
386
+ head = disk.read_text(encoding="utf-8", errors="replace").splitlines()
387
+ except OSError:
388
+ head = []
389
+ return _opt_out_in(list(enumerate(head[:OPT_OUT_HEAD_LINES], 1)))
390
+ return _opt_out_in(
391
+ [(l.new_no, l.text) for l in f.lines if l.kind != "-" and l.new_no <= OPT_OUT_HEAD_LINES]
392
+ )
393
+
394
+
395
+ def scan_file(f: FileDiff) -> list[Finding]:
396
+ found: list[Finding] = []
397
+ if f.deleted:
398
+ if is_test_file(f.path):
399
+ found.append(Finding("test-file-deleted", f.path, 0, "deleted"))
400
+ # 被删文件里的每一行都是删除行,再逐行报断言只是噪音
401
+ return found
402
+
403
+ declared = opt_out(f)
404
+ if declared is not None:
405
+ return [Finding("guard-opt-out", f.path, declared[0], snippet(declared[1]))]
406
+
407
+ added = [l for l in f.lines if l.kind == "+"]
408
+ # 新文件视角的行序(新增 + 上下文),多行空 catch 要看「下一行」
409
+ new_side = [l for l in f.lines if l.kind != "-"]
410
+ test_file = is_test_file(f.path)
411
+ ignore_file = bool(RE_IGNORE_FILE.search(f.path))
412
+ config_file = is_config_file(f.path)
413
+ prose = f.path.lower().endswith(PROSE_EXTENSIONS)
414
+
415
+ for i, l in enumerate(new_side if not prose else []):
416
+ if l.kind != "+":
417
+ continue
418
+ name = match_markers(l.text, SKIP_MARKERS)
419
+ if name and not (name == "pytest.skip" and _under_if(new_side, i)):
420
+ found.append(Finding("skip-marker", f.path, l.new_no, name))
421
+ name = match_markers(l.text, SUPPRESS_MARKERS)
422
+ if name:
423
+ found.append(Finding("suppress-comment", f.path, l.new_no, name))
424
+ name = match_markers(l.text, PLACEHOLDER_MARKERS)
425
+ if name:
426
+ found.append(Finding("placeholder", f.path, l.new_no, name))
427
+
428
+ # 多行空 catch / except:开块那一行是新增的,体里只有注释,然后就收
429
+ for i, l in enumerate(new_side):
430
+ if l.kind != "+":
431
+ continue
432
+ if RE_CATCH_OPEN.search(l.text):
433
+ body = _block_body(new_side, i, RE_JS_COMMENT)
434
+ if body is not None and RE_BLOCK_CLOSE.match(body):
435
+ found.append(Finding("placeholder", f.path, l.new_no, "empty catch"))
436
+ elif RE_EXCEPT_OPEN.match(l.text):
437
+ body = _block_body(new_side, i, RE_PY_COMMENT)
438
+ if body is not None and RE_BARE_PASS.match(body):
439
+ found.append(Finding("placeholder", f.path, l.new_no, "except: pass"))
440
+
441
+ if test_file:
442
+ added_keys = {k for k in (assertion_key(l.text) for l in added) if k}
443
+ for l in f.lines:
444
+ if l.kind != "-":
445
+ continue
446
+ key = assertion_key(l.text)
447
+ if key and key not in added_keys:
448
+ found.append(Finding("assertion-removed", f.path, l.old_no, snippet(l.text)))
449
+
450
+ if ignore_file:
451
+ for l in added:
452
+ s = l.text.strip()
453
+ if s and not s.startswith("#"):
454
+ found.append(Finding("ignore-entry", f.path, l.new_no, snippet(l.text)))
455
+ elif config_file:
456
+ found.extend(scan_config(f, new_side))
457
+
458
+ return found
459
+
460
+
461
+ def _indent(text: str) -> int:
462
+ return len(text) - len(text.lstrip())
463
+
464
+
465
+ def _under_if(lines: list[Line], idx: int) -> bool:
466
+ """这一行自己以 `if …` 开头,或包住它的块头(上方最近一行缩进更浅的非空行)是 `if …:`。
467
+
468
+ 只看缩进更浅的那一行:同缩进的 `if x: y = 1` 是兄弟语句不是块头,不能借它免报。
469
+ 块头不在补丁里(hunk 没带到)就当无条件,照报。
470
+ """
471
+ here = lines[idx].text
472
+ if RE_IF_LINE.match(here):
473
+ return True
474
+ for l in reversed(lines[:idx]):
475
+ if not l.text.strip():
476
+ continue
477
+ if _indent(l.text) < _indent(here):
478
+ return bool(RE_IF_HEADER.match(l.text))
479
+ return False
480
+
481
+
482
+ def _block_body(lines: list[Line], start: int, comment: re.Pattern[str]) -> str | None:
483
+ """开块行之后第一行实质内容(跳过空行与注释);块到补丁尽头没收,返回 None。"""
484
+ for l in lines[start + 1:]:
485
+ if not l.text.strip() or comment.match(l.text):
486
+ continue
487
+ return l.text
488
+ return None
489
+
490
+
491
+ def scan_config(f: FileDiff, new_side: list[Line]) -> list[Finding]:
492
+ """配置里的放行条目:带值的 ignore 键本身,或 ignore 列表块里新增的项。
493
+
494
+ 块的边界靠形状判断:键行的值没有在本行闭合(`[`、`{` 或空)就进入块,
495
+ 遇到另一个「键 = / 键 :」或收尾括号就离开。够用于 JSON / YAML / TOML /
496
+ JS 配置的常见写法,不做完整解析。
497
+ """
498
+ found: list[Finding] = []
499
+ in_block = False
500
+ for l in new_side:
501
+ m = RE_IGNORE_KEY.match(l.text)
502
+ if m:
503
+ value = m.group("value").strip().rstrip(",").strip()
504
+ closed = bool(value) and value[-1] not in "[{("
505
+ if l.kind == "+" and closed and value not in ("[]", "{}", "''", '""'):
506
+ found.append(Finding("ignore-entry", f.path, l.new_no, snippet(l.text)))
507
+ in_block = not closed
508
+ continue
509
+ if in_block and (RE_LIST_CLOSE.match(l.text) or RE_ANY_KEY.match(l.text)):
510
+ in_block = False
511
+ if in_block and l.kind == "+" and l.text.strip() and not l.text.strip().startswith("#"):
512
+ found.append(Finding("ignore-entry", f.path, l.new_no, snippet(l.text)))
513
+ return found
514
+
515
+
516
+ def scan(files: list[FileDiff]) -> list[Finding]:
517
+ found: list[Finding] = []
518
+ for f in files:
519
+ found.extend(scan_file(f))
520
+ return found
521
+
522
+
523
+ RE_C0_CONTROL = re.compile(r"[\x00-\x1f]")
524
+ DISPLAY_ESCAPES = {"\t": "\\t", "\n": "\\n", "\r": "\\r"}
525
+
526
+
527
+ def display_path(path: str) -> str:
528
+ """只用于显示:文件名里的控制字符转义,报告保持一条一行;匹配仍用还原后的原名。"""
529
+ return RE_C0_CONTROL.sub(lambda m: DISPLAY_ESCAPES.get(m.group(), f"\\x{ord(m.group()):02x}"), path)
530
+
531
+
532
+ def format_finding(x: Finding) -> str:
533
+ path = display_path(x.path)
534
+ loc = f"{path}:{x.line_no}" if x.line_no else path
535
+ line = f"{x.rule:<18} {loc} {x.detail}"
536
+ if x.passed_by is not None:
537
+ line += f" 放行:等你处理第 {x.passed_by} 条"
538
+ return line
539
+
540
+
541
+ # ---------------------------------------------------------------------------
542
+ # 放行「等你处理」清单里写明的占位
543
+ # ---------------------------------------------------------------------------
544
+
545
+ # 只有「外部账号·密钥·付费」这一类的条目能放行占位(decision_log.py 的英文类别名)
546
+ PASS_CATEGORY = "account-key-paid"
547
+
548
+
549
+ def pass_placeholders(findings: list[Finding], wait_path: str, log_path: str) -> None:
550
+ """占位被清单里某一条按三条点名时,记下放行它的条目号。
551
+
552
+ 三条:类别是 PASS_CATEGORY;条目还没勾掉,点名这个占位的 `文件:行`、写了缺什么
553
+ (「要你做什么」)和补上后怎么接上(「回来后可以输入」的命令);决定日志里条目记的
554
+ 那个时间上,正是记进这一条的那一行,而且这一行没被顶替。两个文件都由 decision_log.py
555
+ 读,格式认不出就报错退 2。
556
+ """
557
+ try:
558
+ waiting = read_waiting(Path(wait_path))
559
+ log = read_log(Path(log_path))
560
+ except LogError as e:
561
+ raise PatchError(str(e))
562
+ if waiting is None:
563
+ raise PatchError(f"「等你处理」清单不存在:{wait_path}")
564
+ if log is None:
565
+ raise PatchError(f"决定日志不存在:{log_path}")
566
+ lang, _, all_items = waiting
567
+ unknown = WAIT_LANGS[lang].unknown
568
+ superseded = superseded_times(log[1])
569
+ rows = {r.raw_time: r for r in log[1] if r.raw_time not in superseded}
570
+
571
+ def can_pass(item: WaitItem) -> bool:
572
+ ask = str(item.fields.get("ask") or "")
573
+ row = rows.get(str(item.fields.get("log_time") or ""))
574
+ return (
575
+ item.fields.get("category") == PASS_CATEGORY
576
+ and not item.done
577
+ and ask not in ("", unknown)
578
+ and bool(item.fields.get("commands"))
579
+ and row is not None
580
+ and row.result in {spec.wait_added.format(n=item.number) for spec in LANGS.values()}
581
+ )
582
+
583
+ def named(item: WaitItem) -> list[str]:
584
+ value = item.fields.get("placeholders")
585
+ return [str(v) for v in value] if isinstance(value, list) else []
586
+
587
+ items = [i for i in all_items if can_pass(i)]
588
+ for x in findings:
589
+ if x.rule != "placeholder":
590
+ continue
591
+ loc = f"{x.path}:{x.line_no}"
592
+ x.passed_by = next((i.number for i in items if loc in named(i)), None)
593
+
594
+
595
+ # ---------------------------------------------------------------------------
596
+ # 入口
597
+ # ---------------------------------------------------------------------------
598
+
599
+ def read_patch(path_str: str) -> str:
600
+ path = Path(path_str)
601
+ if not path.is_file():
602
+ raise PatchError(f"补丁文件不存在:{path}")
603
+ try:
604
+ text = path.read_text(encoding="utf-8", errors="replace")
605
+ except OSError as e:
606
+ raise PatchError(f"补丁文件读不出来:{path}({e})")
607
+ if not text.strip():
608
+ raise PatchError(f"补丁文件是空的:{path}")
609
+ return text
610
+
611
+
612
+ def main(argv: list[str] | None = None) -> int:
613
+ configure_encoding()
614
+ p = argparse.ArgumentParser(description="ATW 防降级守卫:扫冻结补丁里放低门槛的动作")
615
+ p.add_argument("patch", nargs="?", help="审查技能冻结好的补丁文件路径")
616
+ p.add_argument("--waiting-on-you", help="任务的 waiting-on-you.md")
617
+ p.add_argument("--decision-log", help="任务的 decision-log.md")
618
+ args = p.parse_args(argv)
619
+ try:
620
+ if not args.patch:
621
+ raise PatchError("需要补丁文件路径")
622
+ if bool(args.waiting_on_you) != bool(args.decision_log):
623
+ raise PatchError("--waiting-on-you 与 --decision-log 要一起给")
624
+ findings = scan(parse_patch(read_patch(args.patch)))
625
+ if args.waiting_on_you:
626
+ pass_placeholders(findings, args.waiting_on_you, args.decision_log)
627
+ except PatchError as e:
628
+ print(f"错误:{e}", file=sys.stderr)
629
+ return EXIT_ERROR
630
+ except Exception as e: # 没预料到的异常也是「没跑起来」,不能借默认退出码 1 冒充「有发现」
631
+ print(f"错误:防降级守卫自身出错:{type(e).__name__}: {e}", file=sys.stderr)
632
+ return EXIT_ERROR
633
+ if not findings:
634
+ print("(无发现)")
635
+ return EXIT_CLEAN
636
+ for x in findings:
637
+ print(format_finding(x))
638
+ return EXIT_FOUND if any(x.passed_by is None for x in findings) else EXIT_CLEAN
639
+
640
+
641
+ if __name__ == "__main__":
642
+ sys.exit(main())