plan-governance-cli 0.1.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.
- package/README.md +312 -0
- package/bin/plan-governance-cli.mjs +39 -0
- package/package.json +23 -0
- package/scripts/check_plan_governance.py +748 -0
package/README.md
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
# plan-governance
|
|
2
|
+
|
|
3
|
+
`plan-governance` 是一套面向个人或小团队的轻量计划治理机制。
|
|
4
|
+
|
|
5
|
+
它不是任务清单,也不是完整项目管理系统。它解决的问题是:当一个任务跨阶段推进、影响公共契约、依赖真实反馈,或会和已有计划发生依赖/替代/冲突时,如何避免计划漂移、旧决策被覆盖、阶段边界失控和完成证据缺失。
|
|
6
|
+
|
|
7
|
+
## 什么时候使用
|
|
8
|
+
|
|
9
|
+
适合启用治理的信号:
|
|
10
|
+
|
|
11
|
+
- 跨阶段、里程碑或多轮会话。
|
|
12
|
+
- 涉及架构、公共 API、Schema、兼容性或迁移行为。
|
|
13
|
+
- 后续需要真实运行报告、样本或反馈。
|
|
14
|
+
- 多个计划之间存在依赖、替代、重叠或冲突。
|
|
15
|
+
- 同一模块会被多个后续计划反复修改。
|
|
16
|
+
|
|
17
|
+
不适合启用治理的场景:
|
|
18
|
+
|
|
19
|
+
- 一次性 bugfix。
|
|
20
|
+
- 小范围本地修改。
|
|
21
|
+
- 互不依赖的待办列表。
|
|
22
|
+
- 不影响公共契约的普通实现任务。
|
|
23
|
+
|
|
24
|
+
## 文档结构
|
|
25
|
+
|
|
26
|
+
最小结构:
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
docs/
|
|
30
|
+
PLAN_MAP.md
|
|
31
|
+
plans/
|
|
32
|
+
<plan-name>.md
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
按需增加:
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
docs/
|
|
39
|
+
adr/
|
|
40
|
+
0001-<decision>.md
|
|
41
|
+
migrations/
|
|
42
|
+
<migration-name>.md
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
文档权责:
|
|
46
|
+
|
|
47
|
+
| 文档 | 权威内容 |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `docs/PLAN_MAP.md` | 计划索引、状态、依赖、替代/合并/废弃关系、推荐顺序、阻塞项、证据链接 |
|
|
50
|
+
| `docs/plans/*.md` | 单个计划的目标、阶段、当前步骤、字段方案、Schema、枚举、Step 0 证据、验证方式、完成条件 |
|
|
51
|
+
| `docs/adr/*.md` | 关键架构决策、备选方案和后果 |
|
|
52
|
+
| `docs/migrations/*.md` | 兼容策略、迁移步骤、回滚方式和旧行为保留窗口 |
|
|
53
|
+
|
|
54
|
+
## 多文档同步
|
|
55
|
+
|
|
56
|
+
- 专项计划是实施细节事实源,记录字段方案、Schema、枚举、Step 0 证据、验证方式和完成条件。
|
|
57
|
+
- `docs/PLAN_MAP.md` 是状态、依赖、替代/合并/废弃关系、推荐顺序、阻塞项和证据链接的事实源。
|
|
58
|
+
- 总路线图、优先级计划和索引只记录顺序、状态摘要和专项计划链接,不复制字段级方案、枚举、Step 0 细节或完成定义。
|
|
59
|
+
- 当专项计划的状态、字段方案、完成条件或验证结果变化时,必须同步 `docs/PLAN_MAP.md` 和所有引用该计划的路线图、优先级计划或索引。
|
|
60
|
+
- 验收治理文档时,必须用 `rg` 搜索同名计划、P 编号、状态名和关键字段,检查是否存在重复定义或漂移。
|
|
61
|
+
- 如果同一事实在多个文档中重复,保留一个事实源,其他文档改为链接引用。
|
|
62
|
+
|
|
63
|
+
## 草案和历史文档
|
|
64
|
+
|
|
65
|
+
启用治理后,已有草案、历史设计、归档计划、临时分析文档等默认只作为背景材料,不再作为规范事实源。
|
|
66
|
+
|
|
67
|
+
- 新发生的目标、范围、公共契约、字段、Schema、状态语义、阶段、验证方式、完成条件、风险和回滚,应写入 `docs/plans/*.md`、ADR、migration 或正式 spec。
|
|
68
|
+
- 计划状态、依赖、替代/合并/废弃关系、推荐顺序、阻塞项和证据链接,应写入或同步 `docs/PLAN_MAP.md`。
|
|
69
|
+
- 不要为了“保持一致”而修改草案或历史文档;除非用户明确点名该文件,否则草案只在治理文档中作为背景材料引用。
|
|
70
|
+
- 验收时应搜索并修正“草案为准”“以草案为事实源”“详见草案”等表达。
|
|
71
|
+
|
|
72
|
+
## 初始化
|
|
73
|
+
|
|
74
|
+
在目标项目根目录运行:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
python3 scripts/init_plan_governance.py \
|
|
78
|
+
--root . \
|
|
79
|
+
--plan api-compat-migration \
|
|
80
|
+
--title "API 兼容性迁移" \
|
|
81
|
+
--goal "分阶段完成 API 兼容性迁移" \
|
|
82
|
+
--copy-checker \
|
|
83
|
+
--update-agent-rules
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
这会创建:
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
.git/
|
|
90
|
+
docs/PLAN_MAP.md
|
|
91
|
+
docs/plans/api-compat-migration.md
|
|
92
|
+
scripts/check_plan_governance.py
|
|
93
|
+
CLAUDE.md
|
|
94
|
+
AGENTS.md
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
如果目标目录还不是 Git 仓库,初始化流程会先执行 `git init`;已有 `.git/` 时会跳过,不重复初始化。
|
|
98
|
+
|
|
99
|
+
`--update-agent-rules` 会创建或更新 `CLAUDE.md` 和 `AGENTS.md` 中带标记的计划治理章节,只写稳定执行规则,不写具体计划内容。具体计划仍以 `docs/PLAN_MAP.md` 和 `docs/plans/*.md` 为准。
|
|
100
|
+
|
|
101
|
+
如果只需要更新单个入口,也可以使用:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
--update-claude-md
|
|
105
|
+
--update-agents-md
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
默认不会覆盖已有文件。需要覆盖时显式加:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
--force
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## 更新已有项目
|
|
115
|
+
|
|
116
|
+
如果项目已经有 `docs/PLAN_MAP.md` 和 `docs/plans/*.md`,不要重新初始化计划文档。只更新代理执行规则时运行:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
python3 scripts/init_plan_governance.py \
|
|
120
|
+
--root . \
|
|
121
|
+
--update-agent-rules-only
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
如果只更新单个入口,可以使用 `--update-claude-md-only` 或 `--update-agents-md-only`。
|
|
125
|
+
|
|
126
|
+
如果要升级已有项目的辅助文件,刷新检查脚本并更新代理规则,但不覆盖 `docs/`,运行:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
python3 scripts/init_plan_governance.py \
|
|
130
|
+
--root . \
|
|
131
|
+
--upgrade-existing
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`--upgrade-existing` 会:
|
|
135
|
+
|
|
136
|
+
- 覆盖更新 `scripts/check_plan_governance.py`
|
|
137
|
+
- 创建或更新 `CLAUDE.md` 和 `AGENTS.md` 中带标记的计划治理章节
|
|
138
|
+
- 保留已有 `docs/PLAN_MAP.md` 和 `docs/plans/*.md`
|
|
139
|
+
- 提示缺失的治理文档
|
|
140
|
+
|
|
141
|
+
## 检查
|
|
142
|
+
|
|
143
|
+
在仓库根目录运行:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
python3 scripts/check_plan_governance.py .
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
可选检查:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
python3 scripts/check_plan_governance.py . --drift
|
|
153
|
+
python3 scripts/check_plan_governance.py . --pre-commit
|
|
154
|
+
python3 scripts/check_plan_governance.py . --stale-days 10
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`--drift` 会检查工作区变更是否被活跃计划的 `影响模块或文件` 覆盖;`--pre-commit` 会检查 staged 变更,便于用户手动接入 Git hook。两者只输出 `WARNING`,不改变退出码。
|
|
158
|
+
|
|
159
|
+
如果已安装统一 npm CLI,可用全局入口运行同一套检查器:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
npm install -g plan-governance-cli
|
|
163
|
+
plan-governance-cli . --strict-readiness
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
也可以不全局安装,直接使用锁定版本:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
npx --yes plan-governance-cli@0.1.0 . --strict-readiness
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
npm CLI 只是统一入口,实际检查逻辑仍由包内版本化的 Python 检查器执行;迁移期间项目原有 Python 命令仍可作为回滚路径。
|
|
173
|
+
|
|
174
|
+
作用域匹配规则同样用于 `plan_governance_hook.py --event pre-write`:
|
|
175
|
+
|
|
176
|
+
- 优先提取列表项中的第一个反引号路径,例如 ``- `./scripts/`: 检查脚本``。
|
|
177
|
+
- 没有反引号时提取列表项的第一个纯文本 token,例如 `- README.md`。
|
|
178
|
+
- 匹配前会归一化前导 `./`、尾随 `/` 和重复斜杠。
|
|
179
|
+
- 支持文件精确匹配和目录前缀匹配。
|
|
180
|
+
- 不支持 glob、正则、否定规则或自然语言推断。
|
|
181
|
+
|
|
182
|
+
`--stale-days` 会检查活跃计划的 `最后更新` 日期是否超过阈值;省略数值时默认 10 天。停滞检测只输出 `WARNING`,不自动改变计划状态。
|
|
183
|
+
|
|
184
|
+
## Hook runtime
|
|
185
|
+
|
|
186
|
+
本仓库提供只读 hook runtime,供 Codex、Claude Code 或其他 Agent 的项目级 hooks 手动接入。脚本只输出短提示和检查结果,不修改治理文档,不更新 `最后更新`,不安装或修改全局配置。
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
python3 scripts/plan_governance_hook.py --event session-start
|
|
190
|
+
python3 scripts/plan_governance_hook.py --event pre-write --paths scripts/check_plan_governance.py
|
|
191
|
+
python3 scripts/plan_governance_hook.py --event post-write --paths docs/PLAN_MAP.md
|
|
192
|
+
python3 scripts/plan_governance_hook.py --event stop
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
事件语义:
|
|
196
|
+
|
|
197
|
+
- `session-start`:摘要活跃计划、当前阶段、最后更新、阻塞项和证据链接。
|
|
198
|
+
- `pre-write`:按活跃计划的 `影响模块或文件` 匹配路径,提示当前阶段门禁。
|
|
199
|
+
- `post-write`:写入 `PLAN_MAP.md` 或计划文档后,提示同步状态、证据、覆盖率和反向引用检查。
|
|
200
|
+
- `stop`:运行 `python3 scripts/check_plan_governance.py .` 并转发结果;该事件是非阻塞提示,不实现强制 gate。
|
|
201
|
+
|
|
202
|
+
旧项目如果仍使用五列 `PLAN_MAP.md`,先显式迁移:
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
python3 scripts/init_plan_governance.py --root . --migrate-plan-map-last-updated --last-updated-date 2026-07-05
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
不传 `--last-updated-date` 时使用当天日期。该迁移只修改 `docs/PLAN_MAP.md` 的计划索引表,不会自动改变计划状态。
|
|
209
|
+
|
|
210
|
+
当前检查项包括:
|
|
211
|
+
|
|
212
|
+
- `PLAN_MAP.md` 引用的计划文件是否存在。
|
|
213
|
+
- `docs/plans/*.md` 是否存在未登记到 `PLAN_MAP.md` 的孤立计划;孤立计划以 `WARNING` 提示,不阻断检查。
|
|
214
|
+
- 计划状态是否合法。
|
|
215
|
+
- 计划依赖是否存在环。
|
|
216
|
+
- 活跃计划正文中的计划引用是否与 `PLAN_MAP.md` 依赖列一致;不一致以 `WARNING` 提示。
|
|
217
|
+
- 多个活跃计划是否声明了相同的影响模块或文件;重叠以 `WARNING` 提示。
|
|
218
|
+
- 已完成计划是否有有效 Step 0 证据和验证方式;空章节或纯占位符不视为有效证据。
|
|
219
|
+
- 实施中计划是否依赖已替代、已合并或已废弃计划。
|
|
220
|
+
- 待实施或实施中计划是否仍有未解决的当前阶段阻塞项。
|
|
221
|
+
- 已完成计划是否有测试覆盖率证据。
|
|
222
|
+
- 计划索引是否包含合法的 `最后更新` 日期。
|
|
223
|
+
- `ERROR` 会导致检查失败;`WARNING` 用于提示需要人工复核但不改变退出码的风险。
|
|
224
|
+
|
|
225
|
+
计划停滞检测使用计划索引中的 `最后更新`,不推断文件修改时间。
|
|
226
|
+
|
|
227
|
+
已完成计划修改检测需要完成快照或 hash 等独立元数据;当前版本不锁定已完成计划文件,避免修正文档错误时产生误报。公共契约变化关联验证需要计划文档提供结构化目标文件声明;当前版本不做 spec diff 强校验。
|
|
228
|
+
|
|
229
|
+
## 完成快照
|
|
230
|
+
|
|
231
|
+
已完成计划可以显式创建完成快照,用于后续发现未复核修改。快照文件写入 `docs/attestations/<plan-name>.json`,包含计划文件和 `docs/PLAN_MAP.md` 的 SHA-256。
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
python3 scripts/check_plan_governance.py . --attest agent-runtime-integration
|
|
235
|
+
python3 scripts/check_plan_governance.py . --check-attestations
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
`--attest <plan-name>` 只接受已登记到 `docs/PLAN_MAP.md` 的计划。`--check-attestations` 对计划文件 hash 变化、`PLAN_MAP.md` hash 变化、JSON 损坏、文件缺失或快照引用未登记计划输出 `WARNING`,不改变退出码。人工确认文档修正合理后,可以重新运行 `--attest <plan-name>` 覆盖快照。
|
|
239
|
+
|
|
240
|
+
## 测试覆盖率
|
|
241
|
+
|
|
242
|
+
本项目使用 `pytest` 和 `pytest-cov` 作为测试与覆盖率门禁。首次运行前安装开发依赖:
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
python3 -m pip install -r requirements-dev.txt
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
运行测试:
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
python3 -m pytest
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
覆盖率规则定义在 `pyproject.toml`:
|
|
255
|
+
|
|
256
|
+
- 统计 `scripts/` 下的 Python 代码。
|
|
257
|
+
- 开启分支覆盖率。
|
|
258
|
+
- 总覆盖率低于 85% 时测试失败。
|
|
259
|
+
- CI 会同时运行测试覆盖率检查和计划治理检查。
|
|
260
|
+
|
|
261
|
+
## 状态
|
|
262
|
+
|
|
263
|
+
统一使用中文状态:
|
|
264
|
+
|
|
265
|
+
| 状态 | 含义 |
|
|
266
|
+
|---|---|
|
|
267
|
+
| `候选` | 记录了想法,但尚未承诺实施 |
|
|
268
|
+
| `设计中` | 正在明确范围、契约和门禁 |
|
|
269
|
+
| `待实施` | 当前阶段门禁已通过,但尚未开始 |
|
|
270
|
+
| `实施中` | 当前阶段正在修改代码或文档 |
|
|
271
|
+
| `已完成` | 实现、测试、证据和文档已同步 |
|
|
272
|
+
| `已替代` | 被另一个计划取代 |
|
|
273
|
+
| `已合并` | 并入另一个计划 |
|
|
274
|
+
| `已废弃` | 明确不再推进 |
|
|
275
|
+
|
|
276
|
+
### 阶段准入
|
|
277
|
+
|
|
278
|
+
计划级 `状态` 和 `当前阶段` 由 `docs/PLAN_MAP.md` 维护;专项计划维护当前阶段的准入摘要和独立复核证据。阶段 N 完成后,阶段 N+1 默认保持 `设计中`,不能直接标记为 `待实施`。
|
|
279
|
+
|
|
280
|
+
进入 `待实施` 前,当前阶段必须有明确目标/范围、Step 0 基线、样本或替代基线矩阵、可执行验证方式、失败与回滚边界、无未解决阻塞项,以及最新独立准入复核“通过”。复核记录追加保留,`PLAN_MAP.md` 只链接最新有效结论。
|
|
281
|
+
|
|
282
|
+
阶段准入严格检查命令为 `--strict-readiness`。默认检查先以 `WARNING` 提示历史文档中的机械缺陷;严格模式才返回非零退出码。该检查只判断结构化准入条件,不替代业务验收。
|
|
283
|
+
|
|
284
|
+
## Codex 使用方式
|
|
285
|
+
|
|
286
|
+
如果已经安装本地 Codex skill,可以在对话中触发:
|
|
287
|
+
|
|
288
|
+
```text
|
|
289
|
+
$plan-governance 为这个项目初始化计划治理,计划名是 api-compat-migration,标题是 API 兼容性迁移。
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
或继续推进已有计划:
|
|
293
|
+
|
|
294
|
+
```text
|
|
295
|
+
$plan-governance 继续推进 docs/plans/api-compat-migration.md 的当前阶段,完成后记录验证证据。
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
skill 只负责让 Codex 按流程工作;真实计划状态仍然保存在项目仓库的 `docs/` 中。
|
|
299
|
+
|
|
300
|
+
## 核心规则
|
|
301
|
+
|
|
302
|
+
- 普通任务直接做,不引入治理。
|
|
303
|
+
- 当前阶段写细,后续阶段写粗。
|
|
304
|
+
- 实施前先固定 Step 0 证据。
|
|
305
|
+
- 决策、计划、契约和证据各有权威位置,不重复定义同一事实。
|
|
306
|
+
- 路线图和优先级计划只保留排序、状态摘要和链接,不复制专项计划细节。
|
|
307
|
+
- 验收治理文档时必须做反向引用检查。
|
|
308
|
+
- 验收者不得仅依据计划状态、完成证据文字或文档格式判定完成;必须基于当前仓库内容、可复现验证命令和反向引用检查独立确认。
|
|
309
|
+
- 启用治理后,旧草案停止承载新规范;后续需求默认进入治理文档。
|
|
310
|
+
- 如果新信息改变计划顺序、公共契约、兼容承诺或完成条件,先更新治理文档,再继续实施。
|
|
311
|
+
|
|
312
|
+
完整设计见 [plan-governance-design.md](plan-governance-design.md)。
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { accessSync, constants } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { dirname, resolve } from "node:path";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
|
+
const checker = resolve(packageRoot, "scripts", "check_plan_governance.py");
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
accessSync(checker, constants.R_OK);
|
|
13
|
+
} catch (error) {
|
|
14
|
+
console.error(`plan-governance-cli: 找不到包内检查器:${checker}`);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const args = [checker, ...process.argv.slice(2)];
|
|
19
|
+
const candidates = process.env.PYTHON ? [process.env.PYTHON] : ["python3", "python"];
|
|
20
|
+
|
|
21
|
+
let result;
|
|
22
|
+
for (const command of candidates) {
|
|
23
|
+
result = spawnSync(command, args, { stdio: "inherit" });
|
|
24
|
+
if (!result.error || result.error.code !== "ENOENT") {
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (result?.error) {
|
|
30
|
+
console.error(`plan-governance-cli: 无法启动 Python 检查器:${result.error.message}`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (result?.signal) {
|
|
35
|
+
console.error(`plan-governance-cli: 检查器被信号 ${result.signal} 终止`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
process.exit(result?.status ?? 1);
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "plan-governance-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "统一入口的计划治理检查 CLI",
|
|
5
|
+
"private": false,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"plan-governance-cli": "bin/plan-governance-cli.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/plan-governance-cli.mjs",
|
|
12
|
+
"scripts/check_plan_governance.py"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test tests/npm_cli.test.mjs"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import argparse
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import date, datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
VALID_STATUSES = {
|
|
12
|
+
"候选",
|
|
13
|
+
"设计中",
|
|
14
|
+
"待实施",
|
|
15
|
+
"实施中",
|
|
16
|
+
"已完成",
|
|
17
|
+
"已替代",
|
|
18
|
+
"已合并",
|
|
19
|
+
"已废弃",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
PLACEHOLDER_VALUES = {"-", "待补充", "待补充。", "待确认", "无", "N/A"}
|
|
23
|
+
|
|
24
|
+
COMPLETED = {"已完成"}
|
|
25
|
+
ACTIVE = {"待实施", "实施中"}
|
|
26
|
+
WARNING_ACTIVE = {"候选", "设计中", "待实施", "实施中"}
|
|
27
|
+
IMPLEMENTING = {"实施中"}
|
|
28
|
+
INACTIVE = {"已替代", "已合并", "已废弃"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def fail(errors, message):
|
|
32
|
+
errors.append(message)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def warn(warnings, message):
|
|
36
|
+
warnings.append(message)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def read_utf8(path, errors):
|
|
40
|
+
try:
|
|
41
|
+
return path.read_text(encoding="utf-8")
|
|
42
|
+
except UnicodeDecodeError:
|
|
43
|
+
fail(errors, f"{path}: not valid UTF-8")
|
|
44
|
+
except FileNotFoundError:
|
|
45
|
+
fail(errors, f"{path}: file not found")
|
|
46
|
+
return ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def table_rows(text, heading):
|
|
50
|
+
pattern = re.compile(rf"^##\s+{re.escape(heading)}\s*$", re.MULTILINE)
|
|
51
|
+
match = pattern.search(text)
|
|
52
|
+
if not match:
|
|
53
|
+
return []
|
|
54
|
+
tail = text[match.end():]
|
|
55
|
+
next_heading = re.search(r"^##\s+", tail, re.MULTILINE)
|
|
56
|
+
section = tail[: next_heading.start()] if next_heading else tail
|
|
57
|
+
rows = []
|
|
58
|
+
for line in section.splitlines():
|
|
59
|
+
stripped = line.strip()
|
|
60
|
+
if not stripped.startswith("|") or "---" in stripped:
|
|
61
|
+
continue
|
|
62
|
+
cells = [cell.strip() for cell in stripped.strip("|").split("|")]
|
|
63
|
+
if cells and cells[0] not in {"计划", "问题"}:
|
|
64
|
+
rows.append(cells)
|
|
65
|
+
return rows
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def mask_fenced_code(text):
|
|
69
|
+
"""用等长空格屏蔽 fenced code 中的伪 Markdown 标题,保留偏移量。"""
|
|
70
|
+
masked = []
|
|
71
|
+
in_fence = False
|
|
72
|
+
for line in text.splitlines(keepends=True):
|
|
73
|
+
stripped = line.lstrip()
|
|
74
|
+
if stripped.startswith("```"):
|
|
75
|
+
in_fence = not in_fence
|
|
76
|
+
masked.append("".join("\n" if char == "\n" else "\r" if char == "\r" else " " for char in line))
|
|
77
|
+
continue
|
|
78
|
+
if in_fence:
|
|
79
|
+
masked.append("".join("\n" if char == "\n" else "\r" if char == "\r" else " " for char in line))
|
|
80
|
+
else:
|
|
81
|
+
masked.append(line)
|
|
82
|
+
return "".join(masked)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def markdown_section(text, heading_names):
|
|
86
|
+
heading_pattern = "|".join(re.escape(name) for name in heading_names)
|
|
87
|
+
pattern = re.compile(rf"^#+\s+({heading_pattern})\b.*$", re.MULTILINE)
|
|
88
|
+
match = pattern.search(mask_fenced_code(text))
|
|
89
|
+
if not match:
|
|
90
|
+
return None
|
|
91
|
+
tail = text[match.end():]
|
|
92
|
+
next_heading = re.search(r"^#+\s+", tail, re.MULTILINE)
|
|
93
|
+
return tail[: next_heading.start()] if next_heading else tail
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def markdown_table_rows(section):
|
|
97
|
+
if section is None:
|
|
98
|
+
return []
|
|
99
|
+
rows = []
|
|
100
|
+
for line in section.splitlines():
|
|
101
|
+
stripped = line.strip()
|
|
102
|
+
if not stripped.startswith("|") or "---" in stripped:
|
|
103
|
+
continue
|
|
104
|
+
cells = [cell.strip() for cell in stripped.strip("|").split("|")]
|
|
105
|
+
if cells:
|
|
106
|
+
rows.append(cells)
|
|
107
|
+
return rows
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def key_value_table(section):
|
|
111
|
+
values = {}
|
|
112
|
+
for row in markdown_table_rows(section):
|
|
113
|
+
if len(row) < 2 or row[0] in {"字段", "问题"}:
|
|
114
|
+
continue
|
|
115
|
+
values[row[0]] = row[1]
|
|
116
|
+
return values
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def phase_roadmap_rows(plan_text):
|
|
120
|
+
section = markdown_section(plan_text, ["阶段路线图"])
|
|
121
|
+
return [row for row in markdown_table_rows(section) if row and row[0] != "阶段"]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def review_history_rows(plan_text):
|
|
125
|
+
section = markdown_section(plan_text, ["独立复核记录"])
|
|
126
|
+
return [row for row in markdown_table_rows(section) if row and row[0] != "日期"]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def is_placeholder(value, allow_empty=False):
|
|
130
|
+
normalized = re.sub(r"\s+", "", value or "")
|
|
131
|
+
if not normalized:
|
|
132
|
+
return allow_empty
|
|
133
|
+
return normalized in {re.sub(r"\s+", "", item) for item in PLACEHOLDER_VALUES}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
READINESS_FIELDS = {
|
|
137
|
+
"准入状态",
|
|
138
|
+
"Step 0",
|
|
139
|
+
"样本矩阵",
|
|
140
|
+
"验证方式",
|
|
141
|
+
"失败/回滚边界",
|
|
142
|
+
"当前阻塞项",
|
|
143
|
+
"最新独立准入复核",
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def readiness_issue(warnings, errors, strict, message):
|
|
148
|
+
if strict:
|
|
149
|
+
fail(errors, message)
|
|
150
|
+
else:
|
|
151
|
+
warn(warnings, message)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def check_phase_readiness(plan_map_text, plan_name, data, plan_text, strict, warnings, errors):
|
|
155
|
+
"""检查待实施/实施中计划的阶段准入结构,不判断业务证据真实性。"""
|
|
156
|
+
if data["status"] not in ACTIVE:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
current_phase = data["phase"]
|
|
160
|
+
roadmap = phase_roadmap_rows(plan_text)
|
|
161
|
+
matching_rows = [row for row in roadmap if row and row[0] == current_phase]
|
|
162
|
+
if not matching_rows:
|
|
163
|
+
readiness_issue(
|
|
164
|
+
warnings,
|
|
165
|
+
errors,
|
|
166
|
+
strict,
|
|
167
|
+
f"{plan_name}: PLAN_MAP 当前阶段 {current_phase} 未在计划阶段路线图中找到",
|
|
168
|
+
)
|
|
169
|
+
else:
|
|
170
|
+
roadmap_status = matching_rows[-1][4] if len(matching_rows[-1]) > 4 else ""
|
|
171
|
+
if roadmap_status and roadmap_status != data["status"]:
|
|
172
|
+
readiness_issue(
|
|
173
|
+
warnings,
|
|
174
|
+
errors,
|
|
175
|
+
strict,
|
|
176
|
+
f"{plan_name}: 当前阶段 {current_phase} 的路线图状态 {roadmap_status} 与 PLAN_MAP 状态 {data['status']} 不一致",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if markdown_section(plan_text, ["当前阶段"]) is None:
|
|
180
|
+
readiness_issue(warnings, errors, strict, f"{plan_name}: 缺少 `## 当前阶段` 章节")
|
|
181
|
+
|
|
182
|
+
summary = key_value_table(markdown_section(plan_text, ["阶段准入摘要"]))
|
|
183
|
+
missing_fields = sorted(READINESS_FIELDS - set(summary))
|
|
184
|
+
if missing_fields:
|
|
185
|
+
readiness_issue(
|
|
186
|
+
warnings,
|
|
187
|
+
errors,
|
|
188
|
+
strict,
|
|
189
|
+
f"{plan_name}: 阶段准入摘要缺少字段:{', '.join(missing_fields)}",
|
|
190
|
+
)
|
|
191
|
+
for field in sorted(READINESS_FIELDS - {"当前阻塞项"}):
|
|
192
|
+
if field in summary and is_placeholder(summary[field]):
|
|
193
|
+
readiness_issue(
|
|
194
|
+
warnings,
|
|
195
|
+
errors,
|
|
196
|
+
strict,
|
|
197
|
+
f"{plan_name}: 阶段准入摘要字段 {field} 仍是占位内容",
|
|
198
|
+
)
|
|
199
|
+
if summary.get("当前阻塞项") and is_placeholder(summary["当前阻塞项"]) and summary["当前阻塞项"].strip() != "无":
|
|
200
|
+
readiness_issue(
|
|
201
|
+
warnings,
|
|
202
|
+
errors,
|
|
203
|
+
strict,
|
|
204
|
+
f"{plan_name}: 阶段准入摘要的当前阻塞项不可使用占位内容",
|
|
205
|
+
)
|
|
206
|
+
if summary.get("准入状态") and summary["准入状态"] != data["status"]:
|
|
207
|
+
readiness_issue(
|
|
208
|
+
warnings,
|
|
209
|
+
errors,
|
|
210
|
+
strict,
|
|
211
|
+
f"{plan_name}: 阶段准入摘要状态 {summary['准入状态']} 与 PLAN_MAP 状态 {data['status']} 不一致",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
review = key_value_table(markdown_section(plan_text, ["最新独立准入复核"]))
|
|
215
|
+
review_fields = {"日期", "阶段", "结论", "证据", "复核者"}
|
|
216
|
+
missing_review = sorted(review_fields - set(review))
|
|
217
|
+
if missing_review:
|
|
218
|
+
readiness_issue(
|
|
219
|
+
warnings,
|
|
220
|
+
errors,
|
|
221
|
+
strict,
|
|
222
|
+
f"{plan_name}: 最新独立准入复核缺少字段:{', '.join(missing_review)}",
|
|
223
|
+
)
|
|
224
|
+
if review.get("日期"):
|
|
225
|
+
try:
|
|
226
|
+
parse_plan_date(review["日期"])
|
|
227
|
+
except ValueError:
|
|
228
|
+
readiness_issue(warnings, errors, strict, f"{plan_name}: 最新独立准入复核日期不合法:{review['日期']}")
|
|
229
|
+
if review.get("阶段") and review["阶段"] != current_phase:
|
|
230
|
+
readiness_issue(
|
|
231
|
+
warnings,
|
|
232
|
+
errors,
|
|
233
|
+
strict,
|
|
234
|
+
f"{plan_name}: 最新独立准入复核阶段 {review['阶段']} 与 PLAN_MAP 当前阶段 {current_phase} 不一致",
|
|
235
|
+
)
|
|
236
|
+
if review.get("结论") and not review["结论"].startswith("通过"):
|
|
237
|
+
readiness_issue(
|
|
238
|
+
warnings,
|
|
239
|
+
errors,
|
|
240
|
+
strict,
|
|
241
|
+
f"{plan_name}: 最新独立准入复核结论不是通过:{review['结论']}",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
history = [row for row in review_history_rows(plan_text) if len(row) >= 4 and row[2] == current_phase]
|
|
245
|
+
if not history:
|
|
246
|
+
readiness_issue(warnings, errors, strict, f"{plan_name}: 独立复核记录缺少当前阶段 {current_phase} 的记录")
|
|
247
|
+
else:
|
|
248
|
+
latest_history = history[-1]
|
|
249
|
+
if review.get("日期") and latest_history[0] != review["日期"]:
|
|
250
|
+
readiness_issue(
|
|
251
|
+
warnings,
|
|
252
|
+
errors,
|
|
253
|
+
strict,
|
|
254
|
+
f"{plan_name}: 最新独立准入复核日期与历史记录最后一条不一致",
|
|
255
|
+
)
|
|
256
|
+
if review.get("结论") and latest_history[3] not in review["结论"]:
|
|
257
|
+
readiness_issue(
|
|
258
|
+
warnings,
|
|
259
|
+
errors,
|
|
260
|
+
strict,
|
|
261
|
+
f"{plan_name}: 最新独立准入复核与历史记录最后一条结论冲突",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def markdown_list_items(section):
|
|
266
|
+
if section is None:
|
|
267
|
+
return []
|
|
268
|
+
items = []
|
|
269
|
+
for line in section.splitlines():
|
|
270
|
+
match = re.match(r"\s*[-*]\s+(.+?)\s*$", line)
|
|
271
|
+
if not match:
|
|
272
|
+
continue
|
|
273
|
+
item = match.group(1).strip()
|
|
274
|
+
item = item.strip("` ")
|
|
275
|
+
if item and item not in PLACEHOLDER_VALUES:
|
|
276
|
+
items.append(item)
|
|
277
|
+
return items
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def normalize_scope_path(value):
|
|
281
|
+
normalized = value.strip().strip("`").strip()
|
|
282
|
+
normalized = re.sub(r"/+", "/", normalized)
|
|
283
|
+
while normalized.startswith("./"):
|
|
284
|
+
normalized = normalized[2:]
|
|
285
|
+
normalized = normalized.strip("/")
|
|
286
|
+
return normalized
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def extract_scope_token(item):
|
|
290
|
+
backtick = re.search(r"`([^`]+)`", item)
|
|
291
|
+
if backtick:
|
|
292
|
+
return normalize_scope_path(backtick.group(1))
|
|
293
|
+
token = item.strip().split(None, 1)[0] if item.strip() else ""
|
|
294
|
+
return normalize_scope_path(token.rstrip("::"))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def extract_plan_link(cell):
|
|
298
|
+
match = re.search(r"\((plans/[^)]+\.md)\)", cell)
|
|
299
|
+
if match:
|
|
300
|
+
return match.group(1)
|
|
301
|
+
if cell.endswith(".md") and cell.startswith("plans/"):
|
|
302
|
+
return cell
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def extract_declared_dependencies(depends_cell):
|
|
307
|
+
return [d.strip("` ") for d in re.split(r",|<br>|、", depends_cell) if d.strip("` -")]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def parse_plan_date(value):
|
|
311
|
+
return datetime.strptime(value.strip(), "%Y-%m-%d").date()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def extract_affected_targets(plan_text):
|
|
315
|
+
section = markdown_section(plan_text, ["影响模块或文件"])
|
|
316
|
+
targets = []
|
|
317
|
+
for item in markdown_list_items(section):
|
|
318
|
+
target = extract_scope_token(item)
|
|
319
|
+
if target and target not in PLACEHOLDER_VALUES:
|
|
320
|
+
targets.append(target)
|
|
321
|
+
return targets
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def extract_plan_references(plan_text, known_plans, current_name):
|
|
325
|
+
references = set()
|
|
326
|
+
for match in re.finditer(r"\[\[([^\]]+)\]\]", plan_text):
|
|
327
|
+
name = Path(match.group(1).strip()).stem
|
|
328
|
+
if name in known_plans and name != current_name:
|
|
329
|
+
references.add(name)
|
|
330
|
+
|
|
331
|
+
for match in re.finditer(r"(?:docs/)?plans/([A-Za-z0-9\u4e00-\u9fff._-]+)\.md", plan_text):
|
|
332
|
+
name = Path(match.group(1).strip()).stem
|
|
333
|
+
if name in known_plans and name != current_name:
|
|
334
|
+
references.add(name)
|
|
335
|
+
|
|
336
|
+
for match in re.finditer(r"\]\(([A-Za-z0-9\u4e00-\u9fff._-]+)\.md(?:#[^)]+)?\)", plan_text):
|
|
337
|
+
name = Path(match.group(1).strip()).stem
|
|
338
|
+
if name in known_plans and name != current_name:
|
|
339
|
+
references.add(name)
|
|
340
|
+
|
|
341
|
+
return references
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def has_substantive_evidence(content):
|
|
345
|
+
if content is None:
|
|
346
|
+
return False
|
|
347
|
+
|
|
348
|
+
stripped = content.strip()
|
|
349
|
+
if not stripped:
|
|
350
|
+
return False
|
|
351
|
+
if re.search(r"^(待补充|TODO|TBD|待确认)[。.\s]*$", stripped, re.IGNORECASE):
|
|
352
|
+
return False
|
|
353
|
+
|
|
354
|
+
evidence_patterns = [
|
|
355
|
+
r"```",
|
|
356
|
+
r"\b(python3|python|pytest|bash|sh|rg|npm|make|curl|git)\b",
|
|
357
|
+
r"[\w./-]+/(?:[\w./-]+)",
|
|
358
|
+
r"\b(?:commit|hash)\s+[0-9a-f]{6,40}\b",
|
|
359
|
+
r"\bv?\d+\.\d+(?:\.\d+)?\b",
|
|
360
|
+
r"\d+(?:\.\d+)?%",
|
|
361
|
+
r"(基线|复现|样本|fixture|测试|验证|运行|失败案例|现状|快照|报告|覆盖率|命令|搜索)",
|
|
362
|
+
]
|
|
363
|
+
if any(re.search(pattern, stripped, re.IGNORECASE) for pattern in evidence_patterns):
|
|
364
|
+
return True
|
|
365
|
+
|
|
366
|
+
normalized = re.sub(r"\s+", "", stripped)
|
|
367
|
+
return len(normalized) >= 40
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def has_completion_evidence(plan_text):
|
|
371
|
+
evidence = markdown_section(plan_text, ["Step 0 Evidence", "Step 0 证据", "完成证据", "验证证据"])
|
|
372
|
+
validation = markdown_section(plan_text, ["验证方式", "验证"])
|
|
373
|
+
return has_substantive_evidence(evidence) and has_substantive_evidence(validation)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def has_coverage_evidence(plan_text):
|
|
377
|
+
"""已完成计划是否包含非占位的测试覆盖率证据章节。"""
|
|
378
|
+
section = markdown_section(plan_text, ["测试覆盖率", "测试覆盖", "覆盖率报告", "Coverage", "Test Coverage"])
|
|
379
|
+
if section is None:
|
|
380
|
+
return False
|
|
381
|
+
content = section.strip()
|
|
382
|
+
if not content or re.search(r"^(待补充|TODO|TBD)[。.\s]*$", content, re.IGNORECASE):
|
|
383
|
+
return False
|
|
384
|
+
|
|
385
|
+
return bool(re.search(r"(\d+(?:\.\d+)?%|pytest|coverage|覆盖率|测试通过|passed)", content, re.IGNORECASE))
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def has_current_blocker(plan_text):
|
|
389
|
+
for row in table_rows(plan_text, "未决问题"):
|
|
390
|
+
joined = "|".join(row)
|
|
391
|
+
if re.search(r"\b(Yes|是)\b", joined) and re.search(r"(Open|待确认|未解决|待处理|未决定)", joined, re.IGNORECASE):
|
|
392
|
+
return True
|
|
393
|
+
return False
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def find_orphan_plans(docs, plans):
|
|
397
|
+
plans_dir = docs / "plans"
|
|
398
|
+
if not plans_dir.exists():
|
|
399
|
+
return []
|
|
400
|
+
indexed_paths = {data["path"].resolve() for data in plans.values()}
|
|
401
|
+
return sorted(
|
|
402
|
+
plan_file
|
|
403
|
+
for plan_file in plans_dir.glob("*.md")
|
|
404
|
+
if plan_file.resolve() not in indexed_paths
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def detect_overlapping_targets(active_plan_targets):
|
|
409
|
+
target_to_plans = {}
|
|
410
|
+
for plan_name, targets in active_plan_targets.items():
|
|
411
|
+
for target in targets:
|
|
412
|
+
target_to_plans.setdefault(target, []).append(plan_name)
|
|
413
|
+
return {
|
|
414
|
+
target: sorted(plan_names)
|
|
415
|
+
for target, plan_names in target_to_plans.items()
|
|
416
|
+
if len(plan_names) > 1
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def target_matches_path(target, changed_file):
|
|
421
|
+
normalized_target = normalize_scope_path(target)
|
|
422
|
+
normalized_file = normalize_scope_path(changed_file)
|
|
423
|
+
if not normalized_target or not normalized_file:
|
|
424
|
+
return False
|
|
425
|
+
if normalized_target == normalized_file:
|
|
426
|
+
return True
|
|
427
|
+
return normalized_file.startswith(f"{normalized_target}/")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def uncovered_changed_files(changed_files, active_plan_targets):
|
|
431
|
+
targets = [
|
|
432
|
+
target
|
|
433
|
+
for targets_for_plan in active_plan_targets.values()
|
|
434
|
+
for target in targets_for_plan
|
|
435
|
+
]
|
|
436
|
+
return sorted(
|
|
437
|
+
changed_file
|
|
438
|
+
for changed_file in changed_files
|
|
439
|
+
if not any(target_matches_path(target, changed_file) for target in targets)
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def git_name_only(root, git_args):
|
|
444
|
+
result = subprocess.run(
|
|
445
|
+
["git", *git_args],
|
|
446
|
+
cwd=root,
|
|
447
|
+
check=True,
|
|
448
|
+
capture_output=True,
|
|
449
|
+
text=True,
|
|
450
|
+
)
|
|
451
|
+
return {
|
|
452
|
+
line.strip()
|
|
453
|
+
for line in result.stdout.splitlines()
|
|
454
|
+
if line.strip()
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def changed_files(root, staged=False):
|
|
459
|
+
if staged:
|
|
460
|
+
return git_name_only(root, ["diff", "--cached", "--name-only", "--diff-filter=ACMR"])
|
|
461
|
+
|
|
462
|
+
files = set()
|
|
463
|
+
files.update(git_name_only(root, ["diff", "--name-only", "--diff-filter=ACMR"]))
|
|
464
|
+
files.update(git_name_only(root, ["diff", "--cached", "--name-only", "--diff-filter=ACMR"]))
|
|
465
|
+
files.update(git_name_only(root, ["ls-files", "--others", "--exclude-standard"]))
|
|
466
|
+
return files
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def warn_uncovered_changes(warnings, mode, files, active_plan_targets):
|
|
470
|
+
if not files:
|
|
471
|
+
return
|
|
472
|
+
if not active_plan_targets:
|
|
473
|
+
warn(warnings, f"{mode}: 存在变更文件,但没有活跃计划声明影响范围")
|
|
474
|
+
return
|
|
475
|
+
for changed_file in uncovered_changed_files(files, active_plan_targets):
|
|
476
|
+
warn(warnings, f"{mode}: 变更文件未被活跃计划影响范围覆盖:{changed_file}")
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def warn_stale_plans(warnings, plans, stale_days, today=None):
|
|
480
|
+
today = today or date.today()
|
|
481
|
+
for name, data in plans.items():
|
|
482
|
+
if data["status"] not in WARNING_ACTIVE:
|
|
483
|
+
continue
|
|
484
|
+
age = (today - data["last_updated_date"]).days
|
|
485
|
+
if age > stale_days:
|
|
486
|
+
warn(
|
|
487
|
+
warnings,
|
|
488
|
+
f"{name}: 活跃计划已 {age} 天未更新,超过 --stale-days {stale_days} 阈值",
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def sha256_file(path):
|
|
493
|
+
digest = hashlib.sha256()
|
|
494
|
+
with path.open("rb") as handle:
|
|
495
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
496
|
+
digest.update(chunk)
|
|
497
|
+
return digest.hexdigest()
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def relative_to_root(path, root):
|
|
501
|
+
return path.relative_to(root).as_posix()
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def create_attestation(root, plan_map, plans, plan_name, errors):
|
|
505
|
+
data = plans.get(plan_name)
|
|
506
|
+
if data is None:
|
|
507
|
+
fail(errors, f"{plan_name}: 未登记计划,无法创建完成快照")
|
|
508
|
+
return None
|
|
509
|
+
if not data["path"].exists():
|
|
510
|
+
fail(errors, f"{plan_name}: 计划文件不存在,无法创建完成快照")
|
|
511
|
+
return None
|
|
512
|
+
|
|
513
|
+
attestation = {
|
|
514
|
+
"plan": plan_name,
|
|
515
|
+
"phase": data["phase"],
|
|
516
|
+
"status": data["status"],
|
|
517
|
+
"plan_path": relative_to_root(data["path"], root),
|
|
518
|
+
"plan_map_path": relative_to_root(plan_map, root),
|
|
519
|
+
"plan_sha256": sha256_file(data["path"]),
|
|
520
|
+
"plan_map_sha256": sha256_file(plan_map),
|
|
521
|
+
"created_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
|
522
|
+
"created_by": "plan-governance",
|
|
523
|
+
"reason": "阶段完成快照",
|
|
524
|
+
}
|
|
525
|
+
target = root / "docs" / "attestations" / f"{plan_name}.json"
|
|
526
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
527
|
+
target.write_text(json.dumps(attestation, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
528
|
+
return target
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def warn_attestation_drift(warnings, root, plans):
|
|
532
|
+
attestations_dir = root / "docs" / "attestations"
|
|
533
|
+
if not attestations_dir.exists():
|
|
534
|
+
return
|
|
535
|
+
|
|
536
|
+
for path in sorted(attestations_dir.glob("*.json")):
|
|
537
|
+
try:
|
|
538
|
+
attestation = json.loads(path.read_text(encoding="utf-8"))
|
|
539
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
540
|
+
warn(warnings, f"{path}: attestation JSON 无法解析:{exc}")
|
|
541
|
+
continue
|
|
542
|
+
|
|
543
|
+
plan_name = str(attestation.get("plan", "")).strip()
|
|
544
|
+
if not plan_name or plan_name not in plans:
|
|
545
|
+
warn(warnings, f"{path}: 快照引用了未登记计划:{plan_name or '<missing>'}")
|
|
546
|
+
continue
|
|
547
|
+
|
|
548
|
+
plan_path = root / str(attestation.get("plan_path", ""))
|
|
549
|
+
plan_map_path = root / str(attestation.get("plan_map_path", "docs/PLAN_MAP.md"))
|
|
550
|
+
if not plan_path.exists():
|
|
551
|
+
warn(warnings, f"{path}: 快照引用的计划文件不存在:{plan_path}")
|
|
552
|
+
continue
|
|
553
|
+
if not plan_map_path.exists():
|
|
554
|
+
warn(warnings, f"{path}: 快照引用的 PLAN_MAP.md 不存在:{plan_map_path}")
|
|
555
|
+
continue
|
|
556
|
+
|
|
557
|
+
if sha256_file(plan_path) != attestation.get("plan_sha256"):
|
|
558
|
+
warn(warnings, f"{path}: {plan_name} 计划文件 hash 已变化,需要人工复核")
|
|
559
|
+
if sha256_file(plan_map_path) != attestation.get("plan_map_sha256"):
|
|
560
|
+
warn(warnings, f"{path}: PLAN_MAP.md hash 已变化,需要人工复核")
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def detect_dependency_cycles(edges):
|
|
564
|
+
visited = set()
|
|
565
|
+
stack = set()
|
|
566
|
+
cycles = []
|
|
567
|
+
|
|
568
|
+
def visit(node, path):
|
|
569
|
+
if node in stack:
|
|
570
|
+
cycles.append(" -> ".join(path + [node]))
|
|
571
|
+
return
|
|
572
|
+
if node in visited:
|
|
573
|
+
return
|
|
574
|
+
visited.add(node)
|
|
575
|
+
stack.add(node)
|
|
576
|
+
for dep in edges.get(node, []):
|
|
577
|
+
visit(dep, path + [node])
|
|
578
|
+
stack.remove(node)
|
|
579
|
+
|
|
580
|
+
for node in edges:
|
|
581
|
+
visit(node, [])
|
|
582
|
+
return cycles
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def parse_args(argv):
|
|
586
|
+
parser = argparse.ArgumentParser(description="检查计划治理文档的一致性。")
|
|
587
|
+
parser.add_argument("root", nargs="?", default=".", help="仓库根目录,默认当前目录。")
|
|
588
|
+
parser.add_argument("--drift", action="store_true", help="检查工作区变更是否被活跃计划影响范围覆盖。")
|
|
589
|
+
parser.add_argument("--pre-commit", action="store_true", help="检查 staged 变更是否被活跃计划影响范围覆盖。")
|
|
590
|
+
parser.add_argument(
|
|
591
|
+
"--strict-readiness",
|
|
592
|
+
action="store_true",
|
|
593
|
+
help="将待实施/实施中计划的阶段准入结构缺陷从 WARNING 提升为 ERROR。",
|
|
594
|
+
)
|
|
595
|
+
parser.add_argument("--attest", metavar="PLAN", help="为已登记计划创建或覆盖完成快照。")
|
|
596
|
+
parser.add_argument("--check-attestations", action="store_true", help="检查完成快照 hash 是否漂移。")
|
|
597
|
+
parser.add_argument(
|
|
598
|
+
"--stale-days",
|
|
599
|
+
nargs="?",
|
|
600
|
+
const=10,
|
|
601
|
+
type=int,
|
|
602
|
+
default=None,
|
|
603
|
+
help="检查活跃计划是否超过 N 天未更新;省略 N 时默认 10 天。",
|
|
604
|
+
)
|
|
605
|
+
return parser.parse_args(argv)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def main(argv=None):
|
|
609
|
+
args = parse_args(sys.argv[1:] if argv is None else argv)
|
|
610
|
+
root = Path(args.root)
|
|
611
|
+
docs = root / "docs"
|
|
612
|
+
plan_map = docs / "PLAN_MAP.md"
|
|
613
|
+
errors = []
|
|
614
|
+
warnings = []
|
|
615
|
+
|
|
616
|
+
if not plan_map.exists():
|
|
617
|
+
print("未找到 docs/PLAN_MAP.md;当前仓库尚未初始化计划治理。")
|
|
618
|
+
return 0
|
|
619
|
+
|
|
620
|
+
text = read_utf8(plan_map, errors)
|
|
621
|
+
plan_rows = table_rows(text, "计划索引")
|
|
622
|
+
if not plan_rows:
|
|
623
|
+
fail(errors, "docs/PLAN_MAP.md: 缺少计划索引表")
|
|
624
|
+
|
|
625
|
+
plans = {}
|
|
626
|
+
for row in plan_rows:
|
|
627
|
+
if len(row) < 2:
|
|
628
|
+
continue
|
|
629
|
+
if len(row) < 6:
|
|
630
|
+
fail(errors, "docs/PLAN_MAP.md: 计划索引表必须包含 `最后更新` 列")
|
|
631
|
+
continue
|
|
632
|
+
link = extract_plan_link(row[0])
|
|
633
|
+
name = Path(link).stem if link else row[0].strip("` ")
|
|
634
|
+
status = row[1].strip("` ")
|
|
635
|
+
last_updated = row[3].strip("` ")
|
|
636
|
+
if status not in VALID_STATUSES:
|
|
637
|
+
fail(errors, f"docs/PLAN_MAP.md: {name} 的状态不合法:{status}")
|
|
638
|
+
try:
|
|
639
|
+
last_updated_date = parse_plan_date(last_updated)
|
|
640
|
+
except ValueError:
|
|
641
|
+
fail(errors, f"docs/PLAN_MAP.md: {name} 的最后更新日期不合法:{last_updated}")
|
|
642
|
+
last_updated_date = date.min
|
|
643
|
+
if link:
|
|
644
|
+
path = docs / link
|
|
645
|
+
if not path.exists():
|
|
646
|
+
fail(errors, f"docs/PLAN_MAP.md: 引用的计划文件不存在:{link}")
|
|
647
|
+
plans[name] = {
|
|
648
|
+
"path": path,
|
|
649
|
+
"status": status,
|
|
650
|
+
"phase": row[2].strip("` "),
|
|
651
|
+
"last_updated_date": last_updated_date,
|
|
652
|
+
"depends": row[4],
|
|
653
|
+
}
|
|
654
|
+
else:
|
|
655
|
+
fail(errors, f"docs/PLAN_MAP.md: 计划行缺少 docs/plans 链接:{row[0]}")
|
|
656
|
+
|
|
657
|
+
edges = {}
|
|
658
|
+
inactive = {name for name, data in plans.items() if data["status"] in INACTIVE}
|
|
659
|
+
for name, data in plans.items():
|
|
660
|
+
deps = extract_declared_dependencies(data["depends"])
|
|
661
|
+
edges[name] = [dep for dep in deps if dep in plans]
|
|
662
|
+
if data["status"] in IMPLEMENTING:
|
|
663
|
+
for dep in edges[name]:
|
|
664
|
+
if dep in inactive:
|
|
665
|
+
fail(errors, f"{name}: 实施中计划依赖了非活跃计划 {dep}")
|
|
666
|
+
|
|
667
|
+
for cycle in detect_dependency_cycles(edges):
|
|
668
|
+
fail(errors, f"计划依赖存在环:{cycle}")
|
|
669
|
+
|
|
670
|
+
for orphan in find_orphan_plans(docs, plans):
|
|
671
|
+
warn(warnings, f"{orphan}: docs/plans 中存在未登记到 PLAN_MAP.md 的孤立计划")
|
|
672
|
+
|
|
673
|
+
active_plan_targets = {}
|
|
674
|
+
plan_texts = {}
|
|
675
|
+
for name, data in plans.items():
|
|
676
|
+
plan_text = read_utf8(data["path"], errors)
|
|
677
|
+
plan_texts[name] = plan_text
|
|
678
|
+
if data["status"] in COMPLETED and not has_completion_evidence(plan_text):
|
|
679
|
+
fail(errors, f"{data['path']}: 已完成计划缺少有效 Step 0 证据或验证方式")
|
|
680
|
+
if data["status"] in COMPLETED and not has_coverage_evidence(plan_text):
|
|
681
|
+
fail(errors, f"{data['path']}: 已完成计划缺少测试覆盖率证据")
|
|
682
|
+
if data["status"] in ACTIVE and has_current_blocker(plan_text):
|
|
683
|
+
fail(errors, f"{data['path']}: 活跃计划仍有未解决的当前阶段阻塞项")
|
|
684
|
+
if data["status"] in WARNING_ACTIVE:
|
|
685
|
+
active_plan_targets[name] = extract_affected_targets(plan_text)
|
|
686
|
+
|
|
687
|
+
check_phase_readiness(
|
|
688
|
+
text,
|
|
689
|
+
name,
|
|
690
|
+
data,
|
|
691
|
+
plan_text,
|
|
692
|
+
args.strict_readiness,
|
|
693
|
+
warnings,
|
|
694
|
+
errors,
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
for target, plan_names in detect_overlapping_targets(active_plan_targets).items():
|
|
698
|
+
warn(warnings, f"{target}: 多个活跃计划声明相同影响目标:{', '.join(plan_names)}")
|
|
699
|
+
|
|
700
|
+
known_plans = set(plans)
|
|
701
|
+
for name, data in plans.items():
|
|
702
|
+
if data["status"] not in WARNING_ACTIVE:
|
|
703
|
+
continue
|
|
704
|
+
declared = set(edges.get(name, []))
|
|
705
|
+
referenced = extract_plan_references(plan_texts.get(name, ""), known_plans, name)
|
|
706
|
+
for missing in sorted(referenced - declared):
|
|
707
|
+
warn(warnings, f"{name}: 正文引用了计划 {missing},但 PLAN_MAP.md 依赖列未声明")
|
|
708
|
+
for unreferenced in sorted(declared - referenced):
|
|
709
|
+
warn(warnings, f"{name}: PLAN_MAP.md 声明依赖 {unreferenced},但计划正文未引用")
|
|
710
|
+
|
|
711
|
+
try:
|
|
712
|
+
if args.drift:
|
|
713
|
+
warn_uncovered_changes(warnings, "--drift", changed_files(root), active_plan_targets)
|
|
714
|
+
if args.pre_commit:
|
|
715
|
+
warn_uncovered_changes(warnings, "--pre-commit", changed_files(root, staged=True), active_plan_targets)
|
|
716
|
+
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
|
|
717
|
+
warn(warnings, f"Git 变更检查不可用:{exc}")
|
|
718
|
+
|
|
719
|
+
if args.stale_days is not None:
|
|
720
|
+
if args.stale_days < 0:
|
|
721
|
+
fail(errors, f"--stale-days 必须是非负整数:{args.stale_days}")
|
|
722
|
+
else:
|
|
723
|
+
warn_stale_plans(warnings, plans, args.stale_days)
|
|
724
|
+
|
|
725
|
+
if args.check_attestations:
|
|
726
|
+
warn_attestation_drift(warnings, root, plans)
|
|
727
|
+
|
|
728
|
+
attested_path = None
|
|
729
|
+
if args.attest:
|
|
730
|
+
attested_path = create_attestation(root, plan_map, plans, args.attest, errors)
|
|
731
|
+
|
|
732
|
+
for warning in warnings:
|
|
733
|
+
print(f"WARNING: {warning}")
|
|
734
|
+
|
|
735
|
+
if errors:
|
|
736
|
+
for error in errors:
|
|
737
|
+
print(f"ERROR: {error}")
|
|
738
|
+
return 1
|
|
739
|
+
|
|
740
|
+
if attested_path is not None:
|
|
741
|
+
print(f"已创建完成快照:{attested_path}")
|
|
742
|
+
|
|
743
|
+
print("计划治理检查通过。")
|
|
744
|
+
return 0
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
if __name__ == "__main__":
|
|
748
|
+
raise SystemExit(main())
|